Files
claude-code/src/main/java/com/claudecode/util/PromptShellExecution.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

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) {}
}