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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package com.claudecode.command;
|
||||
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
/**
|
||||
* 命令输出格式化工具 —— 消除命令实现中的重复格式化代码。
|
||||
* <p>
|
||||
* 提供标准化的命令输出格式:标题、分隔线、成功/失败消息、
|
||||
* 字节/时间格式化、进度条等。
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> 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<String, String> env = new TreeMap<>(System.getenv());
|
||||
for (Map.Entry<String, String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, McpClient> 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 持有者(简单单例,供命令和其他组件访问) ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PluginInfo> 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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<TaskInfo> 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 和跨切关注点。
|
||||
* <p>
|
||||
* 工具注册见 {@link ToolConfiguration},
|
||||
* 命令注册见 {@link CommandConfiguration}。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 应用配置类 —— Spring Bean 装配。
|
||||
* <p>
|
||||
@@ -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。
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.claudecode.tool;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 只读工具抽象基类 —— 18+ 只读工具共享的基础实现。
|
||||
* <p>
|
||||
* 提供:
|
||||
* <ul>
|
||||
* <li>{@link #isReadOnly()} 固定返回 true</li>
|
||||
* <li>{@link #isEnabled()} 默认返回 true</li>
|
||||
* <li>{@link #checkPermission} 默认 ALLOW</li>
|
||||
* <li>{@link #errorResult(String)} 标准化错误格式</li>
|
||||
* </ul>
|
||||
* 子类只需实现 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<String, Object> input, ToolContext context) {
|
||||
return PermissionResult.ALLOW;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String activityDescription(Map<String, Object> input) {
|
||||
return "Reading " + name() + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化错误结果。
|
||||
*/
|
||||
protected String errorResult(String message) {
|
||||
return "Error: " + message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 input 中获取必填 String 参数,缺失则返回 null 并可由调用方提前 return errorResult。
|
||||
*/
|
||||
protected String requireParam(Map<String, Object> input, String paramName) {
|
||||
String err = ToolValidator.requireString(input, paramName);
|
||||
return err == null ? input.get(paramName).toString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.claudecode.tool;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* 工具输入验证器 —— 消除 Tool 实现中的重复验证代码。
|
||||
* <p>
|
||||
* 所有 validate* 方法:返回 null 表示验证通过,返回 String 表示错误消息。
|
||||
* 典型用法:
|
||||
* <pre>
|
||||
* String err = ToolValidator.requireString(input, "file_path");
|
||||
* if (err != null) return err;
|
||||
* </pre>
|
||||
*/
|
||||
public final class ToolValidator {
|
||||
|
||||
private ToolValidator() {}
|
||||
|
||||
/**
|
||||
* 验证必填 String 参数(非 null、非空白)。
|
||||
* @return null 表示通过,否则返回错误消息
|
||||
*/
|
||||
public static String requireString(java.util.Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 工具 —— 系统通知。
|
||||
* <p>
|
||||
* 向用户发送系统级通知(桌面弹窗 + 可选声音提示),用于:
|
||||
* <ul>
|
||||
* <li>长时间任务完成时通知用户</li>
|
||||
* <li>需要用户注意的错误或警告</li>
|
||||
* <li>Agent 需要用户输入时的提醒</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 底层使用 Java AWT SystemTray (桌面环境) 或退回到 BEL 字符(终端环境)。
|
||||
* 向用户发送系统级通知(桌面弹窗 + 可选声音提示)。
|
||||
*/
|
||||
public class NotificationTool implements Tool {
|
||||
|
||||
@@ -76,24 +70,21 @@ public class NotificationTool implements Tool {
|
||||
|
||||
@Override
|
||||
public String execute(Map<String, Object> 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
|
||||
|
||||
@@ -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 模式。
|
||||
* <p>
|
||||
* 封装进程创建、超时管理、输出捕获和资源清理。
|
||||
*/
|
||||
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<String> command, Path workDir, long timeoutMs) {
|
||||
return execute(command, workDir, timeoutMs, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定工作目录执行命令,带超时、环境变量和资源清理。
|
||||
*/
|
||||
public static Result execute(List<String> command, Path workDir, long timeoutMs, Map<String, String> 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<String> command;
|
||||
if (isWindows) {
|
||||
command = List.of("cmd.exe", "/c", shellCommand);
|
||||
} else {
|
||||
command = List.of("/bin/sh", "-c", shellCommand);
|
||||
}
|
||||
return execute(command, workDir, timeoutMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user