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>
This commit is contained in:
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user