From d67f41358d2dd4bf55fdec0aec8e675df0ce9283 Mon Sep 17 00:00:00 2001 From: liuzh Date: Wed, 1 Apr 2026 19:22:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase1=20=E9=A1=B9=E7=9B=AE=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=20-=20Maven=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E3=80=81=E6=A0=B8=E5=BF=83AgentLoop=E3=80=816=E4=B8=AA?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E3=80=81=E5=91=BD=E4=BB=A4=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E3=80=81=E6=8E=A7=E5=88=B6=E5=8F=B0=E6=B8=B2=E6=9F=93=E3=80=81?= =?UTF-8?q?REPL=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现内容: - 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> --- .gitignore | 18 ++ pom.xml | 116 +++++++++++ .../com/claudecode/ClaudeCodeApplication.java | 18 ++ .../com/claudecode/cli/ClaudeCodeRunner.java | 30 +++ .../claudecode/command/CommandContext.java | 17 ++ .../claudecode/command/CommandRegistry.java | 66 +++++++ .../com/claudecode/command/SlashCommand.java | 31 +++ .../claudecode/command/impl/ClearCommand.java | 29 +++ .../claudecode/command/impl/ExitCommand.java | 35 ++++ .../claudecode/command/impl/HelpCommand.java | 54 ++++++ .../java/com/claudecode/config/AppConfig.java | 80 ++++++++ .../com/claudecode/console/AnsiStyle.java | 108 +++++++++++ .../com/claudecode/console/BannerPrinter.java | 51 +++++ .../claudecode/console/MarkdownRenderer.java | 79 ++++++++ .../claudecode/console/SpinnerAnimation.java | 72 +++++++ .../claudecode/console/ThinkingRenderer.java | 43 +++++ .../console/ToolStatusRenderer.java | 94 +++++++++ .../claudecode/context/ClaudeMdLoader.java | 91 +++++++++ .../context/SystemPromptBuilder.java | 83 ++++++++ .../java/com/claudecode/core/AgentLoop.java | 182 ++++++++++++++++++ .../java/com/claudecode/repl/ReplSession.java | 125 ++++++++++++ .../com/claudecode/tool/PermissionResult.java | 17 ++ src/main/java/com/claudecode/tool/Tool.java | 72 +++++++ .../claudecode/tool/ToolCallbackAdapter.java | 72 +++++++ .../java/com/claudecode/tool/ToolContext.java | 54 ++++++ .../com/claudecode/tool/ToolRegistry.java | 69 +++++++ .../com/claudecode/tool/impl/BashTool.java | 113 +++++++++++ .../claudecode/tool/impl/FileEditTool.java | 101 ++++++++++ .../claudecode/tool/impl/FileReadTool.java | 123 ++++++++++++ .../claudecode/tool/impl/FileWriteTool.java | 84 ++++++++ .../com/claudecode/tool/impl/GlobTool.java | 127 ++++++++++++ .../com/claudecode/tool/impl/GrepTool.java | 169 ++++++++++++++++ src/main/resources/application.yml | 33 ++++ 33 files changed, 2456 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/claudecode/ClaudeCodeApplication.java create mode 100644 src/main/java/com/claudecode/cli/ClaudeCodeRunner.java create mode 100644 src/main/java/com/claudecode/command/CommandContext.java create mode 100644 src/main/java/com/claudecode/command/CommandRegistry.java create mode 100644 src/main/java/com/claudecode/command/SlashCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/ClearCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/ExitCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/HelpCommand.java create mode 100644 src/main/java/com/claudecode/config/AppConfig.java create mode 100644 src/main/java/com/claudecode/console/AnsiStyle.java create mode 100644 src/main/java/com/claudecode/console/BannerPrinter.java create mode 100644 src/main/java/com/claudecode/console/MarkdownRenderer.java create mode 100644 src/main/java/com/claudecode/console/SpinnerAnimation.java create mode 100644 src/main/java/com/claudecode/console/ThinkingRenderer.java create mode 100644 src/main/java/com/claudecode/console/ToolStatusRenderer.java create mode 100644 src/main/java/com/claudecode/context/ClaudeMdLoader.java create mode 100644 src/main/java/com/claudecode/context/SystemPromptBuilder.java create mode 100644 src/main/java/com/claudecode/core/AgentLoop.java create mode 100644 src/main/java/com/claudecode/repl/ReplSession.java create mode 100644 src/main/java/com/claudecode/tool/PermissionResult.java create mode 100644 src/main/java/com/claudecode/tool/Tool.java create mode 100644 src/main/java/com/claudecode/tool/ToolCallbackAdapter.java create mode 100644 src/main/java/com/claudecode/tool/ToolContext.java create mode 100644 src/main/java/com/claudecode/tool/ToolRegistry.java create mode 100644 src/main/java/com/claudecode/tool/impl/BashTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/FileEditTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/FileReadTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/FileWriteTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/GlobTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/GrepTool.java create mode 100644 src/main/resources/application.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7986e72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Maven +target/ +*.class + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a873404 --- /dev/null +++ b/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.3 + + + + com.claudecode + claude-code-java + 0.1.0-SNAPSHOT + claude-code-java + Claude Code 的 Java Spring AI 重写实现 + jar + + + 25 + 2.0.0-M4 + 3.28.0 + 4.7.6 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.ai + spring-ai-starter-model-anthropic + + + + + org.springframework.ai + spring-ai-starter-model-openai + + + + + org.jline + jline + ${jline.version} + + + + + info.picocli + picocli + ${picocli.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + + --enable-preview + + + + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + diff --git a/src/main/java/com/claudecode/ClaudeCodeApplication.java b/src/main/java/com/claudecode/ClaudeCodeApplication.java new file mode 100644 index 0000000..a3edb82 --- /dev/null +++ b/src/main/java/com/claudecode/ClaudeCodeApplication.java @@ -0,0 +1,18 @@ +package com.claudecode; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Claude Code Java 版主入口。 + *

+ * 对应 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); + } +} diff --git a/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java b/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java new file mode 100644 index 0000000..e9ff323 --- /dev/null +++ b/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java @@ -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 的初始化逻辑。 + *

