diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 52d1d70..ba13b4c 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -43,7 +43,12 @@ public class AppConfig { new FileWriteTool(), new FileEditTool(), new GlobTool(), - new GrepTool() + new GrepTool(), + new ListFilesTool(), + new WebFetchTool(), + new TodoWriteTool(), + new AgentTool(), + new NotebookEditTool() ); return registry; } @@ -76,7 +81,16 @@ public class AppConfig { @Bean public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry, ToolContext toolContext, String systemPrompt) { - return new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt); + AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt); + + // 注册子 Agent 工厂到 ToolContext,使 AgentTool 能创建独立的 AgentLoop + toolContext.set(AgentTool.AGENT_FACTORY_KEY, + (java.util.function.Function) prompt -> { + AgentLoop subLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt); + return subLoop.run(prompt); + }); + + return mainLoop; } @Bean diff --git a/src/main/java/com/claudecode/tool/impl/AgentTool.java b/src/main/java/com/claudecode/tool/impl/AgentTool.java new file mode 100644 index 0000000..df97ad6 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/AgentTool.java @@ -0,0 +1,137 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * 子 Agent 工具 —— 对应 claude-code/src/tools/agent/AgentTool.ts。 + *

+ * 创建一个独立的子 Agent 来处理复杂的子任务。子 Agent 拥有独立的消息历史, + * 但共享工具集和上下文环境。适用于: + *

+ *

