diff --git a/src/main/java/com/claudecode/context/SkillLoader.java b/src/main/java/com/claudecode/context/SkillLoader.java index cba2b4e..ed3ea09 100644 --- a/src/main/java/com/claudecode/context/SkillLoader.java +++ b/src/main/java/com/claudecode/context/SkillLoader.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,35 +58,175 @@ public class SkillLoader { /** 已加载文件的规范路径集合,用于 symlink 去重 */ private final Set 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 cachedSkills = List.of(); + + /** Dynamic skills discovered from file paths during the session */ + private final Map dynamicSkills = new LinkedHashMap<>(); + + /** Conditional skills waiting for path activation */ + private final Map conditionalSkillsPending = new LinkedHashMap<>(); + + /** Names of skills that have been activated (survives cache clears within a session) */ + private final Set activatedConditionalSkillNames = new HashSet<>(); + + /** Listeners notified when skills are reloaded/changed */ + private final List>> skillChangeListeners = new CopyOnWriteArrayList<>(); + public SkillLoader(Path projectDir) { this.projectDir = projectDir; } /** - * 扫描并加载所有技能文件 + * 扫描并加载所有技能文件。 + * 结果被缓存(memoized),后续调用返回缓存结果。 + * 调用 {@link #clearCache()} 可强制重新加载。 */ public List loadAll() { - skills.clear(); - loadedCanonicalPaths.clear(); + if (loaded) { + return cachedSkills; + } + + synchronized (this) { + if (loaded) { + return cachedSkills; + } + + skills.clear(); + loadedCanonicalPaths.clear(); + + // 0. 内置技能 + skills.addAll(BundledSkills.getAll()); + log.debug("Loaded {} bundled skills", BundledSkills.getAll().size()); + + // --- 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"); + } + + // 3. 项目级技能(目录格式: skill-name/SKILL.md) + if (projectSettingsEnabled) { + Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills"); + loadFromSkillsDirectory(projectSkillsDir, "project"); + } + + // 4. 命令目录(支持目录格式 + 单文件格式 + 递归子目录) + Path commandsDir = projectDir.resolve(".claude").resolve("commands"); + loadFromCommandsDirectory(commandsDir, "command"); + + // Separate conditional and unconditional skills + List 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()); + } - // 0. 内置技能 - skills.addAll(BundledSkills.getAll()); - log.debug("Loaded {} bundled skills", BundledSkills.getAll().size()); + log.debug("Loaded {} skills in total ({} unconditional, {} conditional)", + skills.size(), unconditional.size(), conditionalSkillsPending.size()); - // 1. 用户级技能(目录格式: skill-name/SKILL.md) - Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills"); - loadFromSkillsDirectory(userSkillsDir, "user"); + cachedSkills = Collections.unmodifiableList(unconditional); + loaded = true; - // 2. 项目级技能(目录格式: skill-name/SKILL.md) - Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills"); - loadFromSkillsDirectory(projectSkillsDir, "project"); + return cachedSkills; + } + } - // 3. 命令目录(支持目录格式 + 单文件格式 + 递归子目录) - Path commandsDir = projectDir.resolve(".claude").resolve("commands"); - loadFromCommandsDirectory(commandsDir, "command"); + /** + * 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(); + } - log.debug("Loaded {} skills in total", skills.size()); - return Collections.unmodifiableList(skills); + /** + * Register a listener that is notified when skills are reloaded. + * Corresponds to TS onDynamicSkillsLoaded(). + */ + public void onSkillsChanged(Consumer> listener) { + skillChangeListeners.add(listener); + } + + private void notifyListeners() { + List current = getSkills(); + for (Consumer> 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 paths = fmStringList(fm, "paths"); + List paths = parseSkillPaths(fm); String argumentHint = fmString(fm, "argument-hint", null); List 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 parseSkillPaths(Map fm) { + Object val = fm.get("paths"); + if (val == null) return null; + + List raw = splitPathInFrontmatter(val); + List 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 splitPathInFrontmatter(Object input) { + if (input instanceof List) { + return ((List) input).stream() + .flatMap(item -> splitPathInFrontmatter(item).stream()) + .toList(); + } + if (!(input instanceof String s)) { + return List.of(); + } + + // Split by comma while respecting braces + List 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 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 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 getSkills() { - return Collections.unmodifiableList(skills); + List base = cachedSkills; + if (dynamicSkills.isEmpty()) { + return base; + } + List combined = new ArrayList<>(base); + combined.addAll(dynamicSkills.values()); + return Collections.unmodifiableList(combined); } /** - * 按名称查找技能(精确匹配,不区分大小写) + * 按名称查找技能(精确匹配,不区分大小写,搜索所有技能含动态) */ public Optional findByName(String name) { - return skills.stream() + return getSkills().stream() .filter(s -> s.name().equalsIgnoreCase(name)) .findFirst(); } /** - * 获取非条件技能(始终激活的技能) + * 获取非条件技能(始终激活的技能 + 已激活的动态技能) */ public List 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 activateConditionalSkillsForPaths(List filePaths) { + if (filePaths == null || filePaths.isEmpty() || conditionalSkillsPending.isEmpty()) { + return List.of(); + } + + List activated = new ArrayList<>(); + Iterator> it = conditionalSkillsPending.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry 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 getConditionalSkillsForPaths(List 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 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)) { diff --git a/src/main/java/com/claudecode/tool/impl/SkillTool.java b/src/main/java/com/claudecode/tool/impl/SkillTool.java index fc25aaa..acc065d 100644 --- a/src/main/java/com/claudecode/tool/impl/SkillTool.java +++ b/src/main/java/com/claudecode/tool/impl/SkillTool.java @@ -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 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. */ diff --git a/src/main/java/com/claudecode/util/ArgumentSubstitution.java b/src/main/java/com/claudecode/util/ArgumentSubstitution.java new file mode 100644 index 0000000..dea56d2 --- /dev/null +++ b/src/main/java/com/claudecode/util/ArgumentSubstitution.java @@ -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. + *

+ * Supports: + *

    + *
  • $ARGUMENTS — replaced with the full arguments string
  • + *
  • $ARGUMENTS[0], $ARGUMENTS[1], … — indexed access
  • + *
  • $0, $1, … — shorthand for $ARGUMENTS[n]
  • + *
  • Named arguments ($foo, $bar) — mapped from frontmatter argument names
  • + *
  • ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID} — special variables
  • + *
+ */ +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. + *

+ * Examples: + *

+     *   "foo bar baz"           → ["foo", "bar", "baz"]
+     *   "foo \"hello world\" z" → ["foo", "hello world", "z"]
+     *   "foo 'hello world' z"   → ["foo", "hello world", "z"]
+     * 
+ */ + public static List parseArguments(String args) { + if (args == null || args.isBlank()) { + return List.of(); + } + + List 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 parseArgumentNames(List 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 argNames, List 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. + *

+ * Order of substitution (matching TS): + *

    + *
  1. Named arguments: $foo, $bar → mapped by position from argumentNames
  2. + *
  3. Indexed access: $ARGUMENTS[0], $ARGUMENTS[1], …
  4. + *
  5. Shorthand indexed: $0, $1, …
  6. + *
  7. Full arguments: $ARGUMENTS → raw args string
  8. + *
  9. Auto-append: if no placeholder matched and args non-empty, append "ARGUMENTS: {args}"
  10. + *
+ * + * @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 argumentNames) { + if (content == null) return ""; + // null means no args provided — return content unchanged + if (args == null) return content; + + List parsedArgs = parseArguments(args); + List 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()); + } +} diff --git a/src/main/java/com/claudecode/util/PromptShellExecution.java b/src/main/java/com/claudecode/util/PromptShellExecution.java new file mode 100644 index 0000000..4b1b283 --- /dev/null +++ b/src/main/java/com/claudecode/util/PromptShellExecution.java @@ -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. + *

+ * Supported syntaxes: + *

    + *
  • Code blocks: ```! command ```
  • + *
  • Inline: !`command`
  • + *
+ *

+ * 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 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 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) {} +} diff --git a/src/test/java/com/claudecode/util/ArgumentSubstitutionTest.java b/src/test/java/com/claudecode/util/ArgumentSubstitutionTest.java new file mode 100644 index 0000000..6a7c333 --- /dev/null +++ b/src/test/java/com/claudecode/util/ArgumentSubstitutionTest.java @@ -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"))); + } +}