From 80f43480c17e0a99f26ddd30ee1a78e13349df3c Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 12:32:49 +0800 Subject: [PATCH] refactor: extract utility classes and split AppConfig - CommandUtils: shared formatting (header, separator, formatBytes, formatDuration, progressBar, truncate) - ToolValidator: input validation (requireString, getString, getInt, getBoolean, validatePathInWorkDir) - ProcessExecutor: consolidated ProcessBuilder with timeout, cleanup, shell execution - AbstractReadOnlyTool: base class for read-only tools - Split AppConfig -> ToolConfiguration + CommandConfiguration + AppConfig (core) - Applied CommandUtils to 16 command files, removed duplicated private methods - Applied ToolValidator + ProcessExecutor to NotificationTool Net: -403 lines removed, 87 tests still passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/claudecode/command/CommandUtils.java | 159 ++++++++++++++++++ .../command/impl/ContextVizCommand.java | 4 +- .../claudecode/command/impl/CostCommand.java | 7 +- .../claudecode/command/impl/DebugCommand.java | 6 +- .../claudecode/command/impl/EnvCommand.java | 40 ++--- .../command/impl/HeapdumpCommand.java | 56 +++--- .../claudecode/command/impl/McpCommand.java | 27 +-- .../command/impl/PerformanceCommand.java | 51 ++---- .../command/impl/PluginCommand.java | 11 +- .../command/impl/ReleaseNotesCommand.java | 12 +- .../command/impl/ResetLimitsCommand.java | 4 +- .../command/impl/SandboxCommand.java | 6 +- .../command/impl/SkillsCommand.java | 13 +- .../command/impl/StatusCommand.java | 16 +- .../claudecode/command/impl/TasksCommand.java | 20 +-- .../claudecode/command/impl/TraceCommand.java | 6 +- .../command/impl/VersionCommand.java | 5 +- .../java/com/claudecode/config/AppConfig.java | 149 +--------------- .../config/CommandConfiguration.java | 89 ++++++++++ .../claudecode/config/ToolConfiguration.java | 80 +++++++++ .../claudecode/tool/AbstractReadOnlyTool.java | 53 ++++++ .../com/claudecode/tool/ToolValidator.java | 90 ++++++++++ .../tool/impl/NotificationTool.java | 103 ++++-------- .../claudecode/tool/util/ProcessExecutor.java | 115 +++++++++++++ 24 files changed, 719 insertions(+), 403 deletions(-) create mode 100644 src/main/java/com/claudecode/command/CommandUtils.java create mode 100644 src/main/java/com/claudecode/config/CommandConfiguration.java create mode 100644 src/main/java/com/claudecode/config/ToolConfiguration.java create mode 100644 src/main/java/com/claudecode/tool/AbstractReadOnlyTool.java create mode 100644 src/main/java/com/claudecode/tool/ToolValidator.java create mode 100644 src/main/java/com/claudecode/tool/util/ProcessExecutor.java diff --git a/src/main/java/com/claudecode/command/CommandUtils.java b/src/main/java/com/claudecode/command/CommandUtils.java new file mode 100644 index 0000000..373244a --- /dev/null +++ b/src/main/java/com/claudecode/command/CommandUtils.java @@ -0,0 +1,159 @@ +package com.claudecode.command; + +import com.claudecode.console.AnsiStyle; + +/** + * 命令输出格式化工具 —— 消除命令实现中的重复格式化代码。 + *

+ * 提供标准化的命令输出格式:标题、分隔线、成功/失败消息、 + * 字节/时间格式化、进度条等。 + */ +public final class CommandUtils { + + private CommandUtils() {} + + // ==================== 标题和分隔线 ==================== + + /** + * 格式化命令标题行。 + * 输出: "\n ⚡ Title\n ──────────\n\n" + */ + public static String header(String emoji, String title) { + return "\n" + AnsiStyle.bold(" " + emoji + " " + title + "\n") + + separator(40) + "\n\n"; + } + + /** + * 格式化命令标题行(无 emoji)。 + */ + public static String header(String title) { + return "\n" + AnsiStyle.bold(" " + title + "\n") + + separator(40) + "\n\n"; + } + + /** + * 生成分隔线。 + */ + public static String separator(int width) { + return " " + "─".repeat(width); + } + + // ==================== 状态消息 ==================== + + /** + * 成功消息(绿色 ✓)。 + */ + public static String success(String message) { + return " " + AnsiStyle.green("✓") + " " + message; + } + + /** + * 错误消息(红色 ✗)。 + */ + public static String error(String message) { + return " " + AnsiStyle.red("✗") + " " + message; + } + + /** + * 警告消息(黄色 ⚠)。 + */ + public static String warning(String message) { + return " " + AnsiStyle.yellow("⚠") + " " + message; + } + + /** + * 信息消息(蓝色 ℹ)。 + */ + public static String info(String message) { + return " " + AnsiStyle.cyan("ℹ") + " " + message; + } + + /** + * 子标题(粗体缩进)。 + */ + public static String subtitle(String title) { + return AnsiStyle.bold(" " + title); + } + + /** + * 键值对行。 + */ + public static String keyValue(String key, Object value) { + return String.format(" %-12s %s", key + ":", value); + } + + /** + * 键值对行(自定义宽度)。 + */ + public static String keyValue(String key, Object value, int keyWidth) { + return String.format(" %-" + keyWidth + "s %s", key + ":", value); + } + + // ==================== 格式化工具 ==================== + + /** + * 格式化字节数为人类可读形式。 + */ + public static String formatBytes(long bytes) { + if (bytes < 0) return "N/A"; + if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); + if (bytes >= 1_048_576) return String.format("%.1fMB", bytes / 1_048_576.0); + if (bytes >= 1_024) return String.format("%.0fKB", bytes / 1_024.0); + return bytes + "B"; + } + + /** + * 格式化秒数为人类可读时长。 + */ + public static String formatDuration(long seconds) { + if (seconds < 0) return "N/A"; + if (seconds < 60) return seconds + "s"; + if (seconds < 3600) return (seconds / 60) + "m " + (seconds % 60) + "s"; + return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m"; + } + + /** + * 格式化毫秒为人类可读时长。 + */ + public static String formatMillis(long ms) { + if (ms < 1000) return ms + "ms"; + return formatDuration(ms / 1000); + } + + /** + * 截断字符串到最大长度。 + */ + public static String truncate(String text, int maxLen) { + if (text == null) return ""; + if (text.length() <= maxLen) return text; + return text.substring(0, maxLen - 3) + "..."; + } + + // ==================== 进度条 ==================== + + /** + * 生成带颜色的进度条。 + */ + public static String progressBar(double ratio, int width) { + ratio = Math.max(0, Math.min(1.0, ratio)); + int filled = (int) (ratio * width); + String bar = "█".repeat(filled); + String empty = "░".repeat(width - filled); + + String colored; + if (ratio > 0.8) colored = AnsiStyle.red(bar); + else if (ratio > 0.5) colored = AnsiStyle.yellow(bar); + else colored = AnsiStyle.green(bar); + + return "[" + colored + empty + "] " + String.format("%.0f%%", ratio * 100); + } + + // ==================== 参数解析 ==================== + + /** + * 安全解析命令参数(去空、null→空字符串)。 + */ + public static String parseArgs(String args) { + return (args == null) ? "" : args.strip(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/ContextVizCommand.java b/src/main/java/com/claudecode/command/impl/ContextVizCommand.java index fae6e4b..879a578 100644 --- a/src/main/java/com/claudecode/command/impl/ContextVizCommand.java +++ b/src/main/java/com/claudecode/command/impl/ContextVizCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.TokenEstimationService; @@ -24,8 +25,7 @@ public class ContextVizCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 📊 Context Window Visualization\n")); - sb.append(" ").append("─".repeat(45)).append("\n\n"); + sb.append(CommandUtils.header("📊", "Context Window Visualization")); if (context.agentLoop() == null) { return sb.append(" No active agent loop\n").toString(); diff --git a/src/main/java/com/claudecode/command/impl/CostCommand.java b/src/main/java/com/claudecode/command/impl/CostCommand.java index e3fde13..10f6392 100644 --- a/src/main/java/com/claudecode/command/impl/CostCommand.java +++ b/src/main/java/com/claudecode/command/impl/CostCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.TokenTracker; @@ -29,9 +30,7 @@ public class CostCommand implements SlashCommand { int msgCount = context.agentLoop().getMessageHistory().size(); StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 💰 Token Usage & Cost\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("💰", "Token Usage & Cost")); sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n"); sb.append(" ").append(AnsiStyle.bold("API Calls: ")).append(tracker.getApiCallCount()).append("\n"); @@ -47,7 +46,7 @@ public class CostCommand implements SlashCommand { sb.append(" ").append(AnsiStyle.bold("Cache create: ")).append(formatTokenLine(tracker.getCacheCreationTokens())).append("\n"); } - sb.append(" ").append("─".repeat(30)).append("\n"); + sb.append(CommandUtils.separator(30)).append("\n"); sb.append(" ").append(AnsiStyle.bold("Total: ")).append(TokenTracker.formatTokens(tracker.getTotalTokens())).append(" tokens\n"); sb.append(" ").append(AnsiStyle.bold("Est. Cost: ")).append(AnsiStyle.green("$" + String.format("%.4f", tracker.estimateCost()))).append("\n"); diff --git a/src/main/java/com/claudecode/command/impl/DebugCommand.java b/src/main/java/com/claudecode/command/impl/DebugCommand.java index 78e35a4..982f463 100644 --- a/src/main/java/com/claudecode/command/impl/DebugCommand.java +++ b/src/main/java/com/claudecode/command/impl/DebugCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.InternalLogger; @@ -23,11 +24,10 @@ public class DebugCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - String trimmed = (args == null) ? "" : args.trim(); + String trimmed = CommandUtils.parseArgs(args); StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 🐛 Debug Mode\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("🐛", "Debug Mode")); if (context.agentLoop() == null) { return sb.append(" No active agent loop\n").toString(); diff --git a/src/main/java/com/claudecode/command/impl/EnvCommand.java b/src/main/java/com/claudecode/command/impl/EnvCommand.java index f158b4d..5da62fa 100644 --- a/src/main/java/com/claudecode/command/impl/EnvCommand.java +++ b/src/main/java/com/claudecode/command/impl/EnvCommand.java @@ -1,11 +1,11 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import java.io.File; -import java.lang.management.ManagementFactory; import java.util.List; import java.util.Map; import java.util.TreeMap; @@ -23,32 +23,28 @@ public class EnvCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - String trimmed = (args == null) ? "" : args.trim(); + String trimmed = CommandUtils.parseArgs(args); StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 🔧 Environment\n")); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("🔧", "Environment")); - // System info - sb.append(AnsiStyle.bold(" System\n")); + sb.append(CommandUtils.subtitle("System")).append("\n"); sb.append(" OS: ").append(System.getProperty("os.name")).append(" ") .append(System.getProperty("os.version")).append("\n"); sb.append(" Java: ").append(System.getProperty("java.version")) .append(" (").append(System.getProperty("java.vendor")).append(")\n"); sb.append(" JVM: ").append(System.getProperty("java.vm.name")).append("\n"); - sb.append(" Heap: ").append(formatBytes(Runtime.getRuntime().totalMemory())) - .append(" / ").append(formatBytes(Runtime.getRuntime().maxMemory())).append("\n"); + sb.append(" Heap: ").append(CommandUtils.formatBytes(Runtime.getRuntime().totalMemory())) + .append(" / ").append(CommandUtils.formatBytes(Runtime.getRuntime().maxMemory())).append("\n"); sb.append(" PID: ").append(ProcessHandle.current().pid()).append("\n\n"); - // Work directory - sb.append(AnsiStyle.bold(" Paths\n")); + sb.append(CommandUtils.subtitle("Paths")).append("\n"); sb.append(" WorkDir: ").append(System.getProperty("user.dir")).append("\n"); sb.append(" Home: ").append(System.getProperty("user.home")).append("\n"); sb.append(" Config: ").append(System.getProperty("user.home")) .append(File.separator).append(".claude-code-java").append("\n\n"); - // Relevant env vars - sb.append(AnsiStyle.bold(" Environment Variables\n")); + sb.append(CommandUtils.subtitle("Environment Variables")).append("\n"); List relevantVars = List.of( "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CLAUDE_CODE_", "JAVA_HOME", "PATH", "SHELL", "TERM", "EDITOR" @@ -57,24 +53,14 @@ public class EnvCommand implements SlashCommand { Map env = new TreeMap<>(System.getenv()); for (Map.Entry entry : env.entrySet()) { String key = entry.getKey(); - boolean show = false; - for (String prefix : relevantVars) { - if (key.startsWith(prefix) || key.equals(prefix)) { - show = true; - break; - } - } + boolean show = relevantVars.stream().anyMatch(p -> key.startsWith(p) || key.equals(p)); if (!show && !trimmed.equals("all")) continue; String value = entry.getValue(); - // Mask secrets if (key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")) { value = value.length() > 8 ? value.substring(0, 4) + "****" + value.substring(value.length() - 4) : "****"; } - // Truncate long values - if (value.length() > 80) { - value = value.substring(0, 77) + "..."; - } + value = CommandUtils.truncate(value, 80); sb.append(" ").append(AnsiStyle.cyan(key)).append("=").append(value).append("\n"); } @@ -84,10 +70,4 @@ public class EnvCommand implements SlashCommand { return sb.toString(); } - - private String formatBytes(long bytes) { - if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); - if (bytes >= 1_048_576) return String.format("%.0fMB", bytes / 1_048_576.0); - return String.format("%.0fKB", bytes / 1_024.0); - } } diff --git a/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java b/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java index eb223cc..50c075b 100644 --- a/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java +++ b/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java @@ -1,10 +1,10 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; -import java.io.File; import java.lang.management.ManagementFactory; import java.lang.management.MemoryMXBean; import java.lang.management.MemoryPoolMXBean; @@ -12,11 +12,9 @@ import java.lang.management.MemoryUsage; import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.List; /** * /heapdump 命令 —— JVM 堆转储(Java 独有优势)。 - * 使用 HotSpotDiagnosticMXBean 进行堆转储。 */ public class HeapdumpCommand implements SlashCommand { @@ -28,45 +26,39 @@ public class HeapdumpCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - String trimmed = (args == null) ? "" : args.trim(); + String trimmed = CommandUtils.parseArgs(args); StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 📦 JVM Heap Dump\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("📦", "JVM Heap Dump")); if (trimmed.equals("info") || trimmed.isEmpty()) { - // Show memory pool details MemoryMXBean memBean = ManagementFactory.getMemoryMXBean(); MemoryUsage heap = memBean.getHeapMemoryUsage(); MemoryUsage nonHeap = memBean.getNonHeapMemoryUsage(); - sb.append(AnsiStyle.bold(" Heap Memory\n")); - sb.append(" Used: ").append(formatBytes(heap.getUsed())).append("\n"); - sb.append(" Committed: ").append(formatBytes(heap.getCommitted())).append("\n"); - sb.append(" Max: ").append(formatBytes(heap.getMax())).append("\n\n"); + sb.append(CommandUtils.subtitle("Heap Memory")).append("\n"); + sb.append(" Used: ").append(CommandUtils.formatBytes(heap.getUsed())).append("\n"); + sb.append(" Committed: ").append(CommandUtils.formatBytes(heap.getCommitted())).append("\n"); + sb.append(" Max: ").append(CommandUtils.formatBytes(heap.getMax())).append("\n\n"); - sb.append(AnsiStyle.bold(" Non-Heap Memory\n")); - sb.append(" Used: ").append(formatBytes(nonHeap.getUsed())).append("\n"); - sb.append(" Committed: ").append(formatBytes(nonHeap.getCommitted())).append("\n\n"); + sb.append(CommandUtils.subtitle("Non-Heap Memory")).append("\n"); + sb.append(" Used: ").append(CommandUtils.formatBytes(nonHeap.getUsed())).append("\n"); + sb.append(" Committed: ").append(CommandUtils.formatBytes(nonHeap.getCommitted())).append("\n\n"); - sb.append(AnsiStyle.bold(" Memory Pools\n")); + sb.append(CommandUtils.subtitle("Memory Pools")).append("\n"); for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { MemoryUsage usage = pool.getUsage(); if (usage != null && usage.getUsed() > 0) { sb.append(" ").append(String.format("%-25s", pool.getName())) - .append(formatBytes(usage.getUsed())).append("\n"); + .append(CommandUtils.formatBytes(usage.getUsed())).append("\n"); } } - sb.append("\n").append(AnsiStyle.dim(" Run /heapdump dump to generate a heap dump file")); } else if (trimmed.startsWith("dump")) { - // Determine output path String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); String filename = trimmed.length() > 5 ? trimmed.substring(5).trim() : ""; - if (filename.isEmpty()) { - filename = "heapdump-" + timestamp + ".hprof"; - } + if (filename.isEmpty()) filename = "heapdump-" + timestamp + ".hprof"; Path dumpPath = Path.of(System.getProperty("user.dir"), filename); try { @@ -74,29 +66,28 @@ public class HeapdumpCommand implements SlashCommand { com.sun.management.HotSpotDiagnosticMXBean.class); hotspot.dumpHeap(dumpPath.toString(), true); long fileSize = dumpPath.toFile().length(); - sb.append(" ✅ Heap dump saved to:\n"); + sb.append(CommandUtils.success("Heap dump saved to:")).append("\n"); sb.append(" ").append(AnsiStyle.cyan(dumpPath.toString())).append("\n"); - sb.append(" Size: ").append(formatBytes(fileSize)).append("\n\n"); + sb.append(" Size: ").append(CommandUtils.formatBytes(fileSize)).append("\n\n"); sb.append(AnsiStyle.dim(" Analyze with: jhat, MAT, or VisualVM")); } catch (Exception e) { - sb.append(" ❌ Failed to create heap dump: ").append(e.getMessage()).append("\n"); + sb.append(CommandUtils.error("Failed to create heap dump: " + e.getMessage())).append("\n"); sb.append(AnsiStyle.dim(" Requires HotSpot JVM (OpenJDK or Oracle JDK)")); } } else if (trimmed.equals("gc")) { - // Trigger GC and report long beforeUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); System.gc(); long afterUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); long freed = beforeUsed - afterUsed; sb.append(" 🗑 Garbage collection triggered\n"); - sb.append(" Before: ").append(formatBytes(beforeUsed)).append("\n"); - sb.append(" After: ").append(formatBytes(afterUsed)).append("\n"); - sb.append(" Freed: ").append(AnsiStyle.green(formatBytes(Math.max(0, freed)))).append("\n"); + sb.append(" Before: ").append(CommandUtils.formatBytes(beforeUsed)).append("\n"); + sb.append(" After: ").append(CommandUtils.formatBytes(afterUsed)).append("\n"); + sb.append(" Freed: ").append(AnsiStyle.green(CommandUtils.formatBytes(Math.max(0, freed)))).append("\n"); } else { - sb.append(AnsiStyle.bold(" Subcommands\n")); + sb.append(CommandUtils.subtitle("Subcommands")).append("\n"); sb.append(" /heapdump Show memory pool info\n"); sb.append(" /heapdump dump Generate .hprof file\n"); sb.append(" /heapdump gc Trigger garbage collection\n"); @@ -104,11 +95,4 @@ public class HeapdumpCommand implements SlashCommand { return sb.toString(); } - - private String formatBytes(long bytes) { - if (bytes < 0) return "N/A"; - if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); - if (bytes >= 1_048_576) return String.format("%.1fMB", bytes / 1_048_576.0); - return String.format("%.0fKB", bytes / 1_024.0); - } } diff --git a/src/main/java/com/claudecode/command/impl/McpCommand.java b/src/main/java/com/claudecode/command/impl/McpCommand.java index cba18f0..78e5244 100644 --- a/src/main/java/com/claudecode/command/impl/McpCommand.java +++ b/src/main/java/com/claudecode/command/impl/McpCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.mcp.McpClient; @@ -47,7 +48,7 @@ public class McpCommand implements SlashCommand { return AnsiStyle.red(" ❌ MCP manager not initialized"); } - String trimmed = args.strip(); + String trimmed = CommandUtils.parseArgs(args); if (trimmed.isEmpty()) { return showStatus(manager); } @@ -72,9 +73,7 @@ public class McpCommand implements SlashCommand { */ private String showStatus(McpManager manager) { StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🔌 MCP Server Status\n")); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("🔌", "MCP Server Status")); Map clients = manager.getClients(); if (clients.isEmpty()) { @@ -159,7 +158,7 @@ public class McpCommand implements SlashCommand { for (McpClient.McpTool tool : client.getTools()) { sb.append(" • ").append(tool.name()); if (!tool.description().isEmpty()) { - sb.append(AnsiStyle.dim(" - " + truncate(tool.description(), 60))); + sb.append(AnsiStyle.dim(" - " + CommandUtils.truncate(tool.description(), 60))); } sb.append("\n"); } @@ -193,9 +192,7 @@ public class McpCommand implements SlashCommand { */ private String handleTools(McpManager manager, String args) { StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🛠️ MCP Tools\n")); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("🛠️", "MCP Tools")); String serverFilter = args.isEmpty() ? null : args.split("\\s+")[0]; @@ -220,7 +217,7 @@ public class McpCommand implements SlashCommand { } if (tool.inputSchema() != null) { sb.append(" ").append(AnsiStyle.dim("Schema: " + - truncate(tool.inputSchema().toString(), 80))).append("\n"); + CommandUtils.truncate(tool.inputSchema().toString(), 80))).append("\n"); } sb.append("\n"); } @@ -234,9 +231,7 @@ public class McpCommand implements SlashCommand { */ private String handleResources(McpManager manager, String args) { StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 📦 MCP Resources\n")); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("📦", "MCP Resources")); String serverFilter = args.isEmpty() ? null : args.split("\\s+")[0]; @@ -325,14 +320,6 @@ public class McpCommand implements SlashCommand { """; } - /** - * 截断字符串。 - */ - private static String truncate(String s, int maxLen) { - if (s == null) return ""; - return s.length() <= maxLen ? s : s.substring(0, maxLen - 3) + "..."; - } - // ========== McpManager 持有者(简单单例,供命令和其他组件访问) ========== /** diff --git a/src/main/java/com/claudecode/command/impl/PerformanceCommand.java b/src/main/java/com/claudecode/command/impl/PerformanceCommand.java index a6eb78b..dd36363 100644 --- a/src/main/java/com/claudecode/command/impl/PerformanceCommand.java +++ b/src/main/java/com/claudecode/command/impl/PerformanceCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.telemetry.MetricsCollector; @@ -24,45 +25,40 @@ public class PerformanceCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" ⚡ Performance Statistics\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("⚡", "Performance Statistics")); - // JVM stats Runtime runtime = Runtime.getRuntime(); long totalMem = runtime.totalMemory(); long freeMem = runtime.freeMemory(); long usedMem = totalMem - freeMem; long maxMem = runtime.maxMemory(); - sb.append(AnsiStyle.bold(" Memory\n")); - sb.append(" Used: ").append(formatBytes(usedMem)).append("\n"); - sb.append(" Allocated: ").append(formatBytes(totalMem)).append("\n"); - sb.append(" Max: ").append(formatBytes(maxMem)).append("\n"); - sb.append(" Usage: ").append(memBar(usedMem, maxMem)).append("\n\n"); + sb.append(CommandUtils.subtitle("Memory")).append("\n"); + sb.append(" Used: ").append(CommandUtils.formatBytes(usedMem)).append("\n"); + sb.append(" Allocated: ").append(CommandUtils.formatBytes(totalMem)).append("\n"); + sb.append(" Max: ").append(CommandUtils.formatBytes(maxMem)).append("\n"); + sb.append(" Usage: ").append(CommandUtils.progressBar((double) usedMem / maxMem, 20)).append("\n\n"); - // Thread stats int threadCount = Thread.activeCount(); - sb.append(AnsiStyle.bold(" Threads\n")); + sb.append(CommandUtils.subtitle("Threads")).append("\n"); sb.append(" Active: ").append(threadCount).append("\n"); sb.append(" Available: ").append(runtime.availableProcessors()).append(" CPUs\n\n"); - // GC stats long gcCount = 0; long gcTime = 0; for (var gc : java.lang.management.ManagementFactory.getGarbageCollectorMXBeans()) { gcCount += gc.getCollectionCount(); gcTime += gc.getCollectionTime(); } - sb.append(AnsiStyle.bold(" GC\n")); + sb.append(CommandUtils.subtitle("GC")).append("\n"); sb.append(" Collections: ").append(gcCount).append("\n"); - sb.append(" Total time: ").append(gcTime).append("ms\n\n"); + sb.append(" Total time: ").append(CommandUtils.formatMillis(gcTime)).append("\n\n"); - // Metrics if available if (context.agentLoop() != null) { Object metricsObj = context.agentLoop().getToolContext().get("METRICS_COLLECTOR"); if (metricsObj instanceof MetricsCollector metrics) { - sb.append(AnsiStyle.bold(" Session Metrics\n")); - sb.append(" Duration: ").append(formatDuration(metrics.getSessionDurationSeconds())).append("\n"); + sb.append(CommandUtils.subtitle("Session Metrics")).append("\n"); + sb.append(" Duration: ").append(CommandUtils.formatDuration(metrics.getSessionDurationSeconds())).append("\n"); var toolUsage = metrics.getToolUsage(); if (!toolUsage.isEmpty()) { sb.append(" Tool calls: ").append(toolUsage.values().stream().mapToLong(Long::longValue).sum()).append("\n"); @@ -78,27 +74,4 @@ public class PerformanceCommand implements SlashCommand { return sb.toString(); } - - private String memBar(long used, long max) { - int barWidth = 20; - double ratio = (double) used / max; - int filled = (int) (ratio * barWidth); - String color = ratio > 0.8 ? AnsiStyle.red("█".repeat(filled)) - : ratio > 0.5 ? AnsiStyle.yellow("█".repeat(filled)) - : AnsiStyle.green("█".repeat(filled)); - return "[" + color + "░".repeat(barWidth - filled) + "] " + - String.format("%.0f%%", ratio * 100); - } - - private String formatBytes(long bytes) { - if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); - if (bytes >= 1_048_576) return String.format("%.0fMB", bytes / 1_048_576.0); - return String.format("%.0fKB", bytes / 1_024.0); - } - - private String formatDuration(long seconds) { - if (seconds < 60) return seconds + "s"; - if (seconds < 3600) return (seconds / 60) + "m " + (seconds % 60) + "s"; - return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m"; - } } diff --git a/src/main/java/com/claudecode/command/impl/PluginCommand.java b/src/main/java/com/claudecode/command/impl/PluginCommand.java index 860e194..aa29cf5 100644 --- a/src/main/java/com/claudecode/command/impl/PluginCommand.java +++ b/src/main/java/com/claudecode/command/impl/PluginCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.plugin.*; @@ -55,7 +56,7 @@ public class PluginCommand implements SlashCommand { return AnsiStyle.red(" ✗ Plugin system not initialized"); } - String trimmed = (args == null) ? "" : args.trim(); + String trimmed = CommandUtils.parseArgs(args); // 无参数:列出所有插件 if (trimmed.isEmpty()) { @@ -87,9 +88,7 @@ public class PluginCommand implements SlashCommand { private String listPlugins(PluginManager manager) { List plugins = manager.getPlugins(); StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🔌 Loaded Plugins")).append("\n"); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("🔌", "Loaded Plugins")); if (plugins.isEmpty()) { sb.append(AnsiStyle.dim(" No plugins loaded.")).append("\n"); @@ -174,9 +173,7 @@ public class PluginCommand implements SlashCommand { Plugin p = info.plugin(); StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🔌 Plugin Details")).append("\n"); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("🔌", "Plugin Details")); sb.append(" ").append(AnsiStyle.bold("Name: ")).append(p.name()).append("\n"); sb.append(" ").append(AnsiStyle.bold("ID: ")).append(AnsiStyle.cyan(p.id())).append("\n"); diff --git a/src/main/java/com/claudecode/command/impl/ReleaseNotesCommand.java b/src/main/java/com/claudecode/command/impl/ReleaseNotesCommand.java index 9859418..7d656e5 100644 --- a/src/main/java/com/claudecode/command/impl/ReleaseNotesCommand.java +++ b/src/main/java/com/claudecode/command/impl/ReleaseNotesCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; @@ -25,11 +26,10 @@ public class ReleaseNotesCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 📋 Release Notes\n")); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("📋", "Release Notes")); sb.append(AnsiStyle.bold(" v0.4.0 — Phase 4: Commands, Tools & Services\n")); - sb.append(" ").append("─".repeat(45)).append("\n"); + sb.append(CommandUtils.separator(45)).append("\n"); sb.append(" • LSPTool: code navigation via Language Server Protocol\n"); sb.append(" • BriefTool: output verbosity control\n"); sb.append(" • NotificationTool: cross-platform desktop notifications\n"); @@ -38,7 +38,7 @@ public class ReleaseNotesCommand implements SlashCommand { sb.append(" • Debug commands: /debug, /heapdump, /trace, /ctx-viz\n\n"); sb.append(AnsiStyle.bold(" v0.3.0 — Phase 3: Advanced Infrastructure\n")); - sb.append(" ").append("─".repeat(45)).append("\n"); + sb.append(CommandUtils.separator(45)).append("\n"); sb.append(" • Server Mode: WebSocket direct connect for SDK integration\n"); sb.append(" • Git Worktree: parallel branch isolation for agent tasks\n"); sb.append(" • LSP Integration: JSON-RPC client, multi-server, diagnostics\n"); @@ -46,7 +46,7 @@ public class ReleaseNotesCommand implements SlashCommand { sb.append(" • Plugin Marketplace: install, search, auto-update plugins\n\n"); sb.append(AnsiStyle.bold(" v0.2.0 — Phase 2: Core Features\n")); - sb.append(" ").append("─".repeat(45)).append("\n"); + sb.append(CommandUtils.separator(45)).append("\n"); sb.append(" • Plan Mode for multi-step task planning\n"); sb.append(" • Skills execution system with /skill command\n"); sb.append(" • Session Memory with CLAUDE.md auto-persist\n"); @@ -54,7 +54,7 @@ public class ReleaseNotesCommand implements SlashCommand { sb.append(" • MCP enhancements: HTTP+SSE, resources, env vars\n\n"); sb.append(AnsiStyle.bold(" v0.1.0 — Phase 1: Foundation\n")); - sb.append(" ").append("─".repeat(45)).append("\n"); + sb.append(CommandUtils.separator(45)).append("\n"); sb.append(" • Enhanced system prompts (7 security/style sections)\n"); sb.append(" • 8 tool description improvements\n"); sb.append(" • New tools: TaskStop, TaskOutput, Sleep, ToolSearch\n"); diff --git a/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java b/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java index 5b7cf89..12d9bb4 100644 --- a/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java +++ b/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.RateLimiter; @@ -24,8 +25,7 @@ public class ResetLimitsCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 🔄 Rate Limit Reset\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("🔄", "Rate Limit Reset")); if (context.agentLoop() == null) { return sb.append(" No active agent loop\n").toString(); diff --git a/src/main/java/com/claudecode/command/impl/SandboxCommand.java b/src/main/java/com/claudecode/command/impl/SandboxCommand.java index 5f32c00..04ef2f9 100644 --- a/src/main/java/com/claudecode/command/impl/SandboxCommand.java +++ b/src/main/java/com/claudecode/command/impl/SandboxCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; @@ -20,11 +21,10 @@ public class SandboxCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - String trimmed = (args == null) ? "" : args.trim(); + String trimmed = CommandUtils.parseArgs(args); StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 🏖 Sandbox Mode\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("🏖", "Sandbox Mode")); if (context.agentLoop() == null) { return sb.append(" No active agent loop\n").toString(); diff --git a/src/main/java/com/claudecode/command/impl/SkillsCommand.java b/src/main/java/com/claudecode/command/impl/SkillsCommand.java index 4f71aae..e7468d8 100644 --- a/src/main/java/com/claudecode/command/impl/SkillsCommand.java +++ b/src/main/java/com/claudecode/command/impl/SkillsCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.context.SkillLoader; @@ -50,9 +51,7 @@ public class SkillsCommand implements SlashCommand { } StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🎯 Available Skills\n")); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("🎯", "Available Skills")); if (skills.isEmpty()) { sb.append(AnsiStyle.dim(" (No available skills)\n\n")); @@ -90,9 +89,7 @@ public class SkillsCommand implements SlashCommand { private String formatSkillDetail(SkillLoader.Skill skill) { StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🎯 Skill: " + skill.name())).append("\n"); - sb.append(" ").append("─".repeat(50)).append("\n\n"); + sb.append(CommandUtils.header("🎯", "Skill: " + skill.name())); sb.append(" ").append(AnsiStyle.bold("Source: ")).append(skill.source()).append("\n"); if (!skill.description().isEmpty()) { @@ -106,9 +103,7 @@ public class SkillsCommand implements SlashCommand { // Show content preview String content = skill.content(); - if (content.length() > 500) { - content = content.substring(0, 497) + "..."; - } + content = CommandUtils.truncate(content, 500); sb.append(AnsiStyle.dim(" Content:\n")); for (String line : content.lines().toList()) { sb.append(AnsiStyle.dim(" │ " + line)).append("\n"); diff --git a/src/main/java/com/claudecode/command/impl/StatusCommand.java b/src/main/java/com/claudecode/command/impl/StatusCommand.java index a006441..cf27f06 100644 --- a/src/main/java/com/claudecode/command/impl/StatusCommand.java +++ b/src/main/java/com/claudecode/command/impl/StatusCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.TokenTracker; @@ -31,9 +32,7 @@ public class StatusCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 📊 Session Status\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("📊", "Session Status")); // 模型信息 TokenTracker tracker = context.agentLoop().getTokenTracker(); @@ -68,7 +67,7 @@ public class StatusCommand implements SlashCommand { // 运行时间 Duration uptime = Duration.between(startTime, Instant.now()); - sb.append(" ").append(AnsiStyle.bold("Uptime: ")).append(formatDuration(uptime)).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Uptime: ")).append(CommandUtils.formatDuration(uptime.toSeconds())).append("\n"); // Java 版本 sb.append(" ").append(AnsiStyle.bold("JDK: ")).append(System.getProperty("java.version")).append("\n"); @@ -76,12 +75,5 @@ public class StatusCommand implements SlashCommand { return sb.toString(); } - private String formatDuration(Duration d) { - long hours = d.toHours(); - long minutes = d.toMinutesPart(); - long seconds = d.toSecondsPart(); - if (hours > 0) return String.format("%dh %dm %ds", hours, minutes, seconds); - if (minutes > 0) return String.format("%dm %ds", minutes, seconds); - return String.format("%ds", seconds); - } + } diff --git a/src/main/java/com/claudecode/command/impl/TasksCommand.java b/src/main/java/com/claudecode/command/impl/TasksCommand.java index dd31fa0..4d86e38 100644 --- a/src/main/java/com/claudecode/command/impl/TasksCommand.java +++ b/src/main/java/com/claudecode/command/impl/TasksCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.TaskManager; @@ -38,7 +39,7 @@ public class TasksCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { List tasks; - String filter = (args == null) ? "" : args.strip(); + String filter = CommandUtils.parseArgs(args); // Optional status filter if (!filter.isEmpty()) { @@ -59,7 +60,7 @@ public class TasksCommand implements SlashCommand { StringBuilder sb = new StringBuilder(); sb.append("\n ").append(AnsiStyle.bold("📋 Tasks")).append(" (").append(tasks.size()).append(")\n"); - sb.append(" ").append("─".repeat(60)).append("\n"); + sb.append(CommandUtils.separator(60)).append("\n"); for (TaskInfo task : tasks) { String icon = switch (task.status()) { @@ -84,30 +85,21 @@ public class TasksCommand implements SlashCommand { .append(task.description()).append("\n"); // Time info - String age = formatDuration(Duration.between(task.createdAt(), Instant.now())); + String age = CommandUtils.formatDuration(Duration.between(task.createdAt(), Instant.now()).toSeconds()); sb.append(" ").append(AnsiStyle.dim("Created " + age + " ago")); // Result preview for completed/failed if (task.result() != null) { - String preview = task.result().length() > 60 - ? task.result().substring(0, 57) + "..." - : task.result(); + String preview = CommandUtils.truncate(task.result(), 60); sb.append(" ").append(AnsiStyle.dim("→ " + preview)); } sb.append("\n"); } // Summary - sb.append(" ").append("─".repeat(60)).append("\n"); + sb.append(CommandUtils.separator(60)).append("\n"); sb.append(" ").append(AnsiStyle.dim(taskManager.getSummary())).append("\n"); return sb.toString(); } - - private String formatDuration(Duration d) { - if (d.toMinutes() < 1) return d.toSeconds() + "s"; - if (d.toHours() < 1) return d.toMinutes() + "m"; - if (d.toDays() < 1) return d.toHours() + "h"; - return d.toDays() + "d"; - } } diff --git a/src/main/java/com/claudecode/command/impl/TraceCommand.java b/src/main/java/com/claudecode/command/impl/TraceCommand.java index d4cd2a6..9f07f67 100644 --- a/src/main/java/com/claudecode/command/impl/TraceCommand.java +++ b/src/main/java/com/claudecode/command/impl/TraceCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; @@ -23,11 +24,10 @@ public class TraceCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - String trimmed = (args == null) ? "" : args.trim(); + String trimmed = CommandUtils.parseArgs(args); StringBuilder sb = new StringBuilder(); - sb.append("\n").append(AnsiStyle.bold(" 🔍 Request Tracing\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("🔍", "Request Tracing")); if (context.agentLoop() == null) { return sb.append(" No active agent loop\n").toString(); diff --git a/src/main/java/com/claudecode/command/impl/VersionCommand.java b/src/main/java/com/claudecode/command/impl/VersionCommand.java index 5f646dd..53ab9c9 100644 --- a/src/main/java/com/claudecode/command/impl/VersionCommand.java +++ b/src/main/java/com/claudecode/command/impl/VersionCommand.java @@ -1,6 +1,7 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandUtils; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; @@ -28,9 +29,7 @@ public class VersionCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append(AnsiStyle.bold(" 🏷️ Claude Code Java\n")); - sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(CommandUtils.header("🏷️", "Claude Code Java")); sb.append(" ").append(AnsiStyle.bold("Version: ")) .append(AnsiStyle.cyan("v" + VERSION)).append("\n"); diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index a92ae7e..cb72cc1 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -1,7 +1,6 @@ package com.claudecode.config; import com.claudecode.command.CommandRegistry; -import com.claudecode.command.impl.*; import com.claudecode.context.ClaudeMdLoader; import com.claudecode.context.GitContext; import com.claudecode.context.SkillLoader; @@ -21,7 +20,8 @@ import com.claudecode.repl.ReplSession; import com.claudecode.tui.JinkReplSession; import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolRegistry; -import com.claudecode.tool.impl.*; +import com.claudecode.tool.impl.AgentTool; +import com.claudecode.tool.impl.SkillTool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.model.ChatModel; @@ -32,6 +32,13 @@ import org.springframework.context.annotation.Configuration; import java.nio.file.Path; +/** + * 核心应用配置 —— 基础设施 Bean 和跨切关注点。 + *

+ * 工具注册见 {@link ToolConfiguration}, + * 命令注册见 {@link CommandConfiguration}。 + */ + /** * 应用配置类 —— Spring Bean 装配。 *

@@ -79,144 +86,6 @@ public class AppConfig { return manager; } - @Bean - public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager, - ToolContext toolContext, PermissionSettings permissionSettings) { - // 将 TaskManager 和 McpManager 注册到 ToolContext 供工具使用 - toolContext.set("TASK_MANAGER", taskManager); - toolContext.set("MCP_MANAGER", mcpManager); - toolContext.set("PERMISSION_SETTINGS", permissionSettings); - - ToolRegistry registry = new ToolRegistry(); - registry.registerAll( - new BashTool(), - new FileReadTool(), - new FileWriteTool(), - new FileEditTool(), - new GlobTool(), - new GrepTool(), - new ListFilesTool(), - new WebFetchTool(), - new TodoWriteTool(), - new AgentTool(), - new NotebookEditTool(), - new WebSearchTool(), - new AskUserQuestionTool(), - // P2: 任务管理工具 - new TaskCreateTool(), - new TaskGetTool(), - new TaskListTool(), - new TaskUpdateTool(), - new TaskStopTool(), - new TaskOutputTool(), - // P2: 配置工具 - new ConfigTool(), - // P2: 实用工具 - new SleepTool(), - new ToolSearchTool(), - new EnterPlanModeTool(), - new ExitPlanModeTool(), - new SkillTool(), - new SendMessageTool(), - new ListMcpResourcesTool(), - new ReadMcpResourceTool(), - new EnterWorktreeTool(), - new ExitWorktreeTool(), - // P4: 新工具 - new LSPTool(), - new BriefTool(), - new NotificationTool() - ); - - // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) - for (var client : mcpManager.getClients().values()) { - for (var mcpTool : client.getTools()) { - registry.register(new McpToolBridge(client.getServerName(), mcpTool)); - } - } - - // 将 ToolRegistry 注入 ToolContext,供 ToolSearchTool 使用 - toolContext.set("TOOL_REGISTRY", registry); - - return registry; - } - - @Bean - public CommandRegistry commandRegistry(PluginManager pluginManager, PermissionSettings permissionSettings, - TaskManager taskManager) { - ConfigCommand configCommand = new ConfigCommand(permissionSettings); - CommandRegistry registry = new CommandRegistry(); - registry.registerAll( - // 基础命令 - new HelpCommand(), - new ClearCommand(), - new CompactCommand(), - new CostCommand(), - new ModelCommand(), - new StatusCommand(), - new ContextCommand(), - new InitCommand(), - configCommand, - new HistoryCommand(), - // P0 命令 - new DiffCommand(), - new VersionCommand(), - new SkillsCommand(), - new MemoryCommand(), - new CopyCommand(), - // P1 命令 - new ResumeCommand(), - new ExportCommand(), - new CommitCommand(), - new FilesCommand(), - new PermissionsCommand(permissionSettings), - new TasksCommand(taskManager), - new PlanCommand(permissionSettings), - // P2 命令 - new HooksCommand(), - new ReviewCommand(), - new StatsCommand(), - new BranchCommand(), - new RewindCommand(), - new TagCommand(), - new SecurityReviewCommand(), - new McpCommand(), - new PluginCommand(), - // Phase 2F 命令 - new DoctorCommand(), - new SessionCommand(), - new AgentCommand(), - new RenameCommand(), - // Phase 4B 命令 - new BriefCommand(), - new VimCommand(), - new ThemeCommand(), - new UsageCommand(), - new TipsCommand(), - new OutputStyleCommand(), - new EnvCommand(), - new PerformanceCommand(), - new PrivacyCommand(), - new FeedbackCommand(), - new ReleaseNotesCommand(), - new KeybindingsCommand(), - // Phase 4D 调试命令 - new DebugCommand(), - new HeapdumpCommand(), - new TraceCommand(), - new ContextVizCommand(), - new ResetLimitsCommand(), - new SandboxCommand(), - // Exit 放最后 - new ExitCommand() - ); - - // P2: 注册插件提供的命令 - pluginManager.registerCommands(registry); - - return registry; - } - /** * 根据 claude-code.provider 配置选择 ChatModel。 */ diff --git a/src/main/java/com/claudecode/config/CommandConfiguration.java b/src/main/java/com/claudecode/config/CommandConfiguration.java new file mode 100644 index 0000000..4aca2dd --- /dev/null +++ b/src/main/java/com/claudecode/config/CommandConfiguration.java @@ -0,0 +1,89 @@ +package com.claudecode.config; + +import com.claudecode.command.CommandRegistry; +import com.claudecode.command.impl.*; +import com.claudecode.core.TaskManager; +import com.claudecode.permission.PermissionSettings; +import com.claudecode.plugin.PluginManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 命令注册配置 —— 从 AppConfig 拆分出来,专注于 SlashCommand 注册。 + */ +@Configuration +public class CommandConfiguration { + + @Bean + public CommandRegistry commandRegistry(PluginManager pluginManager, PermissionSettings permissionSettings, + TaskManager taskManager) { + ConfigCommand configCommand = new ConfigCommand(permissionSettings); + CommandRegistry registry = new CommandRegistry(); + registry.registerAll( + // 基础命令 + new HelpCommand(), + new ClearCommand(), + new CompactCommand(), + new CostCommand(), + new ModelCommand(), + new StatusCommand(), + new ContextCommand(), + new InitCommand(), + configCommand, + new HistoryCommand(), + // 文件/Git 命令 + new DiffCommand(), + new VersionCommand(), + new SkillsCommand(), + new MemoryCommand(), + new CopyCommand(), + new ResumeCommand(), + new ExportCommand(), + new CommitCommand(), + new FilesCommand(), + new PermissionsCommand(permissionSettings), + new TasksCommand(taskManager), + new PlanCommand(permissionSettings), + // 协作/审查命令 + new HooksCommand(), + new ReviewCommand(), + new StatsCommand(), + new BranchCommand(), + new RewindCommand(), + new TagCommand(), + new SecurityReviewCommand(), + new McpCommand(), + new PluginCommand(), + // 会话/Agent 命令 + new DoctorCommand(), + new SessionCommand(), + new AgentCommand(), + new RenameCommand(), + // UX 命令 + new BriefCommand(), + new VimCommand(), + new ThemeCommand(), + new UsageCommand(), + new TipsCommand(), + new OutputStyleCommand(), + new EnvCommand(), + new PerformanceCommand(), + new PrivacyCommand(), + new FeedbackCommand(), + new ReleaseNotesCommand(), + new KeybindingsCommand(), + // 调试命令 + new DebugCommand(), + new HeapdumpCommand(), + new TraceCommand(), + new ContextVizCommand(), + new ResetLimitsCommand(), + new SandboxCommand(), + // Exit + new ExitCommand() + ); + + pluginManager.registerCommands(registry); + return registry; + } +} diff --git a/src/main/java/com/claudecode/config/ToolConfiguration.java b/src/main/java/com/claudecode/config/ToolConfiguration.java new file mode 100644 index 0000000..3524812 --- /dev/null +++ b/src/main/java/com/claudecode/config/ToolConfiguration.java @@ -0,0 +1,80 @@ +package com.claudecode.config; + +import com.claudecode.mcp.McpManager; +import com.claudecode.tool.ToolContext; +import com.claudecode.tool.ToolRegistry; +import com.claudecode.tool.impl.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.claudecode.core.TaskManager; +import com.claudecode.permission.PermissionSettings; + +/** + * 工具注册配置 —— 从 AppConfig 拆分出来,专注于 Tool 注册。 + */ +@Configuration +public class ToolConfiguration { + + private static final Logger log = LoggerFactory.getLogger(ToolConfiguration.class); + + @Bean + public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager, + ToolContext toolContext, PermissionSettings permissionSettings) { + toolContext.set("TASK_MANAGER", taskManager); + toolContext.set("MCP_MANAGER", mcpManager); + toolContext.set("PERMISSION_SETTINGS", permissionSettings); + + ToolRegistry registry = new ToolRegistry(); + registry.registerAll( + // 核心工具 + new BashTool(), + new FileReadTool(), + new FileWriteTool(), + new FileEditTool(), + new GlobTool(), + new GrepTool(), + new ListFilesTool(), + new WebFetchTool(), + new TodoWriteTool(), + new AgentTool(), + new NotebookEditTool(), + new WebSearchTool(), + new AskUserQuestionTool(), + // 任务管理工具 + new TaskCreateTool(), + new TaskGetTool(), + new TaskListTool(), + new TaskUpdateTool(), + new TaskStopTool(), + new TaskOutputTool(), + // 配置/实用工具 + new ConfigTool(), + new SleepTool(), + new ToolSearchTool(), + new EnterPlanModeTool(), + new ExitPlanModeTool(), + new SkillTool(), + new SendMessageTool(), + new ListMcpResourcesTool(), + new ReadMcpResourceTool(), + new EnterWorktreeTool(), + new ExitWorktreeTool(), + // 高级工具 + new LSPTool(), + new BriefTool(), + new NotificationTool() + ); + + // MCP 工具桥接 + for (var client : mcpManager.getClients().values()) { + for (var mcpTool : client.getTools()) { + registry.register(new McpToolBridge(client.getServerName(), mcpTool)); + } + } + + toolContext.set("TOOL_REGISTRY", registry); + return registry; + } +} diff --git a/src/main/java/com/claudecode/tool/AbstractReadOnlyTool.java b/src/main/java/com/claudecode/tool/AbstractReadOnlyTool.java new file mode 100644 index 0000000..a950858 --- /dev/null +++ b/src/main/java/com/claudecode/tool/AbstractReadOnlyTool.java @@ -0,0 +1,53 @@ +package com.claudecode.tool; + +import java.util.Map; + +/** + * 只读工具抽象基类 —— 18+ 只读工具共享的基础实现。 + *

+ * 提供: + *

+ * 子类只需实现 name/description/inputSchema/execute。 + */ +public abstract class AbstractReadOnlyTool implements Tool { + + @Override + public final boolean isReadOnly() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public PermissionResult checkPermission(Map input, ToolContext context) { + return PermissionResult.ALLOW; + } + + @Override + public String activityDescription(Map input) { + return "Reading " + name() + "..."; + } + + /** + * 标准化错误结果。 + */ + protected String errorResult(String message) { + return "Error: " + message; + } + + /** + * 从 input 中获取必填 String 参数,缺失则返回 null 并可由调用方提前 return errorResult。 + */ + protected String requireParam(Map input, String paramName) { + String err = ToolValidator.requireString(input, paramName); + return err == null ? input.get(paramName).toString() : null; + } +} diff --git a/src/main/java/com/claudecode/tool/ToolValidator.java b/src/main/java/com/claudecode/tool/ToolValidator.java new file mode 100644 index 0000000..2b8939b --- /dev/null +++ b/src/main/java/com/claudecode/tool/ToolValidator.java @@ -0,0 +1,90 @@ +package com.claudecode.tool; + +import java.nio.file.Path; + +/** + * 工具输入验证器 —— 消除 Tool 实现中的重复验证代码。 + *

+ * 所有 validate* 方法:返回 null 表示验证通过,返回 String 表示错误消息。 + * 典型用法: + *

+ *   String err = ToolValidator.requireString(input, "file_path");
+ *   if (err != null) return err;
+ * 
+ */ +public final class ToolValidator { + + private ToolValidator() {} + + /** + * 验证必填 String 参数(非 null、非空白)。 + * @return null 表示通过,否则返回错误消息 + */ + public static String requireString(java.util.Map input, String paramName) { + Object value = input.get(paramName); + if (value == null) { + return "Error: '" + paramName + "' is required."; + } + if (value instanceof String s && s.isBlank()) { + return "Error: '" + paramName + "' must not be empty."; + } + return null; + } + + /** + * 获取 String 参数(带默认值)。 + */ + public static String getString(java.util.Map input, String paramName, String defaultValue) { + Object value = input.get(paramName); + if (value instanceof String s && !s.isBlank()) return s; + return defaultValue; + } + + /** + * 安全获取 int 参数(带默认值,防止 ClassCastException)。 + */ + public static int getInt(java.util.Map input, String paramName, int defaultValue) { + Object value = input.get(paramName); + if (value instanceof Number n) return n.intValue(); + if (value instanceof String s) { + try { return Integer.parseInt(s); } catch (NumberFormatException e) { /* fall through */ } + } + return defaultValue; + } + + /** + * 安全获取 boolean 参数(带默认值)。 + */ + public static boolean getBoolean(java.util.Map input, String paramName, boolean defaultValue) { + Object value = input.get(paramName); + if (value instanceof Boolean b) return b; + if (value instanceof String s) return Boolean.parseBoolean(s); + return defaultValue; + } + + /** + * 验证文件路径在工作目录内(防止路径遍历)。 + * @return null 表示通过,否则返回错误消息 + */ + public static String validatePathInWorkDir(String filePath, Path workDir) { + if (filePath == null || filePath.isBlank()) { + return "Error: file path is required."; + } + Path resolved = workDir.resolve(filePath).normalize(); + if (!resolved.startsWith(workDir.normalize())) { + return "Error: Path traversal not allowed. Path must be within the working directory."; + } + return null; + } + + /** + * 解析并验证路径(合并 resolve + normalize + traversal check)。 + * @return 解析后的路径,如验证失败则为 null(错误通过 errorHolder 返回) + */ + public static Path resolveSafePath(String filePath, Path workDir) { + if (filePath == null || filePath.isBlank()) return null; + Path resolved = workDir.resolve(filePath).normalize(); + if (!resolved.startsWith(workDir.normalize())) return null; + return resolved; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/NotificationTool.java b/src/main/java/com/claudecode/tool/impl/NotificationTool.java index 3763b83..22b9a88 100644 --- a/src/main/java/com/claudecode/tool/impl/NotificationTool.java +++ b/src/main/java/com/claudecode/tool/impl/NotificationTool.java @@ -2,24 +2,18 @@ package com.claudecode.tool.impl; import com.claudecode.tool.Tool; import com.claudecode.tool.ToolContext; +import com.claudecode.tool.ToolValidator; +import com.claudecode.tool.util.ProcessExecutor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.awt.*; -import java.io.IOException; +import java.util.List; import java.util.Map; /** * Notification 工具 —— 系统通知。 *

- * 向用户发送系统级通知(桌面弹窗 + 可选声音提示),用于: - *

    - *
  • 长时间任务完成时通知用户
  • - *
  • 需要用户注意的错误或警告
  • - *
  • Agent 需要用户输入时的提醒
  • - *
- *

- * 底层使用 Java AWT SystemTray (桌面环境) 或退回到 BEL 字符(终端环境)。 + * 向用户发送系统级通知(桌面弹窗 + 可选声音提示)。 */ public class NotificationTool implements Tool { @@ -76,24 +70,21 @@ public class NotificationTool implements Tool { @Override public String execute(Map input, ToolContext context) { - String title = (String) input.get("title"); - String message = (String) input.get("message"); - String level = (String) input.getOrDefault("level", "info"); - Boolean sound = (Boolean) input.getOrDefault("sound", true); + String err = ToolValidator.requireString(input, "title"); + if (err != null) return err; + err = ToolValidator.requireString(input, "message"); + if (err != null) return err; - if (title == null || message == null) { - return "Error: 'title' and 'message' are required"; - } + String title = input.get("title").toString(); + String message = input.get("message").toString(); + String level = ToolValidator.getString(input, "level", "info"); + boolean sound = ToolValidator.getBoolean(input, "sound", true); - // Truncate title if too long - if (title.length() > 80) { - title = title.substring(0, 77) + "..."; - } + title = title.length() > 80 ? title.substring(0, 77) + "..." : title; boolean sent = false; String method = "none"; - // Try OS-specific notification String os = System.getProperty("os.name", "").toLowerCase(); try { if (os.contains("win")) { @@ -110,71 +101,43 @@ public class NotificationTool implements Tool { log.debug("OS notification failed: {}", e.getMessage()); } - // Fallback: terminal bell - if (Boolean.TRUE.equals(sound)) { - System.out.print('\u0007'); // BEL character + if (sound) { + System.out.print('\u0007'); System.out.flush(); } if (!sent) { - // Fallback: just print to terminal - String icon = switch (level) { - case "warning" -> "⚠️"; - case "error" -> "❌"; - default -> "ℹ️"; - }; method = "terminal"; - sent = true; } return String.format("Notification sent via %s: [%s] %s - %s", method, level, title, message); } private boolean notifyWindows(String title, String message) { - try { - // Use PowerShell to send Windows toast notification - String ps = String.format( - "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; " + - "$n = New-Object System.Windows.Forms.NotifyIcon; " + - "$n.Icon = [System.Drawing.SystemIcons]::Information; " + - "$n.Visible = $true; " + - "$n.ShowBalloonTip(5000, '%s', '%s', 'Info'); " + - "Start-Sleep -Seconds 1; $n.Dispose()", - title.replace("'", "''"), message.replace("'", "''")); - - ProcessBuilder pb = new ProcessBuilder("powershell", "-NoProfile", "-Command", ps); - pb.inheritIO(); - Process proc = pb.start(); - return proc.waitFor() == 0; - } catch (Exception e) { - log.debug("Windows notification failed: {}", e.getMessage()); - return false; - } + String ps = String.format( + "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; " + + "$n = New-Object System.Windows.Forms.NotifyIcon; " + + "$n.Icon = [System.Drawing.SystemIcons]::Information; " + + "$n.Visible = $true; " + + "$n.ShowBalloonTip(5000, '%s', '%s', 'Info'); " + + "Start-Sleep -Seconds 1; $n.Dispose()", + title.replace("'", "''"), message.replace("'", "''")); + var result = ProcessExecutor.execute( + List.of("powershell", "-NoProfile", "-Command", ps), null, 10000); + return result.isSuccess(); } private boolean notifyMac(String title, String message) { - try { - String script = String.format( - "display notification \"%s\" with title \"%s\"", - message.replace("\"", "\\\""), title.replace("\"", "\\\"")); - ProcessBuilder pb = new ProcessBuilder("osascript", "-e", script); - Process proc = pb.start(); - return proc.waitFor() == 0; - } catch (Exception e) { - log.debug("macOS notification failed: {}", e.getMessage()); - return false; - } + String script = String.format( + "display notification \"%s\" with title \"%s\"", + message.replace("\"", "\\\""), title.replace("\"", "\\\"")); + var result = ProcessExecutor.execute(List.of("osascript", "-e", script), null, 5000); + return result.isSuccess(); } private boolean notifyLinux(String title, String message) { - try { - ProcessBuilder pb = new ProcessBuilder("notify-send", title, message); - Process proc = pb.start(); - return proc.waitFor() == 0; - } catch (Exception e) { - log.debug("Linux notification failed: {}", e.getMessage()); - return false; - } + var result = ProcessExecutor.execute(List.of("notify-send", title, message), null, 5000); + return result.isSuccess(); } @Override diff --git a/src/main/java/com/claudecode/tool/util/ProcessExecutor.java b/src/main/java/com/claudecode/tool/util/ProcessExecutor.java new file mode 100644 index 0000000..5eabc68 --- /dev/null +++ b/src/main/java/com/claudecode/tool/util/ProcessExecutor.java @@ -0,0 +1,115 @@ +package com.claudecode.tool.util; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 进程执行器 —— 消除 BashTool、GrepTool、NotificationTool 中的重复 ProcessBuilder 模式。 + *

+ * 封装进程创建、超时管理、输出捕获和资源清理。 + */ +public final class ProcessExecutor { + + private ProcessExecutor() {} + + /** + * 进程执行结果。 + */ + public record Result(int exitCode, String stdout, String stderr, boolean timedOut) { + + public boolean isSuccess() { + return exitCode == 0 && !timedOut; + } + + public String output() { + return stdout != null ? stdout : ""; + } + + public String combinedOutput() { + StringBuilder sb = new StringBuilder(); + if (stdout != null && !stdout.isEmpty()) sb.append(stdout); + if (stderr != null && !stderr.isEmpty()) { + if (!sb.isEmpty()) sb.append("\n"); + sb.append(stderr); + } + return sb.toString(); + } + } + + /** + * 在指定工作目录执行命令,带超时和资源清理。 + */ + public static Result execute(List command, Path workDir, long timeoutMs) { + return execute(command, workDir, timeoutMs, null); + } + + /** + * 在指定工作目录执行命令,带超时、环境变量和资源清理。 + */ + public static Result execute(List command, Path workDir, long timeoutMs, Map env) { + ProcessBuilder pb = new ProcessBuilder(command); + if (workDir != null) pb.directory(workDir.toFile()); + pb.redirectErrorStream(false); + + if (env != null && !env.isEmpty()) { + pb.environment().putAll(env); + } + + Process process = null; + try { + process = pb.start(); + + String stdout; + String stderr; + try (var stdoutStream = process.getInputStream(); + var stderrStream = process.getErrorStream()) { + stdout = new String(stdoutStream.readAllBytes()); + stderr = new String(stderrStream.readAllBytes()); + } + + boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS); + if (!finished) { + process.destroyForcibly(); + process.waitFor(2, TimeUnit.SECONDS); + return new Result(-1, stdout, stderr, true); + } + + return new Result(process.exitValue(), stdout, stderr, false); + } catch (IOException e) { + return new Result(-1, "", "IOException: " + e.getMessage(), false); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new Result(-1, "", "Interrupted", false); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + /** + * 快速检查命令是否可用(which/where)。 + */ + public static boolean isCommandAvailable(String command) { + boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); + String checker = isWindows ? "where" : "which"; + Result result = execute(List.of(checker, command), null, 5000); + return result.isSuccess(); + } + + /** + * 执行 shell 命令(通过 sh -c 或 cmd /c)。 + */ + public static Result executeShell(String shellCommand, Path workDir, long timeoutMs) { + boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); + List command; + if (isWindows) { + command = List.of("cmd.exe", "/c", shellCommand); + } else { + command = List.of("/bin/sh", "-c", shellCommand); + } + return execute(command, workDir, timeoutMs); + } +}