8 Commits
Author SHA1 Message Date
abel533andCopilot fb13919dbc feat: implement 12 SkillLoader audit fixes + wire AgentLoader
Audit fixes implemented:
1. SkillTool command filtering via SkillFilters.getSkillToolCommands()
2. getSlashCommandToolSkills() with distinct slash command filters
3. SkillChangeDetector: WatchService-based hot reload with 300ms debounce
4. Skill hooks support: hooks field in Skill record + HooksSchema validation
5. SkillTool UI rendering: renderInlineResult/ForkedResult/Rejected/Error
6. McpSkillBuilders registry: register/unregister/getAllMcpSkills pattern
7. Permission checks in SkillTool via PermissionRuleEngine integration
8. Analytics telemetry: logSkillInvocation + tengu_skill_tool_invocation
9. SkillFilters exports: findCommand/hasCommand/getCommand/groupBySource
10. isOfficialMarketplaceName: marketplace skill provenance check
11. Experimental skill search: isExperimentalSkillSearchEnabled() flag
12. Model alias resolution: ModelResolver with haiku/sonnet/opus aliases

Additional:
- AgentLoader wired as Spring Bean in AppConfig
- PermissionRuleEngine injected into ToolContext for SkillTool
- SkillChangeDetector started on app boot for skill hot reload
- Skill record: 27 -> 28 fields (added hooks Map<String,Object>)
- 34 new tests (138 total, all passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 00:17:32 +08:00
abel533andCopilot 1ca1543662 feat: implement 13 minor SkillLoader/Skill differences
Skill record expanded from 19 to 27 fields:
1. hasUserSpecifiedDescription — track if description from frontmatter vs auto-extracted
2. loadedFrom — aligned with TS values (commands_DEPRECATED/skills/plugin/managed/bundled/mcp)
3. skillRoot — base directory for CLAUDE_PLUGIN_ROOT env var support
4. disableModelInvocation — separate from disallowedTools, prevents model SkillTool invocation
5. hideFromSlashCommandTool — hide-from-slash-command-tool frontmatter field
6. isSensitive — is-sensitive frontmatter, args redacted from history
7. contentLength — content.length() for token estimation
8. progressMessage — default 'running' for UI progress display

New computed methods:
9. isHidden() — derived from !userInvocable || hideFromSlashCommandTool
10. isMcp() — check source/loadedFrom for MCP origin
11. estimateFrontmatterTokens() — rough token count for context budget

Validation & parsing improvements:
12. Effort validation — only accept low/medium/high/max/positive integer (parseEffortValue)
13. model 'inherit' — treated as null (use parent model, matching TS)

Additional fixes:
- when_to_use key support (TS uses underscore variant)
- buildSkillsSummary filters hidden/model-disabled skills
- SkillTool blocks model invocation when disableModelInvocation=true
- SkillTool uses skillRoot for base directory
- Brace expansion in paths (splitPathInFrontmatter + expandBraces)
- Backward-compatible 19-arg and 6-arg constructors preserved

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 23:51:18 +08:00
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
abel533andCopilot c66d58426f feat: align SkillLoader with original claude-code (7 critical fixes)
1. YAML parsing: SnakeYAML instead of naive string splitting
   - Handles arrays, nested objects, glob patterns, special chars
   - quoteProblematicValues() fallback on parse failure
   - extractDescriptionFromMarkdown() fallback for missing description

2. Frontmatter fields: 3 → 19 fields
   - Added: display-name, allowed-tools, disallowed-tools, model, effort,
     user-invocable, context, agent, shell, paths, argument-hint, arguments, version
   - Skill record expanded with convenience methods (isConditional, isForked, userFacingName)

3. Gitignore filtering: git check-ignore integration
   - Skills/commands from gitignored directories are skipped
   - Prevents loading malicious skills from node_modules etc.

4. Symlink resolution & deduplication
   - toRealPath() resolves symlinks to canonical paths
   - Set<Path> tracking prevents duplicate loading
   - Handles broken symlinks gracefully

5. AgentLoader: new .claude/agents/ directory support
   - Supports AGENT.md directory format and single .md files
   - Agent-specific frontmatter: tools, disallowed-tools, max-turns, memory,
     isolation, background, model, effort
   - User-level (~/.claude/agents/) and project-level loading

6. Conditional skills (paths)
   - Parse paths frontmatter field (glob patterns)
   - getConditionalSkillsForPaths() with glob matching
   - getUnconditionalSkills() for always-active skills

7. Enhanced SkillTool execution
   - Inline vs fork execution modes
   - Argument substitution: \, \, \
   - Named argument support from skill definition
   - Tool restrictions in prompt (allowed-tools, disallowed-tools)
   - Model and effort hints in prompt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 23:26:24 +08:00
abel533andCopilot af9934d8c6 fix: SkillLoader support directory-based skills (SKILL.md)
Aligned with original claude-code behavior:

/skills/ directory (loadFromSkillsDirectory):
  - Only directory format: skill-name/SKILL.md
  - Single .md files are NOT loaded (matches TS behavior)
  - Directory name = skill name

/commands/ directory (loadFromCommandsDirectory):
  - Directory format: cmd-name/SKILL.md (priority)
  - Single file format: cmd-name.md
  - Recursive subdirs with namespace: sub/cmd.md → 'sub:cmd'

Previously only loaded flat .md files, missing directory skills entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 23:03:40 +08:00
abel533andCopilot 6fcd0caa9e feat: add ConsoleMain test entry for dumb console
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 23:03:40 +08:00
abel533andCopilot d9ca1c7dd2 chore: add Apache License 2.0
- Added LICENSE file (Apache 2.0)
- Added <licenses> section to pom.xml
- Updated README license section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 20:37:24 +08:00
abel533andCopilot f330803af4 chore: bump version to 0.2.0-SNAPSHOT
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 20:28:27 +08:00
21 changed files with 3323 additions and 95 deletions
+199
View File
@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
authorization letter from Anthropic, Inc. (if applicable).
Copyright 2025 abel533
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -1
View File
@@ -733,4 +733,4 @@ claude-code
## 📄 License ## 📄 License
本项目仅用于学习和研究目的 本项目采用 [Apache License 2.0](LICENSE) 开源协议
+8 -1
View File
@@ -13,11 +13,18 @@
<groupId>com.claudecode</groupId> <groupId>com.claudecode</groupId>
<artifactId>claude-code-java</artifactId> <artifactId>claude-code-java</artifactId>
<version>0.1.0</version> <version>0.2.0-SNAPSHOT</version>
<name>claude-code-java</name> <name>claude-code-java</name>
<description>Claude Code 的 Java Spring AI 重写实现</description> <description>Claude Code 的 Java Spring AI 重写实现</description>
<packaging>jar</packaging> <packaging>jar</packaging>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
</license>
</licenses>
<properties> <properties>
<java.version>25</java.version> <java.version>25</java.version>
<spring-ai.version>2.0.0-M4</spring-ai.version> <spring-ai.version>2.0.0-M4</spring-ai.version>
@@ -45,6 +45,9 @@ public class ClaudeCodeRunner implements CommandLineRunner {
// 检查是否强制使用旧模式 // 检查是否强制使用旧模式
String tuiMode = System.getenv("CLAUDE_CODE_TUI"); String tuiMode = System.getenv("CLAUDE_CODE_TUI");
if (tuiMode == null) {
tuiMode = System.getProperty("CLAUDE_CODE_TUI");
}
if ("legacy".equalsIgnoreCase(tuiMode)) { if ("legacy".equalsIgnoreCase(tuiMode)) {
log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy"); log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy");
replSession.start(); replSession.start();
@@ -1,8 +1,10 @@
package com.claudecode.config; package com.claudecode.config;
import com.claudecode.command.CommandRegistry; import com.claudecode.command.CommandRegistry;
import com.claudecode.context.AgentLoader;
import com.claudecode.context.ClaudeMdLoader; import com.claudecode.context.ClaudeMdLoader;
import com.claudecode.context.GitContext; import com.claudecode.context.GitContext;
import com.claudecode.context.SkillChangeDetector;
import com.claudecode.context.SkillLoader; import com.claudecode.context.SkillLoader;
import com.claudecode.context.SystemPromptBuilder; import com.claudecode.context.SystemPromptBuilder;
import com.claudecode.core.AgentLoop; import com.claudecode.core.AgentLoop;
@@ -150,7 +152,17 @@ public class AppConfig {
} }
@Bean @Bean
public String systemPrompt(ToolContext toolContext, SessionMemoryService sessionMemoryService) { public AgentLoader agentLoader() {
Path projectDir = Path.of(System.getProperty("user.dir"));
AgentLoader loader = new AgentLoader(projectDir);
loader.loadAll();
log.info("Loaded {} agent definitions", loader.getAgents().size());
return loader;
}
@Bean
public String systemPrompt(ToolContext toolContext, SessionMemoryService sessionMemoryService,
PermissionRuleEngine permissionRuleEngine, AgentLoader agentLoader) {
Path projectDir = Path.of(System.getProperty("user.dir")); Path projectDir = Path.of(System.getProperty("user.dir"));
ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir); ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir);
@@ -163,9 +175,20 @@ public class AppConfig {
// Inject SkillLoader into ToolContext for SkillTool // Inject SkillLoader into ToolContext for SkillTool
toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader); toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader);
// Inject PermissionRuleEngine into ToolContext for SkillTool permission checks
toolContext.set(SkillTool.PERMISSION_ENGINE_KEY, permissionRuleEngine);
// Inject AgentLoader into ToolContext for AgentTool
toolContext.set("AGENT_LOADER", agentLoader);
// Inject SessionMemoryService into ToolContext // Inject SessionMemoryService into ToolContext
toolContext.set("SESSION_MEMORY_SERVICE", sessionMemoryService); toolContext.set("SESSION_MEMORY_SERVICE", sessionMemoryService);
// Start skill file watcher for hot reload
SkillChangeDetector changeDetector = new SkillChangeDetector(skillLoader, projectDir);
changeDetector.start();
toolContext.set("SKILL_CHANGE_DETECTOR", changeDetector);
GitContext gitContext = new GitContext(projectDir).collect(); GitContext gitContext = new GitContext(projectDir).collect();
String gitSummary = gitContext.buildSummary(); String gitSummary = gitContext.buildSummary();
@@ -175,7 +198,6 @@ public class AppConfig {
// Check if coordinator mode is enabled // Check if coordinator mode is enabled
if (CoordinatorMode.isCoordinatorMode()) { if (CoordinatorMode.isCoordinatorMode()) {
log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var"); log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var");
// Coordinator uses a specialized system prompt
String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt(); String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt();
String userContext = CoordinatorMode.getCoordinatorUserContext(); String userContext = CoordinatorMode.getCoordinatorUserContext();
return coordinatorPrompt + "\n\n" + userContext; return coordinatorPrompt + "\n\n" + userContext;
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
*/ */
public class BannerPrinter { public class BannerPrinter {
private static final String VERSION = "0.1.0"; private static final String VERSION = "0.2.0-SNAPSHOT";
// 匹配 ANSI 转义序列的正则 // 匹配 ANSI 转义序列的正则
private static final Pattern ANSI_PATTERN = Pattern.compile("\033\\[[0-9;]*m"); private static final Pattern ANSI_PATTERN = Pattern.compile("\033\\[[0-9;]*m");
@@ -0,0 +1,206 @@
package com.claudecode.context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Agent 定义加载器 —— 对应 claude-code/src/tools/AgentTool/loadAgentsDir.ts。
* <p>
* 从 .claude/agents/ 目录加载 Agent 定义文件(AGENT.md 或 .md)。
* Agent 定义支持特殊的 frontmatter 字段(tools, maxTurns, memory, isolation 等)。
* <p>
* 目录结构:
* <pre>
* .claude/agents/
* ├── reviewer/
* │ └── AGENT.md ← Agent 名 = "reviewer"
* └── code-generator.md ← Agent 名 = "code-generator"
* </pre>
*/
public class AgentLoader {
private static final Logger log = LoggerFactory.getLogger(AgentLoader.class);
private final Path projectDir;
private final List<AgentDefinition> agents = new ArrayList<>();
public AgentLoader(Path projectDir) {
this.projectDir = projectDir;
}
/**
* 扫描并加载所有 Agent 定义
*/
public List<AgentDefinition> loadAll() {
agents.clear();
// 1. 用户级 agents
Path userAgentsDir = Path.of(System.getProperty("user.home"), ".claude", "agents");
loadFromDirectory(userAgentsDir, "user");
// 2. 项目级 agents
Path projectAgentsDir = projectDir.resolve(".claude").resolve("agents");
loadFromDirectory(projectAgentsDir, "project");
log.debug("Loaded {} agent definitions in total", agents.size());
return Collections.unmodifiableList(agents);
}
/**
* 从目录加载 Agent 定义。
* 支持两种格式:
* - 目录格式: agent-name/AGENT.md
* - 单文件格式: agent-name.md
*/
private void loadFromDirectory(Path dir, String source) {
if (!Files.isDirectory(dir)) return;
try (var stream = Files.list(dir)) {
stream.sorted().forEach(entry -> {
try {
if (Files.isDirectory(entry)) {
// 目录格式: agent-name/AGENT.md
Path agentFile = entry.resolve("AGENT.md");
if (Files.isRegularFile(agentFile)) {
AgentDefinition agent = parseAgentFile(agentFile, source, entry.getFileName().toString());
agents.add(agent);
log.debug("Loaded agent: {} [{}] from {}/AGENT.md", agent.name(), source, entry.getFileName());
}
} else if (entry.toString().endsWith(".md")) {
// 单文件格式: agent-name.md
String name = entry.getFileName().toString().replace(".md", "");
AgentDefinition agent = parseAgentFile(entry, source, name);
agents.add(agent);
log.debug("Loaded agent: {} [{}] from {}", agent.name(), source, entry.getFileName());
}
} catch (IOException e) {
log.warn("Failed to load agent file: {}: {}", entry, e.getMessage());
}
});
} catch (IOException e) {
log.debug("Failed to scan agents directory: {}: {}", dir, e.getMessage());
}
}
/**
* 解析 Agent 定义文件
*/
@SuppressWarnings("unchecked")
private AgentDefinition parseAgentFile(Path path, String source, String defaultName) throws IOException {
String raw = Files.readString(path, StandardCharsets.UTF_8).strip();
String name = defaultName;
String description = "";
String content = raw;
Map<String, Object> fm = Collections.emptyMap();
// YAML frontmatter
if (raw.startsWith("---")) {
int endIdx = raw.indexOf("---", 3);
if (endIdx > 0) {
String fmRaw = raw.substring(3, endIdx).strip();
content = raw.substring(endIdx + 3).strip();
try {
Yaml yaml = new Yaml();
Object result = yaml.load(fmRaw);
if (result instanceof Map) {
fm = (Map<String, Object>) result;
}
} catch (Exception e) {
log.warn("Failed to parse agent frontmatter in {}: {}", path, e.getMessage());
}
}
}
// 解析字段
if (fm.containsKey("name")) name = fm.get("name").toString();
if (fm.containsKey("description")) description = fm.get("description").toString();
List<String> tools = getStringList(fm, "tools");
List<String> disallowedTools = getStringList(fm, "disallowed-tools");
int maxTurns = getInt(fm, "max-turns", 25);
boolean memory = getBoolean(fm, "memory", false);
String isolation = getString(fm, "isolation", "fork");
boolean background = getBoolean(fm, "background", false);
String model = getString(fm, "model", null);
String effort = getString(fm, "effort", null);
return new AgentDefinition(name, description, content, source, path,
tools, disallowedTools, maxTurns, memory, isolation, background, model, effort);
}
public List<AgentDefinition> getAgents() {
return Collections.unmodifiableList(agents);
}
public Optional<AgentDefinition> findByName(String name) {
return agents.stream()
.filter(a -> a.name().equalsIgnoreCase(name))
.findFirst();
}
// ==================== 辅助方法 ====================
private String getString(Map<String, Object> fm, String key, String defaultValue) {
Object val = fm.get(key);
return val != null ? val.toString().strip() : defaultValue;
}
private int getInt(Map<String, Object> fm, String key, int defaultValue) {
Object val = fm.get(key);
if (val instanceof Number n) return n.intValue();
if (val != null) {
try { return Integer.parseInt(val.toString().strip()); }
catch (NumberFormatException e) { /* ignore */ }
}
return defaultValue;
}
private boolean getBoolean(Map<String, Object> fm, String key, boolean defaultValue) {
Object val = fm.get(key);
if (val instanceof Boolean b) return b;
if (val != null) {
String s = val.toString().strip().toLowerCase();
return "true".equals(s) || "yes".equals(s) || "1".equals(s);
}
return defaultValue;
}
@SuppressWarnings("unchecked")
private List<String> getStringList(Map<String, Object> fm, String key) {
Object val = fm.get(key);
if (val == null) return null;
if (val instanceof List) {
return ((List<Object>) val).stream().map(Object::toString).toList();
}
String s = val.toString().strip();
if (s.isEmpty()) return null;
return Arrays.stream(s.split(",")).map(String::strip).filter(v -> !v.isEmpty()).toList();
}
/**
* Agent 定义数据记录
*/
public record AgentDefinition(
String name,
String description,
String content,
String source,
Path filePath,
List<String> tools,
List<String> disallowedTools,
int maxTurns,
boolean memory,
String isolation,
boolean background,
String model,
String effort
) {}
}
@@ -0,0 +1,116 @@
package com.claudecode.context;
import com.claudecode.context.SkillLoader.Skill;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* MCP Skill Builders 注册表 —— 对应 claude-code/src/skills/mcpSkillBuilders.ts。
* <p>
* 允许 MCP 服务器注册技能构建器,将 MCP prompts 转换为 Skills。
* 使用注册表模式打破 MCP ↔ Skills 循环依赖。
* <p>
* 用法:
* <pre>
* // 在 MCP 模块中注册
* McpSkillBuilders.register("my-server", () -> List.of(
* new Skill("mcp-lint", "Lint code", ...)
* ));
*
* // 在 SkillLoader 中获取
* List&lt;Skill&gt; mcpSkills = McpSkillBuilders.getAllMcpSkills();
* </pre>
*/
public final class McpSkillBuilders {
private static final Logger log = LoggerFactory.getLogger(McpSkillBuilders.class);
/** Registry of MCP skill builders keyed by server name */
private static final Map<String, Supplier<List<Skill>>> builders = new ConcurrentHashMap<>();
/** Cache of built skills (invalidated when builders change) */
private static volatile List<Skill> cachedSkills = null;
private McpSkillBuilders() {}
/**
* Register an MCP skill builder for a given server.
* Corresponds to TS registerMCPSkillBuilders().
*
* @param serverName unique MCP server identifier
* @param builder supplier that produces skills from MCP prompts
*/
public static void register(String serverName, Supplier<List<Skill>> builder) {
builders.put(serverName, builder);
cachedSkills = null; // Invalidate cache
log.debug("Registered MCP skill builder for server: {}", serverName);
}
/**
* Unregister an MCP skill builder.
*/
public static void unregister(String serverName) {
builders.remove(serverName);
cachedSkills = null;
log.debug("Unregistered MCP skill builder for server: {}", serverName);
}
/**
* Get all MCP skills from all registered builders.
* Corresponds to TS getMCPSkillBuilders() result aggregation.
*
* @return combined list of skills from all MCP servers
*/
public static List<Skill> getAllMcpSkills() {
List<Skill> cached = cachedSkills;
if (cached != null) return cached;
List<Skill> result = new ArrayList<>();
for (var entry : builders.entrySet()) {
try {
List<Skill> skills = entry.getValue().get();
if (skills != null) {
result.addAll(skills);
}
} catch (Exception e) {
log.warn("Failed to build MCP skills from server '{}': {}", entry.getKey(), e.getMessage());
}
}
cachedSkills = Collections.unmodifiableList(result);
return cachedSkills;
}
/**
* Get registered server names.
*/
public static Set<String> getRegisteredServers() {
return Collections.unmodifiableSet(builders.keySet());
}
/**
* Clear all registered builders and cache.
*/
public static void clearAll() {
builders.clear();
cachedSkills = null;
}
/**
* Invalidate the cache (force rebuild on next access).
*/
public static void invalidateCache() {
cachedSkills = null;
}
/**
* Check if any builders are registered.
*/
public static boolean hasBuilders() {
return !builders.isEmpty();
}
}
@@ -0,0 +1,267 @@
package com.claudecode.context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Closeable;
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
/**
* Skill 文件变更检测器 —— 对应 claude-code/src/utils/skills/skillChangeDetector.ts。
* <p>
* 使用 Java WatchService 监控 skill 目录文件变更,
* 自动触发 SkillLoader 缓存清除和重新加载。
* <p>
* 特性:
* <ul>
* <li>300ms debounce(合并短时间内的多个文件事件)</li>
* <li>监控 ~/.claude/skills, .claude/skills, .claude/commands</li>
* <li>监控 .md 文件的创建、修改、删除</li>
* <li>自动调用 SkillLoader.clearCache() 触发重新加载</li>
* </ul>
*/
public class SkillChangeDetector implements Closeable {
private static final Logger log = LoggerFactory.getLogger(SkillChangeDetector.class);
/** Debounce delay in milliseconds (matches TS 300ms) */
private static final long DEBOUNCE_MS = 300;
/** File stability check delay — wait for file writes to finish (matches TS 1s) */
private static final long STABILITY_MS = 1000;
private final SkillLoader skillLoader;
private final Path projectDir;
private final List<Consumer<Void>> changeListeners = new CopyOnWriteArrayList<>();
private WatchService watchService;
private final Map<WatchKey, Path> watchKeyPathMap = new ConcurrentHashMap<>();
private ScheduledExecutorService scheduler;
private volatile ScheduledFuture<?> pendingDebounce;
private volatile boolean running = false;
public SkillChangeDetector(SkillLoader skillLoader, Path projectDir) {
this.skillLoader = skillLoader;
this.projectDir = projectDir;
}
/**
* Start watching skill directories for changes.
* Non-blocking — starts a background thread.
*/
public void start() {
if (running) return;
try {
watchService = FileSystems.getDefault().newWatchService();
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "skill-change-detector");
t.setDaemon(true);
return t;
});
// Register directories to watch
List<Path> watchDirs = getWatchDirectories();
for (Path dir : watchDirs) {
registerDirectory(dir);
}
if (watchKeyPathMap.isEmpty()) {
log.debug("No skill directories found to watch");
close();
return;
}
running = true;
// Start polling thread
Thread poller = new Thread(this::pollLoop, "skill-watcher-poll");
poller.setDaemon(true);
poller.start();
log.debug("SkillChangeDetector started, watching {} directories", watchKeyPathMap.size());
} catch (IOException e) {
log.debug("Failed to start skill file watcher: {}", e.getMessage());
}
}
/**
* Get all directories that should be watched.
*/
private List<Path> getWatchDirectories() {
List<Path> dirs = new ArrayList<>();
// User skills directory
Path userSkills = Path.of(System.getProperty("user.home"), ".claude", "skills");
if (Files.isDirectory(userSkills)) dirs.add(userSkills);
// Project skills directory
Path projectSkills = projectDir.resolve(".claude").resolve("skills");
if (Files.isDirectory(projectSkills)) dirs.add(projectSkills);
// Project commands directory
Path projectCommands = projectDir.resolve(".claude").resolve("commands");
if (Files.isDirectory(projectCommands)) dirs.add(projectCommands);
// User agents directory
Path userAgents = Path.of(System.getProperty("user.home"), ".claude", "agents");
if (Files.isDirectory(userAgents)) dirs.add(userAgents);
// Project agents directory
Path projectAgents = projectDir.resolve(".claude").resolve("agents");
if (Files.isDirectory(projectAgents)) dirs.add(projectAgents);
return dirs;
}
/**
* Register a directory (and its subdirectories) with the WatchService.
*/
private void registerDirectory(Path dir) {
try {
WatchKey key = dir.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);
watchKeyPathMap.put(key, dir);
log.debug("Watching directory: {}", dir);
// Also register subdirectories (for skill-name/SKILL.md structure)
try (var stream = Files.list(dir)) {
stream.filter(Files::isDirectory).forEach(subDir -> {
try {
WatchKey subKey = subDir.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);
watchKeyPathMap.put(subKey, subDir);
} catch (IOException e) {
log.debug("Failed to watch subdirectory: {}", subDir);
}
});
}
} catch (IOException e) {
log.debug("Failed to register directory for watching: {}: {}", dir, e.getMessage());
}
}
/**
* Main polling loop — runs on a daemon thread.
*/
private void pollLoop() {
while (running) {
try {
WatchKey key = watchService.poll(2, TimeUnit.SECONDS);
if (key == null) continue;
Path dir = watchKeyPathMap.get(key);
boolean relevant = false;
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) continue;
@SuppressWarnings("unchecked")
WatchEvent<Path> pathEvent = (WatchEvent<Path>) event;
Path changed = dir != null ? dir.resolve(pathEvent.context()) : pathEvent.context();
// Only care about .md files and directories (new skill dirs)
String name = changed.getFileName().toString();
if (name.endsWith(".md") || Files.isDirectory(changed) || kind == StandardWatchEventKinds.ENTRY_DELETE) {
relevant = true;
log.debug("Skill file change detected: {} {}", kind.name(), changed);
// If a new directory was created, register it for watching
if (kind == StandardWatchEventKinds.ENTRY_CREATE && Files.isDirectory(changed)) {
registerDirectory(changed);
}
}
}
key.reset();
if (relevant) {
scheduleDebounce();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (ClosedWatchServiceException e) {
break;
}
}
}
/**
* Schedule a debounced cache clear + reload.
* Multiple rapid file changes are coalesced into a single reload.
*/
private void scheduleDebounce() {
// Cancel any pending debounce
ScheduledFuture<?> pending = this.pendingDebounce;
if (pending != null) {
pending.cancel(false);
}
// Schedule new debounce (DEBOUNCE_MS + STABILITY_MS for file stability)
this.pendingDebounce = scheduler.schedule(() -> {
log.info("Skill files changed, reloading...");
try {
skillLoader.clearCache();
skillLoader.loadAll();
notifyListeners();
log.info("Skills reloaded successfully ({} skills)", skillLoader.getSkills().size());
} catch (Exception e) {
log.warn("Failed to reload skills after file change: {}", e.getMessage());
}
}, DEBOUNCE_MS + STABILITY_MS, TimeUnit.MILLISECONDS);
}
/**
* Register a listener for skill change events.
*/
public void onSkillsChanged(Consumer<Void> listener) {
changeListeners.add(listener);
}
private void notifyListeners() {
for (Consumer<Void> listener : changeListeners) {
try {
listener.accept(null);
} catch (Exception e) {
log.debug("Skill change listener error: {}", e.getMessage());
}
}
}
/**
* Check if the detector is currently running.
*/
public boolean isRunning() {
return running;
}
@Override
public void close() {
running = false;
if (pendingDebounce != null) {
pendingDebounce.cancel(true);
}
if (scheduler != null) {
scheduler.shutdownNow();
}
if (watchService != null) {
try {
watchService.close();
} catch (IOException e) {
log.debug("Error closing WatchService: {}", e.getMessage());
}
}
watchKeyPathMap.clear();
log.debug("SkillChangeDetector stopped");
}
}
@@ -0,0 +1,235 @@
package com.claudecode.context;
import com.claudecode.context.SkillLoader.Skill;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.stream.Collectors;
/**
* Skill 过滤与查询工具 —— 对应 TS commands.ts 中的过滤函数。
* <p>
* 提供多种视图用于不同 UI 场景:
* <ul>
* <li>{@link #getSkillToolCommands} — SkillTool(模型)可见的技能列表</li>
* <li>{@link #getSlashCommandToolSkills} — 斜杠命令可见的技能列表</li>
* <li>{@link #formatDescriptionWithSource} — 带来源标记的描述格式化</li>
* </ul>
*/
public final class SkillFilters {
private static final Logger log = LoggerFactory.getLogger(SkillFilters.class);
/** Known official marketplace skill name prefixes */
private static final Set<String> OFFICIAL_MARKETPLACE_PREFIXES = Set.of(
"anthropic/", "claude/", "official/"
);
private SkillFilters() {}
// ==================== Core Filter Functions ====================
/**
* Get skills visible to the SkillTool (model-invocable skills).
* Corresponds to TS getSkillToolCommands().
* <p>
* Filter logic (matches TS exactly):
* - Must be from loadedFrom: bundled, skills, commands_DEPRECATED
* - OR has hasUserSpecifiedDescription=true
* - OR has non-empty whenToUse
* - AND NOT disableModelInvocation
* - AND NOT isHidden
*
* @param skills all loaded skills
* @return filtered skills visible to model
*/
public static List<Skill> getSkillToolCommands(List<Skill> skills) {
return skills.stream()
.filter(s -> !s.disableModelInvocation())
.filter(s -> !s.isHidden())
.filter(s -> isSkillToolVisible(s))
.toList();
}
/**
* Get skills visible to the slash command tool (user-facing /<name> commands).
* Corresponds to TS getSlashCommandToolSkills().
* <p>
* Filter logic:
* - Must be from loadedFrom: skills, plugin, bundled
* - OR has disableModelInvocation=true (user-only skills always show in slash)
* - MUST have description or whenToUse
* - AND NOT isHidden (unless disableModelInvocation gives visibility)
*
* @param skills all loaded skills
* @return filtered skills visible as slash commands
*/
public static List<Skill> getSlashCommandToolSkills(List<Skill> skills) {
return skills.stream()
.filter(s -> isSlashCommandVisible(s))
.filter(s -> hasVisibleMetadata(s))
.toList();
}
/**
* Format a skill description with its source label.
* Corresponds to TS formatDescriptionWithSource().
* <p>
* Examples:
* - "Run tests" → "Run tests"
* - "Run tests" (from plugin) → "Run tests [plugin]"
* - "Run tests" (from managed) → "Run tests [managed]"
*
* @param skill the skill
* @return formatted description with optional source tag
*/
public static String formatDescriptionWithSource(Skill skill) {
String desc = skill.description();
if (desc == null || desc.isBlank()) {
desc = skill.whenToUse();
}
if (desc == null) desc = "";
String loadedFrom = skill.loadedFrom();
if (loadedFrom != null && !Set.of("bundled", "skills", "commands_DEPRECATED").contains(loadedFrom)) {
return desc.isBlank() ? "[" + loadedFrom + "]" : desc + " [" + loadedFrom + "]";
}
return desc;
}
// ==================== Command Lookup Functions ====================
/**
* Get the canonical command name for a skill.
* Corresponds to TS getCommandName().
*/
public static String getCommandName(Skill skill) {
return skill.name();
}
/**
* Find a command/skill by name (case-insensitive).
* Corresponds to TS findCommand().
*/
public static Optional<Skill> findCommand(List<Skill> skills, String name) {
if (name == null || name.isBlank()) return Optional.empty();
return skills.stream()
.filter(s -> s.name().equalsIgnoreCase(name))
.findFirst();
}
/**
* Check if a command/skill exists by name.
* Corresponds to TS hasCommand().
*/
public static boolean hasCommand(List<Skill> skills, String name) {
return findCommand(skills, name).isPresent();
}
/**
* Get a command/skill by name, or null.
* Corresponds to TS getCommand().
*/
public static Skill getCommand(List<Skill> skills, String name) {
return findCommand(skills, name).orElse(null);
}
/**
* Get all skill names (for typeahead/autocomplete).
*/
public static List<String> getAllSkillNames(List<Skill> skills) {
return skills.stream()
.filter(s -> !s.isHidden())
.map(Skill::name)
.sorted()
.toList();
}
/**
* Get skills grouped by source for display.
*/
public static Map<String, List<Skill>> groupBySource(List<Skill> skills) {
return skills.stream()
.collect(Collectors.groupingBy(
s -> s.loadedFrom() != null ? s.loadedFrom() : "unknown",
LinkedHashMap::new,
Collectors.toList()
));
}
// ==================== Marketplace ====================
/**
* Check if a skill name belongs to the official marketplace.
* Corresponds to TS isOfficialMarketplaceName().
*
* @param skillName the skill name to check
* @return true if the skill is from the official marketplace
*/
public static boolean isOfficialMarketplaceName(String skillName) {
if (skillName == null) return false;
String lower = skillName.toLowerCase();
return OFFICIAL_MARKETPLACE_PREFIXES.stream().anyMatch(lower::startsWith);
}
/**
* Check if a skill is from the official marketplace.
*/
public static boolean isOfficialMarketplace(Skill skill) {
return isOfficialMarketplaceName(skill.name())
|| "plugin".equals(skill.loadedFrom())
|| "managed".equals(skill.loadedFrom());
}
// ==================== Internal Filter Logic ====================
/**
* Check if a skill should be visible to the SkillTool (model).
* Matches TS getSkillToolCommands() filter logic.
*/
private static boolean isSkillToolVisible(Skill skill) {
// Always show bundled, skills-dir, and commands-dir skills
String loadedFrom = skill.loadedFrom();
if (loadedFrom != null) {
if ("bundled".equals(loadedFrom) || "skills".equals(loadedFrom)
|| "commands_DEPRECATED".equals(loadedFrom)) {
return true;
}
}
// Show if user specified a description
if (skill.hasUserSpecifiedDescription()) return true;
// Show if has whenToUse hint
return skill.whenToUse() != null && !skill.whenToUse().isBlank();
}
/**
* Check if a skill should be visible as a slash command.
* Matches TS getSlashCommandToolSkills() filter logic.
*/
private static boolean isSlashCommandVisible(Skill skill) {
String loadedFrom = skill.loadedFrom();
// Skills with disableModelInvocation are user-only → always show in slash
if (skill.disableModelInvocation()) return true;
// Standard visibility sources
if (loadedFrom != null) {
return "skills".equals(loadedFrom)
|| "plugin".equals(loadedFrom)
|| "bundled".equals(loadedFrom);
}
return false;
}
/**
* Check if a skill has enough metadata to be displayed.
*/
private static boolean hasVisibleMetadata(Skill skill) {
return (skill.description() != null && !skill.description().isBlank())
|| (skill.whenToUse() != null && !skill.whenToUse().isBlank());
}
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
package com.claudecode.tool.impl; package com.claudecode.tool.impl;
import com.claudecode.context.SkillFilters;
import com.claudecode.context.SkillLoader; import com.claudecode.context.SkillLoader;
import com.claudecode.context.SkillLoader.Skill; import com.claudecode.context.SkillLoader.Skill;
import com.claudecode.permission.PermissionRuleEngine;
import com.claudecode.permission.PermissionTypes.PermissionDecision;
import com.claudecode.tool.Tool; import com.claudecode.tool.Tool;
import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolContext;
import com.claudecode.util.ArgumentSubstitution;
import com.claudecode.util.PromptShellExecution;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -32,6 +38,12 @@ public class SkillTool implements Tool {
/** ToolContext key for SkillLoader */ /** ToolContext key for SkillLoader */
public static final String SKILL_LOADER_KEY = "SKILL_LOADER"; public static final String SKILL_LOADER_KEY = "SKILL_LOADER";
/** ToolContext key for PermissionRuleEngine */
public static final String PERMISSION_ENGINE_KEY = "PERMISSION_ENGINE";
/** Max progress messages to show in non-verbose mode (matches TS) */
private static final int MAX_PROGRESS_MESSAGES = 3;
@Override @Override
public String name() { public String name() {
return "Skill"; return "Skill";
@@ -84,27 +96,38 @@ public class SkillTool implements Tool {
return "Error: 'skill_name' is required"; return "Error: 'skill_name' is required";
} }
// Normalize leading slash (matches TS behavior)
boolean hasLeadingSlash = skillName.startsWith("/");
if (hasLeadingSlash) {
skillName = skillName.substring(1);
logEvent("tengu_skill_tool_slash_prefix", Map.of());
}
// Get SkillLoader from context // Get SkillLoader from context
SkillLoader skillLoader = context.get(SKILL_LOADER_KEY); SkillLoader skillLoader = context.get(SKILL_LOADER_KEY);
if (skillLoader == null) { if (skillLoader == null) {
return "Error: SkillLoader not configured. No skills available."; return "Error: SkillLoader not configured. No skills available.";
} }
// Find skill by name // Find skill by name (using filtered list for model-invocable skills)
Optional<Skill> skillOpt = skillLoader.findByName(skillName); List<Skill> allSkills = skillLoader.getSkills();
List<Skill> visibleSkills = SkillFilters.getSkillToolCommands(allSkills);
Optional<Skill> skillOpt = SkillFilters.findCommand(allSkills, skillName);
if (skillOpt.isEmpty()) { if (skillOpt.isEmpty()) {
// Try partial match // Try partial match across all skills
skillOpt = findByPartialName(skillLoader.getSkills(), skillName); skillOpt = findByPartialName(allSkills, skillName);
} }
if (skillOpt.isEmpty()) { if (skillOpt.isEmpty()) {
StringBuilder msg = new StringBuilder(); StringBuilder msg = new StringBuilder();
msg.append("Skill '").append(skillName).append("' not found.\n\n"); msg.append("Skill '").append(skillName).append("' not found.\n\n");
msg.append("Available skills:\n"); msg.append("Available skills:\n");
for (Skill s : skillLoader.getSkills()) { for (Skill s : visibleSkills) {
msg.append(" - ").append(s.name()); msg.append(" - ").append(s.userFacingName());
if (!s.description().isEmpty()) { String desc = SkillFilters.formatDescriptionWithSource(s);
msg.append(": ").append(s.description()); if (!desc.isEmpty()) {
msg.append(": ").append(desc);
} }
msg.append("\n"); msg.append("\n");
} }
@@ -112,12 +135,34 @@ public class SkillTool implements Tool {
} }
Skill skill = skillOpt.get(); Skill skill = skillOpt.get();
log.info("Executing skill: {} [{}]", skill.name(), skill.source());
// Check if model invocation is disabled for this skill
if (skill.disableModelInvocation()) {
return renderError(skill, "Skill '" + skill.userFacingName()
+ "' cannot be invoked by the model. It has disable-model-invocation: true.");
}
// Permission check (corresponds to TS checkPermissions)
String permissionError = checkPermissions(skill, skillName, context);
if (permissionError != null) {
return renderRejected(skill, permissionError);
}
// Analytics: log skill invocation
logSkillInvocation(skill, skillName, arguments);
log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context());
// Build skill execution prompt // Build skill execution prompt
String skillPrompt = buildSkillPrompt(skill, arguments); String skillPrompt = buildSkillPrompt(skill, arguments, context);
// Execute via agent factory (same as AgentTool) // 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 renderInlineResult(skill, skillPrompt);
}
// Forked execution: execute via agent factory (same as AgentTool)
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
java.util.function.Function<String, String> agentFactory = java.util.function.Function<String, String> agentFactory =
context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null); context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null);
@@ -132,10 +177,10 @@ public class SkillTool implements Tool {
try { try {
String result = agentFactory.apply(skillPrompt); String result = agentFactory.apply(skillPrompt);
log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length()); log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length());
return result; return renderForkedResult(skill, result);
} catch (Exception e) { } catch (Exception e) {
log.debug("Skill execution failed", e); log.debug("Skill execution failed", e);
return "Error executing skill '" + skill.name() + "': " + e.getMessage(); return renderError(skill, "Error executing skill '" + skill.name() + "': " + e.getMessage());
} }
} }
@@ -150,12 +195,147 @@ public class SkillTool implements Tool {
return name != null ? "Running skill: " + name + "..." : "Running skill..."; return name != null ? "Running skill: " + name + "..." : "Running skill...";
} }
// ==================== Permission Checking ====================
/**
* Check permissions for skill execution.
* Corresponds to TS SkillTool.checkPermissions().
*
* @return error message if denied, null if allowed
*/
private String checkPermissions(Skill skill, String commandName, ToolContext context) {
PermissionRuleEngine engine = context.get(PERMISSION_ENGINE_KEY);
if (engine == null) return null; // No permission engine → allow
// Check permission using the tool name "Skill" and skill name as command
PermissionDecision decision = engine.evaluate("Skill",
Map.of("skill_name", commandName), false, context);
return switch (decision.behavior()) {
case DENY -> decision.reason() != null ? decision.reason() : "Skill execution blocked by permission rules";
case ASK -> null; // ASK = let it through (interactive confirm handled elsewhere)
default -> null; // ALLOW
};
}
// ==================== UI Rendering ====================
/**
* Render result for inline skill execution.
* Corresponds to TS renderToolResultMessage() for inline skills.
*/
private String renderInlineResult(Skill skill, String prompt) {
StringBuilder sb = new StringBuilder();
sb.append("📋 Skill '").append(skill.userFacingName()).append("' loaded (inline mode).");
// Show tools count if restricted
if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) {
sb.append(" [").append(skill.allowedTools().size()).append(" tools allowed]");
}
// Show model if non-default
if (skill.model() != null) {
sb.append(" [model: ").append(skill.model()).append("]");
}
sb.append("\n\nFollow these instructions:\n\n").append(prompt);
return sb.toString();
}
/**
* Render result for forked skill execution.
* Corresponds to TS renderToolResultMessage() for forked skills — shows "Done".
*/
private String renderForkedResult(Skill skill, String result) {
return result;
}
/**
* Render rejection message.
* Corresponds to TS renderToolUseRejectedMessage().
*/
private String renderRejected(Skill skill, String reason) {
return "⛔ Skill '" + skill.userFacingName() + "' rejected: " + reason;
}
/**
* Render error message.
* Corresponds to TS renderToolUseErrorMessage().
*/
private String renderError(Skill skill, String error) {
return "" + error;
}
/**
* Render tool use message (for display during execution).
* Corresponds to TS renderToolUseMessage() — shows legacy /commands/ marker.
*/
public static String renderToolUseMessage(Skill skill) {
if ("commands_DEPRECATED".equals(skill.loadedFrom())) {
return "/" + skill.name();
}
return skill.userFacingName();
}
/**
* Render progress message during skill execution.
* Corresponds to TS renderToolUseProgressMessage().
*/
public static String renderProgressMessage(Skill skill) {
String msg = skill.progressMessage();
return msg != null ? msg : "running";
}
// ==================== Analytics ====================
/**
* Log skill invocation telemetry event.
* Corresponds to TS logEvent('tengu_skill_tool_invocation', ...).
*/
private void logSkillInvocation(Skill skill, String commandName, String arguments) {
boolean isOfficial = SkillFilters.isOfficialMarketplace(skill);
String executionContext = skill.isForked() ? "fork" : "inline";
log.info("SKILL_INVOKED: name={}, source={}, loadedFrom={}, context={}, official={}, argsLen={}",
commandName, skill.source(), skill.loadedFrom(), executionContext,
isOfficial, arguments != null ? arguments.length() : 0);
logEvent("tengu_skill_tool_invocation", Map.of(
"command_name", sanitizeSkillName(commandName),
"execution_context", executionContext,
"skill_source", skill.source() != null ? skill.source() : "",
"skill_loaded_from", skill.loadedFrom() != null ? skill.loadedFrom() : "",
"is_official_marketplace", String.valueOf(isOfficial)
));
}
/**
* Sanitize skill name for telemetry (remove PII).
*/
private String sanitizeSkillName(String name) {
if (name == null) return "unknown";
// Replace user-specific paths with generic markers
return name.replaceAll("[^a-zA-Z0-9_:-]", "_");
}
/**
* Log a telemetry event (stub — integrates with existing TelemetryService if available).
*/
private void logEvent(String eventName, Map<String, String> properties) {
// Log to SLF4J for now; when TelemetryService is wired, delegate there
log.debug("TELEMETRY: {} {}", eventName, properties);
}
// ==================== Prompt Building ====================
/** /**
* Build the full prompt for skill execution. * Build the full prompt for skill execution.
* 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(); StringBuilder sb = new StringBuilder();
sb.append("You are executing a skill: ").append(skill.name()).append("\n\n"); sb.append("You are executing a skill: ").append(skill.userFacingName()).append("\n\n");
if (!skill.description().isEmpty()) { if (!skill.description().isEmpty()) {
sb.append("Description: ").append(skill.description()).append("\n"); sb.append("Description: ").append(skill.description()).append("\n");
@@ -163,14 +343,64 @@ public class SkillTool implements Tool {
if (!skill.whenToUse().isEmpty()) { if (!skill.whenToUse().isEmpty()) {
sb.append("When to use: ").append(skill.whenToUse()).append("\n"); sb.append("When to use: ").append(skill.whenToUse()).append("\n");
} }
if (skill.model() != null) {
sb.append("Preferred model: ").append(skill.model()).append("\n");
}
if (skill.effort() != null) {
sb.append("Effort level: ").append(skill.effort()).append("\n");
}
sb.append("\n"); sb.append("\n");
// Inject skill content as instructions // Tool restrictions
sb.append("## Skill Instructions\n\n"); if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) {
sb.append(skill.content()).append("\n\n"); sb.append("Allowed tools: ").append(String.join(", ", skill.allowedTools())).append("\n");
}
if (skill.disallowedTools() != null && !skill.disallowedTools().isEmpty()) {
sb.append("Disallowed tools: ").append(String.join(", ", skill.disallowedTools())).append("\n");
}
// Inject arguments // Build content with argument substitution
if (arguments != null && !arguments.isBlank()) { String content = skill.content();
// Prepend base directory if available (matches TS behavior)
Path skillDir = skill.skillRoot();
if (skillDir == null && skill.filePath() != null) {
skillDir = skill.filePath().getParent();
}
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 section (for forked context only, inline already has content)
if (arguments != null && !arguments.isBlank() && skill.isForked()) {
sb.append("## User Arguments\n\n"); sb.append("## User Arguments\n\n");
sb.append(arguments).append("\n\n"); sb.append(arguments).append("\n\n");
} }
@@ -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,109 @@
package com.claudecode.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
/**
* 模型别名解析器 —— 对应 TS resolveSkillModelOverride()。
* <p>
* 解析技能 frontmatter 中的 model 字段,支持:
* <ul>
* <li>别名解析:haiku → claude-3-haiku, sonnet → claude-sonnet-4, opus → claude-opus-4</li>
* <li>特殊值:inherit → null(继承父级模型)</li>
* <li>完整模型 ID:直接透传</li>
* </ul>
*/
public final class ModelResolver {
private static final Logger log = LoggerFactory.getLogger(ModelResolver.class);
/** Model alias mapping (short name → full model ID) */
private static final Map<String, String> MODEL_ALIASES = Map.ofEntries(
// Claude 3 family
Map.entry("haiku", "claude-3-haiku-20240307"),
Map.entry("claude-3-haiku", "claude-3-haiku-20240307"),
Map.entry("haiku-3", "claude-3-haiku-20240307"),
// Claude 3.5 family
Map.entry("sonnet-3.5", "claude-3-5-sonnet-20241022"),
Map.entry("claude-3.5-sonnet", "claude-3-5-sonnet-20241022"),
Map.entry("haiku-3.5", "claude-3-5-haiku-20241022"),
Map.entry("claude-3.5-haiku", "claude-3-5-haiku-20241022"),
// Claude 4 family (latest)
Map.entry("sonnet", "claude-sonnet-4-20250514"),
Map.entry("claude-sonnet", "claude-sonnet-4-20250514"),
Map.entry("sonnet-4", "claude-sonnet-4-20250514"),
Map.entry("claude-sonnet-4", "claude-sonnet-4-20250514"),
Map.entry("opus", "claude-opus-4-20250514"),
Map.entry("claude-opus", "claude-opus-4-20250514"),
Map.entry("opus-4", "claude-opus-4-20250514"),
Map.entry("claude-opus-4", "claude-opus-4-20250514"),
// OpenAI aliases (for OpenAI-compatible providers)
Map.entry("gpt-4", "gpt-4"),
Map.entry("gpt-4o", "gpt-4o"),
Map.entry("gpt-4o-mini", "gpt-4o-mini"),
Map.entry("o1", "o1"),
Map.entry("o1-mini", "o1-mini"),
Map.entry("o3", "o3"),
Map.entry("o3-mini", "o3-mini"),
Map.entry("o4-mini", "o4-mini")
);
private ModelResolver() {}
/**
* Resolve a model override from skill frontmatter.
* Corresponds to TS resolveSkillModelOverride().
*
* @param modelValue raw model value from frontmatter (may be alias, "inherit", or full ID)
* @return resolved model ID, or null if "inherit" or invalid
*/
public static String resolveSkillModelOverride(String modelValue) {
if (modelValue == null || modelValue.isBlank()) {
return null;
}
String normalized = modelValue.strip().toLowerCase();
// "inherit" means use parent model
if ("inherit".equals(normalized)) {
return null;
}
// Check aliases
String resolved = MODEL_ALIASES.get(normalized);
if (resolved != null) {
log.debug("Resolved model alias '{}' → '{}'", modelValue, resolved);
return resolved;
}
// If it looks like a full model ID (contains a dash and has enough chars), pass through
if (modelValue.contains("-") || modelValue.contains("/") || modelValue.length() > 10) {
return modelValue.strip();
}
// Unknown short name — log warning but still pass through
log.debug("Unknown model alias '{}', passing through as-is", modelValue);
return modelValue.strip();
}
/**
* Check if a model value is a known alias.
*/
public static boolean isKnownAlias(String modelValue) {
if (modelValue == null) return false;
return MODEL_ALIASES.containsKey(modelValue.strip().toLowerCase());
}
/**
* Get all known model aliases.
*/
public static Map<String, String> getAllAliases() {
return MODEL_ALIASES;
}
}
@@ -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,46 @@
package com.claudecode;
import org.springframework.boot.SpringApplication;
import java.util.HashMap;
import java.util.Map;
/**
* 控制台模式启动入口 —— 跳过 Jink TUI,使用纯文本 Scanner 交互。
* <p>
* 适用于:
* <ul>
* <li>IntelliJ IDEA / Eclipse 等 IDE 内置终端(dumb 模式)</li>
* <li>不支持全屏渲染的终端环境</li>
* <li>调试和开发时的快速启动</li>
* </ul>
* <p>
* 在 IDE 中直接右键 Run 即可使用。
* <p>
* 运行前需设置环境变量:
* <ul>
* <li>{@code AI_API_KEY} — API 密钥(必须)</li>
* <li>{@code CLAUDE_CODE_PROVIDER} — openai 或 anthropic(可选,默认 openai</li>
* <li>{@code AI_BASE_URL} — API 地址(可选)</li>
* <li>{@code AI_MODEL} — 模型名称(可选)</li>
* </ul>
*/
public class ConsoleMain {
public static void main(String[] args) {
// 强制使用 legacy REPLScanner 模式),跳过 Jink TUI
System.setProperty("CLAUDE_CODE_TUI", "legacy");
SpringApplication app = new SpringApplication(ClaudeCodeApplication.class);
Map<String, Object> props = new HashMap<>();
// 关闭 web 服务器(CLI 模式)
props.put("spring.main.web-application-type", "none");
// 减少启动日志噪音
props.put("logging.level.root", "WARN");
props.put("logging.level.com.claudecode", "INFO");
app.setDefaultProperties(props);
app.run(args);
}
}
@@ -0,0 +1,94 @@
package com.claudecode.context;
import com.claudecode.context.SkillLoader.Skill;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for McpSkillBuilders registry.
*/
class McpSkillBuildersTest {
@BeforeEach
@AfterEach
void cleanup() {
McpSkillBuilders.clearAll();
}
@Test
void register_and_getAllMcpSkills() {
Skill s1 = new Skill("mcp-lint", "Lint code", "", "content1", "mcp", null);
Skill s2 = new Skill("mcp-format", "Format code", "", "content2", "mcp", null);
McpSkillBuilders.register("server1", () -> List.of(s1));
McpSkillBuilders.register("server2", () -> List.of(s2));
List<Skill> result = McpSkillBuilders.getAllMcpSkills();
assertEquals(2, result.size());
}
@Test
void getAllMcpSkills_cachesResult() {
McpSkillBuilders.register("server", () -> List.of(
new Skill("mcp-test", "test", "", "content", "mcp", null)
));
List<Skill> first = McpSkillBuilders.getAllMcpSkills();
List<Skill> second = McpSkillBuilders.getAllMcpSkills();
assertSame(first, second); // Same cached instance
}
@Test
void register_invalidatesCache() {
McpSkillBuilders.register("server1", () -> List.of(
new Skill("s1", "test", "", "content", "mcp", null)
));
McpSkillBuilders.getAllMcpSkills(); // populate cache
McpSkillBuilders.register("server2", () -> List.of(
new Skill("s2", "test2", "", "content2", "mcp", null)
));
List<Skill> result = McpSkillBuilders.getAllMcpSkills();
assertEquals(2, result.size()); // Cache was invalidated
}
@Test
void unregister_removesServer() {
McpSkillBuilders.register("server", () -> List.of(
new Skill("s1", "test", "", "content", "mcp", null)
));
McpSkillBuilders.unregister("server");
assertTrue(McpSkillBuilders.getAllMcpSkills().isEmpty());
}
@Test
void getRegisteredServers() {
McpSkillBuilders.register("server1", List::of);
McpSkillBuilders.register("server2", List::of);
assertEquals(2, McpSkillBuilders.getRegisteredServers().size());
}
@Test
void hasBuilders_returnsCorrectly() {
assertFalse(McpSkillBuilders.hasBuilders());
McpSkillBuilders.register("s", List::of);
assertTrue(McpSkillBuilders.hasBuilders());
}
@Test
void builderException_isHandledGracefully() {
McpSkillBuilders.register("bad-server", () -> { throw new RuntimeException("fail"); });
McpSkillBuilders.register("good-server", () -> List.of(
new Skill("ok", "ok", "", "ok", "mcp", null)
));
List<Skill> result = McpSkillBuilders.getAllMcpSkills();
assertEquals(1, result.size());
assertEquals("ok", result.getFirst().name());
}
}
@@ -0,0 +1,137 @@
package com.claudecode.context;
import com.claudecode.context.SkillLoader.Skill;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for SkillFilters utility class.
*/
class SkillFiltersTest {
private Skill makeSkill(String name, String source, String loadedFrom,
boolean disableModelInvocation, boolean hasUserDesc,
String whenToUse, boolean userInvocable) {
return new Skill(name, null, "desc", hasUserDesc, whenToUse,
"content", source, loadedFrom, null, null,
null, null, disableModelInvocation, null, null,
userInvocable, false, false, "inline", null, null,
null, null, null, null, 7, "running", null);
}
@Test
void getSkillToolCommands_filtersByLoadedFrom() {
List<Skill> skills = List.of(
makeSkill("bundled-skill", "bundled", "bundled", false, false, "", true),
makeSkill("skills-skill", "user", "skills", false, false, "", true),
makeSkill("commands-skill", "command", "commands_DEPRECATED", false, false, "", true),
makeSkill("plugin-skill", "plugin", "plugin", false, false, "", true)
);
List<Skill> result = SkillFilters.getSkillToolCommands(skills);
assertEquals(3, result.size());
assertTrue(result.stream().anyMatch(s -> s.name().equals("bundled-skill")));
assertTrue(result.stream().anyMatch(s -> s.name().equals("skills-skill")));
assertTrue(result.stream().anyMatch(s -> s.name().equals("commands-skill")));
}
@Test
void getSkillToolCommands_includesWithUserDescription() {
Skill pluginWithDesc = makeSkill("plugin-desc", "plugin", "plugin", false, true, "", true);
List<Skill> result = SkillFilters.getSkillToolCommands(List.of(pluginWithDesc));
assertEquals(1, result.size());
}
@Test
void getSkillToolCommands_includesWithWhenToUse() {
Skill pluginWithWhen = makeSkill("plugin-when", "plugin", "plugin", false, false, "When testing", true);
List<Skill> result = SkillFilters.getSkillToolCommands(List.of(pluginWithWhen));
assertEquals(1, result.size());
}
@Test
void getSkillToolCommands_excludesDisableModelInvocation() {
Skill disabled = makeSkill("no-model", "bundled", "bundled", true, false, "", true);
List<Skill> result = SkillFilters.getSkillToolCommands(List.of(disabled));
assertEquals(0, result.size());
}
@Test
void getSkillToolCommands_excludesHidden() {
Skill hidden = makeSkill("hidden", "bundled", "bundled", false, false, "", false);
List<Skill> result = SkillFilters.getSkillToolCommands(List.of(hidden));
assertEquals(0, result.size());
}
@Test
void getSlashCommandToolSkills_includesSkillsPluginBundled() {
List<Skill> skills = List.of(
makeSkill("s1", "user", "skills", false, true, "", true),
makeSkill("s2", "plugin", "plugin", false, true, "", true),
makeSkill("s3", "bundled", "bundled", false, true, "", true),
makeSkill("s4", "command", "commands_DEPRECATED", false, true, "", true)
);
List<Skill> result = SkillFilters.getSlashCommandToolSkills(skills);
assertEquals(3, result.size());
assertFalse(result.stream().anyMatch(s -> s.name().equals("s4")));
}
@Test
void getSlashCommandToolSkills_includesDisableModelInvocation() {
Skill userOnly = makeSkill("user-only", "command", "commands_DEPRECATED", true, true, "", true);
List<Skill> result = SkillFilters.getSlashCommandToolSkills(List.of(userOnly));
assertEquals(1, result.size());
}
@Test
void formatDescriptionWithSource_bundledNoTag() {
Skill bundled = makeSkill("test", "bundled", "bundled", false, true, "", true);
assertEquals("desc", SkillFilters.formatDescriptionWithSource(bundled));
}
@Test
void formatDescriptionWithSource_pluginTag() {
Skill plugin = makeSkill("test", "plugin", "plugin", false, true, "", true);
assertEquals("desc [plugin]", SkillFilters.formatDescriptionWithSource(plugin));
}
@Test
void formatDescriptionWithSource_managedTag() {
Skill managed = makeSkill("test", "policySettings", "managed", false, true, "", true);
assertEquals("desc [managed]", SkillFilters.formatDescriptionWithSource(managed));
}
@Test
void findCommand_exactAndCaseInsensitive() {
Skill s = makeSkill("my-skill", "user", "skills", false, true, "", true);
assertTrue(SkillFilters.findCommand(List.of(s), "my-skill").isPresent());
assertTrue(SkillFilters.findCommand(List.of(s), "MY-SKILL").isPresent());
assertFalse(SkillFilters.findCommand(List.of(s), "other").isPresent());
}
@Test
void isOfficialMarketplaceName_matchesPrefixes() {
assertTrue(SkillFilters.isOfficialMarketplaceName("anthropic/lint"));
assertTrue(SkillFilters.isOfficialMarketplaceName("claude/verify"));
assertTrue(SkillFilters.isOfficialMarketplaceName("official/test"));
assertFalse(SkillFilters.isOfficialMarketplaceName("user/my-skill"));
assertFalse(SkillFilters.isOfficialMarketplaceName(null));
}
@Test
void groupBySource_groupsCorrectly() {
List<Skill> skills = List.of(
makeSkill("s1", "user", "skills", false, true, "", true),
makeSkill("s2", "user", "skills", false, true, "", true),
makeSkill("s3", "bundled", "bundled", false, true, "", true)
);
var grouped = SkillFilters.groupBySource(skills);
assertEquals(2, grouped.size());
assertEquals(2, grouped.get("skills").size());
assertEquals(1, grouped.get("bundled").size());
}
}
@@ -0,0 +1,90 @@
package com.claudecode.context;
import com.claudecode.context.SkillLoader.Skill;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for Skill record hooks field and computed methods.
*/
class SkillHooksTest {
@Test
void skill_withHooks_hasHooksTrue() {
Map<String, Object> hooks = Map.of(
"PreToolUse", Map.of("command", "echo pre"),
"PostToolUse", Map.of("command", "echo post")
);
Skill skill = new Skill("test", null, "desc", false, "",
"content", "user", "skills", null, null,
null, null, false, null, null,
true, false, false, "inline", null, null,
null, null, null, null, 7, "running", hooks);
assertTrue(skill.hasHooks());
}
@Test
void skill_withoutHooks_hasHooksFalse() {
Skill skill = new Skill("test", null, "desc", false, "",
"content", "user", "skills", null, null,
null, null, false, null, null,
true, false, false, "inline", null, null,
null, null, null, null, 7, "running", null);
assertFalse(skill.hasHooks());
}
@Test
void skill_emptyHooks_hasHooksFalse() {
Skill skill = new Skill("test", null, "desc", false, "",
"content", "user", "skills", null, null,
null, null, false, null, null,
true, false, false, "inline", null, null,
null, null, null, null, 7, "running", Map.of());
assertFalse(skill.hasHooks());
}
@Test
void skill_backwardCompat_28arg_noHooks() {
Skill skill = new Skill("test", null, "desc", false, "",
"content", "user", "skills", null, null,
null, null, false, null, null,
true, false, false, "inline", null, null,
null, null, null, null, 7, "running");
assertFalse(skill.hasHooks());
assertNull(skill.hooks());
}
@Test
void skill_backwardCompat_19arg() {
Skill skill = new Skill("test", null, "desc", "",
"content", "user", null,
null, null, null, null, true,
"inline", null, null, null, null, null, null);
assertFalse(skill.hasHooks());
}
@Test
void skill_backwardCompat_6arg() {
Skill skill = new Skill("test", "desc", "", "content", "bundled", null);
assertFalse(skill.hasHooks());
}
@Test
void skill_isMcp() {
Skill mcp1 = new Skill("test", "desc", "", "content", "mcp", null);
assertTrue(mcp1.isMcp());
Skill mcp2 = new Skill("test", null, "desc", false, "",
"content", "user", "mcp", null, null,
null, null, false, null, null,
true, false, false, "inline", null, null,
null, null, null, null, 7, "running", null);
assertTrue(mcp2.isMcp());
Skill notMcp = new Skill("test", "desc", "", "content", "user", null);
assertFalse(notMcp.isMcp());
}
}
@@ -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")));
}
}
@@ -0,0 +1,63 @@
package com.claudecode.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for ModelResolver utility.
*/
class ModelResolverTest {
@Test
void resolveSkillModelOverride_inherit_returnsNull() {
assertNull(ModelResolver.resolveSkillModelOverride("inherit"));
assertNull(ModelResolver.resolveSkillModelOverride("INHERIT"));
assertNull(ModelResolver.resolveSkillModelOverride("Inherit"));
}
@Test
void resolveSkillModelOverride_null_returnsNull() {
assertNull(ModelResolver.resolveSkillModelOverride(null));
assertNull(ModelResolver.resolveSkillModelOverride(""));
assertNull(ModelResolver.resolveSkillModelOverride(" "));
}
@Test
void resolveSkillModelOverride_aliases() {
assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("sonnet"));
assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("Sonnet"));
assertEquals("claude-opus-4-20250514", ModelResolver.resolveSkillModelOverride("opus"));
assertEquals("claude-3-haiku-20240307", ModelResolver.resolveSkillModelOverride("haiku"));
}
@Test
void resolveSkillModelOverride_versionedAliases() {
assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("sonnet-4"));
assertEquals("claude-opus-4-20250514", ModelResolver.resolveSkillModelOverride("opus-4"));
assertEquals("claude-3-haiku-20240307", ModelResolver.resolveSkillModelOverride("haiku-3"));
assertEquals("claude-3-5-sonnet-20241022", ModelResolver.resolveSkillModelOverride("sonnet-3.5"));
}
@Test
void resolveSkillModelOverride_fullModelIds_passThrough() {
assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("claude-sonnet-4-20250514"));
assertEquals("gpt-4o", ModelResolver.resolveSkillModelOverride("gpt-4o"));
}
@Test
void resolveSkillModelOverride_openAiAliases() {
assertEquals("gpt-4", ModelResolver.resolveSkillModelOverride("gpt-4"));
assertEquals("gpt-4o", ModelResolver.resolveSkillModelOverride("gpt-4o"));
assertEquals("o3-mini", ModelResolver.resolveSkillModelOverride("o3-mini"));
}
@Test
void isKnownAlias_works() {
assertTrue(ModelResolver.isKnownAlias("sonnet"));
assertTrue(ModelResolver.isKnownAlias("haiku"));
assertTrue(ModelResolver.isKnownAlias("opus"));
assertFalse(ModelResolver.isKnownAlias("unknown-model"));
assertFalse(ModelResolver.isKnownAlias(null));
}
}