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
|
## 📄 License
|
||||||
|
|
||||||
本项目仅用于学习和研究目的。
|
本项目采用 [Apache License 2.0](LICENSE) 开源协议。
|
||||||
|
|||||||
@@ -13,11 +13,18 @@
|
|||||||
|
|
||||||
<groupId>com.claudecode</groupId>
|
<groupId>com.claudecode</groupId>
|
||||||
<artifactId>claude-code-java</artifactId>
|
<artifactId>claude-code-java</artifactId>
|
||||||
<version>0.1.0</version>
|
<version>0.2.0-SNAPSHOT</version>
|
||||||
<name>claude-code-java</name>
|
<name>claude-code-java</name>
|
||||||
<description>Claude Code 的 Java Spring AI 重写实现</description>
|
<description>Claude Code 的 Java Spring AI 重写实现</description>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<licenses>
|
||||||
|
<license>
|
||||||
|
<name>Apache License, Version 2.0</name>
|
||||||
|
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||||
|
</license>
|
||||||
|
</licenses>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
<spring-ai.version>2.0.0-M4</spring-ai.version>
|
<spring-ai.version>2.0.0-M4</spring-ai.version>
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ public class ClaudeCodeRunner implements CommandLineRunner {
|
|||||||
|
|
||||||
// 检查是否强制使用旧模式
|
// 检查是否强制使用旧模式
|
||||||
String tuiMode = System.getenv("CLAUDE_CODE_TUI");
|
String tuiMode = System.getenv("CLAUDE_CODE_TUI");
|
||||||
|
if (tuiMode == null) {
|
||||||
|
tuiMode = System.getProperty("CLAUDE_CODE_TUI");
|
||||||
|
}
|
||||||
if ("legacy".equalsIgnoreCase(tuiMode)) {
|
if ("legacy".equalsIgnoreCase(tuiMode)) {
|
||||||
log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy");
|
log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy");
|
||||||
replSession.start();
|
replSession.start();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.claudecode.config;
|
package com.claudecode.config;
|
||||||
|
|
||||||
import com.claudecode.command.CommandRegistry;
|
import com.claudecode.command.CommandRegistry;
|
||||||
|
import com.claudecode.context.AgentLoader;
|
||||||
import com.claudecode.context.ClaudeMdLoader;
|
import com.claudecode.context.ClaudeMdLoader;
|
||||||
import com.claudecode.context.GitContext;
|
import com.claudecode.context.GitContext;
|
||||||
|
import com.claudecode.context.SkillChangeDetector;
|
||||||
import com.claudecode.context.SkillLoader;
|
import com.claudecode.context.SkillLoader;
|
||||||
import com.claudecode.context.SystemPromptBuilder;
|
import com.claudecode.context.SystemPromptBuilder;
|
||||||
import com.claudecode.core.AgentLoop;
|
import com.claudecode.core.AgentLoop;
|
||||||
@@ -150,7 +152,17 @@ public class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public String systemPrompt(ToolContext toolContext, SessionMemoryService sessionMemoryService) {
|
public AgentLoader agentLoader() {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
AgentLoader loader = new AgentLoader(projectDir);
|
||||||
|
loader.loadAll();
|
||||||
|
log.info("Loaded {} agent definitions", loader.getAgents().size());
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public String systemPrompt(ToolContext toolContext, SessionMemoryService sessionMemoryService,
|
||||||
|
PermissionRuleEngine permissionRuleEngine, AgentLoader agentLoader) {
|
||||||
Path projectDir = Path.of(System.getProperty("user.dir"));
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
|
||||||
ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir);
|
ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir);
|
||||||
@@ -163,9 +175,20 @@ public class AppConfig {
|
|||||||
// Inject SkillLoader into ToolContext for SkillTool
|
// Inject SkillLoader into ToolContext for SkillTool
|
||||||
toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader);
|
toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader);
|
||||||
|
|
||||||
|
// Inject PermissionRuleEngine into ToolContext for SkillTool permission checks
|
||||||
|
toolContext.set(SkillTool.PERMISSION_ENGINE_KEY, permissionRuleEngine);
|
||||||
|
|
||||||
|
// Inject AgentLoader into ToolContext for AgentTool
|
||||||
|
toolContext.set("AGENT_LOADER", agentLoader);
|
||||||
|
|
||||||
// Inject SessionMemoryService into ToolContext
|
// Inject SessionMemoryService into ToolContext
|
||||||
toolContext.set("SESSION_MEMORY_SERVICE", sessionMemoryService);
|
toolContext.set("SESSION_MEMORY_SERVICE", sessionMemoryService);
|
||||||
|
|
||||||
|
// Start skill file watcher for hot reload
|
||||||
|
SkillChangeDetector changeDetector = new SkillChangeDetector(skillLoader, projectDir);
|
||||||
|
changeDetector.start();
|
||||||
|
toolContext.set("SKILL_CHANGE_DETECTOR", changeDetector);
|
||||||
|
|
||||||
GitContext gitContext = new GitContext(projectDir).collect();
|
GitContext gitContext = new GitContext(projectDir).collect();
|
||||||
String gitSummary = gitContext.buildSummary();
|
String gitSummary = gitContext.buildSummary();
|
||||||
|
|
||||||
@@ -175,7 +198,6 @@ public class AppConfig {
|
|||||||
// Check if coordinator mode is enabled
|
// Check if coordinator mode is enabled
|
||||||
if (CoordinatorMode.isCoordinatorMode()) {
|
if (CoordinatorMode.isCoordinatorMode()) {
|
||||||
log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var");
|
log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var");
|
||||||
// Coordinator uses a specialized system prompt
|
|
||||||
String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt();
|
String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt();
|
||||||
String userContext = CoordinatorMode.getCoordinatorUserContext();
|
String userContext = CoordinatorMode.getCoordinatorUserContext();
|
||||||
return coordinatorPrompt + "\n\n" + userContext;
|
return coordinatorPrompt + "\n\n" + userContext;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
|
|||||||
*/
|
*/
|
||||||
public class BannerPrinter {
|
public class BannerPrinter {
|
||||||
|
|
||||||
private static final String VERSION = "0.1.0";
|
private static final String VERSION = "0.2.0-SNAPSHOT";
|
||||||
|
|
||||||
// 匹配 ANSI 转义序列的正则
|
// 匹配 ANSI 转义序列的正则
|
||||||
private static final Pattern ANSI_PATTERN = Pattern.compile("\033\\[[0-9;]*m");
|
private static final Pattern ANSI_PATTERN = Pattern.compile("\033\\[[0-9;]*m");
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.claudecode.context;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 定义加载器 —— 对应 claude-code/src/tools/AgentTool/loadAgentsDir.ts。
|
||||||
|
* <p>
|
||||||
|
* 从 .claude/agents/ 目录加载 Agent 定义文件(AGENT.md 或 .md)。
|
||||||
|
* Agent 定义支持特殊的 frontmatter 字段(tools, maxTurns, memory, isolation 等)。
|
||||||
|
* <p>
|
||||||
|
* 目录结构:
|
||||||
|
* <pre>
|
||||||
|
* .claude/agents/
|
||||||
|
* ├── reviewer/
|
||||||
|
* │ └── AGENT.md ← Agent 名 = "reviewer"
|
||||||
|
* └── code-generator.md ← Agent 名 = "code-generator"
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class AgentLoader {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentLoader.class);
|
||||||
|
|
||||||
|
private final Path projectDir;
|
||||||
|
private final List<AgentDefinition> agents = new ArrayList<>();
|
||||||
|
|
||||||
|
public AgentLoader(Path projectDir) {
|
||||||
|
this.projectDir = projectDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描并加载所有 Agent 定义
|
||||||
|
*/
|
||||||
|
public List<AgentDefinition> loadAll() {
|
||||||
|
agents.clear();
|
||||||
|
|
||||||
|
// 1. 用户级 agents
|
||||||
|
Path userAgentsDir = Path.of(System.getProperty("user.home"), ".claude", "agents");
|
||||||
|
loadFromDirectory(userAgentsDir, "user");
|
||||||
|
|
||||||
|
// 2. 项目级 agents
|
||||||
|
Path projectAgentsDir = projectDir.resolve(".claude").resolve("agents");
|
||||||
|
loadFromDirectory(projectAgentsDir, "project");
|
||||||
|
|
||||||
|
log.debug("Loaded {} agent definitions in total", agents.size());
|
||||||
|
return Collections.unmodifiableList(agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从目录加载 Agent 定义。
|
||||||
|
* 支持两种格式:
|
||||||
|
* - 目录格式: agent-name/AGENT.md
|
||||||
|
* - 单文件格式: agent-name.md
|
||||||
|
*/
|
||||||
|
private void loadFromDirectory(Path dir, String source) {
|
||||||
|
if (!Files.isDirectory(dir)) return;
|
||||||
|
|
||||||
|
try (var stream = Files.list(dir)) {
|
||||||
|
stream.sorted().forEach(entry -> {
|
||||||
|
try {
|
||||||
|
if (Files.isDirectory(entry)) {
|
||||||
|
// 目录格式: agent-name/AGENT.md
|
||||||
|
Path agentFile = entry.resolve("AGENT.md");
|
||||||
|
if (Files.isRegularFile(agentFile)) {
|
||||||
|
AgentDefinition agent = parseAgentFile(agentFile, source, entry.getFileName().toString());
|
||||||
|
agents.add(agent);
|
||||||
|
log.debug("Loaded agent: {} [{}] from {}/AGENT.md", agent.name(), source, entry.getFileName());
|
||||||
|
}
|
||||||
|
} else if (entry.toString().endsWith(".md")) {
|
||||||
|
// 单文件格式: agent-name.md
|
||||||
|
String name = entry.getFileName().toString().replace(".md", "");
|
||||||
|
AgentDefinition agent = parseAgentFile(entry, source, name);
|
||||||
|
agents.add(agent);
|
||||||
|
log.debug("Loaded agent: {} [{}] from {}", agent.name(), source, entry.getFileName());
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to load agent file: {}: {}", entry, e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to scan agents directory: {}: {}", dir, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Agent 定义文件
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private AgentDefinition parseAgentFile(Path path, String source, String defaultName) throws IOException {
|
||||||
|
String raw = Files.readString(path, StandardCharsets.UTF_8).strip();
|
||||||
|
|
||||||
|
String name = defaultName;
|
||||||
|
String description = "";
|
||||||
|
String content = raw;
|
||||||
|
Map<String, Object> fm = Collections.emptyMap();
|
||||||
|
|
||||||
|
// YAML frontmatter
|
||||||
|
if (raw.startsWith("---")) {
|
||||||
|
int endIdx = raw.indexOf("---", 3);
|
||||||
|
if (endIdx > 0) {
|
||||||
|
String fmRaw = raw.substring(3, endIdx).strip();
|
||||||
|
content = raw.substring(endIdx + 3).strip();
|
||||||
|
try {
|
||||||
|
Yaml yaml = new Yaml();
|
||||||
|
Object result = yaml.load(fmRaw);
|
||||||
|
if (result instanceof Map) {
|
||||||
|
fm = (Map<String, Object>) result;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse agent frontmatter in {}: {}", path, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析字段
|
||||||
|
if (fm.containsKey("name")) name = fm.get("name").toString();
|
||||||
|
if (fm.containsKey("description")) description = fm.get("description").toString();
|
||||||
|
|
||||||
|
List<String> tools = getStringList(fm, "tools");
|
||||||
|
List<String> disallowedTools = getStringList(fm, "disallowed-tools");
|
||||||
|
int maxTurns = getInt(fm, "max-turns", 25);
|
||||||
|
boolean memory = getBoolean(fm, "memory", false);
|
||||||
|
String isolation = getString(fm, "isolation", "fork");
|
||||||
|
boolean background = getBoolean(fm, "background", false);
|
||||||
|
String model = getString(fm, "model", null);
|
||||||
|
String effort = getString(fm, "effort", null);
|
||||||
|
|
||||||
|
return new AgentDefinition(name, description, content, source, path,
|
||||||
|
tools, disallowedTools, maxTurns, memory, isolation, background, model, effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AgentDefinition> getAgents() {
|
||||||
|
return Collections.unmodifiableList(agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AgentDefinition> findByName(String name) {
|
||||||
|
return agents.stream()
|
||||||
|
.filter(a -> a.name().equalsIgnoreCase(name))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助方法 ====================
|
||||||
|
|
||||||
|
private String getString(Map<String, Object> fm, String key, String defaultValue) {
|
||||||
|
Object val = fm.get(key);
|
||||||
|
return val != null ? val.toString().strip() : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getInt(Map<String, Object> fm, String key, int defaultValue) {
|
||||||
|
Object val = fm.get(key);
|
||||||
|
if (val instanceof Number n) return n.intValue();
|
||||||
|
if (val != null) {
|
||||||
|
try { return Integer.parseInt(val.toString().strip()); }
|
||||||
|
catch (NumberFormatException e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean getBoolean(Map<String, Object> fm, String key, boolean defaultValue) {
|
||||||
|
Object val = fm.get(key);
|
||||||
|
if (val instanceof Boolean b) return b;
|
||||||
|
if (val != null) {
|
||||||
|
String s = val.toString().strip().toLowerCase();
|
||||||
|
return "true".equals(s) || "yes".equals(s) || "1".equals(s);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<String> getStringList(Map<String, Object> fm, String key) {
|
||||||
|
Object val = fm.get(key);
|
||||||
|
if (val == null) return null;
|
||||||
|
if (val instanceof List) {
|
||||||
|
return ((List<Object>) val).stream().map(Object::toString).toList();
|
||||||
|
}
|
||||||
|
String s = val.toString().strip();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
return Arrays.stream(s.split(",")).map(String::strip).filter(v -> !v.isEmpty()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 定义数据记录
|
||||||
|
*/
|
||||||
|
public record AgentDefinition(
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String content,
|
||||||
|
String source,
|
||||||
|
Path filePath,
|
||||||
|
List<String> tools,
|
||||||
|
List<String> disallowedTools,
|
||||||
|
int maxTurns,
|
||||||
|
boolean memory,
|
||||||
|
String isolation,
|
||||||
|
boolean background,
|
||||||
|
String model,
|
||||||
|
String effort
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.claudecode.context;
|
||||||
|
|
||||||
|
import com.claudecode.context.SkillLoader.Skill;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Skill Builders 注册表 —— 对应 claude-code/src/skills/mcpSkillBuilders.ts。
|
||||||
|
* <p>
|
||||||
|
* 允许 MCP 服务器注册技能构建器,将 MCP prompts 转换为 Skills。
|
||||||
|
* 使用注册表模式打破 MCP ↔ Skills 循环依赖。
|
||||||
|
* <p>
|
||||||
|
* 用法:
|
||||||
|
* <pre>
|
||||||
|
* // 在 MCP 模块中注册
|
||||||
|
* McpSkillBuilders.register("my-server", () -> List.of(
|
||||||
|
* new Skill("mcp-lint", "Lint code", ...)
|
||||||
|
* ));
|
||||||
|
*
|
||||||
|
* // 在 SkillLoader 中获取
|
||||||
|
* List<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;
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.context.SkillFilters;
|
||||||
import com.claudecode.context.SkillLoader;
|
import com.claudecode.context.SkillLoader;
|
||||||
import com.claudecode.context.SkillLoader.Skill;
|
import com.claudecode.context.SkillLoader.Skill;
|
||||||
|
import com.claudecode.permission.PermissionRuleEngine;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionDecision;
|
||||||
import com.claudecode.tool.Tool;
|
import com.claudecode.tool.Tool;
|
||||||
import com.claudecode.tool.ToolContext;
|
import com.claudecode.tool.ToolContext;
|
||||||
|
import com.claudecode.util.ArgumentSubstitution;
|
||||||
|
import com.claudecode.util.PromptShellExecution;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -32,6 +38,12 @@ public class SkillTool implements Tool {
|
|||||||
/** ToolContext key for SkillLoader */
|
/** ToolContext key for SkillLoader */
|
||||||
public static final String SKILL_LOADER_KEY = "SKILL_LOADER";
|
public static final String SKILL_LOADER_KEY = "SKILL_LOADER";
|
||||||
|
|
||||||
|
/** ToolContext key for PermissionRuleEngine */
|
||||||
|
public static final String PERMISSION_ENGINE_KEY = "PERMISSION_ENGINE";
|
||||||
|
|
||||||
|
/** Max progress messages to show in non-verbose mode (matches TS) */
|
||||||
|
private static final int MAX_PROGRESS_MESSAGES = 3;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String name() {
|
public String name() {
|
||||||
return "Skill";
|
return "Skill";
|
||||||
@@ -84,27 +96,38 @@ public class SkillTool implements Tool {
|
|||||||
return "Error: 'skill_name' is required";
|
return "Error: 'skill_name' is required";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize leading slash (matches TS behavior)
|
||||||
|
boolean hasLeadingSlash = skillName.startsWith("/");
|
||||||
|
if (hasLeadingSlash) {
|
||||||
|
skillName = skillName.substring(1);
|
||||||
|
logEvent("tengu_skill_tool_slash_prefix", Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
// Get SkillLoader from context
|
// Get SkillLoader from context
|
||||||
SkillLoader skillLoader = context.get(SKILL_LOADER_KEY);
|
SkillLoader skillLoader = context.get(SKILL_LOADER_KEY);
|
||||||
if (skillLoader == null) {
|
if (skillLoader == null) {
|
||||||
return "Error: SkillLoader not configured. No skills available.";
|
return "Error: SkillLoader not configured. No skills available.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find skill by name
|
// Find skill by name (using filtered list for model-invocable skills)
|
||||||
Optional<Skill> skillOpt = skillLoader.findByName(skillName);
|
List<Skill> allSkills = skillLoader.getSkills();
|
||||||
|
List<Skill> visibleSkills = SkillFilters.getSkillToolCommands(allSkills);
|
||||||
|
|
||||||
|
Optional<Skill> skillOpt = SkillFilters.findCommand(allSkills, skillName);
|
||||||
if (skillOpt.isEmpty()) {
|
if (skillOpt.isEmpty()) {
|
||||||
// Try partial match
|
// Try partial match across all skills
|
||||||
skillOpt = findByPartialName(skillLoader.getSkills(), skillName);
|
skillOpt = findByPartialName(allSkills, skillName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skillOpt.isEmpty()) {
|
if (skillOpt.isEmpty()) {
|
||||||
StringBuilder msg = new StringBuilder();
|
StringBuilder msg = new StringBuilder();
|
||||||
msg.append("Skill '").append(skillName).append("' not found.\n\n");
|
msg.append("Skill '").append(skillName).append("' not found.\n\n");
|
||||||
msg.append("Available skills:\n");
|
msg.append("Available skills:\n");
|
||||||
for (Skill s : skillLoader.getSkills()) {
|
for (Skill s : visibleSkills) {
|
||||||
msg.append(" - ").append(s.name());
|
msg.append(" - ").append(s.userFacingName());
|
||||||
if (!s.description().isEmpty()) {
|
String desc = SkillFilters.formatDescriptionWithSource(s);
|
||||||
msg.append(": ").append(s.description());
|
if (!desc.isEmpty()) {
|
||||||
|
msg.append(": ").append(desc);
|
||||||
}
|
}
|
||||||
msg.append("\n");
|
msg.append("\n");
|
||||||
}
|
}
|
||||||
@@ -112,12 +135,34 @@ public class SkillTool implements Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Skill skill = skillOpt.get();
|
Skill skill = skillOpt.get();
|
||||||
log.info("Executing skill: {} [{}]", skill.name(), skill.source());
|
|
||||||
|
// Check if model invocation is disabled for this skill
|
||||||
|
if (skill.disableModelInvocation()) {
|
||||||
|
return renderError(skill, "Skill '" + skill.userFacingName()
|
||||||
|
+ "' cannot be invoked by the model. It has disable-model-invocation: true.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission check (corresponds to TS checkPermissions)
|
||||||
|
String permissionError = checkPermissions(skill, skillName, context);
|
||||||
|
if (permissionError != null) {
|
||||||
|
return renderRejected(skill, permissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics: log skill invocation
|
||||||
|
logSkillInvocation(skill, skillName, arguments);
|
||||||
|
|
||||||
|
log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context());
|
||||||
|
|
||||||
// Build skill execution prompt
|
// Build skill execution prompt
|
||||||
String skillPrompt = buildSkillPrompt(skill, arguments);
|
String skillPrompt = buildSkillPrompt(skill, arguments, context);
|
||||||
|
|
||||||
// Execute via agent factory (same as AgentTool)
|
// Check if skill should be forked (sub-agent) or inline
|
||||||
|
if (!skill.isForked()) {
|
||||||
|
// Inline execution: return the skill prompt for the current agent to follow
|
||||||
|
return renderInlineResult(skill, skillPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forked execution: execute via agent factory (same as AgentTool)
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
java.util.function.Function<String, String> agentFactory =
|
java.util.function.Function<String, String> agentFactory =
|
||||||
context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null);
|
context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null);
|
||||||
@@ -132,10 +177,10 @@ public class SkillTool implements Tool {
|
|||||||
try {
|
try {
|
||||||
String result = agentFactory.apply(skillPrompt);
|
String result = agentFactory.apply(skillPrompt);
|
||||||
log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length());
|
log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length());
|
||||||
return result;
|
return renderForkedResult(skill, result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Skill execution failed", e);
|
log.debug("Skill execution failed", e);
|
||||||
return "Error executing skill '" + skill.name() + "': " + e.getMessage();
|
return renderError(skill, "Error executing skill '" + skill.name() + "': " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +195,147 @@ public class SkillTool implements Tool {
|
|||||||
return name != null ? "Running skill: " + name + "..." : "Running skill...";
|
return name != null ? "Running skill: " + name + "..." : "Running skill...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Permission Checking ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check permissions for skill execution.
|
||||||
|
* Corresponds to TS SkillTool.checkPermissions().
|
||||||
|
*
|
||||||
|
* @return error message if denied, null if allowed
|
||||||
|
*/
|
||||||
|
private String checkPermissions(Skill skill, String commandName, ToolContext context) {
|
||||||
|
PermissionRuleEngine engine = context.get(PERMISSION_ENGINE_KEY);
|
||||||
|
if (engine == null) return null; // No permission engine → allow
|
||||||
|
|
||||||
|
// Check permission using the tool name "Skill" and skill name as command
|
||||||
|
PermissionDecision decision = engine.evaluate("Skill",
|
||||||
|
Map.of("skill_name", commandName), false, context);
|
||||||
|
|
||||||
|
return switch (decision.behavior()) {
|
||||||
|
case DENY -> decision.reason() != null ? decision.reason() : "Skill execution blocked by permission rules";
|
||||||
|
case ASK -> null; // ASK = let it through (interactive confirm handled elsewhere)
|
||||||
|
default -> null; // ALLOW
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UI Rendering ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render result for inline skill execution.
|
||||||
|
* Corresponds to TS renderToolResultMessage() for inline skills.
|
||||||
|
*/
|
||||||
|
private String renderInlineResult(Skill skill, String prompt) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("📋 Skill '").append(skill.userFacingName()).append("' loaded (inline mode).");
|
||||||
|
|
||||||
|
// Show tools count if restricted
|
||||||
|
if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) {
|
||||||
|
sb.append(" [").append(skill.allowedTools().size()).append(" tools allowed]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show model if non-default
|
||||||
|
if (skill.model() != null) {
|
||||||
|
sb.append(" [model: ").append(skill.model()).append("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n\nFollow these instructions:\n\n").append(prompt);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render result for forked skill execution.
|
||||||
|
* Corresponds to TS renderToolResultMessage() for forked skills — shows "Done".
|
||||||
|
*/
|
||||||
|
private String renderForkedResult(Skill skill, String result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render rejection message.
|
||||||
|
* Corresponds to TS renderToolUseRejectedMessage().
|
||||||
|
*/
|
||||||
|
private String renderRejected(Skill skill, String reason) {
|
||||||
|
return "⛔ Skill '" + skill.userFacingName() + "' rejected: " + reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render error message.
|
||||||
|
* Corresponds to TS renderToolUseErrorMessage().
|
||||||
|
*/
|
||||||
|
private String renderError(Skill skill, String error) {
|
||||||
|
return "❌ " + error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tool use message (for display during execution).
|
||||||
|
* Corresponds to TS renderToolUseMessage() — shows legacy /commands/ marker.
|
||||||
|
*/
|
||||||
|
public static String renderToolUseMessage(Skill skill) {
|
||||||
|
if ("commands_DEPRECATED".equals(skill.loadedFrom())) {
|
||||||
|
return "/" + skill.name();
|
||||||
|
}
|
||||||
|
return skill.userFacingName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render progress message during skill execution.
|
||||||
|
* Corresponds to TS renderToolUseProgressMessage().
|
||||||
|
*/
|
||||||
|
public static String renderProgressMessage(Skill skill) {
|
||||||
|
String msg = skill.progressMessage();
|
||||||
|
return msg != null ? msg : "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log skill invocation telemetry event.
|
||||||
|
* Corresponds to TS logEvent('tengu_skill_tool_invocation', ...).
|
||||||
|
*/
|
||||||
|
private void logSkillInvocation(Skill skill, String commandName, String arguments) {
|
||||||
|
boolean isOfficial = SkillFilters.isOfficialMarketplace(skill);
|
||||||
|
String executionContext = skill.isForked() ? "fork" : "inline";
|
||||||
|
|
||||||
|
log.info("SKILL_INVOKED: name={}, source={}, loadedFrom={}, context={}, official={}, argsLen={}",
|
||||||
|
commandName, skill.source(), skill.loadedFrom(), executionContext,
|
||||||
|
isOfficial, arguments != null ? arguments.length() : 0);
|
||||||
|
|
||||||
|
logEvent("tengu_skill_tool_invocation", Map.of(
|
||||||
|
"command_name", sanitizeSkillName(commandName),
|
||||||
|
"execution_context", executionContext,
|
||||||
|
"skill_source", skill.source() != null ? skill.source() : "",
|
||||||
|
"skill_loaded_from", skill.loadedFrom() != null ? skill.loadedFrom() : "",
|
||||||
|
"is_official_marketplace", String.valueOf(isOfficial)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize skill name for telemetry (remove PII).
|
||||||
|
*/
|
||||||
|
private String sanitizeSkillName(String name) {
|
||||||
|
if (name == null) return "unknown";
|
||||||
|
// Replace user-specific paths with generic markers
|
||||||
|
return name.replaceAll("[^a-zA-Z0-9_:-]", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a telemetry event (stub — integrates with existing TelemetryService if available).
|
||||||
|
*/
|
||||||
|
private void logEvent(String eventName, Map<String, String> properties) {
|
||||||
|
// Log to SLF4J for now; when TelemetryService is wired, delegate there
|
||||||
|
log.debug("TELEMETRY: {} {}", eventName, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Prompt Building ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full prompt for skill execution.
|
* Build the full prompt for skill execution.
|
||||||
|
* Supports argument substitution ($ARGUMENTS, $n, $name, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}).
|
||||||
|
* Supports embedded shell command execution (!`cmd` and ```! cmd ```).
|
||||||
*/
|
*/
|
||||||
private String buildSkillPrompt(Skill skill, String arguments) {
|
private String buildSkillPrompt(Skill skill, String arguments, ToolContext context) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("You are executing a skill: ").append(skill.name()).append("\n\n");
|
sb.append("You are executing a skill: ").append(skill.userFacingName()).append("\n\n");
|
||||||
|
|
||||||
if (!skill.description().isEmpty()) {
|
if (!skill.description().isEmpty()) {
|
||||||
sb.append("Description: ").append(skill.description()).append("\n");
|
sb.append("Description: ").append(skill.description()).append("\n");
|
||||||
@@ -163,14 +343,64 @@ public class SkillTool implements Tool {
|
|||||||
if (!skill.whenToUse().isEmpty()) {
|
if (!skill.whenToUse().isEmpty()) {
|
||||||
sb.append("When to use: ").append(skill.whenToUse()).append("\n");
|
sb.append("When to use: ").append(skill.whenToUse()).append("\n");
|
||||||
}
|
}
|
||||||
|
if (skill.model() != null) {
|
||||||
|
sb.append("Preferred model: ").append(skill.model()).append("\n");
|
||||||
|
}
|
||||||
|
if (skill.effort() != null) {
|
||||||
|
sb.append("Effort level: ").append(skill.effort()).append("\n");
|
||||||
|
}
|
||||||
sb.append("\n");
|
sb.append("\n");
|
||||||
|
|
||||||
// Inject skill content as instructions
|
// Tool restrictions
|
||||||
sb.append("## Skill Instructions\n\n");
|
if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) {
|
||||||
sb.append(skill.content()).append("\n\n");
|
sb.append("Allowed tools: ").append(String.join(", ", skill.allowedTools())).append("\n");
|
||||||
|
}
|
||||||
|
if (skill.disallowedTools() != null && !skill.disallowedTools().isEmpty()) {
|
||||||
|
sb.append("Disallowed tools: ").append(String.join(", ", skill.disallowedTools())).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// Inject arguments
|
// Build content with argument substitution
|
||||||
if (arguments != null && !arguments.isBlank()) {
|
String content = skill.content();
|
||||||
|
|
||||||
|
// Prepend base directory if available (matches TS behavior)
|
||||||
|
Path skillDir = skill.skillRoot();
|
||||||
|
if (skillDir == null && skill.filePath() != null) {
|
||||||
|
skillDir = skill.filePath().getParent();
|
||||||
|
}
|
||||||
|
if (skillDir != null) {
|
||||||
|
content = "Base directory for this skill: " + skillDir + "\n\n" + content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argument substitution using new utility (matches TS substituteArguments)
|
||||||
|
List<String> argNames = skill.arguments() != null ? skill.arguments() : List.of();
|
||||||
|
content = ArgumentSubstitution.substituteArguments(content, arguments, true, argNames);
|
||||||
|
|
||||||
|
// Replace ${CLAUDE_SKILL_DIR} with normalized path
|
||||||
|
if (skillDir != null) {
|
||||||
|
String skillDirStr = skillDir.toString();
|
||||||
|
// Normalize backslashes to forward slashes on Windows (matches TS)
|
||||||
|
if (System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||||
|
skillDirStr = skillDirStr.replace('\\', '/');
|
||||||
|
}
|
||||||
|
content = content.replace("${CLAUDE_SKILL_DIR}", skillDirStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace ${CLAUDE_SESSION_ID}
|
||||||
|
content = content.replace("${CLAUDE_SESSION_ID}",
|
||||||
|
System.getProperty("claude.session.id", "default-session"));
|
||||||
|
|
||||||
|
// Execute embedded shell commands (!`cmd` and ```! cmd ```)
|
||||||
|
// Security: skip for MCP skills (remote/untrusted)
|
||||||
|
if (!"mcp".equals(skill.source())) {
|
||||||
|
Path workDir = context != null ? context.getWorkDir() : skillDir;
|
||||||
|
content = PromptShellExecution.executeShellCommandsInPrompt(content, skill.shell(), workDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("## Skill Instructions\n\n");
|
||||||
|
sb.append(content).append("\n\n");
|
||||||
|
|
||||||
|
// Inject arguments section (for forked context only, inline already has content)
|
||||||
|
if (arguments != null && !arguments.isBlank() && skill.isForked()) {
|
||||||
sb.append("## User Arguments\n\n");
|
sb.append("## User Arguments\n\n");
|
||||||
sb.append(arguments).append("\n\n");
|
sb.append(arguments).append("\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.claudecode.util;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Argument substitution for skill/command prompts.
|
||||||
|
* Aligns with claude-code/src/utils/argumentSubstitution.ts.
|
||||||
|
* <p>
|
||||||
|
* Supports:
|
||||||
|
* <ul>
|
||||||
|
* <li>$ARGUMENTS — replaced with the full arguments string</li>
|
||||||
|
* <li>$ARGUMENTS[0], $ARGUMENTS[1], … — indexed access</li>
|
||||||
|
* <li>$0, $1, … — shorthand for $ARGUMENTS[n]</li>
|
||||||
|
* <li>Named arguments ($foo, $bar) — mapped from frontmatter argument names</li>
|
||||||
|
* <li>${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID} — special variables</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ArgumentSubstitution {
|
||||||
|
|
||||||
|
private ArgumentSubstitution() {}
|
||||||
|
|
||||||
|
// $ARGUMENTS[n]
|
||||||
|
private static final Pattern INDEXED_PATTERN = Pattern.compile("\\$ARGUMENTS\\[(\\d+)]");
|
||||||
|
// $n (not followed by word chars, to avoid matching $100foo)
|
||||||
|
private static final Pattern SHORTHAND_PATTERN = Pattern.compile("\\$(\\d+)(?!\\w)");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw argument string into individual arguments with shell-quote awareness.
|
||||||
|
* Handles double-quoted and single-quoted strings.
|
||||||
|
* <p>
|
||||||
|
* Examples:
|
||||||
|
* <pre>
|
||||||
|
* "foo bar baz" → ["foo", "bar", "baz"]
|
||||||
|
* "foo \"hello world\" z" → ["foo", "hello world", "z"]
|
||||||
|
* "foo 'hello world' z" → ["foo", "hello world", "z"]
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public static List<String> parseArguments(String args) {
|
||||||
|
if (args == null || args.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
StringBuilder current = new StringBuilder();
|
||||||
|
boolean inDoubleQuote = false;
|
||||||
|
boolean inSingleQuote = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < args.length(); i++) {
|
||||||
|
char c = args.charAt(i);
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
current.append(c);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '\\' && !inSingleQuote) {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '"' && !inSingleQuote) {
|
||||||
|
inDoubleQuote = !inDoubleQuote;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '\'' && !inDoubleQuote) {
|
||||||
|
inSingleQuote = !inSingleQuote;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Character.isWhitespace(c) && !inDoubleQuote && !inSingleQuote) {
|
||||||
|
if (!current.isEmpty()) {
|
||||||
|
result.add(current.toString());
|
||||||
|
current.setLength(0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.isEmpty()) {
|
||||||
|
result.add(current.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse argument names from frontmatter 'arguments' field.
|
||||||
|
* Rejects numeric-only names (which conflict with $0, $1 shorthand).
|
||||||
|
*/
|
||||||
|
public static List<String> parseArgumentNames(List<String> argumentNames) {
|
||||||
|
if (argumentNames == null || argumentNames.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return argumentNames.stream()
|
||||||
|
.filter(name -> name != null && !name.isBlank() && !name.matches("^\\d+$"))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate argument hint showing remaining unfilled args.
|
||||||
|
*
|
||||||
|
* @param argNames argument names from frontmatter
|
||||||
|
* @param typedArgs arguments the user has typed so far
|
||||||
|
* @return hint like "[arg2] [arg3]" or null if all filled
|
||||||
|
*/
|
||||||
|
public static String generateProgressiveArgumentHint(List<String> argNames, List<String> typedArgs) {
|
||||||
|
if (argNames == null || argNames.size() <= typedArgs.size()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return argNames.subList(typedArgs.size(), argNames.size()).stream()
|
||||||
|
.map(name -> "[" + name + "]")
|
||||||
|
.reduce((a, b) -> a + " " + b)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitute argument placeholders in content.
|
||||||
|
* <p>
|
||||||
|
* Order of substitution (matching TS):
|
||||||
|
* <ol>
|
||||||
|
* <li>Named arguments: $foo, $bar → mapped by position from argumentNames</li>
|
||||||
|
* <li>Indexed access: $ARGUMENTS[0], $ARGUMENTS[1], …</li>
|
||||||
|
* <li>Shorthand indexed: $0, $1, …</li>
|
||||||
|
* <li>Full arguments: $ARGUMENTS → raw args string</li>
|
||||||
|
* <li>Auto-append: if no placeholder matched and args non-empty, append "ARGUMENTS: {args}"</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param content the content containing placeholders
|
||||||
|
* @param args the raw arguments string (null = no args, return unchanged)
|
||||||
|
* @param appendIfNoPlaceholder if true, appends "ARGUMENTS: {args}" when no placeholder found
|
||||||
|
* @param argumentNames named arguments from frontmatter
|
||||||
|
*/
|
||||||
|
public static String substituteArguments(String content, String args,
|
||||||
|
boolean appendIfNoPlaceholder,
|
||||||
|
List<String> argumentNames) {
|
||||||
|
if (content == null) return "";
|
||||||
|
// null means no args provided — return content unchanged
|
||||||
|
if (args == null) return content;
|
||||||
|
|
||||||
|
List<String> parsedArgs = parseArguments(args);
|
||||||
|
List<String> validNames = parseArgumentNames(argumentNames);
|
||||||
|
String original = content;
|
||||||
|
|
||||||
|
// 1. Replace named arguments: $foo, $bar (not followed by [ or word chars)
|
||||||
|
for (int i = 0; i < validNames.size(); i++) {
|
||||||
|
String name = validNames.get(i);
|
||||||
|
String value = i < parsedArgs.size() ? parsedArgs.get(i) : "";
|
||||||
|
// Match $name but not $name[…] or $nameXxx
|
||||||
|
content = content.replaceAll("\\$" + Pattern.quote(name) + "(?![\\[\\w])",
|
||||||
|
Matcher.quoteReplacement(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Replace indexed: $ARGUMENTS[0], $ARGUMENTS[1], …
|
||||||
|
content = INDEXED_PATTERN.matcher(content).replaceAll(mr -> {
|
||||||
|
int idx = Integer.parseInt(mr.group(1));
|
||||||
|
return Matcher.quoteReplacement(idx < parsedArgs.size() ? parsedArgs.get(idx) : "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Replace shorthand: $0, $1, …
|
||||||
|
content = SHORTHAND_PATTERN.matcher(content).replaceAll(mr -> {
|
||||||
|
int idx = Integer.parseInt(mr.group(1));
|
||||||
|
return Matcher.quoteReplacement(idx < parsedArgs.size() ? parsedArgs.get(idx) : "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Replace $ARGUMENTS with full raw args string
|
||||||
|
content = content.replace("$ARGUMENTS", args);
|
||||||
|
|
||||||
|
// 5. Auto-append if no placeholder found and args non-empty
|
||||||
|
if (content.equals(original) && appendIfNoPlaceholder && !args.isBlank()) {
|
||||||
|
content = content + "\n\nARGUMENTS: " + args;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overload with default appendIfNoPlaceholder=true, no named args.
|
||||||
|
*/
|
||||||
|
public static String substituteArguments(String content, String args) {
|
||||||
|
return substituteArguments(content, args, true, List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.claudecode.util;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型别名解析器 —— 对应 TS resolveSkillModelOverride()。
|
||||||
|
* <p>
|
||||||
|
* 解析技能 frontmatter 中的 model 字段,支持:
|
||||||
|
* <ul>
|
||||||
|
* <li>别名解析:haiku → claude-3-haiku, sonnet → claude-sonnet-4, opus → claude-opus-4</li>
|
||||||
|
* <li>特殊值:inherit → null(继承父级模型)</li>
|
||||||
|
* <li>完整模型 ID:直接透传</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ModelResolver {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ModelResolver.class);
|
||||||
|
|
||||||
|
/** Model alias mapping (short name → full model ID) */
|
||||||
|
private static final Map<String, String> MODEL_ALIASES = Map.ofEntries(
|
||||||
|
// Claude 3 family
|
||||||
|
Map.entry("haiku", "claude-3-haiku-20240307"),
|
||||||
|
Map.entry("claude-3-haiku", "claude-3-haiku-20240307"),
|
||||||
|
Map.entry("haiku-3", "claude-3-haiku-20240307"),
|
||||||
|
|
||||||
|
// Claude 3.5 family
|
||||||
|
Map.entry("sonnet-3.5", "claude-3-5-sonnet-20241022"),
|
||||||
|
Map.entry("claude-3.5-sonnet", "claude-3-5-sonnet-20241022"),
|
||||||
|
Map.entry("haiku-3.5", "claude-3-5-haiku-20241022"),
|
||||||
|
Map.entry("claude-3.5-haiku", "claude-3-5-haiku-20241022"),
|
||||||
|
|
||||||
|
// Claude 4 family (latest)
|
||||||
|
Map.entry("sonnet", "claude-sonnet-4-20250514"),
|
||||||
|
Map.entry("claude-sonnet", "claude-sonnet-4-20250514"),
|
||||||
|
Map.entry("sonnet-4", "claude-sonnet-4-20250514"),
|
||||||
|
Map.entry("claude-sonnet-4", "claude-sonnet-4-20250514"),
|
||||||
|
|
||||||
|
Map.entry("opus", "claude-opus-4-20250514"),
|
||||||
|
Map.entry("claude-opus", "claude-opus-4-20250514"),
|
||||||
|
Map.entry("opus-4", "claude-opus-4-20250514"),
|
||||||
|
Map.entry("claude-opus-4", "claude-opus-4-20250514"),
|
||||||
|
|
||||||
|
// OpenAI aliases (for OpenAI-compatible providers)
|
||||||
|
Map.entry("gpt-4", "gpt-4"),
|
||||||
|
Map.entry("gpt-4o", "gpt-4o"),
|
||||||
|
Map.entry("gpt-4o-mini", "gpt-4o-mini"),
|
||||||
|
Map.entry("o1", "o1"),
|
||||||
|
Map.entry("o1-mini", "o1-mini"),
|
||||||
|
Map.entry("o3", "o3"),
|
||||||
|
Map.entry("o3-mini", "o3-mini"),
|
||||||
|
Map.entry("o4-mini", "o4-mini")
|
||||||
|
);
|
||||||
|
|
||||||
|
private ModelResolver() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a model override from skill frontmatter.
|
||||||
|
* Corresponds to TS resolveSkillModelOverride().
|
||||||
|
*
|
||||||
|
* @param modelValue raw model value from frontmatter (may be alias, "inherit", or full ID)
|
||||||
|
* @return resolved model ID, or null if "inherit" or invalid
|
||||||
|
*/
|
||||||
|
public static String resolveSkillModelOverride(String modelValue) {
|
||||||
|
if (modelValue == null || modelValue.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = modelValue.strip().toLowerCase();
|
||||||
|
|
||||||
|
// "inherit" means use parent model
|
||||||
|
if ("inherit".equals(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check aliases
|
||||||
|
String resolved = MODEL_ALIASES.get(normalized);
|
||||||
|
if (resolved != null) {
|
||||||
|
log.debug("Resolved model alias '{}' → '{}'", modelValue, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it looks like a full model ID (contains a dash and has enough chars), pass through
|
||||||
|
if (modelValue.contains("-") || modelValue.contains("/") || modelValue.length() > 10) {
|
||||||
|
return modelValue.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown short name — log warning but still pass through
|
||||||
|
log.debug("Unknown model alias '{}', passing through as-is", modelValue);
|
||||||
|
return modelValue.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model value is a known alias.
|
||||||
|
*/
|
||||||
|
public static boolean isKnownAlias(String modelValue) {
|
||||||
|
if (modelValue == null) return false;
|
||||||
|
return MODEL_ALIASES.containsKey(modelValue.strip().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known model aliases.
|
||||||
|
*/
|
||||||
|
public static Map<String, String> getAllAliases() {
|
||||||
|
return MODEL_ALIASES;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.claudecode.util;
|
||||||
|
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes embedded shell commands in skill/command markdown content.
|
||||||
|
* Aligns with claude-code/src/utils/promptShellExecution.ts.
|
||||||
|
* <p>
|
||||||
|
* Supported syntaxes:
|
||||||
|
* <ul>
|
||||||
|
* <li>Code blocks: ```! command ```</li>
|
||||||
|
* <li>Inline: !`command`</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Commands are executed via the system shell (bash or powershell as specified
|
||||||
|
* in frontmatter). Output replaces the command placeholder in the content.
|
||||||
|
*/
|
||||||
|
public final class PromptShellExecution {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PromptShellExecution.class);
|
||||||
|
|
||||||
|
private PromptShellExecution() {}
|
||||||
|
|
||||||
|
/** Pattern for code blocks: ```! command ``` */
|
||||||
|
private static final Pattern BLOCK_PATTERN = Pattern.compile("```!\\s*\\n?([\\s\\S]*?)\\n?```");
|
||||||
|
|
||||||
|
/** Pattern for inline: !`command` (preceded by whitespace or start-of-line) */
|
||||||
|
private static final Pattern INLINE_PATTERN = Pattern.compile("(?<=^|\\s)!`([^`]+)`", Pattern.MULTILINE);
|
||||||
|
|
||||||
|
/** Default command timeout in seconds */
|
||||||
|
private static final int DEFAULT_TIMEOUT = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and execute embedded shell commands in the given text.
|
||||||
|
* Replaces each command placeholder with its output.
|
||||||
|
*
|
||||||
|
* @param text the skill/command markdown content
|
||||||
|
* @param shell "bash" or "powershell" (null defaults to bash)
|
||||||
|
* @param workDir working directory for command execution
|
||||||
|
* @return content with command outputs substituted
|
||||||
|
*/
|
||||||
|
public static String executeShellCommandsInPrompt(String text, String shell, Path workDir) {
|
||||||
|
if (text == null || text.isEmpty()) return text;
|
||||||
|
|
||||||
|
// Collect all matches (block + inline)
|
||||||
|
List<CommandMatch> matches = new ArrayList<>();
|
||||||
|
|
||||||
|
Matcher blockMatcher = BLOCK_PATTERN.matcher(text);
|
||||||
|
while (blockMatcher.find()) {
|
||||||
|
String command = blockMatcher.group(1);
|
||||||
|
if (command != null && !command.isBlank()) {
|
||||||
|
matches.add(new CommandMatch(blockMatcher.start(), blockMatcher.end(),
|
||||||
|
blockMatcher.group(0), command.strip()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only scan for inline pattern if text contains !` (optimization from TS)
|
||||||
|
if (text.contains("!`")) {
|
||||||
|
Matcher inlineMatcher = INLINE_PATTERN.matcher(text);
|
||||||
|
while (inlineMatcher.find()) {
|
||||||
|
String command = inlineMatcher.group(1);
|
||||||
|
if (command != null && !command.isBlank()) {
|
||||||
|
matches.add(new CommandMatch(inlineMatcher.start(), inlineMatcher.end(),
|
||||||
|
inlineMatcher.group(0), command.strip()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.isEmpty()) return text;
|
||||||
|
|
||||||
|
// Execute commands and replace (reverse order to preserve offsets)
|
||||||
|
matches.sort((a, b) -> Integer.compare(b.start, a.start));
|
||||||
|
|
||||||
|
String result = text;
|
||||||
|
for (CommandMatch match : matches) {
|
||||||
|
try {
|
||||||
|
String output = executeShellCommand(match.command, shell, workDir);
|
||||||
|
result = result.replace(match.fullMatch, output);
|
||||||
|
log.debug("Shell command executed in skill: {} → {} chars output",
|
||||||
|
match.command.substring(0, Math.min(50, match.command.length())), output.length());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Shell command failed in skill content: {}: {}", match.command, e.getMessage());
|
||||||
|
result = result.replace(match.fullMatch,
|
||||||
|
"[Error executing command: " + e.getMessage() + "]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single shell command and return its stdout.
|
||||||
|
*/
|
||||||
|
private static String executeShellCommand(String command, String shell, Path workDir) throws IOException {
|
||||||
|
List<String> cmd;
|
||||||
|
boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win");
|
||||||
|
|
||||||
|
if ("powershell".equalsIgnoreCase(shell)) {
|
||||||
|
if (isWindows) {
|
||||||
|
cmd = List.of("powershell", "-NoProfile", "-Command", command);
|
||||||
|
} else {
|
||||||
|
cmd = List.of("pwsh", "-NoProfile", "-Command", command);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: bash
|
||||||
|
if (isWindows) {
|
||||||
|
// Try bash (Git Bash / WSL), fallback to cmd
|
||||||
|
cmd = List.of("bash", "-c", command);
|
||||||
|
} else {
|
||||||
|
cmd = List.of("bash", "-c", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
if (workDir != null) {
|
||||||
|
pb.directory(workDir.toFile());
|
||||||
|
}
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Process process = pb.start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes());
|
||||||
|
boolean finished = process.waitFor(DEFAULT_TIMEOUT, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new IOException("Command timed out after " + DEFAULT_TIMEOUT + "s: " + command);
|
||||||
|
}
|
||||||
|
int exitCode = process.exitValue();
|
||||||
|
if (exitCode != 0) {
|
||||||
|
log.debug("Shell command exited with code {}: {}", exitCode, command);
|
||||||
|
}
|
||||||
|
return output.strip();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Command interrupted: " + command, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CommandMatch(int start, int end, String fullMatch, String command) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.claudecode;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制台模式启动入口 —— 跳过 Jink TUI,使用纯文本 Scanner 交互。
|
||||||
|
* <p>
|
||||||
|
* 适用于:
|
||||||
|
* <ul>
|
||||||
|
* <li>IntelliJ IDEA / Eclipse 等 IDE 内置终端(dumb 模式)</li>
|
||||||
|
* <li>不支持全屏渲染的终端环境</li>
|
||||||
|
* <li>调试和开发时的快速启动</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 在 IDE 中直接右键 Run 即可使用。
|
||||||
|
* <p>
|
||||||
|
* 运行前需设置环境变量:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code AI_API_KEY} — API 密钥(必须)</li>
|
||||||
|
* <li>{@code CLAUDE_CODE_PROVIDER} — openai 或 anthropic(可选,默认 openai)</li>
|
||||||
|
* <li>{@code AI_BASE_URL} — API 地址(可选)</li>
|
||||||
|
* <li>{@code AI_MODEL} — 模型名称(可选)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ConsoleMain {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 强制使用 legacy 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