+ * 在 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(); + } +} diff --git a/src/main/java/com/claudecode/command/CommandContext.java b/src/main/java/com/claudecode/command/CommandContext.java new file mode 100644 index 0000000..9fc4952 --- /dev/null +++ b/src/main/java/com/claudecode/command/CommandContext.java @@ -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 +) { +} diff --git a/src/main/java/com/claudecode/command/CommandRegistry.java b/src/main/java/com/claudecode/command/CommandRegistry.java new file mode 100644 index 0000000..ac6905c --- /dev/null +++ b/src/main/java/com/claudecode/command/CommandRegistry.java @@ -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 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 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 getCommands() { + return commands.values().stream().distinct().toList(); + } + + /** 获取命令名称(用于 Tab 补全) */ + public Set getCommandNames() { + return Set.copyOf(commands.keySet()); + } +} diff --git a/src/main/java/com/claudecode/command/SlashCommand.java b/src/main/java/com/claudecode/command/SlashCommand.java new file mode 100644 index 0000000..ea36632 --- /dev/null +++ b/src/main/java/com/claudecode/command/SlashCommand.java @@ -0,0 +1,31 @@ +package com.claudecode.command; + +import java.util.List; + +/** + * 斜杠命令接口 —— 对应 claude-code/src/commands.ts 中的 Command 类型。 + *

+ * 用于处理以 / 开头的用户输入命令。 + */ +public interface SlashCommand { + + /** 命令名称(不含 / 前缀) */ + String name(); + + /** 命令描述 */ + String description(); + + /** 命令别名列表 */ + default List aliases() { + return List.of(); + } + + /** + * 执行命令。 + * + * @param args 命令参数(/ 后的文本去掉命令名后的部分) + * @param context 命令执行上下文 + * @return 命令输出文本 + */ + String execute(String args, CommandContext context); +} diff --git a/src/main/java/com/claudecode/command/impl/ClearCommand.java b/src/main/java/com/claudecode/command/impl/ClearCommand.java new file mode 100644 index 0000000..8e8fe80 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ClearCommand.java @@ -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."); + } +} diff --git a/src/main/java/com/claudecode/command/impl/ExitCommand.java b/src/main/java/com/claudecode/command/impl/ExitCommand.java new file mode 100644 index 0000000..11e7b5d --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ExitCommand.java @@ -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 aliases() { + return List.of("quit", "q"); + } + + @Override + public String execute(String args, CommandContext context) { + if (context.exitCallback() != null) { + context.exitCallback().run(); + } + return "Goodbye!"; + } +} diff --git a/src/main/java/com/claudecode/command/impl/HelpCommand.java b/src/main/java/com/claudecode/command/impl/HelpCommand.java new file mode 100644 index 0000000..2c5d5cd --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/HelpCommand.java @@ -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 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.of() // 这里后续会获取注册的命令 + : List.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(); + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java new file mode 100644 index 0000000..f937c2c --- /dev/null +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -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 装配。 + *