+ * 注意:子 Agent 使用主 Agent 的 ChatModel 和工具集, + * 通过 ToolContext 中的 "agentLoop.factory" 获取 AgentLoop 工厂方法。 + */ +public class AgentTool implements Tool { + + private static final Logger log = LoggerFactory.getLogger(AgentTool.class); + + /** ToolContext 中存储 AgentLoop 工厂的键名 */ + public static final String AGENT_FACTORY_KEY = "__agent_factory__"; + + @Override + public String name() { + return "Agent"; + } + + @Override + public String description() { + return """ + Launch a sub-agent to handle a complex task independently. \ + The sub-agent has its own conversation context but shares tools \ + and environment. Use this for tasks that require focused attention \ + or when you want to isolate a subtask. \ + The sub-agent will execute the given prompt and return its final response."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The task description / prompt for the sub-agent" + }, + "context": { + "type": "string", + "description": "Additional context or instructions (optional)" + } + }, + "required": ["prompt"] + }"""; + } + + @Override + public String execute(Map input, ToolContext context) { + String prompt = (String) input.get("prompt"); + String additionalContext = (String) input.getOrDefault("context", ""); + + if (prompt == null || prompt.isBlank()) { + return "Error: 'prompt' is required"; + } + + // 从 ToolContext 获取 AgentLoop 工厂方法 + @SuppressWarnings("unchecked") + java.util.function.Function agentFactory = + context.getOrDefault(AGENT_FACTORY_KEY, null); + + if (agentFactory == null) { + log.warn("AgentTool: 未配置 Agent 工厂,无法创建子 Agent"); + return "Error: Sub-agent capability is not configured. " + + "The Agent tool requires an agent factory to be registered in the ToolContext."; + } + + // 构建完整的子 Agent 提示 + String fullPrompt = buildSubAgentPrompt(prompt, additionalContext); + + log.info("启动子 Agent,任务: {}", truncate(prompt, 80)); + + try { + String result = agentFactory.apply(fullPrompt); + log.info("子 Agent 完成,结果长度: {} chars", result.length()); + return result; + } catch (Exception e) { + log.error("子 Agent 执行失败", e); + return "Error: Sub-agent failed: " + e.getMessage(); + } + } + + /** + * 构建子 Agent 的完整提示词 + */ + private String buildSubAgentPrompt(String prompt, String additionalContext) { + StringBuilder sb = new StringBuilder(); + sb.append("You are a sub-agent tasked with a specific job. "); + sb.append("Complete the following task thoroughly and return your findings/results:\n\n"); + sb.append("## Task\n"); + sb.append(prompt); + + if (additionalContext != null && !additionalContext.isBlank()) { + sb.append("\n\n## Additional Context\n"); + sb.append(additionalContext); + } + + sb.append("\n\n## Instructions\n"); + sb.append("- Focus only on the given task\n"); + sb.append("- Use available tools as needed\n"); + sb.append("- Provide a clear, concise result\n"); + sb.append("- If the task cannot be completed, explain why\n"); + + return sb.toString(); + } + + private String truncate(String text, int maxLen) { + if (text.length() <= maxLen) return text; + return text.substring(0, maxLen - 3) + "..."; + } + + @Override + public String activityDescription(Map input) { + String prompt = (String) input.getOrDefault("prompt", ""); + if (prompt.length() > 40) { + prompt = prompt.substring(0, 37) + "..."; + } + return "🤖 Sub-agent: " + prompt; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/ListFilesTool.java b/src/main/java/com/claudecode/tool/impl/ListFilesTool.java new file mode 100644 index 0000000..1fb282c --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/ListFilesTool.java @@ -0,0 +1,169 @@ +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.Stream; + +/** + * 目录列表工具 —— 对应 claude-code/src/tools/ls/LsTool.ts。 + *

+ * 列出指定目录的文件和子目录,支持递归深度控制。 + * 类似 Unix 的 ls / Windows 的 dir。 + */ +public class ListFilesTool implements Tool { + + private static final int DEFAULT_DEPTH = 1; + private static final int MAX_DEPTH = 5; + private static final int MAX_ENTRIES = 500; + + @Override + public String name() { + return "ListFiles"; + } + + @Override + public String description() { + return """ + List files and directories in a given path. Returns a structured listing \ + with file types and sizes. Useful for exploring project structure. \ + By default lists the immediate contents (depth=1)."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path to list (default: working directory)" + }, + "depth": { + "type": "integer", + "description": "Recursion depth (default: 1, max: 5)" + }, + "includeHidden": { + "type": "boolean", + "description": "Whether to include hidden files/dirs (default: false)" + } + } + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + String pathStr = (String) input.getOrDefault("path", "."); + int depth = Math.min( + input.containsKey("depth") ? ((Number) input.get("depth")).intValue() : DEFAULT_DEPTH, + MAX_DEPTH); + boolean includeHidden = Boolean.TRUE.equals(input.get("includeHidden")); + + Path baseDir = context.getWorkDir().resolve(pathStr).normalize(); + + if (!Files.isDirectory(baseDir)) { + return "Error: Not a directory: " + baseDir; + } + + try { + List entries = new ArrayList<>(); + listRecursive(baseDir, baseDir, depth, includeHidden, entries); + + if (entries.isEmpty()) { + return "Directory is empty: " + pathStr; + } + + StringBuilder sb = new StringBuilder(); + sb.append("Directory: ").append(baseDir).append("\n"); + sb.append("Entries: ").append(entries.size()); + if (entries.size() >= MAX_ENTRIES) { + sb.append(" (truncated)"); + } + sb.append("\n\n"); + + for (String entry : entries) { + sb.append(entry).append("\n"); + } + + return sb.toString().stripTrailing(); + + } catch (IOException e) { + return "Error listing directory: " + e.getMessage(); + } + } + + /** + * 递归列出目录内容,带缩进和类型标记 + */ + private void listRecursive(Path baseDir, Path currentDir, int remainingDepth, + boolean includeHidden, List entries) throws IOException { + if (remainingDepth < 0 || entries.size() >= MAX_ENTRIES) { + return; + } + + try (Stream stream = Files.list(currentDir).sorted()) { + List paths = stream.toList(); + for (Path path : paths) { + if (entries.size() >= MAX_ENTRIES) break; + + String fileName = path.getFileName().toString(); + + // 跳过隐藏文件 + if (!includeHidden && fileName.startsWith(".")) { + continue; + } + + // 跳过常见忽略目录 + if (Files.isDirectory(path) && isIgnoredDir(fileName)) { + continue; + } + + Path relative = baseDir.relativize(path); + String relStr = relative.toString().replace('\\', '/'); + boolean isDir = Files.isDirectory(path); + + if (isDir) { + entries.add("📁 " + relStr + "/"); + // 递归进入子目录 + if (remainingDepth > 1) { + listRecursive(baseDir, path, remainingDepth - 1, includeHidden, entries); + } + } else { + long size = Files.size(path); + entries.add("📄 " + relStr + " (" + formatSize(size) + ")"); + } + } + } + } + + /** 是否为应该忽略的目录 */ + private boolean isIgnoredDir(String name) { + return name.equals("node_modules") || name.equals("target") + || name.equals("build") || name.equals(".git") + || name.equals("__pycache__") || name.equals(".idea") + || name.equals(".vscode"); + } + + /** 友好的文件大小格式化 */ + private String formatSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024)); + return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024)); + } + + @Override + public String activityDescription(Map input) { + return "📁 Listing " + input.getOrDefault("path", "."); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/NotebookEditTool.java b/src/main/java/com/claudecode/tool/impl/NotebookEditTool.java new file mode 100644 index 0000000..215487d --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/NotebookEditTool.java @@ -0,0 +1,343 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** + * Jupyter Notebook 编辑工具 —— 对应 claude-code/src/tools/NotebookEditTool.ts。 + *

+ * 操作 .ipynb 文件中的单元格(cell),支持: + *

+ *

+ * Notebook 格式遵循 nbformat 4.x 规范。 + */ +public class NotebookEditTool implements Tool { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String name() { + return "NotebookEdit"; + } + + @Override + public String description() { + return """ + Edit Jupyter Notebook (.ipynb) cells. Supports insert, replace, delete, \ + and move operations on notebook cells. Works with nbformat 4.x notebooks. \ + Use this to modify code cells, add markdown documentation, or restructure notebooks."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the .ipynb notebook file" + }, + "operation": { + "type": "string", + "enum": ["insert", "replace", "delete", "move", "read"], + "description": "The operation to perform on the notebook" + }, + "cellIndex": { + "type": "integer", + "description": "Target cell index (0-based)" + }, + "cellType": { + "type": "string", + "enum": ["code", "markdown", "raw"], + "description": "Type of cell (for insert/replace, default: code)" + }, + "source": { + "type": "string", + "description": "Cell content/source code (for insert/replace)" + }, + "targetIndex": { + "type": "integer", + "description": "Target position for move operation" + } + }, + "required": ["path", "operation"] + }"""; + } + + @Override + public String execute(Map input, ToolContext context) { + String pathStr = (String) input.get("path"); + String operation = (String) input.get("operation"); + + if (pathStr == null || operation == null) { + return "Error: 'path' and 'operation' are required"; + } + + Path filePath = context.getWorkDir().resolve(pathStr).normalize(); + + return switch (operation) { + case "read" -> readNotebook(filePath); + case "insert" -> insertCell(filePath, input); + case "replace" -> replaceCell(filePath, input); + case "delete" -> deleteCell(filePath, input); + case "move" -> moveCell(filePath, input); + default -> "Error: Unknown operation '" + operation + "'. Use: read, insert, replace, delete, move"; + }; + } + + /** 读取 notebook 结构概览 */ + private String readNotebook(Path filePath) { + try { + JsonNode root = readNotebookJson(filePath); + ArrayNode cells = (ArrayNode) root.get("cells"); + if (cells == null) { + return "Error: Invalid notebook format (no 'cells' array)"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("📓 Notebook: ").append(filePath.getFileName()).append("\n"); + sb.append("Cells: ").append(cells.size()).append("\n\n"); + + for (int i = 0; i < cells.size(); i++) { + JsonNode cell = cells.get(i); + String cellType = cell.has("cell_type") ? cell.get("cell_type").asText() : "unknown"; + String source = extractSource(cell); + + sb.append("--- Cell ").append(i).append(" [").append(cellType).append("] ---\n"); + // 截断过长的内容 + if (source.length() > 200) { + sb.append(source, 0, 200).append("...(truncated)\n"); + } else { + sb.append(source).append("\n"); + } + } + + return sb.toString().stripTrailing(); + + } catch (IOException e) { + return "Error reading notebook: " + e.getMessage(); + } + } + + /** 在指定位置插入新单元格 */ + private String insertCell(Path filePath, Map input) { + int cellIndex = input.containsKey("cellIndex") ? ((Number) input.get("cellIndex")).intValue() : -1; + String cellType = (String) input.getOrDefault("cellType", "code"); + String source = (String) input.get("source"); + + if (source == null) { + return "Error: 'source' is required for insert operation"; + } + + try { + ObjectNode root = (ObjectNode) readNotebookJson(filePath); + ArrayNode cells = (ArrayNode) root.get("cells"); + if (cells == null) { + return "Error: Invalid notebook format"; + } + + // 创建新单元格 + ObjectNode newCell = createCell(cellType, source); + + // 插入位置:-1 或超出范围则追加到末尾 + if (cellIndex < 0 || cellIndex >= cells.size()) { + cells.add(newCell); + cellIndex = cells.size() - 1; + } else { + cells.insert(cellIndex, newCell); + } + + writeNotebookJson(filePath, root); + + return "✅ Inserted " + cellType + " cell at index " + cellIndex; + + } catch (IOException e) { + return "Error: " + e.getMessage(); + } + } + + /** 替换指定单元格内容 */ + private String replaceCell(Path filePath, Map input) { + if (!input.containsKey("cellIndex")) { + return "Error: 'cellIndex' is required for replace operation"; + } + int cellIndex = ((Number) input.get("cellIndex")).intValue(); + String cellType = (String) input.get("cellType"); // null 表示保持原类型 + String source = (String) input.get("source"); + + if (source == null) { + return "Error: 'source' is required for replace operation"; + } + + try { + ObjectNode root = (ObjectNode) readNotebookJson(filePath); + ArrayNode cells = (ArrayNode) root.get("cells"); + + if (cellIndex < 0 || cellIndex >= cells.size()) { + return "Error: Cell index " + cellIndex + " out of range (0-" + (cells.size() - 1) + ")"; + } + + if (cellType == null) { + cellType = cells.get(cellIndex).get("cell_type").asText(); + } + + ObjectNode newCell = createCell(cellType, source); + cells.set(cellIndex, newCell); + + writeNotebookJson(filePath, root); + + return "✅ Replaced cell " + cellIndex + " [" + cellType + "]"; + + } catch (IOException e) { + return "Error: " + e.getMessage(); + } + } + + /** 删除指定单元格 */ + private String deleteCell(Path filePath, Map input) { + if (!input.containsKey("cellIndex")) { + return "Error: 'cellIndex' is required for delete operation"; + } + int cellIndex = ((Number) input.get("cellIndex")).intValue(); + + try { + ObjectNode root = (ObjectNode) readNotebookJson(filePath); + ArrayNode cells = (ArrayNode) root.get("cells"); + + if (cellIndex < 0 || cellIndex >= cells.size()) { + return "Error: Cell index " + cellIndex + " out of range (0-" + (cells.size() - 1) + ")"; + } + + String cellType = cells.get(cellIndex).get("cell_type").asText(); + cells.remove(cellIndex); + + writeNotebookJson(filePath, root); + + return "🗑️ Deleted cell " + cellIndex + " [" + cellType + "]. Remaining: " + cells.size() + " cells"; + + } catch (IOException e) { + return "Error: " + e.getMessage(); + } + } + + /** 移动单元格到新位置 */ + private String moveCell(Path filePath, Map input) { + if (!input.containsKey("cellIndex") || !input.containsKey("targetIndex")) { + return "Error: 'cellIndex' and 'targetIndex' are required for move operation"; + } + int fromIndex = ((Number) input.get("cellIndex")).intValue(); + int toIndex = ((Number) input.get("targetIndex")).intValue(); + + try { + ObjectNode root = (ObjectNode) readNotebookJson(filePath); + ArrayNode cells = (ArrayNode) root.get("cells"); + int size = cells.size(); + + if (fromIndex < 0 || fromIndex >= size) { + return "Error: Source index " + fromIndex + " out of range"; + } + if (toIndex < 0 || toIndex >= size) { + return "Error: Target index " + toIndex + " out of range"; + } + if (fromIndex == toIndex) { + return "Cell is already at index " + toIndex; + } + + // 取出单元格 + JsonNode cell = cells.remove(fromIndex); + // 插入到新位置 + cells.insert(toIndex, cell); + + writeNotebookJson(filePath, root); + + return "↕️ Moved cell from " + fromIndex + " to " + toIndex; + + } catch (IOException e) { + return "Error: " + e.getMessage(); + } + } + + // ==================== 辅助方法 ==================== + + /** 读取 notebook JSON */ + private JsonNode readNotebookJson(Path filePath) throws IOException { + if (!Files.exists(filePath)) { + throw new IOException("File not found: " + filePath); + } + String content = Files.readString(filePath, StandardCharsets.UTF_8); + return MAPPER.readTree(content); + } + + /** 写入 notebook JSON(保持格式化) */ + private void writeNotebookJson(Path filePath, JsonNode root) throws IOException { + String json = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root); + Files.writeString(filePath, json, StandardCharsets.UTF_8); + } + + /** 创建 nbformat 4.x 单元格节点 */ + private ObjectNode createCell(String cellType, String source) { + ObjectNode cell = MAPPER.createObjectNode(); + cell.put("cell_type", cellType); + + // source 是行数组 + ArrayNode sourceArray = MAPPER.createArrayNode(); + String[] lines = source.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i < lines.length - 1) { + sourceArray.add(lines[i] + "\n"); + } else { + sourceArray.add(lines[i]); + } + } + cell.set("source", sourceArray); + + // metadata 空对象 + cell.set("metadata", MAPPER.createObjectNode()); + + // code 单元格需要额外字段 + if ("code".equals(cellType)) { + cell.putNull("execution_count"); + cell.set("outputs", MAPPER.createArrayNode()); + } + + return cell; + } + + /** 从单元格节点提取 source 文本 */ + private String extractSource(JsonNode cell) { + JsonNode source = cell.get("source"); + if (source == null) return ""; + + if (source.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode line : source) { + sb.append(line.asText()); + } + return sb.toString(); + } + return source.asText(); + } + + @Override + public String activityDescription(Map input) { + String op = (String) input.getOrDefault("operation", "editing"); + String path = (String) input.getOrDefault("path", "notebook"); + return "📓 Notebook " + op + ": " + path; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/TodoWriteTool.java b/src/main/java/com/claudecode/tool/impl/TodoWriteTool.java new file mode 100644 index 0000000..750c4d3 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/TodoWriteTool.java @@ -0,0 +1,241 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 待办任务工具 —— 对应 claude-code/src/tools/TodoWriteTool。 + *

+ * 管理 AI 工作过程中的待办事项列表,支持创建、更新、完成和删除任务。 + * 任务存储在内存中(ToolContext 的共享状态中),生命周期与会话一致。 + */ +public class TodoWriteTool implements Tool { + + private static final String TODOS_KEY = "__todos__"; + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + @Override + public String name() { + return "TodoWrite"; + } + + @Override + public String description() { + return """ + Manage a todo list for tracking tasks during the conversation. \ + Supports operations: add, update, complete, delete, list. \ + Use this to track multi-step tasks, record progress, and organize work."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "update", "complete", "delete", "list"], + "description": "The operation to perform" + }, + "id": { + "type": "string", + "description": "Task ID (required for update/complete/delete)" + }, + "title": { + "type": "string", + "description": "Task title (required for add)" + }, + "description": { + "type": "string", + "description": "Task description (optional)" + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "done", "blocked"], + "description": "Task status (for update)" + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Task priority (default: medium)" + } + }, + "required": ["operation"] + }"""; + } + + @Override + @SuppressWarnings("unchecked") + public String execute(Map input, ToolContext context) { + String operation = (String) input.get("operation"); + if (operation == null) { + return "Error: 'operation' is required"; + } + + // 从 ToolContext 获取或初始化 todo 列表 + Map todos = context.getOrDefault(TODOS_KEY, null); + if (todos == null) { + todos = new ConcurrentHashMap<>(); + context.set(TODOS_KEY, todos); + } + + return switch (operation) { + case "add" -> addTodo(input, todos); + case "update" -> updateTodo(input, todos); + case "complete" -> completeTodo(input, todos); + case "delete" -> deleteTodo(input, todos); + case "list" -> listTodos(todos); + default -> "Error: Unknown operation '" + operation + "'. Use: add, update, complete, delete, list"; + }; + } + + private String addTodo(Map input, Map todos) { + String title = (String) input.get("title"); + if (title == null || title.isBlank()) { + return "Error: 'title' is required for add operation"; + } + + String id = generateId(); + String description = (String) input.getOrDefault("description", ""); + String priority = (String) input.getOrDefault("priority", "medium"); + + TodoItem item = new TodoItem(id, title, description, "pending", priority, LocalDateTime.now()); + todos.put(id, item); + + return "✅ Task added:\n" + formatItem(item); + } + + private String updateTodo(Map input, Map todos) { + String id = (String) input.get("id"); + if (id == null) { + return "Error: 'id' is required for update operation"; + } + + TodoItem item = todos.get(id); + if (item == null) { + return "Error: Task not found: " + id; + } + + // 更新字段 + String title = (String) input.getOrDefault("title", item.title()); + String description = (String) input.getOrDefault("description", item.description()); + String status = (String) input.getOrDefault("status", item.status()); + String priority = (String) input.getOrDefault("priority", item.priority()); + + TodoItem updated = new TodoItem(id, title, description, status, priority, item.createdAt()); + todos.put(id, updated); + + return "✏️ Task updated:\n" + formatItem(updated); + } + + private String completeTodo(Map input, Map todos) { + String id = (String) input.get("id"); + if (id == null) { + return "Error: 'id' is required for complete operation"; + } + + TodoItem item = todos.get(id); + if (item == null) { + return "Error: Task not found: " + id; + } + + TodoItem completed = new TodoItem(id, item.title(), item.description(), "done", item.priority(), item.createdAt()); + todos.put(id, completed); + + return "✅ Task completed: " + item.title(); + } + + private String deleteTodo(Map input, Map todos) { + String id = (String) input.get("id"); + if (id == null) { + return "Error: 'id' is required for delete operation"; + } + + TodoItem removed = todos.remove(id); + if (removed == null) { + return "Error: Task not found: " + id; + } + + return "🗑️ Task deleted: " + removed.title(); + } + + private String listTodos(Map todos) { + if (todos.isEmpty()) { + return "📋 No tasks. Use 'add' operation to create one."; + } + + // 按状态分组,优先级排序 + Map> byStatus = todos.values().stream() + .sorted(Comparator.comparingInt(this::priorityOrder)) + .collect(Collectors.groupingBy(TodoItem::status, LinkedHashMap::new, Collectors.toList())); + + StringBuilder sb = new StringBuilder(); + sb.append("📋 Task List (").append(todos.size()).append(" tasks)\n"); + sb.append("━".repeat(40)).append("\n"); + + for (Map.Entry> entry : byStatus.entrySet()) { + String statusIcon = statusIcon(entry.getKey()); + sb.append("\n").append(statusIcon).append(" ").append(entry.getKey().toUpperCase()).append(":\n"); + for (TodoItem item : entry.getValue()) { + sb.append(formatItem(item)).append("\n"); + } + } + + return sb.toString().stripTrailing(); + } + + private String formatItem(TodoItem item) { + String priorityIcon = switch (item.priority()) { + case "high" -> "🔴"; + case "medium" -> "🟡"; + case "low" -> "🟢"; + default -> "⚪"; + }; + return String.format(" %s [%s] %s - %s (%s)", + priorityIcon, item.id(), item.title(), + item.description().isEmpty() ? "(no description)" : item.description(), + item.createdAt().format(FMT)); + } + + private int priorityOrder(TodoItem item) { + return switch (item.priority()) { + case "high" -> 0; + case "medium" -> 1; + case "low" -> 2; + default -> 3; + }; + } + + private String statusIcon(String status) { + return switch (status) { + case "pending" -> "⏳"; + case "in_progress" -> "🔄"; + case "done" -> "✅"; + case "blocked" -> "🚫"; + default -> "❓"; + }; + } + + /** 生成短 ID(4 位十六进制) */ + private String generateId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + /** 不可变任务数据记录 */ + record TodoItem(String id, String title, String description, String status, + String priority, LocalDateTime createdAt) { + } + + @Override + public String activityDescription(Map input) { + String op = (String) input.getOrDefault("operation", "managing"); + return "📋 Todo: " + op; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/WebFetchTool.java b/src/main/java/com/claudecode/tool/impl/WebFetchTool.java new file mode 100644 index 0000000..c1ca92c --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/WebFetchTool.java @@ -0,0 +1,211 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 网页获取工具 —— 对应 claude-code/src/tools/WebFetchTool。 + *

+ * 使用 HTTP GET 获取指定 URL 的内容,自动将 HTML 简化为纯文本。 + * 支持大小限制、超时控制和基本的 HTML→文本转换。 + */ +public class WebFetchTool implements Tool { + + /** 最大响应体大小:100KB */ + private static final int MAX_BODY_SIZE = 100 * 1024; + + /** HTTP 请求超时 */ + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + /** User-Agent 标识 */ + private static final String USER_AGENT = "ClaudeCode-Java/0.1 (WebFetchTool)"; + + @Override + public String name() { + return "WebFetch"; + } + + @Override + public String description() { + return """ + Fetch the content of a URL. Returns the page content as text. \ + HTML pages are automatically simplified to readable text. \ + Useful for reading documentation, API responses, or web pages. \ + Has a 100KB size limit and 30s timeout."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch (must start with http:// or https://)" + }, + "maxLength": { + "type": "integer", + "description": "Maximum number of characters to return (default: 50000)" + } + }, + "required": ["url"] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + String url = (String) input.get("url"); + int maxLength = input.containsKey("maxLength") + ? ((Number) input.get("maxLength")).intValue() + : 50000; + + // URL 校验 + if (url == null || url.isBlank()) { + return "Error: URL is required"; + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return "Error: URL must start with http:// or https://"; + } + + try { + URI uri = URI.create(url); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/json,text/plain,*/*") + .timeout(TIMEOUT) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int statusCode = response.statusCode(); + String body = response.body(); + + if (statusCode >= 400) { + return "Error: HTTP " + statusCode + "\n" + truncate(body, 2000); + } + + // 检查大小限制 + if (body.length() > MAX_BODY_SIZE) { + body = body.substring(0, MAX_BODY_SIZE); + } + + // 根据内容类型处理 + String contentType = response.headers().firstValue("Content-Type").orElse("text/plain"); + + String result; + if (contentType.contains("text/html") || contentType.contains("application/xhtml")) { + result = htmlToText(body); + } else { + result = body; + } + + // 截断到最大长度 + result = truncate(result, maxLength); + + StringBuilder sb = new StringBuilder(); + sb.append("URL: ").append(url).append("\n"); + sb.append("Status: ").append(statusCode).append("\n"); + sb.append("Content-Type: ").append(contentType).append("\n"); + sb.append("---\n"); + sb.append(result); + + return sb.toString(); + + } catch (IllegalArgumentException e) { + return "Error: Invalid URL: " + e.getMessage(); + } catch (java.net.http.HttpTimeoutException e) { + return "Error: Request timed out after " + TIMEOUT.toSeconds() + " seconds"; + } catch (Exception e) { + return "Error fetching URL: " + e.getMessage(); + } + } + + /** + * 简单的 HTML → 纯文本转换。 + * 移除脚本/样式块,转换常见标签为文本格式。 + */ + private String htmlToText(String html) { + // 移除 script 和 style 块 + String text = html.replaceAll("(?is)]*>.*?", ""); + text = text.replaceAll("(?is)]*>.*?", ""); + + // 移除 HTML 注释 + text = text.replaceAll("(?s)", ""); + + // 将块级元素转为换行 + text = text.replaceAll("(?i)", "\n"); + text = text.replaceAll("(?i)", "\n"); + text = text.replaceAll("(?i)<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>", "\n"); + + // 将链接转为 [text](url) 格式 + Pattern linkPattern = Pattern.compile("]*href=[\"']([^\"']*)[\"'][^>]*>(.*?)", Pattern.CASE_INSENSITIVE); + Matcher linkMatcher = linkPattern.matcher(text); + text = linkMatcher.replaceAll("[$2]($1)"); + + // 移除所有剩余 HTML 标签 + text = text.replaceAll("<[^>]+>", ""); + + // 解码常见 HTML 实体 + text = text.replace("&", "&"); + text = text.replace("<", "<"); + text = text.replace(">", ">"); + text = text.replace(""", "\""); + text = text.replace("'", "'"); + text = text.replace(" ", " "); + // 数字实体 + java.util.regex.Pattern numEntity = java.util.regex.Pattern.compile("&#(\\d+);"); + java.util.regex.Matcher numMatcher = numEntity.matcher(text); + text = numMatcher.replaceAll(mr -> { + try { + return String.valueOf((char) Integer.parseInt(mr.group(1))); + } catch (Exception e) { + return mr.group(); + } + }); + + // 压缩多余空行(3个以上连续空行压缩为2个) + text = text.replaceAll("\\n{3,}", "\n\n"); + // 压缩行内多余空格 + text = text.replaceAll("[ \\t]+", " "); + + return text.strip(); + } + + /** 截断文本到指定长度 */ + private String truncate(String text, int maxLength) { + if (text.length() <= maxLength) return text; + return text.substring(0, maxLength) + "\n...[truncated at " + maxLength + " chars]"; + } + + @Override + public String activityDescription(Map input) { + String url = (String) input.getOrDefault("url", ""); + // 截断过长的 URL + if (url.length() > 50) { + url = url.substring(0, 47) + "..."; + } + return "🌐 Fetching " + url; + } +}