Files
claude-code/src/main/java/com/claudecode/util/ArgumentSubstitution.java
T
abel533andCopilot b7a7d35025 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>
2026-04-09 23:44:08 +08:00

191 lines
6.9 KiB
Java

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());
}
}