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>
151 lines
5.7 KiB
Java
151 lines
5.7 KiB
Java
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) {}
|
|
}
|