Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb13919dbc | ||
|
|
1ca1543662 | ||
|
|
b7a7d35025 | ||
|
|
c66d58426f | ||
|
|
af9934d8c6 | ||
|
|
6fcd0caa9e | ||
|
|
d9ca1c7dd2 | ||
|
|
f330803af4 |
@@ -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.
|
||||
@@ -733,4 +733,4 @@ claude-code
|
||||
|
||||
## 📄 License
|
||||
|
||||
本项目仅用于学习和研究目的。
|
||||
本项目采用 [Apache License 2.0](LICENSE) 开源协议。
|
||||
|
||||
@@ -13,11 +13,18 @@
|
||||
|
||||
<groupId>com.claudecode</groupId>
|
||||
<artifactId>claude-code-java</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<version>0.2.0-SNAPSHOT</version>
|
||||
<name>claude-code-java</name>
|
||||
<description>Claude Code 的 Java Spring AI 重写实现</description>
|
||||
<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>
|
||||
<java.version>25</java.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");
|
||||
if (tuiMode == null) {
|
||||
tuiMode = System.getProperty("CLAUDE_CODE_TUI");
|
||||
}
|
||||
if ("legacy".equalsIgnoreCase(tuiMode)) {
|
||||
log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy");
|
||||
replSession.start();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.claudecode.config;
|
||||
|
||||
import com.claudecode.command.CommandRegistry;
|
||||
import com.claudecode.context.AgentLoader;
|
||||
import com.claudecode.context.ClaudeMdLoader;
|
||||
import com.claudecode.context.GitContext;
|
||||
import com.claudecode.context.SkillChangeDetector;
|
||||
import com.claudecode.context.SkillLoader;
|
||||
import com.claudecode.context.SystemPromptBuilder;
|
||||
import com.claudecode.core.AgentLoop;
|
||||
@@ -150,7 +152,17 @@ public class AppConfig {
|
||||
}
|
||||
|
||||
@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"));
|
||||
|
||||
ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir);
|
||||
@@ -163,9 +175,20 @@ public class AppConfig {
|
||||
// Inject SkillLoader into ToolContext for SkillTool
|
||||
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
|
||||
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();
|
||||
String gitSummary = gitContext.buildSummary();
|
||||
|
||||
@@ -175,7 +198,6 @@ public class AppConfig {
|
||||
// Check if coordinator mode is enabled
|
||||
if (CoordinatorMode.isCoordinatorMode()) {
|
||||
log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var");
|
||||
// Coordinator uses a specialized system prompt
|
||||
String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt();
|
||||
String userContext = CoordinatorMode.getCoordinatorUserContext();
|
||||
return coordinatorPrompt + "\n\n" + userContext;
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
|
||||
*/
|
||||
public class BannerPrinter {
|
||||
|
||||
private static final String VERSION = "0.1.0";
|
||||
private static final String VERSION = "0.2.0-SNAPSHOT";
|
||||
|
||||
// 匹配 ANSI 转义序列的正则
|
||||
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<Skill> 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;
|
||||
|
||||
import com.claudecode.context.SkillFilters;
|
||||
import com.claudecode.context.SkillLoader;
|
||||
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.ToolContext;
|
||||
import com.claudecode.util.ArgumentSubstitution;
|
||||
import com.claudecode.util.PromptShellExecution;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -32,6 +38,12 @@ public class SkillTool implements Tool {
|
||||
/** ToolContext key for SkillLoader */
|
||||
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
|
||||
public String name() {
|
||||
return "Skill";
|
||||
@@ -84,27 +96,38 @@ public class SkillTool implements Tool {
|
||||
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
|
||||
SkillLoader skillLoader = context.get(SKILL_LOADER_KEY);
|
||||
if (skillLoader == null) {
|
||||
return "Error: SkillLoader not configured. No skills available.";
|
||||
}
|
||||
|
||||
// Find skill by name
|
||||
Optional<Skill> skillOpt = skillLoader.findByName(skillName);
|
||||
// Find skill by name (using filtered list for model-invocable skills)
|
||||
List<Skill> allSkills = skillLoader.getSkills();
|
||||
List<Skill> visibleSkills = SkillFilters.getSkillToolCommands(allSkills);
|
||||
|
||||
Optional<Skill> skillOpt = SkillFilters.findCommand(allSkills, skillName);
|
||||
if (skillOpt.isEmpty()) {
|
||||
// Try partial match
|
||||
skillOpt = findByPartialName(skillLoader.getSkills(), skillName);
|
||||
// Try partial match across all skills
|
||||
skillOpt = findByPartialName(allSkills, skillName);
|
||||
}
|
||||
|
||||
if (skillOpt.isEmpty()) {
|
||||
StringBuilder msg = new StringBuilder();
|
||||
msg.append("Skill '").append(skillName).append("' not found.\n\n");
|
||||
msg.append("Available skills:\n");
|
||||
for (Skill s : skillLoader.getSkills()) {
|
||||
msg.append(" - ").append(s.name());
|
||||
if (!s.description().isEmpty()) {
|
||||
msg.append(": ").append(s.description());
|
||||
for (Skill s : visibleSkills) {
|
||||
msg.append(" - ").append(s.userFacingName());
|
||||
String desc = SkillFilters.formatDescriptionWithSource(s);
|
||||
if (!desc.isEmpty()) {
|
||||
msg.append(": ").append(desc);
|
||||
}
|
||||
msg.append("\n");
|
||||
}
|
||||
@@ -112,12 +135,34 @@ public class SkillTool implements Tool {
|
||||
}
|
||||
|
||||
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
|
||||
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")
|
||||
java.util.function.Function<String, String> agentFactory =
|
||||
context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null);
|
||||
@@ -132,10 +177,10 @@ public class SkillTool implements Tool {
|
||||
try {
|
||||
String result = agentFactory.apply(skillPrompt);
|
||||
log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length());
|
||||
return result;
|
||||
return renderForkedResult(skill, result);
|
||||
} catch (Exception 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...";
|
||||
}
|
||||
|
||||
// ==================== 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.
|
||||
* Supports argument substitution ($ARGUMENTS, $n, $name, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}).
|
||||
* Supports embedded shell command execution (!`cmd` and ```! cmd ```).
|
||||
*/
|
||||
private String buildSkillPrompt(Skill skill, String arguments) {
|
||||
private String buildSkillPrompt(Skill skill, String arguments, ToolContext context) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("You are executing a skill: ").append(skill.name()).append("\n\n");
|
||||
sb.append("You are executing a skill: ").append(skill.userFacingName()).append("\n\n");
|
||||
|
||||
if (!skill.description().isEmpty()) {
|
||||
sb.append("Description: ").append(skill.description()).append("\n");
|
||||
@@ -163,14 +343,64 @@ public class SkillTool implements Tool {
|
||||
if (!skill.whenToUse().isEmpty()) {
|
||||
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");
|
||||
|
||||
// Inject skill content as instructions
|
||||
sb.append("## Skill Instructions\n\n");
|
||||
sb.append(skill.content()).append("\n\n");
|
||||
// Tool restrictions
|
||||
if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) {
|
||||
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
|
||||
if (arguments != null && !arguments.isBlank()) {
|
||||
// Build content with argument substitution
|
||||
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(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 REPL(Scanner 模式),跳过 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user