+ * 集中管理所有组件的创建和依赖注入。 + */ +@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); + } +} diff --git a/src/main/java/com/claudecode/console/AnsiStyle.java b/src/main/java/com/claudecode/console/AnsiStyle.java new file mode 100644 index 0000000..64a153a --- /dev/null +++ b/src/main/java/com/claudecode/console/AnsiStyle.java @@ -0,0 +1,108 @@ +package com.claudecode.console; + +/** + * ANSI 终端样式工具类 —— 对应 claude-code/src/utils/terminal.ts 的颜色输出功能。 + *

+ * 提供终端颜色和样式的便捷方法。 + */ +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"; + } +} diff --git a/src/main/java/com/claudecode/console/BannerPrinter.java b/src/main/java/com/claudecode/console/BannerPrinter.java new file mode 100644 index 0000000..2b0e528 --- /dev/null +++ b/src/main/java/com/claudecode/console/BannerPrinter.java @@ -0,0 +1,51 @@ +package com.claudecode.console; + +import java.io.PrintStream; + +/** + * Banner 打印器 —— 对应 claude-code/src/components/Banner.tsx。 + *

+ * 在启动时打印 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(); + } +} diff --git a/src/main/java/com/claudecode/console/MarkdownRenderer.java b/src/main/java/com/claudecode/console/MarkdownRenderer.java new file mode 100644 index 0000000..d901d2a --- /dev/null +++ b/src/main/java/com/claudecode/console/MarkdownRenderer.java @@ -0,0 +1,79 @@ +package com.claudecode.console; + +import java.io.PrintStream; + +/** + * Markdown 简易渲染器 —— 对应 claude-code/src/renderers/markdown.ts。 + *

+ * 将 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; + } +} diff --git a/src/main/java/com/claudecode/console/SpinnerAnimation.java b/src/main/java/com/claudecode/console/SpinnerAnimation.java new file mode 100644 index 0000000..dcffe44 --- /dev/null +++ b/src/main/java/com/claudecode/console/SpinnerAnimation.java @@ -0,0 +1,72 @@ +package com.claudecode.console; + +import java.io.PrintStream; + +/** + * 加载动画(Spinner)—— 对应 claude-code/src/components/Spinner.tsx。 + *

+ * 在等待 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; + } +} diff --git a/src/main/java/com/claudecode/console/ThinkingRenderer.java b/src/main/java/com/claudecode/console/ThinkingRenderer.java new file mode 100644 index 0000000..d04d780 --- /dev/null +++ b/src/main/java/com/claudecode/console/ThinkingRenderer.java @@ -0,0 +1,43 @@ +package com.claudecode.console; + +import java.io.PrintStream; + +/** + * Thinking 内容渲染器 —— 对应 claude-code/src/components/Thinking.tsx。 + *

+ * 显示 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()); + } +} diff --git a/src/main/java/com/claudecode/console/ToolStatusRenderer.java b/src/main/java/com/claudecode/console/ToolStatusRenderer.java new file mode 100644 index 0000000..5f940f6 --- /dev/null +++ b/src/main/java/com/claudecode/console/ToolStatusRenderer.java @@ -0,0 +1,94 @@ +package com.claudecode.console; + +import java.io.PrintStream; + +/** + * 工具调用状态渲染器 —— 对应 claude-code/src/components/ToolStatus.tsx。 + *

+ * 在终端中显示工具调用的进度和结果。 + */ +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; + } +} diff --git a/src/main/java/com/claudecode/context/ClaudeMdLoader.java b/src/main/java/com/claudecode/context/ClaudeMdLoader.java new file mode 100644 index 0000000..fa9bb27 --- /dev/null +++ b/src/main/java/com/claudecode/context/ClaudeMdLoader.java @@ -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 加载逻辑。 + *

