feat: Phase1 项目骨架 - Maven项目结构、核心AgentLoop、6个工具、命令系统、控制台渲染、REPL会话
实现内容: - 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>
This commit is contained in:
+18
@@ -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,101 @@
|
|||||||
|
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.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件编辑工具 —— 对应 claude-code/src/tools/edit/EditFileTool.ts。
|
||||||
|
* <p>
|
||||||
|
* 通过精确匹配 old_string 并替换为 new_string 来编辑文件。
|
||||||
|
*/
|
||||||
|
public class FileEditTool implements Tool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "Edit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
Make a targeted edit to a file by replacing an exact string match with new content. \
|
||||||
|
The old_string must match exactly one location in the file. \
|
||||||
|
Use Read tool first to understand the file content before editing.""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to edit"
|
||||||
|
},
|
||||||
|
"old_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The exact string to find and replace (must be unique)"
|
||||||
|
},
|
||||||
|
"new_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The replacement string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file_path", "old_string", "new_string"]
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String filePath = (String) input.get("file_path");
|
||||||
|
String oldString = (String) input.get("old_string");
|
||||||
|
String newString = (String) input.get("new_string");
|
||||||
|
Path path = context.getWorkDir().resolve(filePath).normalize();
|
||||||
|
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
return "Error: File not found: " + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 检查 old_string 唯一性
|
||||||
|
int firstIdx = content.indexOf(oldString);
|
||||||
|
if (firstIdx == -1) {
|
||||||
|
return "Error: old_string not found in file";
|
||||||
|
}
|
||||||
|
|
||||||
|
int secondIdx = content.indexOf(oldString, firstIdx + 1);
|
||||||
|
if (secondIdx != -1) {
|
||||||
|
return "Error: old_string matches multiple locations. Be more specific.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行替换
|
||||||
|
String newContent = content.substring(0, firstIdx) + newString + content.substring(firstIdx + oldString.length());
|
||||||
|
Files.writeString(path, newContent, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 计算变更范围
|
||||||
|
long oldLines = oldString.lines().count();
|
||||||
|
long newLines = newString.lines().count();
|
||||||
|
|
||||||
|
return "✅ Edited " + path + " (replaced " + oldLines + " lines with " + newLines + " lines)";
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error editing file: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
return "✏️ Editing " + input.getOrDefault("file_path", "file");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,84 @@
|
|||||||
|
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.nio.file.StandardOpenOption;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件写入工具 —— 对应 claude-code/src/tools/write/WriteFileTool.ts。
|
||||||
|
* <p>
|
||||||
|
* 将内容写入文件(创建或覆盖)。
|
||||||
|
*/
|
||||||
|
public class FileWriteTool implements Tool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "Write";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
Write content to a file. Creates the file and any parent directories if they don't exist. \
|
||||||
|
If the file exists, it will be overwritten. Use this for creating new files or completely \
|
||||||
|
replacing file content.""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute or relative path to the file"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The content to write to the file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file_path", "content"]
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String filePath = (String) input.get("file_path");
|
||||||
|
String content = (String) input.get("content");
|
||||||
|
Path path = context.getWorkDir().resolve(filePath).normalize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 自动创建父目录
|
||||||
|
if (path.getParent() != null) {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean existed = Files.exists(path);
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
|
||||||
|
long lines = content.lines().count();
|
||||||
|
if (existed) {
|
||||||
|
return "✅ Updated " + path + " (" + lines + " lines)";
|
||||||
|
} else {
|
||||||
|
return "✅ Created " + path + " (" + lines + " lines)";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error writing file: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
return "✏️ Writing " + 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
|
||||||
Reference in New Issue
Block a user