feat: implement 4 major SkillLoader gaps (cache/settings/shell/args)

1. Memoization/Caching
   - loadAll() now memoized (volatile loaded flag + double-check lock)
   - clearCache() invalidates and notifies listeners
   - Dynamic skills map for runtime-discovered skills
   - Conditional skills pending map with activation tracking
   - onSkillsChanged() listener registration (matches TS onDynamicSkillsLoaded)

2. Setting Source Enforcement
   - isSettingSourceEnabled() checks env CLAUDE_CODE_DISABLE_{SOURCE}
   - isEnvTruthy() for boolean env vars (1/true/yes)
   - CLAUDE_CODE_DISABLE_POLICY_SKILLS skips managed skills
   - getManagedSkillsPath() for policy-managed skill directory
   - Conditional loading per source (user/project/policy)

3. Shell Command Execution (PromptShellExecution)
   - Parse \\\! command \\\ code blocks and !\command\ inline syntax
   - Execute via bash or powershell per skill frontmatter shell field
   - Output replaces command placeholder in skill content
   - 30s timeout, proper error handling
   - MCP skills excluded from shell execution (security)

4. Argument Substitution (ArgumentSubstitution)
   - \ — full raw arguments string
   - \[n] — indexed access
   - \ — shorthand for indexed (bash-style)
   - Named arguments (\, \) mapped from frontmatter
   - Shell-quote aware parsing (handles quoted multi-word args)
   - Auto-append 'ARGUMENTS: ...' if no placeholder found
   - Progressive argument hints for UI