+ * 按优先级从低到高加载: + *

    + *
  1. 系统级: /etc/claude-code/CLAUDE.md (Unix) 或默认模板
  2. + *
  3. 用户级: ~/.claude/CLAUDE.md
  4. + *
  5. 项目级: ./CLAUDE.md 或 ./.claude/CLAUDE.md
  6. + *
  7. 本地级: ./CLAUDE.local.md
  8. + *
+ */ +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 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 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(); + } +} diff --git a/src/main/java/com/claudecode/context/SystemPromptBuilder.java b/src/main/java/com/claudecode/context/SystemPromptBuilder.java new file mode 100644 index 0000000..a6e8446 --- /dev/null +++ b/src/main/java/com/claudecode/context/SystemPromptBuilder.java @@ -0,0 +1,83 @@ +package com.claudecode.context; + +/** + * 系统提示词构建器 —— 对应 claude-code/src/prompts.ts。 + *

+ * 组装完整的系统提示词,包括核心指令、环境信息、工具说明等。 + */ +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(); + } +} diff --git a/src/main/java/com/claudecode/core/AgentLoop.java b/src/main/java/com/claudecode/core/AgentLoop.java new file mode 100644 index 0000000..fd3044a --- /dev/null +++ b/src/main/java/com/claudecode/core/AgentLoop.java @@ -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。 + *

+ * 使用 ChatModel(非 ChatClient)的显式循环,完整控制每一轮: + *

    + *
  1. 构建 Prompt(消息历史 + 系统提示 + 工具定义)
  2. + *
  3. 调用 ChatModel.call()
  4. + *
  5. 检查工具调用 → 执行工具 → 结果回传
  6. + *
  7. 循环直到无工具调用或达到最大迭代
  8. + *
+ */ +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 messageHistory = new ArrayList<>(); + + /** 工具调用事件回调:在每次工具调用前/后通知 UI */ + private Consumer onToolEvent; + + /** 助手文本回调:在每次助手回复时通知 UI */ + private Consumer 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 onToolEvent) { + this.onToolEvent = onToolEvent; + } + + public void setOnAssistantMessage(Consumer onAssistantMessage) { + this.onAssistantMessage = onAssistantMessage; + } + + /** + * 执行一轮用户输入的完整 Agent 循环。 + * + * @param userInput 用户输入文本 + * @return 最终助手回复文本 + */ + public String run(String userInput) { + messageHistory.add(new UserMessage(userInput)); + + List 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 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 callbacks, String name) { + for (ToolCallback cb : callbacks) { + if (cb instanceof ToolCallbackAdapter adapter && adapter.getTool().name().equals(name)) { + return adapter; + } + } + return null; + } + + /** 获取消息历史(用于上下文压缩等场景) */ + public List 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 } + } +} diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java new file mode 100644 index 0000000..0276e83 --- /dev/null +++ b/src/main/java/com/claudecode/repl/ReplSession.java @@ -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。 + *

