diff --git a/src/main/java/com/claudecode/command/impl/AgentCommand.java b/src/main/java/com/claudecode/command/impl/AgentCommand.java new file mode 100644 index 0000000..0b037d2 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/AgentCommand.java @@ -0,0 +1,204 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.TaskManager; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** + * /agent 命令 —— Agent 状态管理。 + *

+ * 对应 claude-code 的 agent 管理功能,显示运行中的子 agent/worker 信息。 + *

+ */ +public class AgentCommand implements SlashCommand { + + @Override + public String name() { + return "agent"; + } + + @Override + public String description() { + return "View and manage sub-agents/workers"; + } + + @Override + public List aliases() { + return List.of("agents", "workers"); + } + + @Override + public String execute(String args, CommandContext context) { + args = args == null ? "" : args.strip(); + + // Get TaskManager from AgentLoop's tool context + TaskManager taskManager = getTaskManager(context); + if (taskManager == null) { + return AnsiStyle.dim(" No task manager available. Agent management requires an active session."); + } + + if (args.equals("stop-all")) { + return handleStopAll(taskManager); + } else if (args.startsWith("stop ")) { + return handleStop(args.substring(5).strip(), taskManager); + } else if (!args.isBlank()) { + return handleDetail(args, taskManager); + } else { + return handleList(taskManager); + } + } + + private String handleList(TaskManager taskManager) { + var allTasks = taskManager.listTasks(); + if (allTasks.isEmpty()) { + return AnsiStyle.dim(" No agents/workers running."); + } + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🤖 Agents/Workers\n")); + sb.append(" ").append("─".repeat(60)).append("\n"); + + for (var task : allTasks) { + String statusIcon = switch (task.status()) { + case RUNNING -> "🟢"; + case PENDING -> "🟡"; + case COMPLETED -> "✅"; + case FAILED -> "❌"; + case CANCELLED -> "⛔"; + }; + + String age = formatAge(task.createdAt()); + + sb.append(" ").append(statusIcon).append(" ") + .append(AnsiStyle.bold(task.id())) + .append(" ").append(task.status().name()) + .append(" ").append(AnsiStyle.dim(age)) + .append("\n"); + sb.append(" ").append(truncate(task.description(), 60)).append("\n"); + } + + long running = allTasks.stream() + .filter(t -> t.status() == TaskManager.TaskStatus.RUNNING).count(); + long completed = allTasks.stream() + .filter(t -> t.status() == TaskManager.TaskStatus.COMPLETED).count(); + + sb.append("\n ").append(AnsiStyle.dim( + "Total: " + allTasks.size() + " | Running: " + running + " | Completed: " + completed)); + sb.append("\n"); + + return sb.toString(); + } + + private String handleDetail(String taskId, TaskManager taskManager) { + var taskOpt = taskManager.getTask(taskId); + if (taskOpt.isEmpty()) { + // Try partial match + var allTasks = taskManager.listTasks(); + var matched = allTasks.stream() + .filter(t -> t.id().startsWith(taskId) || t.description().toLowerCase().contains(taskId.toLowerCase())) + .findFirst(); + if (matched.isEmpty()) { + return AnsiStyle.red(" ✗ No agent found with ID: " + taskId); + } + taskOpt = matched; + } + + var task = taskOpt.get(); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🤖 Agent Detail: " + task.id() + "\n")); + sb.append(" ").append("─".repeat(50)).append("\n"); + sb.append(" ").append(AnsiStyle.dim("Status: ")).append(task.status().name()).append("\n"); + sb.append(" ").append(AnsiStyle.dim("Task: ")).append(task.description()).append("\n"); + sb.append(" ").append(AnsiStyle.dim("Created: ")).append(task.createdAt()).append("\n"); + sb.append(" ").append(AnsiStyle.dim("Updated: ")).append(task.updatedAt()).append("\n"); + + if (task.result() != null && !task.result().isBlank()) { + sb.append(" ").append(AnsiStyle.dim("Result:")).append("\n"); + task.result().lines().limit(20).forEach(line -> + sb.append(" ").append(line).append("\n")); + long totalLines = task.result().lines().count(); + if (totalLines > 20) { + sb.append(" ").append(AnsiStyle.dim("... (" + (totalLines - 20) + " more lines)")).append("\n"); + } + } + + if (!task.metadata().isEmpty()) { + sb.append(" ").append(AnsiStyle.dim("Metadata:")).append("\n"); + task.metadata().forEach((k, v) -> + sb.append(" ").append(k).append(": ").append(v).append("\n")); + } + + return sb.toString(); + } + + private String handleStop(String taskId, TaskManager taskManager) { + boolean cancelled = taskManager.cancelTask(taskId); + if (cancelled) { + return AnsiStyle.green(" ✓ Agent " + taskId + " stopped"); + } + + // Try partial match + var allTasks = taskManager.listTasks(); + var matched = allTasks.stream() + .filter(t -> t.id().startsWith(taskId)) + .findFirst(); + if (matched.isPresent()) { + cancelled = taskManager.cancelTask(matched.get().id()); + if (cancelled) { + return AnsiStyle.green(" ✓ Agent " + matched.get().id() + " stopped"); + } + return AnsiStyle.yellow(" ⚠ Agent " + matched.get().id() + + " is already " + matched.get().status().name()); + } + + return AnsiStyle.red(" ✗ No running agent found with ID: " + taskId); + } + + private String handleStopAll(TaskManager taskManager) { + var running = taskManager.listTasks(TaskManager.TaskStatus.RUNNING); + if (running.isEmpty()) { + return AnsiStyle.dim(" No running agents to stop."); + } + + int stopped = 0; + for (var task : running) { + if (taskManager.cancelTask(task.id())) stopped++; + } + + return AnsiStyle.green(" ✓ Stopped " + stopped + " agent(s)"); + } + + private TaskManager getTaskManager(CommandContext context) { + // The TaskManager is typically accessible from the tool context + // via AgentLoop's tool context. Try to access it. + if (context.agentLoop() == null) return null; + var toolContext = context.agentLoop().getToolContext(); + if (toolContext == null) return null; + return toolContext.getOrDefault("TASK_MANAGER", null); + } + + private String formatAge(Instant created) { + long seconds = Duration.between(created, Instant.now()).getSeconds(); + if (seconds < 60) return seconds + "s ago"; + if (seconds < 3600) return (seconds / 60) + "m ago"; + return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m ago"; + } + + private String truncate(String text, int maxLen) { + if (text == null) return ""; + if (text.length() <= maxLen) return text; + return text.substring(0, maxLen - 3) + "..."; + } +} diff --git a/src/main/java/com/claudecode/command/impl/DoctorCommand.java b/src/main/java/com/claudecode/command/impl/DoctorCommand.java new file mode 100644 index 0000000..fb1bf27 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/DoctorCommand.java @@ -0,0 +1,166 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.io.File; +import java.util.List; + +/** + * /doctor 命令 —— 环境诊断。 + *

+ * 对应 claude-code 的 /doctor 命令,检查运行环境是否配置正确。 + * 检查项: + *

+ */ +public class DoctorCommand implements SlashCommand { + + @Override + public String name() { + return "doctor"; + } + + @Override + public String description() { + return "Run environment diagnostics"; + } + + @Override + public List aliases() { + return List.of("diag", "check"); + } + + @Override + public String execute(String args, CommandContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🩺 Environment Diagnostics\n")); + sb.append(" ").append("═".repeat(50)).append("\n\n"); + + int passed = 0, warned = 0, failed = 0; + + // Java Version + String javaVersion = System.getProperty("java.version"); + String javaVendor = System.getProperty("java.vendor"); + boolean javaOk = javaVersion != null && (javaVersion.startsWith("21") + || javaVersion.startsWith("22") || javaVersion.startsWith("23") + || javaVersion.startsWith("24") || javaVersion.startsWith("25")); + sb.append(check("Java", javaVersion + " (" + javaVendor + ")", javaOk)); + if (javaOk) passed++; else warned++; + + // OS Info + String osName = System.getProperty("os.name"); + String osVersion = System.getProperty("os.version"); + String osArch = System.getProperty("os.arch"); + sb.append(check("OS", osName + " " + osVersion + " (" + osArch + ")", true)); + passed++; + + // Working Directory + String workDir = System.getProperty("user.dir"); + sb.append(check("Working Dir", workDir, true)); + passed++; + + // Git + String gitVersion = runCommand("git", "--version"); + boolean gitOk = gitVersion != null && gitVersion.contains("git version"); + sb.append(check("Git", gitOk ? gitVersion.strip() : "not found", gitOk)); + if (gitOk) passed++; else failed++; + + // Git config + if (gitOk) { + String userName = runCommand("git", "config", "user.name"); + String userEmail = runCommand("git", "config", "user.email"); + boolean configOk = userName != null && !userName.isBlank() + && userEmail != null && !userEmail.isBlank(); + String configInfo = configOk + ? userName.strip() + " <" + userEmail.strip() + ">" + : "user.name or user.email not set"; + sb.append(check("Git Config", configInfo, configOk)); + if (configOk) passed++; else warned++; + } + + // API Key + boolean hasAnthropicKey = System.getenv("ANTHROPIC_API_KEY") != null; + boolean hasOpenAiKey = System.getenv("OPENAI_API_KEY") != null; + boolean hasApiKey = hasAnthropicKey || hasOpenAiKey; + String apiKeyInfo = hasAnthropicKey ? "ANTHROPIC_API_KEY set" + : hasOpenAiKey ? "OPENAI_API_KEY set" : "No API key found"; + sb.append(check("API Key", apiKeyInfo, hasApiKey)); + if (hasApiKey) passed++; else failed++; + + // CLAUDE.md + boolean hasClaudeMd = new File(workDir, "CLAUDE.md").exists(); + sb.append(check("CLAUDE.md", hasClaudeMd ? "found" : "not found (optional)", true)); + passed++; + + // Config directory + String userHome = System.getProperty("user.home"); + File configDir = new File(userHome, ".claude-code"); + boolean hasConfigDir = configDir.exists() && configDir.isDirectory(); + sb.append(check("Config Dir", configDir.getPath() + + (hasConfigDir ? " (exists)" : " (will be created)"), true)); + passed++; + + // MCP config + boolean hasProjectMcp = new File(workDir, ".mcp.json").exists(); + boolean hasGlobalMcp = new File(userHome, ".claude-code-java/mcp.json").exists(); + String mcpInfo = hasProjectMcp ? ".mcp.json found (project)" + : hasGlobalMcp ? "mcp.json found (global)" : "no config"; + sb.append(check("MCP Config", mcpInfo, true)); + passed++; + + // Disk Space + File root = new File(workDir); + long freeSpace = root.getFreeSpace(); + long totalSpace = root.getTotalSpace(); + double freeGb = freeSpace / (1024.0 * 1024.0 * 1024.0); + double totalGb = totalSpace / (1024.0 * 1024.0 * 1024.0); + boolean diskOk = freeGb > 1.0; + sb.append(check("Disk Space", String.format("%.1f GB free / %.1f GB total", freeGb, totalGb), diskOk)); + if (diskOk) passed++; else warned++; + + // Memory + Runtime runtime = Runtime.getRuntime(); + long maxMem = runtime.maxMemory() / (1024 * 1024); + long usedMem = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024); + sb.append(check("JVM Memory", usedMem + " MB used / " + maxMem + " MB max", maxMem > 256)); + passed++; + + // Summary + sb.append("\n ").append("─".repeat(50)).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Summary: ")); + sb.append(AnsiStyle.green(passed + " passed")); + if (warned > 0) sb.append(", ").append(AnsiStyle.yellow(warned + " warnings")); + if (failed > 0) sb.append(", ").append(AnsiStyle.red(failed + " failed")); + sb.append("\n"); + + return sb.toString(); + } + + private String check(String label, String value, boolean ok) { + String icon = ok ? AnsiStyle.green("✓") : AnsiStyle.red("✗"); + return " " + icon + " " + AnsiStyle.bold(String.format("%-14s", label)) + + " " + value + "\n"; + } + + private String runCommand(String... cmd) { + try { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process p = pb.start(); + String output = new String(p.getInputStream().readAllBytes()).strip(); + int exit = p.waitFor(); + return exit == 0 ? output : null; + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/RenameCommand.java b/src/main/java/com/claudecode/command/impl/RenameCommand.java new file mode 100644 index 0000000..c564b49 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/RenameCommand.java @@ -0,0 +1,74 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.util.List; + +/** + * /rename 命令 —— 会话重命名。 + *

+ * 对应 claude-code 的 /rename 命令,给当前会话设置一个友好名称。 + * 会话名称在 UI 标题栏和会话列表中显示。 + */ +public class RenameCommand implements SlashCommand { + + /** 当前会话名称(进程级别) */ + private static volatile String sessionName = null; + + @Override + public String name() { + return "rename"; + } + + @Override + public String description() { + return "Rename the current session"; + } + + @Override + public List aliases() { + return List.of("name", "title"); + } + + @Override + public String execute(String args, CommandContext context) { + args = args == null ? "" : args.strip(); + + if (args.isBlank()) { + if (sessionName == null) { + return AnsiStyle.dim(" No session name set. Use /rename to set one."); + } + return " Current session name: " + AnsiStyle.bold(sessionName); + } + + if (args.equals("clear") || args.equals("reset")) { + sessionName = null; + return AnsiStyle.green(" ✓ Session name cleared"); + } + + // Validate name + if (args.length() > 100) { + return AnsiStyle.yellow(" ⚠ Session name too long (max 100 characters)"); + } + + String oldName = sessionName; + sessionName = args; + + if (oldName != null) { + return AnsiStyle.green(" ✓ Session renamed: " + oldName + " → " + sessionName); + } + return AnsiStyle.green(" ✓ Session named: " + sessionName); + } + + /** 获取当前会话名称(供其他组件使用) */ + public static String getSessionName() { + return sessionName; + } + + /** 设置会话名称(供程序化设置) */ + public static void setSessionName(String name) { + sessionName = name; + } +} diff --git a/src/main/java/com/claudecode/command/impl/SessionCommand.java b/src/main/java/com/claudecode/command/impl/SessionCommand.java new file mode 100644 index 0000000..bb4b7e4 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/SessionCommand.java @@ -0,0 +1,210 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * /session 命令 —— 会话管理。 + *

+ * 显示当前会话信息,支持: + *

+ */ +public class SessionCommand implements SlashCommand { + + private static final DateTimeFormatter FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + private final Instant startTime = Instant.now(); + + @Override + public String name() { + return "session"; + } + + @Override + public String description() { + return "View and manage the current session"; + } + + @Override + public List aliases() { + return List.of("sess"); + } + + @Override + public String execute(String args, CommandContext context) { + args = args == null ? "" : args.strip(); + + if (args.startsWith("save")) { + String sessionName = args.length() > 5 ? args.substring(5).strip() : ""; + return handleSave(sessionName, context); + } else if (args.equals("list")) { + return handleList(); + } else if (args.equals("export")) { + return handleExport(context); + } else { + return showSessionInfo(context); + } + } + + private String showSessionInfo(CommandContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 📋 Session Info\n")); + sb.append(" ").append("─".repeat(50)).append("\n"); + + sb.append(" ").append(AnsiStyle.dim("Started: ")).append(FMT.format(startTime)).append("\n"); + + // Duration + long seconds = java.time.Duration.between(startTime, Instant.now()).getSeconds(); + String duration; + if (seconds < 60) duration = seconds + "s"; + else if (seconds < 3600) duration = (seconds / 60) + "m " + (seconds % 60) + "s"; + else duration = (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m"; + sb.append(" ").append(AnsiStyle.dim("Duration: ")).append(duration).append("\n"); + + sb.append(" ").append(AnsiStyle.dim("Work Dir: ")).append(System.getProperty("user.dir")).append("\n"); + + // Token usage (from AgentLoop's TokenTracker) + var agentLoop = context.agentLoop(); + if (agentLoop != null && agentLoop.getAutoCompactManager() != null) { + sb.append(" ").append(AnsiStyle.dim("Auto-compact: ")).append("enabled").append("\n"); + } + + // Tool count + int toolCount = context.toolRegistry() != null ? context.toolRegistry().size() : 0; + sb.append(" ").append(AnsiStyle.dim("Tools: ")).append(toolCount).append(" registered\n"); + + // Command count + int cmdCount = context.commandRegistry() != null ? context.commandRegistry().getCommands().size() : 0; + sb.append(" ").append(AnsiStyle.dim("Commands: ")).append(cmdCount).append(" available\n"); + + // Session memory + Path memDir = getSessionDir(); + boolean hasMemory = memDir != null && Files.exists(memDir.resolve("SESSION_MEMORY.md")); + sb.append(" ").append(AnsiStyle.dim("Session Memory: ")) + .append(hasMemory ? "active" : "not yet created").append("\n"); + + sb.append("\n ").append(AnsiStyle.dim("Use /session save [name] to save, /session list to view saved")).append("\n"); + + return sb.toString(); + } + + private String handleSave(String sessionName, CommandContext context) { + Path sessionsDir = getSessionsDir(); + if (sessionsDir == null) { + return AnsiStyle.red(" ✗ Cannot determine sessions directory"); + } + + try { + Files.createDirectories(sessionsDir); + } catch (IOException e) { + return AnsiStyle.red(" ✗ Cannot create sessions directory: " + e.getMessage()); + } + + if (sessionName.isBlank()) { + sessionName = "session-" + FMT.format(Instant.now()).replace(" ", "_").replace(":", "-"); + } + + Path sessionFile = sessionsDir.resolve(sessionName + ".md"); + try { + StringBuilder content = new StringBuilder(); + content.append("# Session: ").append(sessionName).append("\n\n"); + content.append("- Started: ").append(FMT.format(startTime)).append("\n"); + content.append("- Saved: ").append(FMT.format(Instant.now())).append("\n"); + content.append("- Working Directory: ").append(System.getProperty("user.dir")).append("\n"); + content.append("\n## Notes\n\n"); + content.append("(Add session notes here)\n"); + + Files.writeString(sessionFile, content.toString(), StandardCharsets.UTF_8); + return AnsiStyle.green(" ✓ Session saved: " + sessionFile); + } catch (IOException e) { + return AnsiStyle.red(" ✗ Failed to save session: " + e.getMessage()); + } + } + + private String handleList() { + Path sessionsDir = getSessionsDir(); + if (sessionsDir == null || !Files.exists(sessionsDir)) { + return AnsiStyle.dim(" No saved sessions."); + } + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 📁 Saved Sessions\n")); + sb.append(" ").append("─".repeat(50)).append("\n"); + + try (var stream = Files.list(sessionsDir)) { + var files = stream + .filter(f -> f.toString().endsWith(".md")) + .sorted() + .toList(); + + if (files.isEmpty()) { + return AnsiStyle.dim(" No saved sessions."); + } + + for (var file : files) { + String name = file.getFileName().toString().replace(".md", ""); + long size = Files.size(file); + sb.append(" • ").append(name).append(" (").append(size).append(" bytes)\n"); + } + } catch (IOException e) { + return AnsiStyle.red(" ✗ Error listing sessions: " + e.getMessage()); + } + + return sb.toString(); + } + + private String handleExport(CommandContext context) { + Path exportFile = Path.of(System.getProperty("user.dir"), "session-export.json"); + try { + StringBuilder json = new StringBuilder(); + json.append("{\n"); + json.append(" \"started\": \"").append(startTime).append("\",\n"); + json.append(" \"exported\": \"").append(Instant.now()).append("\",\n"); + json.append(" \"workDir\": \"") + .append(System.getProperty("user.dir").replace("\\", "\\\\")).append("\",\n"); + json.append(" \"tools\": ").append(context.toolRegistry() != null + ? context.toolRegistry().size() : 0).append(",\n"); + json.append(" \"commands\": ").append(context.commandRegistry() != null + ? context.commandRegistry().getCommands().size() : 0).append("\n"); + json.append("}\n"); + + Files.writeString(exportFile, json.toString(), StandardCharsets.UTF_8); + return AnsiStyle.green(" ✓ Session exported to: " + exportFile); + } catch (IOException e) { + return AnsiStyle.red(" ✗ Export failed: " + e.getMessage()); + } + } + + private Path getSessionsDir() { + String userHome = System.getProperty("user.home"); + if (userHome == null) return null; + return Path.of(userHome, ".claude", "sessions"); + } + + private Path getSessionDir() { + String workDir = System.getProperty("user.dir"); + String sanitized = Path.of(workDir).toAbsolutePath().toString() + .replace(":", "_").replace("\\", "_").replace("/", "_"); + return Path.of(System.getProperty("user.home")) + .resolve(".claude").resolve("projects").resolve(sanitized); + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 60013d8..1bc3e2e 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -176,6 +176,11 @@ public class AppConfig { new SecurityReviewCommand(), new McpCommand(), new PluginCommand(), + // Phase 2F 命令 + new DoctorCommand(), + new SessionCommand(), + new AgentCommand(), + new RenameCommand(), // Exit 放最后 new ExitCommand() );