Also:
- Paths frontmatter: brace expansion (src/*.{ts,tsx} → [src/*.ts, src/*.tsx])
- splitPathInFrontmatter with comma-respecting-braces parsing
- activateConditionalSkillsForPaths() with proper activation model
- 17 new unit tests (104 total, 0 failures)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
main
abel533 4 weeks ago
parent c66d58426f
commit b7a7d35025
  1. 331
      src/main/java/com/claudecode/context/SkillLoader.java
  2. 85
      src/main/java/com/claudecode/tool/impl/SkillTool.java
  3. 190
      src/main/java/com/claudecode/util/ArgumentSubstitution.java
  4. 150
      src/main/java/com/claudecode/util/PromptShellExecution.java
  5. 120
      src/test/java/com/claudecode/util/ArgumentSubstitutionTest.java

@ -9,6 +9,8 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
* Skills 技能加载器 对应 claude-code/src/skills/loadSkillsDir.ts
@ -56,14 +58,45 @@ public class SkillLoader {
/** 已加载文件的规范路径集合,用于 symlink 去重 */
private final Set<Path> loadedCanonicalPaths = new HashSet<>();
// ==================== Memoization / Caching ====================
/** Whether skills have been loaded (memoization flag) */
private volatile boolean loaded = false;
/** Cached immutable skill list from last loadAll() */
private volatile List<Skill> cachedSkills = List.of();
/** Dynamic skills discovered from file paths during the session */
private final Map<String, Skill> dynamicSkills = new LinkedHashMap<>();
/** Conditional skills waiting for path activation */
private final Map<String, Skill> conditionalSkillsPending = new LinkedHashMap<>();
/** Names of skills that have been activated (survives cache clears within a session) */
private final Set<String> activatedConditionalSkillNames = new HashSet<>();
/** Listeners notified when skills are reloaded/changed */
private final List<Consumer<List<Skill>>> skillChangeListeners = new CopyOnWriteArrayList<>();
public SkillLoader(Path projectDir) {
this.projectDir = projectDir;
}
/**
* 扫描并加载所有技能文件
* 扫描并加载所有技能文件
* 结果被缓存memoized后续调用返回缓存结果
* 调用 {@link #clearCache()} 可强制重新加载
*/
public List<Skill> loadAll() {
if (loaded) {
return cachedSkills;
}
synchronized (this) {
if (loaded) {
return cachedSkills;
}
skills.clear();
loadedCanonicalPaths.clear();
@ -71,20 +104,129 @@ public class SkillLoader {
skills.addAll(BundledSkills.getAll());
log.debug("Loaded {} bundled skills", BundledSkills.getAll().size());
// 1. 用户级技能(目录格式: skill-name/SKILL.md)
// --- Setting source checks (aligned with TS isSettingSourceEnabled) ---
boolean policyDisabled = isEnvTruthy("CLAUDE_CODE_DISABLE_POLICY_SKILLS");
boolean userSettingsEnabled = isSettingSourceEnabled("userSettings");
boolean projectSettingsEnabled = isSettingSourceEnabled("projectSettings");
// 1. Managed/policy skills (policySettings source)
if (!policyDisabled) {
Path managedSkillsDir = getManagedSkillsPath();
if (managedSkillsDir != null) {
loadFromSkillsDirectory(managedSkillsDir, "policySettings");
}
}
// 2. 用户级技能(目录格式: skill-name/SKILL.md)
if (userSettingsEnabled) {
Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills");
loadFromSkillsDirectory(userSkillsDir, "user");
}
// 2. 项目级技能(目录格式: skill-name/SKILL.md)
// 3. 项目级技能(目录格式: skill-name/SKILL.md)
if (projectSettingsEnabled) {
Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills");
loadFromSkillsDirectory(projectSkillsDir, "project");
}
// 3. 命令目录(支持目录格式 + 单文件格式 + 递归子目录)
// 4. 命令目录(支持目录格式 + 单文件格式 + 递归子目录)
Path commandsDir = projectDir.resolve(".claude").resolve("commands");
loadFromCommandsDirectory(commandsDir, "command");
log.debug("Loaded {} skills in total", skills.size());
return Collections.unmodifiableList(skills);
// Separate conditional and unconditional skills
List<Skill> unconditional = new ArrayList<>();
for (Skill skill : skills) {
if (skill.isConditional() && !activatedConditionalSkillNames.contains(skill.name())) {
conditionalSkillsPending.put(skill.name(), skill);
} else {
unconditional.add(skill);
}
}
if (!conditionalSkillsPending.isEmpty()) {
log.debug("{} conditional skills stored (activated when matching files are touched)",
conditionalSkillsPending.size());
}
log.debug("Loaded {} skills in total ({} unconditional, {} conditional)",
skills.size(), unconditional.size(), conditionalSkillsPending.size());
cachedSkills = Collections.unmodifiableList(unconditional);
loaded = true;
return cachedSkills;
}
}
/**
* Clear the memoization cache. Next call to {@link #loadAll()} will rescan directories.
* Also clears conditional skills pending state.
* Corresponds to TS clearSkillCaches().
*/
public void clearCache() {
synchronized (this) {
loaded = false;
cachedSkills = List.of();
skills.clear();
loadedCanonicalPaths.clear();
conditionalSkillsPending.clear();
activatedConditionalSkillNames.clear();
dynamicSkills.clear();
}
notifyListeners();
}
/**
* Register a listener that is notified when skills are reloaded.
* Corresponds to TS onDynamicSkillsLoaded().
*/
public void onSkillsChanged(Consumer<List<Skill>> listener) {
skillChangeListeners.add(listener);
}
private void notifyListeners() {
List<Skill> current = getSkills();
for (Consumer<List<Skill>> listener : skillChangeListeners) {
try {
listener.accept(current);
} catch (Exception e) {
log.warn("Skill change listener failed: {}", e.getMessage());
}
}
}
// ==================== Setting Source Helpers ====================
/**
* Check if an environment variable is truthy (1, true, yes).
* Corresponds to TS isEnvTruthy().
*/
private static boolean isEnvTruthy(String name) {
String val = System.getenv(name);
if (val == null) val = System.getProperty(name);
if (val == null) return false;
return "1".equals(val) || "true".equalsIgnoreCase(val) || "yes".equalsIgnoreCase(val);
}
/**
* Check if a setting source is enabled.
* Default: all sources enabled unless explicitly disabled via environment.
* Corresponds to TS isSettingSourceEnabled().
*/
private static boolean isSettingSourceEnabled(String source) {
// CLAUDE_CODE_DISABLE_{SOURCE} → disables that source
String envKey = "CLAUDE_CODE_DISABLE_" + source.toUpperCase().replace("SETTINGS", "_SETTINGS");
return !isEnvTruthy(envKey);
}
/**
* Get managed/policy skills directory path (if configured).
*/
private static Path getManagedSkillsPath() {
String managedPath = System.getenv("CLAUDE_CODE_MANAGED_PATH");
if (managedPath == null) managedPath = System.getProperty("claude.managed.path");
if (managedPath == null) return null;
return Path.of(managedPath, ".claude", "skills");
}
/**
@ -277,7 +419,7 @@ public class SkillLoader {
log.warn("Invalid shell '{}' in {}, ignoring (use 'bash' or 'powershell')", shell, path);
shell = null;
}
List<String> paths = fmStringList(fm, "paths");
List<String> paths = parseSkillPaths(fm);
String argumentHint = fmString(fm, "argument-hint", null);
List<String> arguments = fmStringList(fm, "arguments");
String version = fmString(fm, "version", null);
@ -405,6 +547,102 @@ public class SkillLoader {
return "";
}
// ==================== Paths Parsing (brace expansion) ====================
/**
* Parse and validate paths frontmatter field with brace expansion.
* Corresponds to TS parseSkillPaths() + splitPathInFrontmatter().
*
* @return parsed path patterns, or null if no paths / all match-all
*/
private List<String> parseSkillPaths(Map<String, Object> fm) {
Object val = fm.get("paths");
if (val == null) return null;
List<String> raw = splitPathInFrontmatter(val);
List<String> patterns = raw.stream()
.map(p -> p.endsWith("/**") ? p.substring(0, p.length() - 3) : p)
.filter(p -> !p.isEmpty())
.toList();
// If all patterns are ** (match-all), treat as no paths
if (patterns.isEmpty() || patterns.stream().allMatch(p -> p.equals("**"))) {
return null;
}
return patterns;
}
/**
* Split a comma-separated string (or YAML list) and expand brace patterns.
* Commas inside braces are not treated as separators.
* Corresponds to TS splitPathInFrontmatter().
*/
@SuppressWarnings("unchecked")
private List<String> splitPathInFrontmatter(Object input) {
if (input instanceof List) {
return ((List<Object>) input).stream()
.flatMap(item -> splitPathInFrontmatter(item).stream())
.toList();
}
if (!(input instanceof String s)) {
return List.of();
}
// Split by comma while respecting braces
List<String> parts = new ArrayList<>();
StringBuilder current = new StringBuilder();
int braceDepth = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '{') {
braceDepth++;
current.append(c);
} else if (c == '}') {
braceDepth--;
current.append(c);
} else if (c == ',' && braceDepth == 0) {
String trimmed = current.toString().strip();
if (!trimmed.isEmpty()) parts.add(trimmed);
current.setLength(0);
} else {
current.append(c);
}
}
String trimmed = current.toString().strip();
if (!trimmed.isEmpty()) parts.add(trimmed);
// Expand brace patterns
return parts.stream()
.filter(p -> !p.isEmpty())
.flatMap(p -> expandBraces(p).stream())
.toList();
}
/**
* Expand brace patterns in a glob string.
* e.g. "src/*.{ts,tsx}" ["src/*.ts", "src/*.tsx"]
*/
private List<String> expandBraces(String pattern) {
// Find the first brace group
int braceStart = pattern.indexOf('{');
if (braceStart < 0) return List.of(pattern);
int braceEnd = pattern.indexOf('}', braceStart);
if (braceEnd < 0) return List.of(pattern);
String prefix = pattern.substring(0, braceStart);
String alternatives = pattern.substring(braceStart + 1, braceEnd);
String suffix = pattern.substring(braceEnd + 1);
List<String> expanded = new ArrayList<>();
for (String alt : alternatives.split(",")) {
String combined = prefix + alt.strip() + suffix;
expanded.addAll(expandBraces(combined));
}
return expanded;
}
// ==================== Gitignore 过滤 & Symlink 去重 ====================
/**
@ -447,32 +685,89 @@ public class SkillLoader {
}
/**
* 获取已加载的技能列表
* 获取已加载的技能列表包含动态发现的技能
*/
public List<Skill> getSkills() {
return Collections.unmodifiableList(skills);
List<Skill> base = cachedSkills;
if (dynamicSkills.isEmpty()) {
return base;
}
List<Skill> combined = new ArrayList<>(base);
combined.addAll(dynamicSkills.values());
return Collections.unmodifiableList(combined);
}
/**
* 按名称查找技能精确匹配不区分大小写
* 按名称查找技能精确匹配不区分大小写搜索所有技能含动态
*/
public Optional<Skill> findByName(String name) {
return skills.stream()
return getSkills().stream()
.filter(s -> s.name().equalsIgnoreCase(name))
.findFirst();
}
/**
* 获取非条件技能始终激活的技能
* 获取非条件技能始终激活的技能 + 已激活的动态技能
*/
public List<Skill> getUnconditionalSkills() {
return skills.stream()
return getSkills().stream()
.filter(s -> !s.isConditional())
.toList();
}
/**
* 获取匹配指定文件路径的条件技能
* Get the number of pending conditional skills (for testing/debugging).
*/
public int getConditionalSkillCount() {
return conditionalSkillsPending.size();
}
/**
* Activate conditional skills whose path patterns match the given file paths.
* Activated skills are moved to the dynamic skills map.
* Corresponds to TS activateConditionalSkillsForPaths().
*
* @param filePaths 当前操作的文件路径列表
* @return 新激活的技能名称列表
*/
public List<String> activateConditionalSkillsForPaths(List<String> filePaths) {
if (filePaths == null || filePaths.isEmpty() || conditionalSkillsPending.isEmpty()) {
return List.of();
}
List<String> activated = new ArrayList<>();
Iterator<Map.Entry<String, Skill>> it = conditionalSkillsPending.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Skill> entry = it.next();
Skill skill = entry.getValue();
if (skill.paths() == null) continue;
for (String pattern : skill.paths()) {
for (String filePath : filePaths) {
String relative = projectDir != null
? projectDir.relativize(Path.of(filePath).toAbsolutePath()).toString().replace('\\', '/')
: filePath.replace('\\', '/');
if (matchGlob(pattern, relative)) {
dynamicSkills.put(skill.name(), skill);
activatedConditionalSkillNames.add(skill.name());
activated.add(skill.name());
it.remove();
log.debug("Activated conditional skill '{}' (matched path: {})", skill.name(), relative);
break;
}
}
if (activated.contains(entry.getKey())) break;
}
}
if (!activated.isEmpty()) {
notifyListeners();
}
return activated;
}
/**
* 获取匹配指定文件路径的条件技能不修改状态仅查询
* 对应 TS discoverSkillDirsForPaths()
*
* @param filePaths 当前编辑的文件路径列表
@ -481,9 +776,13 @@ public class SkillLoader {
public List<Skill> getConditionalSkillsForPaths(List<String> filePaths) {
if (filePaths == null || filePaths.isEmpty()) return List.of();
return skills.stream()
.filter(Skill::isConditional)
// Search both pending conditional skills and all loaded conditional skills
List<Skill> allConditional = new ArrayList<>(conditionalSkillsPending.values());
allConditional.addAll(getSkills().stream().filter(Skill::isConditional).toList());
return allConditional.stream()
.filter(skill -> {
if (skill.paths() == null) return false;
for (String pattern : skill.paths()) {
for (String filePath : filePaths) {
if (matchGlob(pattern, filePath)) {

@ -4,6 +4,8 @@ import com.claudecode.context.SkillLoader;
import com.claudecode.context.SkillLoader.Skill;
import com.claudecode.tool.Tool;
import com.claudecode.tool.ToolContext;
import com.claudecode.util.ArgumentSubstitution;
import com.claudecode.util.PromptShellExecution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -116,13 +118,13 @@ public class SkillTool implements Tool {
log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context());
// Build skill execution prompt
String skillPrompt = buildSkillPrompt(skill, arguments);
String skillPrompt = buildSkillPrompt(skill, arguments, context);
// Check if skill should be forked (sub-agent) or inline
if (!skill.isForked()) {
// Inline execution: return the skill prompt for the current agent to follow
return "📋 Skill '" + skill.userFacingName() + "' loaded (inline mode).\n\n"
+ "Follow these instructions:\n\n" + skill.content();
+ "Follow these instructions:\n\n" + skillPrompt;
}
// Forked execution: execute via agent factory (same as AgentTool)
@ -160,9 +162,10 @@ public class SkillTool implements Tool {
/**
* Build the full prompt for skill execution.
* Supports argument substitution (${ARGUMENT_NAME}, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}).
* Supports argument substitution ($ARGUMENTS, $n, $name, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}).
* Supports embedded shell command execution (!`cmd` and ```! cmd ```).
*/
private String buildSkillPrompt(Skill skill, String arguments) {
private String buildSkillPrompt(Skill skill, String arguments, ToolContext context) {
StringBuilder sb = new StringBuilder();
sb.append("You are executing a skill: ").append(skill.userFacingName()).append("\n\n");
@ -188,14 +191,45 @@ public class SkillTool implements Tool {
sb.append("Disallowed tools: ").append(String.join(", ", skill.disallowedTools())).append("\n");
}
// Inject skill content with argument substitution
// Build content with argument substitution
String content = skill.content();
content = substituteArguments(content, arguments, skill);
// Prepend base directory if available (matches TS behavior)
Path skillDir = skill.filePath() != null ? skill.filePath().getParent() : null;
if (skillDir != null) {
content = "Base directory for this skill: " + skillDir + "\n\n" + content;
}
// Argument substitution using new utility (matches TS substituteArguments)
List<String> argNames = skill.arguments() != null ? skill.arguments() : List.of();
content = ArgumentSubstitution.substituteArguments(content, arguments, true, argNames);
// Replace ${CLAUDE_SKILL_DIR} with normalized path
if (skillDir != null) {
String skillDirStr = skillDir.toString();
// Normalize backslashes to forward slashes on Windows (matches TS)
if (System.getProperty("os.name", "").toLowerCase().contains("win")) {
skillDirStr = skillDirStr.replace('\\', '/');
}
content = content.replace("${CLAUDE_SKILL_DIR}", skillDirStr);
}
// Replace ${CLAUDE_SESSION_ID}
content = content.replace("${CLAUDE_SESSION_ID}",
System.getProperty("claude.session.id", "default-session"));
// Execute embedded shell commands (!`cmd` and ```! cmd ```)
// Security: skip for MCP skills (remote/untrusted)
if (!"mcp".equals(skill.source())) {
Path workDir = context != null ? context.getWorkDir() : skillDir;
content = PromptShellExecution.executeShellCommandsInPrompt(content, skill.shell(), workDir);
}
sb.append("## Skill Instructions\n\n");
sb.append(content).append("\n\n");
// Inject arguments
if (arguments != null && !arguments.isBlank()) {
// Inject arguments section (for forked context only, inline already has content)
if (arguments != null && !arguments.isBlank() && skill.isForked()) {
sb.append("## User Arguments\n\n");
sb.append(arguments).append("\n\n");
}
@ -211,41 +245,6 @@ public class SkillTool implements Tool {
return sb.toString();
}
/**
* Argument substitution replaces ${ARGUMENT_NAME}, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}.
* Corresponds to TS substituteArguments() + template variables.
*/
private String substituteArguments(String content, String arguments, Skill skill) {
if (content == null) return "";
// Replace ${CLAUDE_SKILL_DIR} with the skill's directory
if (skill.filePath() != null) {
Path skillDir = skill.filePath().getParent();
if (skillDir != null) {
content = content.replace("${CLAUDE_SKILL_DIR}", skillDir.toString());
}
}
// Replace ${CLAUDE_SESSION_ID} with a session identifier
content = content.replace("${CLAUDE_SESSION_ID}",
System.getProperty("claude.session.id", "default-session"));
// Replace generic ${ARGUMENTS} or positional args
if (arguments != null && !arguments.isBlank()) {
content = content.replace("${ARGUMENTS}", arguments);
// Parse named arguments from skill definition
if (skill.arguments() != null) {
String[] argValues = arguments.split("\\s+", skill.arguments().size());
for (int i = 0; i < skill.arguments().size() && i < argValues.length; i++) {
content = content.replace("${" + skill.arguments().get(i) + "}", argValues[i]);
}
}
}
return content;
}
/**
* Partial name match for skills.
*/

@ -0,0 +1,190 @@
package com.claudecode.util;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Argument substitution for skill/command prompts.
* Aligns with claude-code/src/utils/argumentSubstitution.ts.
* <p>
* Supports:
* <ul>
* <li>$ARGUMENTS replaced with the full arguments string</li>
* <li>$ARGUMENTS[0], $ARGUMENTS[1], indexed access</li>
* <li>$0, $1, shorthand for $ARGUMENTS[n]</li>
* <li>Named arguments ($foo, $bar) mapped from frontmatter argument names</li>
* <li>${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID} special variables</li>
* </ul>
*/
public final class ArgumentSubstitution {
private ArgumentSubstitution() {}
// $ARGUMENTS[n]
private static final Pattern INDEXED_PATTERN = Pattern.compile("\\$ARGUMENTS\\[(\\d+)]");
// $n (not followed by word chars, to avoid matching $100foo)
private static final Pattern SHORTHAND_PATTERN = Pattern.compile("\\$(\\d+)(?!\\w)");
/**
* Parse a raw argument string into individual arguments with shell-quote awareness.
* Handles double-quoted and single-quoted strings.
* <p>
* Examples:
* <pre>
* "foo bar baz" ["foo", "bar", "baz"]
* "foo \"hello world\" z" ["foo", "hello world", "z"]
* "foo 'hello world' z" ["foo", "hello world", "z"]
* </pre>
*/
public static List<String> parseArguments(String args) {
if (args == null || args.isBlank()) {
return List.of();
}
List<String> result = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inDoubleQuote = false;
boolean inSingleQuote = false;
boolean escaped = false;
for (int i = 0; i < args.length(); i++) {
char c = args.charAt(i);
if (escaped) {
current.append(c);
escaped = false;
continue;
}
if (c == '\\' && !inSingleQuote) {
escaped = true;
continue;
}
if (c == '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (c == '\'' && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (Character.isWhitespace(c) && !inDoubleQuote && !inSingleQuote) {
if (!current.isEmpty()) {
result.add(current.toString());
current.setLength(0);
}
continue;
}
current.append(c);
}
if (!current.isEmpty()) {
result.add(current.toString());
}
return result;
}
/**
* Parse argument names from frontmatter 'arguments' field.
* Rejects numeric-only names (which conflict with $0, $1 shorthand).
*/
public static List<String> parseArgumentNames(List<String> argumentNames) {
if (argumentNames == null || argumentNames.isEmpty()) {
return List.of();
}
return argumentNames.stream()
.filter(name -> name != null && !name.isBlank() && !name.matches("^\\d+$"))
.toList();
}
/**
* Generate argument hint showing remaining unfilled args.
*
* @param argNames argument names from frontmatter
* @param typedArgs arguments the user has typed so far
* @return hint like "[arg2] [arg3]" or null if all filled
*/
public static String generateProgressiveArgumentHint(List<String> argNames, List<String> typedArgs) {
if (argNames == null || argNames.size() <= typedArgs.size()) {
return null;
}
return argNames.subList(typedArgs.size(), argNames.size()).stream()
.map(name -> "[" + name + "]")
.reduce((a, b) -> a + " " + b)
.orElse(null);
}
/**
* Substitute argument placeholders in content.
* <p>
* Order of substitution (matching TS):
* <ol>
* <li>Named arguments: $foo, $bar mapped by position from argumentNames</li>
* <li>Indexed access: $ARGUMENTS[0], $ARGUMENTS[1], </li>
* <li>Shorthand indexed: $0, $1, </li>
* <li>Full arguments: $ARGUMENTS raw args string</li>
* <li>Auto-append: if no placeholder matched and args non-empty, append "ARGUMENTS: {args}"</li>
* </ol>
*
* @param content the content containing placeholders
* @param args the raw arguments string (null = no args, return unchanged)
* @param appendIfNoPlaceholder if true, appends "ARGUMENTS: {args}" when no placeholder found
* @param argumentNames named arguments from frontmatter
*/
public static String substituteArguments(String content, String args,
boolean appendIfNoPlaceholder,
List<String> argumentNames) {
if (content == null) return "";
// null means no args provided — return content unchanged
if (args == null) return content;
List<String> parsedArgs = parseArguments(args);
List<String> validNames = parseArgumentNames(argumentNames);
String original = content;
// 1. Replace named arguments: $foo, $bar (not followed by [ or word chars)
for (int i = 0; i < validNames.size(); i++) {
String name = validNames.get(i);
String value = i < parsedArgs.size() ? parsedArgs.get(i) : "";
// Match $name but not $name[…] or $nameXxx
content = content.replaceAll("\\$" + Pattern.quote(name) + "(?![\\[\\w])",
Matcher.quoteReplacement(value));
}
// 2. Replace indexed: $ARGUMENTS[0], $ARGUMENTS[1], …
content = INDEXED_PATTERN.matcher(content).replaceAll(mr -> {
int idx = Integer.parseInt(mr.group(1));
return Matcher.quoteReplacement(idx < parsedArgs.size() ? parsedArgs.get(idx) : "");
});
// 3. Replace shorthand: $0, $1, …
content = SHORTHAND_PATTERN.matcher(content).replaceAll(mr -> {
int idx = Integer.parseInt(mr.group(1));
return Matcher.quoteReplacement(idx < parsedArgs.size() ? parsedArgs.get(idx) : "");
});
// 4. Replace $ARGUMENTS with full raw args string
content = content.replace("$ARGUMENTS", args);
// 5. Auto-append if no placeholder found and args non-empty
if (content.equals(original) && appendIfNoPlaceholder && !args.isBlank()) {
content = content + "\n\nARGUMENTS: " + args;
}
return content;
}
/**
* Overload with default appendIfNoPlaceholder=true, no named args.
*/
public static String substituteArguments(String content, String args) {
return substituteArguments(content, args, true, List.of());
}
}

@ -0,0 +1,150 @@
package com.claudecode.util;
import com.claudecode.tool.ToolContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Executes embedded shell commands in skill/command markdown content.
* Aligns with claude-code/src/utils/promptShellExecution.ts.
* <p>
* Supported syntaxes:
* <ul>
* <li>Code blocks: ```! command ```</li>
* <li>Inline: !`command`</li>
* </ul>
* <p>
* Commands are executed via the system shell (bash or powershell as specified
* in frontmatter). Output replaces the command placeholder in the content.
*/
public final class PromptShellExecution {
private static final Logger log = LoggerFactory.getLogger(PromptShellExecution.class);
private PromptShellExecution() {}
/** Pattern for code blocks: ```! command ``` */
private static final Pattern BLOCK_PATTERN = Pattern.compile("```!\\s*\\n?([\\s\\S]*?)\\n?```");
/** Pattern for inline: !`command` (preceded by whitespace or start-of-line) */
private static final Pattern INLINE_PATTERN = Pattern.compile("(?<=^|\\s)!`([^`]+)`", Pattern.MULTILINE);
/** Default command timeout in seconds */
private static final int DEFAULT_TIMEOUT = 30;
/**
* Parse and execute embedded shell commands in the given text.
* Replaces each command placeholder with its output.
*
* @param text the skill/command markdown content
* @param shell "bash" or "powershell" (null defaults to bash)
* @param workDir working directory for command execution
* @return content with command outputs substituted
*/
public static String executeShellCommandsInPrompt(String text, String shell, Path workDir) {
if (text == null || text.isEmpty()) return text;
// Collect all matches (block + inline)
List<CommandMatch> matches = new ArrayList<>();
Matcher blockMatcher = BLOCK_PATTERN.matcher(text);
while (blockMatcher.find()) {
String command = blockMatcher.group(1);
if (command != null && !command.isBlank()) {
matches.add(new CommandMatch(blockMatcher.start(), blockMatcher.end(),
blockMatcher.group(0), command.strip()));
}
}
// Only scan for inline pattern if text contains !` (optimization from TS)
if (text.contains("!`")) {
Matcher inlineMatcher = INLINE_PATTERN.matcher(text);
while (inlineMatcher.find()) {
String command = inlineMatcher.group(1);
if (command != null && !command.isBlank()) {
matches.add(new CommandMatch(inlineMatcher.start(), inlineMatcher.end(),
inlineMatcher.group(0), command.strip()));
}
}
}
if (matches.isEmpty()) return text;
// Execute commands and replace (reverse order to preserve offsets)
matches.sort((a, b) -> Integer.compare(b.start, a.start));
String result = text;
for (CommandMatch match : matches) {
try {
String output = executeShellCommand(match.command, shell, workDir);
result = result.replace(match.fullMatch, output);
log.debug("Shell command executed in skill: {} → {} chars output",
match.command.substring(0, Math.min(50, match.command.length())), output.length());
} catch (Exception e) {
log.warn("Shell command failed in skill content: {}: {}", match.command, e.getMessage());
result = result.replace(match.fullMatch,
"[Error executing command: " + e.getMessage() + "]");
}
}
return result;
}
/**
* Execute a single shell command and return its stdout.
*/
private static String executeShellCommand(String command, String shell, Path workDir) throws IOException {
List<String> cmd;
boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win");
if ("powershell".equalsIgnoreCase(shell)) {
if (isWindows) {
cmd = List.of("powershell", "-NoProfile", "-Command", command);
} else {
cmd = List.of("pwsh", "-NoProfile", "-Command", command);
}
} else {
// Default: bash
if (isWindows) {
// Try bash (Git Bash / WSL), fallback to cmd
cmd = List.of("bash", "-c", command);
} else {
cmd = List.of("bash", "-c", command);
}
}
ProcessBuilder pb = new ProcessBuilder(cmd);
if (workDir != null) {
pb.directory(workDir.toFile());
}
pb.redirectErrorStream(true);
try {
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
boolean finished = process.waitFor(DEFAULT_TIMEOUT, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new IOException("Command timed out after " + DEFAULT_TIMEOUT + "s: " + command);
}
int exitCode = process.exitValue();
if (exitCode != 0) {
log.debug("Shell command exited with code {}: {}", exitCode, command);
}
return output.strip();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Command interrupted: " + command, e);
}
}
private record CommandMatch(int start, int end, String fullMatch, String command) {}
}

@ -0,0 +1,120 @@
package com.claudecode.util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ArgumentSubstitutionTest {
@Test
void parseArguments_simpleWhitespace() {
assertEquals(List.of("foo", "bar", "baz"), ArgumentSubstitution.parseArguments("foo bar baz"));
}
@Test
void parseArguments_doubleQuotedString() {
assertEquals(List.of("foo", "hello world", "baz"),
ArgumentSubstitution.parseArguments("foo \"hello world\" baz"));
}
@Test
void parseArguments_singleQuotedString() {
assertEquals(List.of("foo", "hello world", "baz"),
ArgumentSubstitution.parseArguments("foo 'hello world' baz"));
}
@Test
void parseArguments_escapedSpaces() {
assertEquals(List.of("hello world"), ArgumentSubstitution.parseArguments("hello\\ world"));
}
@Test
void parseArguments_emptyAndNull() {
assertEquals(List.of(), ArgumentSubstitution.parseArguments(null));
assertEquals(List.of(), ArgumentSubstitution.parseArguments(""));
assertEquals(List.of(), ArgumentSubstitution.parseArguments(" "));
}
@Test
void parseArgumentNames_filtersNumericOnly() {
assertEquals(List.of("foo", "bar"),
ArgumentSubstitution.parseArgumentNames(List.of("foo", "123", "bar", "0")));
}
@Test
void substituteArguments_fullArguments() {
String result = ArgumentSubstitution.substituteArguments(
"Run $ARGUMENTS now", "test.js", true, List.of());
assertEquals("Run test.js now", result);
}
@Test
void substituteArguments_indexedAccess() {
String result = ArgumentSubstitution.substituteArguments(
"File: $ARGUMENTS[0] Line: $ARGUMENTS[1]", "test.js 42", true, List.of());
assertEquals("File: test.js Line: 42", result);
}
@Test
void substituteArguments_shorthandIndexed() {
String result = ArgumentSubstitution.substituteArguments(
"File: $0 Line: $1", "test.js 42", true, List.of());
assertEquals("File: test.js Line: 42", result);
}
@Test
void substituteArguments_namedArguments() {
String result = ArgumentSubstitution.substituteArguments(
"File: $file Line: $line", "test.js 42", true, List.of("file", "line"));
assertEquals("File: test.js Line: 42", result);
}
@Test
void substituteArguments_quotedMultiWord() {
String result = ArgumentSubstitution.substituteArguments(
"Greeting: $0", "\"hello world\"", true, List.of());
assertEquals("Greeting: hello world", result);
}
@Test
void substituteArguments_autoAppendWhenNoPlaceholder() {
String result = ArgumentSubstitution.substituteArguments(
"No placeholders here.", "some args", true, List.of());
assertEquals("No placeholders here.\n\nARGUMENTS: some args", result);
}
@Test
void substituteArguments_noAutoAppendWhenDisabled() {
String result = ArgumentSubstitution.substituteArguments(
"No placeholders here.", "some args", false, List.of());
assertEquals("No placeholders here.", result);
}
@Test
void substituteArguments_nullArgsUnchanged() {
String content = "Content with $ARGUMENTS placeholder";
assertSame(content, ArgumentSubstitution.substituteArguments(content, null, true, List.of()));
}
@Test
void substituteArguments_emptyArgsReplaces() {
String result = ArgumentSubstitution.substituteArguments(
"Run $ARGUMENTS now", "", true, List.of());
assertEquals("Run now", result);
}
@Test
void generateProgressiveArgumentHint_basic() {
assertEquals("[arg2] [arg3]",
ArgumentSubstitution.generateProgressiveArgumentHint(
List.of("arg1", "arg2", "arg3"), List.of("val1")));
}
@Test
void generateProgressiveArgumentHint_allFilled() {
assertNull(ArgumentSubstitution.generateProgressiveArgumentHint(
List.of("arg1"), List.of("val1")));
}
}
Loading…
Cancel
Save