+ * 管理用户输入循环、命令分发、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; + } +} diff --git a/src/main/java/com/claudecode/tool/PermissionResult.java b/src/main/java/com/claudecode/tool/PermissionResult.java new file mode 100644 index 0000000..43f4f03 --- /dev/null +++ b/src/main/java/com/claudecode/tool/PermissionResult.java @@ -0,0 +1,17 @@ +package com.claudecode.tool; + +/** + * 工具权限检查结果。 + *

+ * 对应 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); + } +} diff --git a/src/main/java/com/claudecode/tool/Tool.java b/src/main/java/com/claudecode/tool/Tool.java new file mode 100644 index 0000000..a83cf6b --- /dev/null +++ b/src/main/java/com/claudecode/tool/Tool.java @@ -0,0 +1,72 @@ +package com.claudecode.tool; + +import java.util.Map; + +/** + * 工具协议接口 —— 对应 claude-code/src/Tool.ts 中的 Tool 类型定义。 + *

+ * 每个工具是一个完整的协议实现,包含: + *

    + *
  • 工具定义(name、description、inputSchema)—— 告知 LLM 如何调用
  • + *
  • 执行逻辑(execute)—— 实际运行
  • + *
  • 权限检查(checkPermission)—— 安全前置检查
  • + *
  • 特性门控(isEnabled)—— 条件注册
  • + *
  • 活动描述(activityDescription)—— 人类可读的进度
  • + *
+ */ +public interface Tool { + + /** 工具唯一名称标识 */ + String name(); + + /** 给 LLM 看的工具描述 */ + String description(); + + /** + * 输入参数的 JSON Schema 定义。 + *

+ * 示例: + *

{@code
+     * {
+     *   "type": "object",
+     *   "properties": {
+     *     "command": { "type": "string", "description": "Shell command to execute" }
+     *   },
+     *   "required": ["command"]
+     * }
+     * }
+ */ + String inputSchema(); + + /** + * 执行工具。 + * + * @param input JSON 解析后的输入参数 + * @param context 执行上下文(工作目录、会话状态等) + * @return 执行结果文本 + */ + String execute(Map input, ToolContext context); + + /** + * 权限前置检查,在 execute 之前调用。 + * 默认放行。 + */ + default PermissionResult checkPermission(Map input, ToolContext context) { + return PermissionResult.ALLOW; + } + + /** 工具是否启用(特性门控),返回 false 则不注册 */ + default boolean isEnabled() { + return true; + } + + /** 是否为只读操作 */ + default boolean isReadOnly() { + return false; + } + + /** 人类可读的活动描述,用于 UI 显示执行进度 */ + default String activityDescription(Map input) { + return "Running " + name() + "..."; + } +} diff --git a/src/main/java/com/claudecode/tool/ToolCallbackAdapter.java b/src/main/java/com/claudecode/tool/ToolCallbackAdapter.java new file mode 100644 index 0000000..1b75df2 --- /dev/null +++ b/src/main/java/com/claudecode/tool/ToolCallbackAdapter.java @@ -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 适配器。 + *

+ * 将自定义 Tool 协议适配为 Spring AI 的 ToolCallback 接口, + * 在调用时处理 JSON 解析、权限检查和异常捕获。 + *

+ * 对应 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 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; + } +} diff --git a/src/main/java/com/claudecode/tool/ToolContext.java b/src/main/java/com/claudecode/tool/ToolContext.java new file mode 100644 index 0000000..ae4409f --- /dev/null +++ b/src/main/java/com/claudecode/tool/ToolContext.java @@ -0,0 +1,54 @@ +package com.claudecode.tool; + +import java.nio.file.Path; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 工具执行上下文 —— 对应 claude-code 中 ToolUseContext。 + *

