实现内容: - pom.xml: JDK25 + Spring AI 2.0.0-M4 + JLine3 + Picocli - core/AgentLoop: 基于ChatModel的显式工具循环(非ChatClient) - tool/: Tool接口 + ToolRegistry + ToolCallbackAdapter(适配Spring AI) - tool/impl/: BashTool, FileReadTool, FileWriteTool, FileEditTool, GlobTool, GrepTool - command/: SlashCommand接口 + CommandRegistry + /help, /clear, /exit - console/: AnsiStyle, BannerPrinter, ToolStatusRenderer, ThinkingRenderer, SpinnerAnimation, MarkdownRenderer - context/: SystemPromptBuilder, ClaudeMdLoader(多级CLAUDE.md加载) - repl/ReplSession: REPL主循环(Scanner降级方案) - config/AppConfig: Spring Bean装配 - application.yml: Anthropic/OpenAI双模型配置 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
2a565d314d
commit
d67f41358d
@ -0,0 +1,18 @@ |
|||||||
|
# Maven |
||||||
|
target/ |
||||||
|
*.class |
||||||
|
|
||||||
|
# IDE |
||||||
|
.idea/ |
||||||
|
*.iml |
||||||
|
.vscode/ |
||||||
|
.settings/ |
||||||
|
.project |
||||||
|
.classpath |
||||||
|
|
||||||
|
# OS |
||||||
|
.DS_Store |
||||||
|
Thumbs.db |
||||||
|
|
||||||
|
# Logs |
||||||
|
*.log |
||||||
@ -0,0 +1,116 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" |
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
||||||
|
<modelVersion>4.0.0</modelVersion> |
||||||
|
|
||||||
|
<parent> |
||||||
|
<groupId>org.springframework.boot</groupId> |
||||||
|
<artifactId>spring-boot-starter-parent</artifactId> |
||||||
|
<version>3.5.3</version> |
||||||
|
<relativePath/> |
||||||
|
</parent> |
||||||
|
|
||||||
|
<groupId>com.claudecode</groupId> |
||||||
|
<artifactId>claude-code-java</artifactId> |
||||||
|
<version>0.1.0-SNAPSHOT</version> |
||||||
|
<name>claude-code-java</name> |
||||||
|
<description>Claude Code 的 Java Spring AI 重写实现</description> |
||||||
|
<packaging>jar</packaging> |
||||||
|
|
||||||
|
<properties> |
||||||
|
<java.version>25</java.version> |
||||||
|
<spring-ai.version>2.0.0-M4</spring-ai.version> |
||||||
|
<jline.version>3.28.0</jline.version> |
||||||
|
<picocli.version>4.7.6</picocli.version> |
||||||
|
</properties> |
||||||
|
|
||||||
|
<dependencyManagement> |
||||||
|
<dependencies> |
||||||
|
<dependency> |
||||||
|
<groupId>org.springframework.ai</groupId> |
||||||
|
<artifactId>spring-ai-bom</artifactId> |
||||||
|
<version>${spring-ai.version}</version> |
||||||
|
<type>pom</type> |
||||||
|
<scope>import</scope> |
||||||
|
</dependency> |
||||||
|
</dependencies> |
||||||
|
</dependencyManagement> |
||||||
|
|
||||||
|
<dependencies> |
||||||
|
<!-- Spring Boot Web(用于 HTTP 功能) --> |
||||||
|
<dependency> |
||||||
|
<groupId>org.springframework.boot</groupId> |
||||||
|
<artifactId>spring-boot-starter-web</artifactId> |
||||||
|
</dependency> |
||||||
|
|
||||||
|
<!-- Spring AI Anthropic(Claude 模型调用) --> |
||||||
|
<dependency> |
||||||
|
<groupId>org.springframework.ai</groupId> |
||||||
|
<artifactId>spring-ai-starter-model-anthropic</artifactId> |
||||||
|
</dependency> |
||||||
|
|
||||||
|
<!-- Spring AI OpenAI(兼容 OpenAI 格式的 API) --> |
||||||
|
<dependency> |
||||||
|
<groupId>org.springframework.ai</groupId> |
||||||
|
<artifactId>spring-ai-starter-model-openai</artifactId> |
||||||
|
</dependency> |
||||||
|
|
||||||
|
<!-- JLine 3: 终端交互(行编辑、历史、Tab补全、ANSI样式) --> |
||||||
|
<dependency> |
||||||
|
<groupId>org.jline</groupId> |
||||||
|
<artifactId>jline</artifactId> |
||||||
|
<version>${jline.version}</version> |
||||||
|
</dependency> |
||||||
|
|
||||||
|
<!-- Picocli: CLI 命令解析 --> |
||||||
|
<dependency> |
||||||
|
<groupId>info.picocli</groupId> |
||||||
|
<artifactId>picocli</artifactId> |
||||||
|
<version>${picocli.version}</version> |
||||||
|
</dependency> |
||||||
|
|
||||||
|
<!-- Jackson(JSON 处理) --> |
||||||
|
<dependency> |
||||||
|
<groupId>com.fasterxml.jackson.core</groupId> |
||||||
|
<artifactId>jackson-databind</artifactId> |
||||||
|
</dependency> |
||||||
|
|
||||||
|
<!-- Test --> |
||||||
|
<dependency> |
||||||
|
<groupId>org.springframework.boot</groupId> |
||||||
|
<artifactId>spring-boot-starter-test</artifactId> |
||||||
|
<scope>test</scope> |
||||||
|
</dependency> |
||||||
|
</dependencies> |
||||||
|
|
||||||
|
<build> |
||||||
|
<plugins> |
||||||
|
<plugin> |
||||||
|
<groupId>org.springframework.boot</groupId> |
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId> |
||||||
|
</plugin> |
||||||
|
<plugin> |
||||||
|
<groupId>org.apache.maven.plugins</groupId> |
||||||
|
<artifactId>maven-compiler-plugin</artifactId> |
||||||
|
<configuration> |
||||||
|
<release>${java.version}</release> |
||||||
|
<compilerArgs> |
||||||
|
<arg>--enable-preview</arg> |
||||||
|
</compilerArgs> |
||||||
|
</configuration> |
||||||
|
</plugin> |
||||||
|
</plugins> |
||||||
|
</build> |
||||||
|
|
||||||
|
<repositories> |
||||||
|
<repository> |
||||||
|
<id>spring-milestones</id> |
||||||
|
<name>Spring Milestones</name> |
||||||
|
<url>https://repo.spring.io/milestone</url> |
||||||
|
<snapshots> |
||||||
|
<enabled>false</enabled> |
||||||
|
</snapshots> |
||||||
|
</repository> |
||||||
|
</repositories> |
||||||
|
</project> |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
package com.claudecode; |
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication; |
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication; |
||||||
|
|
||||||
|
/** |
||||||
|
* Claude Code Java 版主入口。 |
||||||
|
* <p> |
||||||
|
* 对应 claude-code/src/entrypoints/cli.tsx |
||||||
|
* 以 Spring Boot 应用启动,但关闭 Web 服务器(纯 CLI 模式)。 |
||||||
|
*/ |
||||||
|
@SpringBootApplication |
||||||
|
public class ClaudeCodeApplication { |
||||||
|
|
||||||
|
public static void main(String[] args) { |
||||||
|
SpringApplication.run(ClaudeCodeApplication.class, args); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
package com.claudecode.cli; |
||||||
|
|
||||||
|
import com.claudecode.repl.ReplSession; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
import org.springframework.boot.CommandLineRunner; |
||||||
|
import org.springframework.stereotype.Component; |
||||||
|
|
||||||
|
/** |
||||||
|
* 启动编排器 —— 对应 claude-code/src/main.tsx 的初始化逻辑。 |
||||||
|
* <p> |
||||||
|
* 在 Spring Boot 启动完成后执行,初始化并启动 REPL 会话。 |
||||||
|
*/ |
||||||
|
@Component |
||||||
|
public class ClaudeCodeRunner implements CommandLineRunner { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClaudeCodeRunner.class); |
||||||
|
|
||||||
|
private final ReplSession replSession; |
||||||
|
|
||||||
|
public ClaudeCodeRunner(ReplSession replSession) { |
||||||
|
this.replSession = replSession; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void run(String... args) { |
||||||
|
log.info("Claude Code (Java) 启动中..."); |
||||||
|
replSession.start(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
package com.claudecode.command; |
||||||
|
|
||||||
|
import com.claudecode.core.AgentLoop; |
||||||
|
import com.claudecode.tool.ToolRegistry; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* 命令执行上下文。 |
||||||
|
*/ |
||||||
|
public record CommandContext( |
||||||
|
AgentLoop agentLoop, |
||||||
|
ToolRegistry toolRegistry, |
||||||
|
PrintStream out, |
||||||
|
Runnable exitCallback |
||||||
|
) { |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
package com.claudecode.command; |
||||||
|
|
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import java.util.*; |
||||||
|
|
||||||
|
/** |
||||||
|
* 命令注册中心 —— 对应 claude-code/src/commands.ts 中的命令集合管理。 |
||||||
|
*/ |
||||||
|
public class CommandRegistry { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CommandRegistry.class); |
||||||
|
|
||||||
|
private final Map<String, SlashCommand> commands = new LinkedHashMap<>(); |
||||||
|
|
||||||
|
/** 注册命令(包括别名) */ |
||||||
|
public void register(SlashCommand command) { |
||||||
|
commands.put(command.name().toLowerCase(), command); |
||||||
|
for (String alias : command.aliases()) { |
||||||
|
commands.put(alias.toLowerCase(), command); |
||||||
|
} |
||||||
|
log.debug("注册命令: /{}", command.name()); |
||||||
|
} |
||||||
|
|
||||||
|
/** 批量注册 */ |
||||||
|
public void registerAll(SlashCommand... cmds) { |
||||||
|
for (SlashCommand cmd : cmds) { |
||||||
|
register(cmd); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 解析并执行命令 */ |
||||||
|
public Optional<String> dispatch(String input, CommandContext context) { |
||||||
|
if (!input.startsWith("/")) { |
||||||
|
return Optional.empty(); |
||||||
|
} |
||||||
|
|
||||||
|
String stripped = input.substring(1).strip(); |
||||||
|
String[] parts = stripped.split("\\s+", 2); |
||||||
|
String cmdName = parts[0].toLowerCase(); |
||||||
|
String args = parts.length > 1 ? parts[1] : ""; |
||||||
|
|
||||||
|
SlashCommand cmd = commands.get(cmdName); |
||||||
|
if (cmd == null) { |
||||||
|
return Optional.of("Unknown command: /" + cmdName + ". Type /help for available commands."); |
||||||
|
} |
||||||
|
|
||||||
|
return Optional.of(cmd.execute(args, context)); |
||||||
|
} |
||||||
|
|
||||||
|
/** 判断输入是否是斜杠命令 */ |
||||||
|
public boolean isCommand(String input) { |
||||||
|
return input != null && input.startsWith("/"); |
||||||
|
} |
||||||
|
|
||||||
|
/** 获取所有唯一命令(用于 /help) */ |
||||||
|
public List<SlashCommand> getCommands() { |
||||||
|
return commands.values().stream().distinct().toList(); |
||||||
|
} |
||||||
|
|
||||||
|
/** 获取命令名称(用于 Tab 补全) */ |
||||||
|
public Set<String> getCommandNames() { |
||||||
|
return Set.copyOf(commands.keySet()); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
package com.claudecode.command; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* 斜杠命令接口 —— 对应 claude-code/src/commands.ts 中的 Command 类型。 |
||||||
|
* <p> |
||||||
|
* 用于处理以 / 开头的用户输入命令。 |
||||||
|
*/ |
||||||
|
public interface SlashCommand { |
||||||
|
|
||||||
|
/** 命令名称(不含 / 前缀) */ |
||||||
|
String name(); |
||||||
|
|
||||||
|
/** 命令描述 */ |
||||||
|
String description(); |
||||||
|
|
||||||
|
/** 命令别名列表 */ |
||||||
|
default List<String> aliases() { |
||||||
|
return List.of(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 执行命令。 |
||||||
|
* |
||||||
|
* @param args 命令参数(/ 后的文本去掉命令名后的部分) |
||||||
|
* @param context 命令执行上下文 |
||||||
|
* @return 命令输出文本 |
||||||
|
*/ |
||||||
|
String execute(String args, CommandContext context); |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
import com.claudecode.console.AnsiStyle; |
||||||
|
|
||||||
|
/** |
||||||
|
* /clear 命令 —— 清除对话历史。 |
||||||
|
*/ |
||||||
|
public class ClearCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "clear"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return "Clear conversation history"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
if (context.agentLoop() != null) { |
||||||
|
context.agentLoop().reset(); |
||||||
|
} |
||||||
|
return AnsiStyle.green(" ✓ Conversation history cleared."); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* /exit 命令 —— 退出应用。 |
||||||
|
*/ |
||||||
|
public class ExitCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "exit"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return "Exit the application"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public List<String> aliases() { |
||||||
|
return List.of("quit", "q"); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
if (context.exitCallback() != null) { |
||||||
|
context.exitCallback().run(); |
||||||
|
} |
||||||
|
return "Goodbye!"; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
import com.claudecode.console.AnsiStyle; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* /help 命令 —— 对应 claude-code/src/commands/help.ts。 |
||||||
|
*/ |
||||||
|
public class HelpCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "help"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return "Show available commands"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public List<String> aliases() { |
||||||
|
return List.of("?"); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append(AnsiStyle.bold("\n Available Commands:\n\n")); |
||||||
|
|
||||||
|
for (SlashCommand cmd : context.toolRegistry() != null |
||||||
|
? List.<SlashCommand>of() // 这里后续会获取注册的命令
|
||||||
|
: List.<SlashCommand>of()) { |
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", |
||||||
|
AnsiStyle.CYAN, "/" + cmd.name(), AnsiStyle.RESET, cmd.description())); |
||||||
|
} |
||||||
|
|
||||||
|
// 硬编码展示(后续重构为动态)
|
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/help", AnsiStyle.RESET, "Show available commands")); |
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/clear", AnsiStyle.RESET, "Clear conversation history")); |
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/compact", AnsiStyle.RESET, "Compact conversation context")); |
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/cost", AnsiStyle.RESET, "Show token usage and cost")); |
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/model", AnsiStyle.RESET, "Show or switch AI model")); |
||||||
|
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/exit", AnsiStyle.RESET, "Exit the application")); |
||||||
|
|
||||||
|
sb.append("\n"); |
||||||
|
sb.append(AnsiStyle.dim(" Tips: Press Tab for command completion, Ctrl+D to exit\n")); |
||||||
|
|
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
package com.claudecode.config; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandRegistry; |
||||||
|
import com.claudecode.command.impl.ClearCommand; |
||||||
|
import com.claudecode.command.impl.ExitCommand; |
||||||
|
import com.claudecode.command.impl.HelpCommand; |
||||||
|
import com.claudecode.context.ClaudeMdLoader; |
||||||
|
import com.claudecode.context.SystemPromptBuilder; |
||||||
|
import com.claudecode.core.AgentLoop; |
||||||
|
import com.claudecode.repl.ReplSession; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
import com.claudecode.tool.ToolRegistry; |
||||||
|
import com.claudecode.tool.impl.*; |
||||||
|
import org.springframework.ai.chat.model.ChatModel; |
||||||
|
import org.springframework.context.annotation.Bean; |
||||||
|
import org.springframework.context.annotation.Configuration; |
||||||
|
|
||||||
|
import java.nio.file.Path; |
||||||
|
|
||||||
|
/** |
||||||
|
* 应用配置类 —— Spring Bean 装配。 |
||||||
|
* <p> |
||||||
|
* 集中管理所有组件的创建和依赖注入。 |
||||||
|
*/ |
||||||
|
@Configuration |
||||||
|
public class AppConfig { |
||||||
|
|
||||||
|
@Bean |
||||||
|
public ToolContext toolContext() { |
||||||
|
return ToolContext.defaultContext(); |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
public ToolRegistry toolRegistry() { |
||||||
|
ToolRegistry registry = new ToolRegistry(); |
||||||
|
registry.registerAll( |
||||||
|
new BashTool(), |
||||||
|
new FileReadTool(), |
||||||
|
new FileWriteTool(), |
||||||
|
new FileEditTool(), |
||||||
|
new GlobTool(), |
||||||
|
new GrepTool() |
||||||
|
); |
||||||
|
return registry; |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
public CommandRegistry commandRegistry() { |
||||||
|
CommandRegistry registry = new CommandRegistry(); |
||||||
|
registry.registerAll( |
||||||
|
new HelpCommand(), |
||||||
|
new ClearCommand(), |
||||||
|
new ExitCommand() |
||||||
|
); |
||||||
|
return registry; |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
public String systemPrompt() { |
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir")); |
||||||
|
ClaudeMdLoader loader = new ClaudeMdLoader(projectDir); |
||||||
|
String claudeMd = loader.load(); |
||||||
|
|
||||||
|
return new SystemPromptBuilder() |
||||||
|
.claudeMd(claudeMd) |
||||||
|
.build(); |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
public AgentLoop agentLoop(ChatModel chatModel, ToolRegistry toolRegistry, |
||||||
|
ToolContext toolContext, String systemPrompt) { |
||||||
|
return new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt); |
||||||
|
} |
||||||
|
|
||||||
|
@Bean |
||||||
|
public ReplSession replSession(AgentLoop agentLoop, ToolRegistry toolRegistry, |
||||||
|
CommandRegistry commandRegistry) { |
||||||
|
return new ReplSession(agentLoop, toolRegistry, commandRegistry); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,108 @@ |
|||||||
|
package com.claudecode.console; |
||||||
|
|
||||||
|
/** |
||||||
|
* ANSI 终端样式工具类 —— 对应 claude-code/src/utils/terminal.ts 的颜色输出功能。 |
||||||
|
* <p> |
||||||
|
* 提供终端颜色和样式的便捷方法。 |
||||||
|
*/ |
||||||
|
public final class AnsiStyle { |
||||||
|
|
||||||
|
private AnsiStyle() {} |
||||||
|
|
||||||
|
// 重置
|
||||||
|
public static final String RESET = "\033[0m"; |
||||||
|
|
||||||
|
// 基本样式
|
||||||
|
public static final String BOLD = "\033[1m"; |
||||||
|
public static final String DIM = "\033[2m"; |
||||||
|
public static final String ITALIC = "\033[3m"; |
||||||
|
public static final String UNDERLINE = "\033[4m"; |
||||||
|
|
||||||
|
// 前景色
|
||||||
|
public static final String BLACK = "\033[30m"; |
||||||
|
public static final String RED = "\033[31m"; |
||||||
|
public static final String GREEN = "\033[32m"; |
||||||
|
public static final String YELLOW = "\033[33m"; |
||||||
|
public static final String BLUE = "\033[34m"; |
||||||
|
public static final String MAGENTA = "\033[35m"; |
||||||
|
public static final String CYAN = "\033[36m"; |
||||||
|
public static final String WHITE = "\033[37m"; |
||||||
|
|
||||||
|
// 亮色前景
|
||||||
|
public static final String BRIGHT_BLACK = "\033[90m"; |
||||||
|
public static final String BRIGHT_RED = "\033[91m"; |
||||||
|
public static final String BRIGHT_GREEN = "\033[92m"; |
||||||
|
public static final String BRIGHT_YELLOW = "\033[93m"; |
||||||
|
public static final String BRIGHT_BLUE = "\033[94m"; |
||||||
|
public static final String BRIGHT_MAGENTA = "\033[95m"; |
||||||
|
public static final String BRIGHT_CYAN = "\033[96m"; |
||||||
|
public static final String BRIGHT_WHITE = "\033[97m"; |
||||||
|
|
||||||
|
// 背景色
|
||||||
|
public static final String BG_RED = "\033[41m"; |
||||||
|
public static final String BG_GREEN = "\033[42m"; |
||||||
|
public static final String BG_YELLOW = "\033[43m"; |
||||||
|
public static final String BG_BLUE = "\033[44m"; |
||||||
|
|
||||||
|
// ---- 便捷方法 ----
|
||||||
|
|
||||||
|
public static String bold(String text) { |
||||||
|
return BOLD + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String dim(String text) { |
||||||
|
return DIM + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String italic(String text) { |
||||||
|
return ITALIC + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String red(String text) { |
||||||
|
return RED + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String green(String text) { |
||||||
|
return GREEN + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String yellow(String text) { |
||||||
|
return YELLOW + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String blue(String text) { |
||||||
|
return BLUE + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String cyan(String text) { |
||||||
|
return CYAN + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String magenta(String text) { |
||||||
|
return MAGENTA + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
public static String brightBlack(String text) { |
||||||
|
return BRIGHT_BLACK + text + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
/** 带颜色的前缀标签 */ |
||||||
|
public static String tag(String label, String color) { |
||||||
|
return color + BOLD + "[" + label + "]" + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
/** 带背景色的标签 */ |
||||||
|
public static String badge(String label, String bgColor) { |
||||||
|
return bgColor + WHITE + BOLD + " " + label + " " + RESET; |
||||||
|
} |
||||||
|
|
||||||
|
/** 清除当前行 */ |
||||||
|
public static String clearLine() { |
||||||
|
return "\033[2K\r"; |
||||||
|
} |
||||||
|
|
||||||
|
/** 光标上移 n 行 */ |
||||||
|
public static String cursorUp(int n) { |
||||||
|
return "\033[" + n + "A"; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
package com.claudecode.console; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* Banner 打印器 —— 对应 claude-code/src/components/Banner.tsx。 |
||||||
|
* <p> |
||||||
|
* 在启动时打印 ASCII Art Logo 和版本信息。 |
||||||
|
*/ |
||||||
|
public class BannerPrinter { |
||||||
|
|
||||||
|
private static final String VERSION = "0.1.0-SNAPSHOT"; |
||||||
|
|
||||||
|
/** |
||||||
|
* 打印 claude-code-java 启动 banner。 |
||||||
|
*/ |
||||||
|
public static void print(PrintStream out) { |
||||||
|
String banner = """ |
||||||
|
%s |
||||||
|
██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗ |
||||||
|
██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝ |
||||||
|
██║ ██║ ███████║██║ ██║██║ ██║█████╗ |
||||||
|
██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝ |
||||||
|
╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗ |
||||||
|
╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ |
||||||
|
%s ██████╗ ██████╗ ██████╗ ███████╗ |
||||||
|
██╔════╝██╔═══██╗██╔══██╗██╔════╝ |
||||||
|
██║ ██║ ██║██║ ██║█████╗ |
||||||
|
██║ ██║ ██║██║ ██║██╔══╝ |
||||||
|
╚██████╗╚██████╔╝██████╔╝███████╗ |
||||||
|
╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ |
||||||
|
%s |
||||||
|
""".formatted(AnsiStyle.BRIGHT_CYAN, AnsiStyle.BRIGHT_MAGENTA, AnsiStyle.RESET); |
||||||
|
|
||||||
|
out.println(banner); |
||||||
|
out.println(AnsiStyle.bold(" Claude Code (Java)") + AnsiStyle.dim(" v" + VERSION)); |
||||||
|
out.println(AnsiStyle.dim(" Powered by Spring AI • Type /help for commands")); |
||||||
|
out.println(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 精简版 banner(用于窄终端)。 |
||||||
|
*/ |
||||||
|
public static void printCompact(PrintStream out) { |
||||||
|
out.println(); |
||||||
|
out.println(AnsiStyle.BRIGHT_CYAN + AnsiStyle.BOLD + " ◆ Claude Code (Java)" + AnsiStyle.RESET |
||||||
|
+ AnsiStyle.dim(" v" + VERSION)); |
||||||
|
out.println(AnsiStyle.dim(" Type /help for commands • Ctrl+D to exit")); |
||||||
|
out.println(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,79 @@ |
|||||||
|
package com.claudecode.console; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* Markdown 简易渲染器 —— 对应 claude-code/src/renderers/markdown.ts。 |
||||||
|
* <p> |
||||||
|
* 将 AI 回复中的 Markdown 格式转换为终端 ANSI 样式输出。 |
||||||
|
* 这是一个简化版,支持常见格式。 |
||||||
|
*/ |
||||||
|
public class MarkdownRenderer { |
||||||
|
|
||||||
|
private final PrintStream out; |
||||||
|
|
||||||
|
public MarkdownRenderer(PrintStream out) { |
||||||
|
this.out = out; |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染 Markdown 文本 */ |
||||||
|
public void render(String markdown) { |
||||||
|
if (markdown == null || markdown.isBlank()) return; |
||||||
|
|
||||||
|
boolean inCodeBlock = false; |
||||||
|
String codeBlockLang = ""; |
||||||
|
|
||||||
|
for (String line : markdown.lines().toList()) { |
||||||
|
// 代码块
|
||||||
|
if (line.stripLeading().startsWith("```")) { |
||||||
|
if (!inCodeBlock) { |
||||||
|
codeBlockLang = line.stripLeading().substring(3).strip(); |
||||||
|
inCodeBlock = true; |
||||||
|
out.println(AnsiStyle.dim(" ┌─" + (codeBlockLang.isEmpty() ? "code" : codeBlockLang) + "─")); |
||||||
|
continue; |
||||||
|
} else { |
||||||
|
inCodeBlock = false; |
||||||
|
out.println(AnsiStyle.dim(" └─────")); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (inCodeBlock) { |
||||||
|
out.println(AnsiStyle.BRIGHT_GREEN + " │ " + line + AnsiStyle.RESET); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// 标题
|
||||||
|
if (line.startsWith("### ")) { |
||||||
|
out.println(AnsiStyle.bold(AnsiStyle.CYAN + " " + line.substring(4)) + AnsiStyle.RESET); |
||||||
|
} else if (line.startsWith("## ")) { |
||||||
|
out.println(AnsiStyle.bold(AnsiStyle.BLUE + " " + line.substring(3)) + AnsiStyle.RESET); |
||||||
|
} else if (line.startsWith("# ")) { |
||||||
|
out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET); |
||||||
|
} |
||||||
|
// 列表项
|
||||||
|
else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) { |
||||||
|
out.println(" " + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2))); |
||||||
|
} |
||||||
|
// 分隔线
|
||||||
|
else if (line.strip().matches("^-{3,}$") || line.strip().matches("^\\*{3,}$")) { |
||||||
|
out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); |
||||||
|
} |
||||||
|
// 普通文本
|
||||||
|
else { |
||||||
|
out.println(" " + renderInline(line)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 行内格式渲染 */ |
||||||
|
private String renderInline(String text) { |
||||||
|
// 粗体 **text**
|
||||||
|
text = text.replaceAll("\\*\\*(.+?)\\*\\*", AnsiStyle.BOLD + "$1" + AnsiStyle.RESET); |
||||||
|
// 行内代码 `text`
|
||||||
|
text = text.replaceAll("`(.+?)`", AnsiStyle.BRIGHT_GREEN + "$1" + AnsiStyle.RESET); |
||||||
|
// 斜体 *text*
|
||||||
|
text = text.replaceAll("\\*(.+?)\\*", AnsiStyle.ITALIC + "$1" + AnsiStyle.RESET); |
||||||
|
return text; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
package com.claudecode.console; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* 加载动画(Spinner)—— 对应 claude-code/src/components/Spinner.tsx。 |
||||||
|
* <p> |
||||||
|
* 在等待 AI 响应时显示旋转动画。 |
||||||
|
*/ |
||||||
|
public class SpinnerAnimation { |
||||||
|
|
||||||
|
private static final String[] FRAMES = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}; |
||||||
|
private static final int INTERVAL_MS = 80; |
||||||
|
|
||||||
|
private final PrintStream out; |
||||||
|
private volatile boolean running; |
||||||
|
private Thread thread; |
||||||
|
private String message = "Thinking"; |
||||||
|
|
||||||
|
public SpinnerAnimation(PrintStream out) { |
||||||
|
this.out = out; |
||||||
|
} |
||||||
|
|
||||||
|
/** 启动 spinner */ |
||||||
|
public void start(String message) { |
||||||
|
if (running) return; |
||||||
|
this.message = message; |
||||||
|
this.running = true; |
||||||
|
|
||||||
|
thread = Thread.ofVirtual().name("spinner").start(() -> { |
||||||
|
int idx = 0; |
||||||
|
while (running) { |
||||||
|
out.print(AnsiStyle.clearLine()); |
||||||
|
out.print(AnsiStyle.CYAN + " " + FRAMES[idx % FRAMES.length] |
||||||
|
+ " " + AnsiStyle.RESET + AnsiStyle.dim(this.message)); |
||||||
|
out.flush(); |
||||||
|
idx++; |
||||||
|
try { |
||||||
|
Thread.sleep(INTERVAL_MS); |
||||||
|
} catch (InterruptedException e) { |
||||||
|
Thread.currentThread().interrupt(); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
// 清除 spinner 行
|
||||||
|
out.print(AnsiStyle.clearLine()); |
||||||
|
out.flush(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** 停止 spinner */ |
||||||
|
public void stop() { |
||||||
|
running = false; |
||||||
|
if (thread != null) { |
||||||
|
thread.interrupt(); |
||||||
|
try { |
||||||
|
thread.join(200); |
||||||
|
} catch (InterruptedException e) { |
||||||
|
Thread.currentThread().interrupt(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 更新消息 */ |
||||||
|
public void updateMessage(String newMessage) { |
||||||
|
this.message = newMessage; |
||||||
|
} |
||||||
|
|
||||||
|
public boolean isRunning() { |
||||||
|
return running; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
package com.claudecode.console; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* Thinking 内容渲染器 —— 对应 claude-code/src/components/Thinking.tsx。 |
||||||
|
* <p> |
||||||
|
* 显示 AI 模型的思考过程(extended thinking)。 |
||||||
|
*/ |
||||||
|
public class ThinkingRenderer { |
||||||
|
|
||||||
|
private final PrintStream out; |
||||||
|
|
||||||
|
public ThinkingRenderer(PrintStream out) { |
||||||
|
this.out = out; |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染 thinking 内容块 */ |
||||||
|
public void render(String thinkingContent) { |
||||||
|
if (thinkingContent == null || thinkingContent.isBlank()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
out.println(); |
||||||
|
out.println(AnsiStyle.DIM + AnsiStyle.ITALIC + " 💭 Thinking..." + AnsiStyle.RESET); |
||||||
|
|
||||||
|
// 显示 thinking 内容(缩进并用暗色)
|
||||||
|
for (String line : thinkingContent.lines().toList()) { |
||||||
|
out.println(AnsiStyle.DIM + " │ " + line + AnsiStyle.RESET); |
||||||
|
} |
||||||
|
out.println(); |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染 thinking 开始标记 */ |
||||||
|
public void renderStart() { |
||||||
|
out.print(AnsiStyle.DIM + AnsiStyle.ITALIC + " 💭 Thinking..." + AnsiStyle.RESET); |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染 thinking 结束标记 */ |
||||||
|
public void renderEnd() { |
||||||
|
out.println(AnsiStyle.clearLine()); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
package com.claudecode.console; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* 工具调用状态渲染器 —— 对应 claude-code/src/components/ToolStatus.tsx。 |
||||||
|
* <p> |
||||||
|
* 在终端中显示工具调用的进度和结果。 |
||||||
|
*/ |
||||||
|
public class ToolStatusRenderer { |
||||||
|
|
||||||
|
private final PrintStream out; |
||||||
|
|
||||||
|
public ToolStatusRenderer(PrintStream out) { |
||||||
|
this.out = out; |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染工具调用开始 */ |
||||||
|
public void renderStart(String toolName, String args) { |
||||||
|
out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); |
||||||
|
out.print(AnsiStyle.YELLOW + " ⚙ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET); |
||||||
|
out.println(AnsiStyle.dim(" running...")); |
||||||
|
// 如果有简短参数,显示
|
||||||
|
if (args != null && !args.isBlank()) { |
||||||
|
String summary = extractSummary(toolName, args); |
||||||
|
if (summary != null) { |
||||||
|
out.println(AnsiStyle.dim(" " + summary)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染工具调用完成 */ |
||||||
|
public void renderEnd(String toolName, String result) { |
||||||
|
// 截断长结果
|
||||||
|
String display = result; |
||||||
|
if (display != null && display.length() > 500) { |
||||||
|
display = display.substring(0, 497) + "..."; |
||||||
|
} |
||||||
|
|
||||||
|
out.println(AnsiStyle.GREEN + " ✓ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET |
||||||
|
+ AnsiStyle.dim(" done")); |
||||||
|
if (display != null && !display.isBlank()) { |
||||||
|
// 缩进输出每一行
|
||||||
|
for (String line : display.lines().toList()) { |
||||||
|
out.println(AnsiStyle.dim(" " + line)); |
||||||
|
} |
||||||
|
} |
||||||
|
out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); |
||||||
|
} |
||||||
|
|
||||||
|
/** 渲染工具错误 */ |
||||||
|
public void renderError(String toolName, String error) { |
||||||
|
out.println(AnsiStyle.RED + " ✗ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET |
||||||
|
+ AnsiStyle.red(" error")); |
||||||
|
if (error != null) { |
||||||
|
out.println(AnsiStyle.red(" " + error)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 从 JSON 参数中提取人类可读的摘要 */ |
||||||
|
private String extractSummary(String toolName, String args) { |
||||||
|
try { |
||||||
|
// 简单提取关键字段
|
||||||
|
if (args.contains("\"command\"")) { |
||||||
|
int start = args.indexOf("\"command\""); |
||||||
|
int valStart = args.indexOf("\"", start + 10) + 1; |
||||||
|
int valEnd = args.indexOf("\"", valStart); |
||||||
|
if (valStart > 0 && valEnd > valStart) { |
||||||
|
String cmd = args.substring(valStart, Math.min(valEnd, valStart + 80)); |
||||||
|
return "$ " + cmd; |
||||||
|
} |
||||||
|
} |
||||||
|
if (args.contains("\"file_path\"")) { |
||||||
|
int start = args.indexOf("\"file_path\""); |
||||||
|
int valStart = args.indexOf("\"", start + 12) + 1; |
||||||
|
int valEnd = args.indexOf("\"", valStart); |
||||||
|
if (valStart > 0 && valEnd > valStart) { |
||||||
|
return args.substring(valStart, valEnd); |
||||||
|
} |
||||||
|
} |
||||||
|
if (args.contains("\"pattern\"")) { |
||||||
|
int start = args.indexOf("\"pattern\""); |
||||||
|
int valStart = args.indexOf("\"", start + 10) + 1; |
||||||
|
int valEnd = args.indexOf("\"", valStart); |
||||||
|
if (valStart > 0 && valEnd > valStart) { |
||||||
|
return "pattern: " + args.substring(valStart, valEnd); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (Exception e) { |
||||||
|
// 忽略解析错误
|
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,91 @@ |
|||||||
|
package com.claudecode.context; |
||||||
|
|
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* CLAUDE.md 加载器 —— 对应 claude-code/src/context.ts 中的 CLAUDE.md 加载逻辑。 |
||||||
|
* <p> |
||||||
|
* 按优先级从低到高加载: |
||||||
|
* <ol> |
||||||
|
* <li>系统级: /etc/claude-code/CLAUDE.md (Unix) 或默认模板</li> |
||||||
|
* <li>用户级: ~/.claude/CLAUDE.md</li> |
||||||
|
* <li>项目级: ./CLAUDE.md 或 ./.claude/CLAUDE.md</li> |
||||||
|
* <li>本地级: ./CLAUDE.local.md</li> |
||||||
|
* </ol> |
||||||
|
*/ |
||||||
|
public class ClaudeMdLoader { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClaudeMdLoader.class); |
||||||
|
|
||||||
|
private final Path projectDir; |
||||||
|
|
||||||
|
public ClaudeMdLoader(Path projectDir) { |
||||||
|
this.projectDir = projectDir; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 加载并合并所有 CLAUDE.md 内容。 |
||||||
|
*/ |
||||||
|
public String load() { |
||||||
|
List<String> sections = new ArrayList<>(); |
||||||
|
|
||||||
|
// 1. 用户级
|
||||||
|
Path userMd = Path.of(System.getProperty("user.home"), ".claude", "CLAUDE.md"); |
||||||
|
loadFile(userMd, "user").ifPresent(sections::add); |
||||||
|
|
||||||
|
// 2. 项目级 —— 优先检查 .claude/CLAUDE.md,然后 CLAUDE.md
|
||||||
|
Path projectClaudeDir = projectDir.resolve(".claude").resolve("CLAUDE.md"); |
||||||
|
Path projectRoot = projectDir.resolve("CLAUDE.md"); |
||||||
|
if (Files.exists(projectClaudeDir)) { |
||||||
|
loadFile(projectClaudeDir, "project").ifPresent(sections::add); |
||||||
|
} else { |
||||||
|
loadFile(projectRoot, "project").ifPresent(sections::add); |
||||||
|
} |
||||||
|
|
||||||
|
// 3. 本地级
|
||||||
|
Path localMd = projectDir.resolve("CLAUDE.local.md"); |
||||||
|
loadFile(localMd, "local").ifPresent(sections::add); |
||||||
|
|
||||||
|
// 4. 加载 .claude/rules/*.md 目录
|
||||||
|
Path rulesDir = projectDir.resolve(".claude").resolve("rules"); |
||||||
|
if (Files.isDirectory(rulesDir)) { |
||||||
|
try (var stream = Files.list(rulesDir)) { |
||||||
|
stream.filter(p -> p.toString().endsWith(".md")) |
||||||
|
.sorted() |
||||||
|
.forEach(p -> loadFile(p, "rule").ifPresent(sections::add)); |
||||||
|
} catch (IOException e) { |
||||||
|
log.debug("加载规则目录失败: {}", e.getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (sections.isEmpty()) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
|
||||||
|
return String.join("\n\n---\n\n", sections); |
||||||
|
} |
||||||
|
|
||||||
|
private java.util.Optional<String> loadFile(Path path, String level) { |
||||||
|
if (!Files.exists(path) || !Files.isRegularFile(path)) { |
||||||
|
return java.util.Optional.empty(); |
||||||
|
} |
||||||
|
try { |
||||||
|
String content = Files.readString(path, StandardCharsets.UTF_8).strip(); |
||||||
|
if (!content.isEmpty()) { |
||||||
|
log.debug("已加载 {} 级 CLAUDE.md: {}", level, path); |
||||||
|
return java.util.Optional.of(content); |
||||||
|
} |
||||||
|
} catch (IOException e) { |
||||||
|
log.warn("读取 {} 失败: {}", path, e.getMessage()); |
||||||
|
} |
||||||
|
return java.util.Optional.empty(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
package com.claudecode.context; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统提示词构建器 —— 对应 claude-code/src/prompts.ts。 |
||||||
|
* <p> |
||||||
|
* 组装完整的系统提示词,包括核心指令、环境信息、工具说明等。 |
||||||
|
*/ |
||||||
|
public class SystemPromptBuilder { |
||||||
|
|
||||||
|
private String workDir; |
||||||
|
private String osName; |
||||||
|
private String userName; |
||||||
|
private String claudeMdContent; |
||||||
|
private String customInstructions; |
||||||
|
|
||||||
|
public SystemPromptBuilder() { |
||||||
|
this.workDir = System.getProperty("user.dir"); |
||||||
|
this.osName = System.getProperty("os.name"); |
||||||
|
this.userName = System.getProperty("user.name"); |
||||||
|
} |
||||||
|
|
||||||
|
public SystemPromptBuilder workDir(String workDir) { |
||||||
|
this.workDir = workDir; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public SystemPromptBuilder claudeMd(String content) { |
||||||
|
this.claudeMdContent = content; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public SystemPromptBuilder customInstructions(String instructions) { |
||||||
|
this.customInstructions = instructions; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 构建完整的系统提示词。 |
||||||
|
*/ |
||||||
|
public String build() { |
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
|
||||||
|
// 核心角色定义
|
||||||
|
sb.append(""" |
||||||
|
You are Claude, an AI assistant made by Anthropic, operating as a CLI coding agent. |
||||||
|
You are an interactive CLI tool that helps users with software engineering tasks. |
||||||
|
Use the provided tools to help the user with their request. |
||||||
|
|
||||||
|
"""); |
||||||
|
|
||||||
|
// 环境信息
|
||||||
|
sb.append("# Environment\n"); |
||||||
|
sb.append("- Working directory: ").append(workDir).append("\n"); |
||||||
|
sb.append("- OS: ").append(osName).append("\n"); |
||||||
|
sb.append("- User: ").append(userName).append("\n"); |
||||||
|
sb.append("\n"); |
||||||
|
|
||||||
|
// 行为准则
|
||||||
|
sb.append(""" |
||||||
|
# Guidelines |
||||||
|
- Be concise in responses, but thorough in implementation |
||||||
|
- Always verify changes work before considering a task done |
||||||
|
- Use tools to explore the codebase before making changes |
||||||
|
- When writing code, follow existing patterns and conventions |
||||||
|
- Ask for clarification when requirements are ambiguous |
||||||
|
|
||||||
|
"""); |
||||||
|
|
||||||
|
// CLAUDE.md 内容
|
||||||
|
if (claudeMdContent != null && !claudeMdContent.isBlank()) { |
||||||
|
sb.append("# Project Instructions (CLAUDE.md)\n"); |
||||||
|
sb.append(claudeMdContent).append("\n\n"); |
||||||
|
} |
||||||
|
|
||||||
|
// 自定义指令
|
||||||
|
if (customInstructions != null && !customInstructions.isBlank()) { |
||||||
|
sb.append("# Custom Instructions\n"); |
||||||
|
sb.append(customInstructions).append("\n\n"); |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,182 @@ |
|||||||
|
package com.claudecode.core; |
||||||
|
|
||||||
|
import com.claudecode.tool.ToolCallbackAdapter; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
import com.claudecode.tool.ToolRegistry; |
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException; |
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
import org.springframework.ai.chat.model.ChatModel; |
||||||
|
import org.springframework.ai.chat.model.ChatResponse; |
||||||
|
import org.springframework.ai.chat.messages.*; |
||||||
|
import org.springframework.ai.chat.prompt.ChatOptions; |
||||||
|
import org.springframework.ai.chat.prompt.Prompt; |
||||||
|
import org.springframework.ai.model.tool.ToolCallingChatOptions; |
||||||
|
import org.springframework.ai.tool.ToolCallback; |
||||||
|
|
||||||
|
import java.util.*; |
||||||
|
import java.util.function.Consumer; |
||||||
|
|
||||||
|
/** |
||||||
|
* Agent 循环 —— 对应 claude-code/src/core/query.ts 的 agent loop。 |
||||||
|
* <p> |
||||||
|
* 使用 ChatModel(非 ChatClient)的显式循环,完整控制每一轮: |
||||||
|
* <ol> |
||||||
|
* <li>构建 Prompt(消息历史 + 系统提示 + 工具定义)</li> |
||||||
|
* <li>调用 ChatModel.call()</li> |
||||||
|
* <li>检查工具调用 → 执行工具 → 结果回传</li> |
||||||
|
* <li>循环直到无工具调用或达到最大迭代</li> |
||||||
|
* </ol> |
||||||
|
*/ |
||||||
|
public class AgentLoop { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentLoop.class); |
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||||
|
|
||||||
|
/** 单轮最大迭代次数,防止无限循环 */ |
||||||
|
private static final int MAX_ITERATIONS = 50; |
||||||
|
|
||||||
|
private final ChatModel chatModel; |
||||||
|
private final ToolRegistry toolRegistry; |
||||||
|
private final ToolContext toolContext; |
||||||
|
private final String systemPrompt; |
||||||
|
|
||||||
|
/** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */ |
||||||
|
private final List<Message> messageHistory = new ArrayList<>(); |
||||||
|
|
||||||
|
/** 工具调用事件回调:在每次工具调用前/后通知 UI */ |
||||||
|
private Consumer<ToolEvent> onToolEvent; |
||||||
|
|
||||||
|
/** 助手文本回调:在每次助手回复时通知 UI */ |
||||||
|
private Consumer<String> onAssistantMessage; |
||||||
|
|
||||||
|
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry, |
||||||
|
ToolContext toolContext, String systemPrompt) { |
||||||
|
this.chatModel = chatModel; |
||||||
|
this.toolRegistry = toolRegistry; |
||||||
|
this.toolContext = toolContext; |
||||||
|
this.systemPrompt = systemPrompt; |
||||||
|
// 添加系统提示词到消息历史
|
||||||
|
this.messageHistory.add(new SystemMessage(systemPrompt)); |
||||||
|
} |
||||||
|
|
||||||
|
public void setOnToolEvent(Consumer<ToolEvent> onToolEvent) { |
||||||
|
this.onToolEvent = onToolEvent; |
||||||
|
} |
||||||
|
|
||||||
|
public void setOnAssistantMessage(Consumer<String> onAssistantMessage) { |
||||||
|
this.onAssistantMessage = onAssistantMessage; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 执行一轮用户输入的完整 Agent 循环。 |
||||||
|
* |
||||||
|
* @param userInput 用户输入文本 |
||||||
|
* @return 最终助手回复文本 |
||||||
|
*/ |
||||||
|
public String run(String userInput) { |
||||||
|
messageHistory.add(new UserMessage(userInput)); |
||||||
|
|
||||||
|
List<ToolCallback> callbacks = toolRegistry.toCallbacks(toolContext); |
||||||
|
ChatOptions options = ToolCallingChatOptions.builder() |
||||||
|
.toolCallbacks(callbacks) |
||||||
|
.internalToolExecutionEnabled(false) |
||||||
|
.build(); |
||||||
|
|
||||||
|
int iteration = 0; |
||||||
|
String lastAssistantText = ""; |
||||||
|
|
||||||
|
while (iteration < MAX_ITERATIONS) { |
||||||
|
iteration++; |
||||||
|
log.debug("Agent 循环 第{}轮", iteration); |
||||||
|
|
||||||
|
Prompt prompt = new Prompt(List.copyOf(messageHistory), options); |
||||||
|
ChatResponse response = chatModel.call(prompt); |
||||||
|
|
||||||
|
AssistantMessage assistant = response.getResult().getOutput(); |
||||||
|
messageHistory.add(assistant); |
||||||
|
|
||||||
|
// 提取并通知助手文本
|
||||||
|
String text = assistant.getText(); |
||||||
|
if (text != null && !text.isBlank()) { |
||||||
|
lastAssistantText = text; |
||||||
|
if (onAssistantMessage != null) { |
||||||
|
onAssistantMessage.accept(text); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 检查是否有工具调用
|
||||||
|
if (!assistant.hasToolCalls()) { |
||||||
|
log.debug("无工具调用,循环结束(共{}轮)", iteration); |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
// 逐个执行工具调用
|
||||||
|
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>(); |
||||||
|
for (AssistantMessage.ToolCall toolCall : assistant.getToolCalls()) { |
||||||
|
String toolName = toolCall.name(); |
||||||
|
String toolArgs = toolCall.arguments(); |
||||||
|
String callId = toolCall.id(); |
||||||
|
|
||||||
|
// 通知 UI 工具调用开始
|
||||||
|
if (onToolEvent != null) { |
||||||
|
onToolEvent.accept(new ToolEvent(toolName, ToolEvent.Phase.START, toolArgs, null)); |
||||||
|
} |
||||||
|
|
||||||
|
// 查找并执行工具
|
||||||
|
String result; |
||||||
|
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName); |
||||||
|
if (adapter != null) { |
||||||
|
result = adapter.call(toolArgs); |
||||||
|
} else { |
||||||
|
result = "Error: Unknown tool '" + toolName + "'"; |
||||||
|
log.warn("未知工具: {}", toolName); |
||||||
|
} |
||||||
|
|
||||||
|
// 通知 UI 工具调用完成
|
||||||
|
if (onToolEvent != null) { |
||||||
|
onToolEvent.accept(new ToolEvent(toolName, ToolEvent.Phase.END, toolArgs, result)); |
||||||
|
} |
||||||
|
|
||||||
|
toolResponses.add(new ToolResponseMessage.ToolResponse(callId, toolName, result)); |
||||||
|
} |
||||||
|
|
||||||
|
// 将工具结果加入消息历史
|
||||||
|
messageHistory.add(ToolResponseMessage.builder().responses(toolResponses).build()); |
||||||
|
} |
||||||
|
|
||||||
|
if (iteration >= MAX_ITERATIONS) { |
||||||
|
log.warn("Agent 循环已达最大迭代次数 {},强制终止", MAX_ITERATIONS); |
||||||
|
lastAssistantText += "\n\n[WARNING: 达到最大循环次数限制]"; |
||||||
|
} |
||||||
|
|
||||||
|
return lastAssistantText; |
||||||
|
} |
||||||
|
|
||||||
|
/** 从 ToolCallback 列表中查找匹配名称的适配器 */ |
||||||
|
private ToolCallbackAdapter findCallbackByName(List<ToolCallback> callbacks, String name) { |
||||||
|
for (ToolCallback cb : callbacks) { |
||||||
|
if (cb instanceof ToolCallbackAdapter adapter && adapter.getTool().name().equals(name)) { |
||||||
|
return adapter; |
||||||
|
} |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** 获取消息历史(用于上下文压缩等场景) */ |
||||||
|
public List<Message> getMessageHistory() { |
||||||
|
return Collections.unmodifiableList(messageHistory); |
||||||
|
} |
||||||
|
|
||||||
|
/** 重置历史(保留系统提示词) */ |
||||||
|
public void reset() { |
||||||
|
messageHistory.clear(); |
||||||
|
messageHistory.add(new SystemMessage(systemPrompt)); |
||||||
|
} |
||||||
|
|
||||||
|
/** 工具事件,用于 UI 展示 */ |
||||||
|
public record ToolEvent(String toolName, Phase phase, String arguments, String result) { |
||||||
|
public enum Phase { START, END } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
package com.claudecode.repl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.CommandRegistry; |
||||||
|
import com.claudecode.console.*; |
||||||
|
import com.claudecode.core.AgentLoop; |
||||||
|
import com.claudecode.tool.ToolRegistry; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import java.io.PrintStream; |
||||||
|
import java.util.Scanner; |
||||||
|
|
||||||
|
/** |
||||||
|
* REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。 |
||||||
|
* <p> |
||||||
|
* 管理用户输入循环、命令分发、Agent 调用和输出渲染。 |
||||||
|
* 当前版本使用 Scanner 作为输入方式(Phase 2 会升级到 JLine)。 |
||||||
|
*/ |
||||||
|
public class ReplSession { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ReplSession.class); |
||||||
|
|
||||||
|
private final AgentLoop agentLoop; |
||||||
|
private final ToolRegistry toolRegistry; |
||||||
|
private final CommandRegistry commandRegistry; |
||||||
|
private final PrintStream out; |
||||||
|
private final ToolStatusRenderer toolStatusRenderer; |
||||||
|
private final MarkdownRenderer markdownRenderer; |
||||||
|
private final SpinnerAnimation spinner; |
||||||
|
|
||||||
|
private volatile boolean running = true; |
||||||
|
|
||||||
|
public ReplSession(AgentLoop agentLoop, |
||||||
|
ToolRegistry toolRegistry, |
||||||
|
CommandRegistry commandRegistry) { |
||||||
|
this.agentLoop = agentLoop; |
||||||
|
this.toolRegistry = toolRegistry; |
||||||
|
this.commandRegistry = commandRegistry; |
||||||
|
this.out = System.out; |
||||||
|
this.toolStatusRenderer = new ToolStatusRenderer(out); |
||||||
|
this.markdownRenderer = new MarkdownRenderer(out); |
||||||
|
this.spinner = new SpinnerAnimation(out); |
||||||
|
|
||||||
|
// 注册 AgentLoop 事件回调
|
||||||
|
agentLoop.setOnToolEvent(event -> { |
||||||
|
switch (event.phase()) { |
||||||
|
case START -> { |
||||||
|
spinner.stop(); |
||||||
|
toolStatusRenderer.renderStart(event.toolName(), event.arguments()); |
||||||
|
} |
||||||
|
case END -> { |
||||||
|
toolStatusRenderer.renderEnd(event.toolName(), event.result()); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
agentLoop.setOnAssistantMessage(text -> { |
||||||
|
// 助手文本在 agent 循环结束后由 REPL 统一渲染
|
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 启动 REPL 主循环。 |
||||||
|
*/ |
||||||
|
public void start() { |
||||||
|
BannerPrinter.printCompact(out); |
||||||
|
out.println(AnsiStyle.dim(" Working directory: " + System.getProperty("user.dir"))); |
||||||
|
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " registered")); |
||||||
|
out.println(); |
||||||
|
|
||||||
|
Scanner scanner = new Scanner(System.in); |
||||||
|
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); |
||||||
|
|
||||||
|
while (running) { |
||||||
|
// 输入提示符
|
||||||
|
out.print(AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + " ❯ " + AnsiStyle.RESET); |
||||||
|
out.flush(); |
||||||
|
|
||||||
|
String input; |
||||||
|
try { |
||||||
|
if (!scanner.hasNextLine()) { |
||||||
|
break; // EOF (Ctrl+D)
|
||||||
|
} |
||||||
|
input = scanner.nextLine().strip(); |
||||||
|
} catch (Exception e) { |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
if (input.isEmpty()) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// 检查斜杠命令
|
||||||
|
if (commandRegistry.isCommand(input)) { |
||||||
|
var result = commandRegistry.dispatch(input, cmdContext); |
||||||
|
result.ifPresent(out::println); |
||||||
|
out.println(); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// 调用 Agent 循环
|
||||||
|
try { |
||||||
|
spinner.start("Thinking..."); |
||||||
|
String response = agentLoop.run(input); |
||||||
|
spinner.stop(); |
||||||
|
|
||||||
|
out.println(); |
||||||
|
markdownRenderer.render(response); |
||||||
|
out.println(); |
||||||
|
} catch (Exception e) { |
||||||
|
spinner.stop(); |
||||||
|
out.println(AnsiStyle.red("\n ✗ Error: " + e.getMessage())); |
||||||
|
log.error("Agent 循环异常", e); |
||||||
|
out.println(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
out.println(AnsiStyle.dim("\n Goodbye! 👋\n")); |
||||||
|
} |
||||||
|
|
||||||
|
public void stop() { |
||||||
|
running = false; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
package com.claudecode.tool; |
||||||
|
|
||||||
|
/** |
||||||
|
* 工具权限检查结果。 |
||||||
|
* <p> |
||||||
|
* 对应 claude-code 中 Tool.checkPermissions() 的返回值。 |
||||||
|
*/ |
||||||
|
public record PermissionResult(boolean allowed, String message) { |
||||||
|
|
||||||
|
/** 放行 */ |
||||||
|
public static final PermissionResult ALLOW = new PermissionResult(true, null); |
||||||
|
|
||||||
|
/** 拒绝,附带原因 */ |
||||||
|
public static PermissionResult deny(String reason) { |
||||||
|
return new PermissionResult(false, reason); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
package com.claudecode.tool; |
||||||
|
|
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
/** |
||||||
|
* 工具协议接口 —— 对应 claude-code/src/Tool.ts 中的 Tool 类型定义。 |
||||||
|
* <p> |
||||||
|
* 每个工具是一个完整的协议实现,包含: |
||||||
|
* <ul> |
||||||
|
* <li>工具定义(name、description、inputSchema)—— 告知 LLM 如何调用</li> |
||||||
|
* <li>执行逻辑(execute)—— 实际运行</li> |
||||||
|
* <li>权限检查(checkPermission)—— 安全前置检查</li> |
||||||
|
* <li>特性门控(isEnabled)—— 条件注册</li> |
||||||
|
* <li>活动描述(activityDescription)—— 人类可读的进度</li> |
||||||
|
* </ul> |
||||||
|
*/ |
||||||
|
public interface Tool { |
||||||
|
|
||||||
|
/** 工具唯一名称标识 */ |
||||||
|
String name(); |
||||||
|
|
||||||
|
/** 给 LLM 看的工具描述 */ |
||||||
|
String description(); |
||||||
|
|
||||||
|
/** |
||||||
|
* 输入参数的 JSON Schema 定义。 |
||||||
|
* <p> |
||||||
|
* 示例: |
||||||
|
* <pre>{@code |
||||||
|
* { |
||||||
|
* "type": "object", |
||||||
|
* "properties": { |
||||||
|
* "command": { "type": "string", "description": "Shell command to execute" } |
||||||
|
* }, |
||||||
|
* "required": ["command"] |
||||||
|
* } |
||||||
|
* }</pre> |
||||||
|
*/ |
||||||
|
String inputSchema(); |
||||||
|
|
||||||
|
/** |
||||||
|
* 执行工具。 |
||||||
|
* |
||||||
|
* @param input JSON 解析后的输入参数 |
||||||
|
* @param context 执行上下文(工作目录、会话状态等) |
||||||
|
* @return 执行结果文本 |
||||||
|
*/ |
||||||
|
String execute(Map<String, Object> input, ToolContext context); |
||||||
|
|
||||||
|
/** |
||||||
|
* 权限前置检查,在 execute 之前调用。 |
||||||
|
* 默认放行。 |
||||||
|
*/ |
||||||
|
default PermissionResult checkPermission(Map<String, Object> input, ToolContext context) { |
||||||
|
return PermissionResult.ALLOW; |
||||||
|
} |
||||||
|
|
||||||
|
/** 工具是否启用(特性门控),返回 false 则不注册 */ |
||||||
|
default boolean isEnabled() { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
/** 是否为只读操作 */ |
||||||
|
default boolean isReadOnly() { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** 人类可读的活动描述,用于 UI 显示执行进度 */ |
||||||
|
default String activityDescription(Map<String, Object> input) { |
||||||
|
return "Running " + name() + "..."; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
package com.claudecode.tool; |
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException; |
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
import org.springframework.ai.tool.ToolCallback; |
||||||
|
import org.springframework.ai.tool.definition.DefaultToolDefinition; |
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition; |
||||||
|
|
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
/** |
||||||
|
* Tool → Spring AI ToolCallback 适配器。 |
||||||
|
* <p> |
||||||
|
* 将自定义 Tool 协议适配为 Spring AI 的 ToolCallback 接口, |
||||||
|
* 在调用时处理 JSON 解析、权限检查和异常捕获。 |
||||||
|
* <p> |
||||||
|
* 对应 claude-code-learn 中的 AgentToolCallback。 |
||||||
|
*/ |
||||||
|
public class ToolCallbackAdapter implements ToolCallback { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ToolCallbackAdapter.class); |
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||||
|
|
||||||
|
private final Tool tool; |
||||||
|
private final ToolDefinition toolDefinition; |
||||||
|
private final ToolContext context; |
||||||
|
|
||||||
|
public ToolCallbackAdapter(Tool tool, ToolContext context) { |
||||||
|
this.tool = tool; |
||||||
|
this.context = context; |
||||||
|
this.toolDefinition = DefaultToolDefinition.builder() |
||||||
|
.name(tool.name()) |
||||||
|
.description(tool.description()) |
||||||
|
.inputSchema(tool.inputSchema()) |
||||||
|
.build(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public ToolDefinition getToolDefinition() { |
||||||
|
return toolDefinition; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
public String call(String jsonInput) { |
||||||
|
try { |
||||||
|
Map<String, Object> input = MAPPER.readValue(jsonInput, Map.class); |
||||||
|
|
||||||
|
// 权限前置检查
|
||||||
|
PermissionResult perm = tool.checkPermission(input, context); |
||||||
|
if (!perm.allowed()) { |
||||||
|
log.warn("[{}] 权限拒绝: {}", tool.name(), perm.message()); |
||||||
|
return "Permission denied: " + perm.message(); |
||||||
|
} |
||||||
|
|
||||||
|
log.debug("[{}] {}", tool.name(), tool.activityDescription(input)); |
||||||
|
return tool.execute(input, context); |
||||||
|
} catch (JsonProcessingException e) { |
||||||
|
log.warn("[{}] JSON 解析失败: {}", tool.name(), e.getMessage()); |
||||||
|
return "Error: Invalid JSON input: " + e.getMessage(); |
||||||
|
} catch (Exception e) { |
||||||
|
log.warn("[{}] 执行异常: {}", tool.name(), e.getMessage()); |
||||||
|
return "Error: " + e.getMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public Tool getTool() { |
||||||
|
return tool; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
package com.claudecode.tool; |
||||||
|
|
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||||
|
|
||||||
|
/** |
||||||
|
* 工具执行上下文 —— 对应 claude-code 中 ToolUseContext。 |
||||||
|
* <p> |
||||||
|
* 提供工具执行时所需的环境信息和共享状态。 |
||||||
|
*/ |
||||||
|
public class ToolContext { |
||||||
|
|
||||||
|
private final Path workDir; |
||||||
|
private final String model; |
||||||
|
private final ConcurrentHashMap<String, Object> state; |
||||||
|
|
||||||
|
public ToolContext(Path workDir, String model) { |
||||||
|
this.workDir = workDir; |
||||||
|
this.model = model; |
||||||
|
this.state = new ConcurrentHashMap<>(); |
||||||
|
} |
||||||
|
|
||||||
|
public static ToolContext defaultContext() { |
||||||
|
return new ToolContext(Path.of(System.getProperty("user.dir")), "claude-sonnet-4-20250514"); |
||||||
|
} |
||||||
|
|
||||||
|
public Path getWorkDir() { |
||||||
|
return workDir; |
||||||
|
} |
||||||
|
|
||||||
|
public String getModel() { |
||||||
|
return model; |
||||||
|
} |
||||||
|
|
||||||
|
/** 获取共享状态值 */ |
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
public <T> T get(String key) { |
||||||
|
return (T) state.get(key); |
||||||
|
} |
||||||
|
|
||||||
|
/** 设置共享状态值 */ |
||||||
|
public void set(String key, Object value) { |
||||||
|
state.put(key, value); |
||||||
|
} |
||||||
|
|
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
public <T> T getOrDefault(String key, T defaultValue) { |
||||||
|
return (T) state.getOrDefault(key, defaultValue); |
||||||
|
} |
||||||
|
|
||||||
|
public boolean has(String key) { |
||||||
|
return state.containsKey(key); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
package com.claudecode.tool; |
||||||
|
|
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
import org.springframework.ai.tool.ToolCallback; |
||||||
|
|
||||||
|
import java.util.*; |
||||||
|
|
||||||
|
/** |
||||||
|
* 工具注册中心 —— 对应 claude-code/src/tools.ts 中的工具集合管理。 |
||||||
|
* <p> |
||||||
|
* 管理 Tool 的注册、查找和到 Spring AI ToolCallback 的转换。 |
||||||
|
*/ |
||||||
|
public class ToolRegistry { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ToolRegistry.class); |
||||||
|
|
||||||
|
private final Map<String, Tool> tools = new LinkedHashMap<>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* 注册工具。若工具 isEnabled() 返回 false 则跳过。 |
||||||
|
*/ |
||||||
|
public void register(Tool tool) { |
||||||
|
if (!tool.isEnabled()) { |
||||||
|
log.debug("工具 [{}] 未启用,跳过注册", tool.name()); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (tools.containsKey(tool.name())) { |
||||||
|
log.warn("工具 [{}] 已注册,将被覆盖", tool.name()); |
||||||
|
} |
||||||
|
tools.put(tool.name(), tool); |
||||||
|
log.debug("注册工具: [{}]", tool.name()); |
||||||
|
} |
||||||
|
|
||||||
|
/** 批量注册 */ |
||||||
|
public void registerAll(Tool... toolArray) { |
||||||
|
for (Tool t : toolArray) { |
||||||
|
register(t); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 按名称查找 */ |
||||||
|
public Optional<Tool> findByName(String name) { |
||||||
|
return Optional.ofNullable(tools.get(name)); |
||||||
|
} |
||||||
|
|
||||||
|
/** 获取所有已注册工具 */ |
||||||
|
public List<Tool> getTools() { |
||||||
|
return List.copyOf(tools.values()); |
||||||
|
} |
||||||
|
|
||||||
|
/** 获取所有工具名称 */ |
||||||
|
public Set<String> getToolNames() { |
||||||
|
return Set.copyOf(tools.keySet()); |
||||||
|
} |
||||||
|
|
||||||
|
/** 转换为 Spring AI ToolCallback 列表 */ |
||||||
|
public List<ToolCallback> toCallbacks(ToolContext context) { |
||||||
|
List<ToolCallback> callbacks = new ArrayList<>(); |
||||||
|
for (Tool tool : tools.values()) { |
||||||
|
callbacks.add(new ToolCallbackAdapter(tool, context)); |
||||||
|
} |
||||||
|
return callbacks; |
||||||
|
} |
||||||
|
|
||||||
|
public int size() { |
||||||
|
return tools.size(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
package com.claudecode.tool.impl; |
||||||
|
|
||||||
|
import com.claudecode.tool.Tool; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
|
||||||
|
import java.io.BufferedReader; |
||||||
|
import java.io.InputStreamReader; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.concurrent.TimeUnit; |
||||||
|
|
||||||
|
/** |
||||||
|
* Bash 工具 —— 对应 claude-code/src/tools/bash/BashTool.ts。 |
||||||
|
* <p> |
||||||
|
* 在指定工作目录中执行 shell 命令,返回 stdout/stderr 输出。 |
||||||
|
*/ |
||||||
|
public class BashTool implements Tool { |
||||||
|
|
||||||
|
/** 默认超时(秒) */ |
||||||
|
private static final int DEFAULT_TIMEOUT = 120; |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "Bash"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return """ |
||||||
|
Execute a bash command in the working directory. \ |
||||||
|
Use this for file operations, running scripts, installing packages, \ |
||||||
|
or any system command. Commands run in a subprocess with timeout protection."""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String inputSchema() { |
||||||
|
return """ |
||||||
|
{ |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"command": { |
||||||
|
"type": "string", |
||||||
|
"description": "The shell command to execute" |
||||||
|
}, |
||||||
|
"timeout": { |
||||||
|
"type": "integer", |
||||||
|
"description": "Timeout in seconds (default: 120)" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": ["command"] |
||||||
|
}"""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(Map<String, Object> input, ToolContext context) { |
||||||
|
String command = (String) input.get("command"); |
||||||
|
int timeout = input.containsKey("timeout") |
||||||
|
? ((Number) input.get("timeout")).intValue() |
||||||
|
: DEFAULT_TIMEOUT; |
||||||
|
Path workDir = context.getWorkDir(); |
||||||
|
|
||||||
|
try { |
||||||
|
// 根据操作系统选择 shell
|
||||||
|
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); |
||||||
|
ProcessBuilder pb; |
||||||
|
if (isWindows) { |
||||||
|
pb = new ProcessBuilder("cmd", "/c", command); |
||||||
|
} else { |
||||||
|
pb = new ProcessBuilder("bash", "-c", command); |
||||||
|
} |
||||||
|
|
||||||
|
pb.directory(workDir.toFile()); |
||||||
|
pb.redirectErrorStream(true); |
||||||
|
|
||||||
|
Process process = pb.start(); |
||||||
|
StringBuilder output = new StringBuilder(); |
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { |
||||||
|
String line; |
||||||
|
while ((line = reader.readLine()) != null) { |
||||||
|
output.append(line).append("\n"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS); |
||||||
|
if (!finished) { |
||||||
|
process.destroyForcibly(); |
||||||
|
return output + "\n[ERROR: Command timed out after " + timeout + " seconds]"; |
||||||
|
} |
||||||
|
|
||||||
|
int exitCode = process.exitValue(); |
||||||
|
String result = output.toString().stripTrailing(); |
||||||
|
|
||||||
|
if (exitCode != 0) { |
||||||
|
return result + "\n[Exit code: " + exitCode + "]"; |
||||||
|
} |
||||||
|
return result; |
||||||
|
|
||||||
|
} catch (Exception e) { |
||||||
|
return "Error executing command: " + e.getMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String activityDescription(Map<String, Object> input) { |
||||||
|
String cmd = (String) input.getOrDefault("command", ""); |
||||||
|
// 截断过长的命令
|
||||||
|
if (cmd.length() > 60) { |
||||||
|
cmd = cmd.substring(0, 57) + "..."; |
||||||
|
} |
||||||
|
return "⚡ " + cmd; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
package com.claudecode.tool.impl; |
||||||
|
|
||||||
|
import com.claudecode.tool.Tool; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.stream.Collectors; |
||||||
|
|
||||||
|
/** |
||||||
|
* 文件读取工具 —— 对应 claude-code/src/tools/read/ReadFileTool.ts。 |
||||||
|
* <p> |
||||||
|
* 读取文件内容,支持行号范围过滤。 |
||||||
|
*/ |
||||||
|
public class FileReadTool implements Tool { |
||||||
|
|
||||||
|
/** 单次读取最大行数 */ |
||||||
|
private static final int MAX_LINES = 2000; |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "Read"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return """ |
||||||
|
Read the contents of a file. Use line_start and line_end to read specific line ranges. \ |
||||||
|
For large files, read in chunks. Supports text files only."""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String inputSchema() { |
||||||
|
return """ |
||||||
|
{ |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"file_path": { |
||||||
|
"type": "string", |
||||||
|
"description": "Absolute or relative path to the file" |
||||||
|
}, |
||||||
|
"line_start": { |
||||||
|
"type": "integer", |
||||||
|
"description": "Starting line number (1-based, inclusive)" |
||||||
|
}, |
||||||
|
"line_end": { |
||||||
|
"type": "integer", |
||||||
|
"description": "Ending line number (1-based, inclusive)" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": ["file_path"] |
||||||
|
}"""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean isReadOnly() { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(Map<String, Object> input, ToolContext context) { |
||||||
|
String filePath = (String) input.get("file_path"); |
||||||
|
Path path = context.getWorkDir().resolve(filePath).normalize(); |
||||||
|
|
||||||
|
if (!Files.exists(path)) { |
||||||
|
return "Error: File not found: " + path; |
||||||
|
} |
||||||
|
if (!Files.isRegularFile(path)) { |
||||||
|
return "Error: Not a regular file: " + path; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
var allLines = Files.readAllLines(path, StandardCharsets.UTF_8); |
||||||
|
int total = allLines.size(); |
||||||
|
|
||||||
|
int start = 1; |
||||||
|
int end = total; |
||||||
|
|
||||||
|
if (input.containsKey("line_start")) { |
||||||
|
start = ((Number) input.get("line_start")).intValue(); |
||||||
|
} |
||||||
|
if (input.containsKey("line_end")) { |
||||||
|
end = ((Number) input.get("line_end")).intValue(); |
||||||
|
} |
||||||
|
|
||||||
|
// 参数校验
|
||||||
|
start = Math.max(1, start); |
||||||
|
end = Math.min(total, end); |
||||||
|
|
||||||
|
if (start > end) { |
||||||
|
return "Error: line_start (" + start + ") > line_end (" + end + ")"; |
||||||
|
} |
||||||
|
|
||||||
|
// 限制行数
|
||||||
|
if (end - start + 1 > MAX_LINES) { |
||||||
|
end = start + MAX_LINES - 1; |
||||||
|
} |
||||||
|
|
||||||
|
// 构建带行号的输出
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
for (int i = start - 1; i < end; i++) { |
||||||
|
sb.append(String.format("%4d | %s%n", i + 1, allLines.get(i))); |
||||||
|
} |
||||||
|
|
||||||
|
if (end < total) { |
||||||
|
sb.append(String.format("... (%d more lines)%n", total - end)); |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString().stripTrailing(); |
||||||
|
|
||||||
|
} catch (IOException e) { |
||||||
|
return "Error reading file: " + e.getMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String activityDescription(Map<String, Object> input) { |
||||||
|
return "📄 Reading " + input.getOrDefault("file_path", "file"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,127 @@ |
|||||||
|
package com.claudecode.tool.impl; |
||||||
|
|
||||||
|
import com.claudecode.tool.Tool; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.nio.file.*; |
||||||
|
import java.nio.file.attribute.BasicFileAttributes; |
||||||
|
import java.util.*; |
||||||
|
import java.util.stream.Collectors; |
||||||
|
|
||||||
|
/** |
||||||
|
* Glob 文件搜索工具 —— 对应 claude-code/src/tools/glob/GlobTool.ts。 |
||||||
|
* <p> |
||||||
|
* 根据 glob 模式搜索文件。 |
||||||
|
*/ |
||||||
|
public class GlobTool implements Tool { |
||||||
|
|
||||||
|
private static final int MAX_RESULTS = 200; |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "Glob"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return """ |
||||||
|
Find files matching a glob pattern. Returns a list of matching file paths \ |
||||||
|
relative to the working directory. Useful for finding files by name or extension."""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String inputSchema() { |
||||||
|
return """ |
||||||
|
{ |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"pattern": { |
||||||
|
"type": "string", |
||||||
|
"description": "Glob pattern (e.g., '**/*.java', 'src/**/*.ts')" |
||||||
|
}, |
||||||
|
"path": { |
||||||
|
"type": "string", |
||||||
|
"description": "Directory to search in (default: working directory)" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": ["pattern"] |
||||||
|
}"""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean isReadOnly() { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(Map<String, Object> input, ToolContext context) { |
||||||
|
String pattern = (String) input.get("pattern"); |
||||||
|
String searchPath = (String) input.getOrDefault("path", "."); |
||||||
|
Path baseDir = context.getWorkDir().resolve(searchPath).normalize(); |
||||||
|
|
||||||
|
if (!Files.isDirectory(baseDir)) { |
||||||
|
return "Error: Directory not found: " + baseDir; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); |
||||||
|
List<String> matches = new ArrayList<>(); |
||||||
|
|
||||||
|
Files.walkFileTree(baseDir, EnumSet.of(FileVisitOption.FOLLOW_LINKS), 20, new SimpleFileVisitor<>() { |
||||||
|
@Override |
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { |
||||||
|
Path relative = baseDir.relativize(file); |
||||||
|
// 将路径分隔符统一为 /(跨平台兼容)
|
||||||
|
String relStr = relative.toString().replace('\\', '/'); |
||||||
|
if (matcher.matches(Path.of(relStr))) { |
||||||
|
matches.add(relStr); |
||||||
|
} |
||||||
|
if (matches.size() >= MAX_RESULTS) { |
||||||
|
return FileVisitResult.TERMINATE; |
||||||
|
} |
||||||
|
return FileVisitResult.CONTINUE; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { |
||||||
|
String name = dir.getFileName() != null ? dir.getFileName().toString() : ""; |
||||||
|
// 跳过隐藏目录和常见忽略目录
|
||||||
|
if (name.startsWith(".") || name.equals("node_modules") || name.equals("target") || name.equals("build")) { |
||||||
|
return FileVisitResult.SKIP_SUBTREE; |
||||||
|
} |
||||||
|
return FileVisitResult.CONTINUE; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public FileVisitResult visitFileFailed(Path file, IOException exc) { |
||||||
|
return FileVisitResult.CONTINUE; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
Collections.sort(matches); |
||||||
|
|
||||||
|
if (matches.isEmpty()) { |
||||||
|
return "No files matching pattern: " + pattern; |
||||||
|
} |
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append("Found ").append(matches.size()).append(" file(s):\n"); |
||||||
|
for (String m : matches) { |
||||||
|
sb.append(" ").append(m).append("\n"); |
||||||
|
} |
||||||
|
if (matches.size() >= MAX_RESULTS) { |
||||||
|
sb.append(" ... (results truncated at ").append(MAX_RESULTS).append(")\n"); |
||||||
|
} |
||||||
|
return sb.toString().stripTrailing(); |
||||||
|
|
||||||
|
} catch (IOException e) { |
||||||
|
return "Error searching files: " + e.getMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String activityDescription(Map<String, Object> input) { |
||||||
|
return "🔍 Searching " + input.getOrDefault("pattern", "files"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,169 @@ |
|||||||
|
package com.claudecode.tool.impl; |
||||||
|
|
||||||
|
import com.claudecode.tool.Tool; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
|
||||||
|
import java.io.BufferedReader; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.InputStreamReader; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.concurrent.TimeUnit; |
||||||
|
|
||||||
|
/** |
||||||
|
* Grep 搜索工具 —— 对应 claude-code/src/tools/grep/GrepTool.ts。 |
||||||
|
* <p> |
||||||
|
* 在文件中搜索文本模式(正则),优先使用 ripgrep(rg),降级为系统 grep。 |
||||||
|
*/ |
||||||
|
public class GrepTool implements Tool { |
||||||
|
|
||||||
|
private static final int MAX_RESULTS = 100; |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "Grep"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return """ |
||||||
|
Search for a pattern in file contents using regex. Returns matching lines with \ |
||||||
|
file paths and line numbers. Uses ripgrep (rg) if available, falls back to grep."""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String inputSchema() { |
||||||
|
return """ |
||||||
|
{ |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"pattern": { |
||||||
|
"type": "string", |
||||||
|
"description": "Regular expression pattern to search for" |
||||||
|
}, |
||||||
|
"path": { |
||||||
|
"type": "string", |
||||||
|
"description": "Directory or file to search in (default: working directory)" |
||||||
|
}, |
||||||
|
"include": { |
||||||
|
"type": "string", |
||||||
|
"description": "File glob pattern to include (e.g., '*.java')" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": ["pattern"] |
||||||
|
}"""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean isReadOnly() { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(Map<String, Object> input, ToolContext context) { |
||||||
|
String pattern = (String) input.get("pattern"); |
||||||
|
String searchPath = (String) input.getOrDefault("path", "."); |
||||||
|
String include = (String) input.getOrDefault("include", null); |
||||||
|
Path baseDir = context.getWorkDir().resolve(searchPath).normalize(); |
||||||
|
|
||||||
|
try { |
||||||
|
List<String> cmd = buildCommand(pattern, baseDir.toString(), include); |
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd); |
||||||
|
pb.directory(context.getWorkDir().toFile()); |
||||||
|
pb.redirectErrorStream(true); |
||||||
|
|
||||||
|
Process process = pb.start(); |
||||||
|
List<String> lines = new ArrayList<>(); |
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { |
||||||
|
String line; |
||||||
|
while ((line = reader.readLine()) != null && lines.size() < MAX_RESULTS) { |
||||||
|
lines.add(line); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
process.waitFor(30, TimeUnit.SECONDS); |
||||||
|
|
||||||
|
if (lines.isEmpty()) { |
||||||
|
return "No matches found for pattern: " + pattern; |
||||||
|
} |
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
for (String line : lines) { |
||||||
|
sb.append(line).append("\n"); |
||||||
|
} |
||||||
|
if (lines.size() >= MAX_RESULTS) { |
||||||
|
sb.append("... (results truncated at ").append(MAX_RESULTS).append(")\n"); |
||||||
|
} |
||||||
|
return sb.toString().stripTrailing(); |
||||||
|
|
||||||
|
} catch (Exception e) { |
||||||
|
return "Error searching: " + e.getMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 构建搜索命令(优先 rg,降级 grep/findstr) */ |
||||||
|
private List<String> buildCommand(String pattern, String path, String include) { |
||||||
|
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); |
||||||
|
List<String> cmd = new ArrayList<>(); |
||||||
|
|
||||||
|
// 尝试 ripgrep
|
||||||
|
if (isCommandAvailable("rg")) { |
||||||
|
cmd.add("rg"); |
||||||
|
cmd.add("--no-heading"); |
||||||
|
cmd.add("--line-number"); |
||||||
|
cmd.add("--color=never"); |
||||||
|
cmd.add("--max-count=100"); |
||||||
|
if (include != null) { |
||||||
|
cmd.add("--glob=" + include); |
||||||
|
} |
||||||
|
cmd.add(pattern); |
||||||
|
cmd.add(path); |
||||||
|
} else if (isWindows) { |
||||||
|
// Windows 降级到 findstr(功能有限)
|
||||||
|
cmd.add("findstr"); |
||||||
|
cmd.add("/s"); |
||||||
|
cmd.add("/n"); |
||||||
|
cmd.add("/r"); |
||||||
|
cmd.add(pattern); |
||||||
|
if (include != null) { |
||||||
|
cmd.add(path + "\\" + include); |
||||||
|
} else { |
||||||
|
cmd.add(path + "\\*"); |
||||||
|
} |
||||||
|
} else { |
||||||
|
cmd.add("grep"); |
||||||
|
cmd.add("-rn"); |
||||||
|
cmd.add("--color=never"); |
||||||
|
if (include != null) { |
||||||
|
cmd.add("--include=" + include); |
||||||
|
} |
||||||
|
cmd.add(pattern); |
||||||
|
cmd.add(path); |
||||||
|
} |
||||||
|
|
||||||
|
return cmd; |
||||||
|
} |
||||||
|
|
||||||
|
private boolean isCommandAvailable(String command) { |
||||||
|
try { |
||||||
|
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); |
||||||
|
Process p; |
||||||
|
if (isWindows) { |
||||||
|
p = new ProcessBuilder("where", command).start(); |
||||||
|
} else { |
||||||
|
p = new ProcessBuilder("which", command).start(); |
||||||
|
} |
||||||
|
return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0; |
||||||
|
} catch (Exception e) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String activityDescription(Map<String, Object> input) { |
||||||
|
return "🔍 Searching for '" + input.getOrDefault("pattern", "...") + "'"; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
spring: |
||||||
|
# 主配置:使用 Anthropic 原生 API |
||||||
|
ai: |
||||||
|
anthropic: |
||||||
|
api-key: ${ANTHROPIC_API_KEY:} |
||||||
|
chat: |
||||||
|
options: |
||||||
|
model: ${AI_MODEL:claude-sonnet-4-20250514} |
||||||
|
max-tokens: ${AI_MAX_TOKENS:8096} |
||||||
|
temperature: 0.7 |
||||||
|
# 备选:兼容 OpenAI 格式的 API(如自建代理) |
||||||
|
openai: |
||||||
|
api-key: ${AI_API_KEY:} |
||||||
|
base-url: ${AI_BASE_URL:https://api.openai.com} |
||||||
|
chat: |
||||||
|
options: |
||||||
|
model: ${AI_OPENAI_MODEL:} |
||||||
|
|
||||||
|
# 不启动 Web 服务器(纯 CLI 模式) |
||||||
|
main: |
||||||
|
web-application-type: none |
||||||
|
|
||||||
|
# 虚拟线程 |
||||||
|
threads: |
||||||
|
virtual: |
||||||
|
enabled: true |
||||||
|
|
||||||
|
# 日志 |
||||||
|
logging: |
||||||
|
level: |
||||||
|
com.claudecode: INFO |
||||||
|
org.springframework.ai: WARN |
||||||
|
org.springframework.boot.autoconfigure: WARN |
||||||
Loading…
Reference in new issue