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>
pull/1/head
liuzh 1 month ago
parent 2a565d314d
commit d67f41358d
  1. 18
      .gitignore
  2. 116
      pom.xml
  3. 18
      src/main/java/com/claudecode/ClaudeCodeApplication.java
  4. 30
      src/main/java/com/claudecode/cli/ClaudeCodeRunner.java
  5. 17
      src/main/java/com/claudecode/command/CommandContext.java
  6. 66
      src/main/java/com/claudecode/command/CommandRegistry.java
  7. 31
      src/main/java/com/claudecode/command/SlashCommand.java
  8. 29
      src/main/java/com/claudecode/command/impl/ClearCommand.java
  9. 35
      src/main/java/com/claudecode/command/impl/ExitCommand.java
  10. 54
      src/main/java/com/claudecode/command/impl/HelpCommand.java
  11. 80
      src/main/java/com/claudecode/config/AppConfig.java
  12. 108
      src/main/java/com/claudecode/console/AnsiStyle.java
  13. 51
      src/main/java/com/claudecode/console/BannerPrinter.java
  14. 79
      src/main/java/com/claudecode/console/MarkdownRenderer.java
  15. 72
      src/main/java/com/claudecode/console/SpinnerAnimation.java
  16. 43
      src/main/java/com/claudecode/console/ThinkingRenderer.java
  17. 94
      src/main/java/com/claudecode/console/ToolStatusRenderer.java
  18. 91
      src/main/java/com/claudecode/context/ClaudeMdLoader.java
  19. 83
      src/main/java/com/claudecode/context/SystemPromptBuilder.java
  20. 182
      src/main/java/com/claudecode/core/AgentLoop.java
  21. 125
      src/main/java/com/claudecode/repl/ReplSession.java
  22. 17
      src/main/java/com/claudecode/tool/PermissionResult.java
  23. 72
      src/main/java/com/claudecode/tool/Tool.java
  24. 72
      src/main/java/com/claudecode/tool/ToolCallbackAdapter.java
  25. 54
      src/main/java/com/claudecode/tool/ToolContext.java
  26. 69
      src/main/java/com/claudecode/tool/ToolRegistry.java
  27. 113
      src/main/java/com/claudecode/tool/impl/BashTool.java
  28. 101
      src/main/java/com/claudecode/tool/impl/FileEditTool.java
  29. 123
      src/main/java/com/claudecode/tool/impl/FileReadTool.java
  30. 84
      src/main/java/com/claudecode/tool/impl/FileWriteTool.java
  31. 127
      src/main/java/com/claudecode/tool/impl/GlobTool.java
  32. 169
      src/main/java/com/claudecode/tool/impl/GrepTool.java
  33. 33
      src/main/resources/application.yml

18
.gitignore vendored

@ -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>工具定义namedescriptioninputSchema 告知 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>
* 在文件中搜索文本模式正则优先使用 ripgreprg降级为系统 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…
Cancel
Save