+ * 提供工具执行时所需的环境信息和共享状态。 + */ +public class ToolContext { + + private final Path workDir; + private final String model; + private final ConcurrentHashMap 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 get(String key) { + return (T) state.get(key); + } + + /** 设置共享状态值 */ + public void set(String key, Object value) { + state.put(key, value); + } + + @SuppressWarnings("unchecked") + public T getOrDefault(String key, T defaultValue) { + return (T) state.getOrDefault(key, defaultValue); + } + + public boolean has(String key) { + return state.containsKey(key); + } +} diff --git a/src/main/java/com/claudecode/tool/ToolRegistry.java b/src/main/java/com/claudecode/tool/ToolRegistry.java new file mode 100644 index 0000000..1e8f9a1 --- /dev/null +++ b/src/main/java/com/claudecode/tool/ToolRegistry.java @@ -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 中的工具集合管理。 + *

+ * 管理 Tool 的注册、查找和到 Spring AI ToolCallback 的转换。 + */ +public class ToolRegistry { + + private static final Logger log = LoggerFactory.getLogger(ToolRegistry.class); + + private final Map 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 findByName(String name) { + return Optional.ofNullable(tools.get(name)); + } + + /** 获取所有已注册工具 */ + public List getTools() { + return List.copyOf(tools.values()); + } + + /** 获取所有工具名称 */ + public Set getToolNames() { + return Set.copyOf(tools.keySet()); + } + + /** 转换为 Spring AI ToolCallback 列表 */ + public List toCallbacks(ToolContext context) { + List callbacks = new ArrayList<>(); + for (Tool tool : tools.values()) { + callbacks.add(new ToolCallbackAdapter(tool, context)); + } + return callbacks; + } + + public int size() { + return tools.size(); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/BashTool.java b/src/main/java/com/claudecode/tool/impl/BashTool.java new file mode 100644 index 0000000..135dff5 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/BashTool.java @@ -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。 + *

+ * 在指定工作目录中执行 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 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 input) { + String cmd = (String) input.getOrDefault("command", ""); + // 截断过长的命令 + if (cmd.length() > 60) { + cmd = cmd.substring(0, 57) + "..."; + } + return "⚡ " + cmd; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/FileEditTool.java b/src/main/java/com/claudecode/tool/impl/FileEditTool.java new file mode 100644 index 0000000..4e0d9e4 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/FileEditTool.java @@ -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。 + *

+ * 通过精确匹配 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 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 input) { + return "✏️ Editing " + input.getOrDefault("file_path", "file"); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/FileReadTool.java b/src/main/java/com/claudecode/tool/impl/FileReadTool.java new file mode 100644 index 0000000..54159bb --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/FileReadTool.java @@ -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。 + *

+ * 读取文件内容,支持行号范围过滤。 + */ +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 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 input) { + return "📄 Reading " + input.getOrDefault("file_path", "file"); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/FileWriteTool.java b/src/main/java/com/claudecode/tool/impl/FileWriteTool.java new file mode 100644 index 0000000..41627a4 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/FileWriteTool.java @@ -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。 + *

+ * 将内容写入文件(创建或覆盖)。 + */ +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 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 input) { + return "✏️ Writing " + input.getOrDefault("file_path", "file"); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/GlobTool.java b/src/main/java/com/claudecode/tool/impl/GlobTool.java new file mode 100644 index 0000000..95233de --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/GlobTool.java @@ -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。 + *

+ * 根据 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 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 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 input) { + return "🔍 Searching " + input.getOrDefault("pattern", "files"); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/GrepTool.java b/src/main/java/com/claudecode/tool/impl/GrepTool.java new file mode 100644 index 0000000..433ae95 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/GrepTool.java @@ -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。 + *

+ * 在文件中搜索文本模式(正则),优先使用 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 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 cmd = buildCommand(pattern, baseDir.toString(), include); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(context.getWorkDir().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + List 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 buildCommand(String pattern, String path, String include) { + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + List 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 input) { + return "🔍 Searching for '" + input.getOrDefault("pattern", "...") + "'"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d3b79ce --- /dev/null +++ b/src/main/resources/application.yml @@ -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