feat: /doctor, /session, /agent, /rename commands (Phase 2F)

- /doctor: environment diagnostics (Java, Git, API keys, disk, memory)
- /session: session info, save/load/export functionality
- /agent: list/detail/stop sub-agents and workers
- /rename: session naming with clear/reset support
- Registered all 4 commands in AppConfig

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
abel533
2026-04-05 09:56:49 +08:00
co-authored by Copilot
parent 5a6798540a
commit a926b31e34
5 changed files with 659 additions and 0 deletions
@@ -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 状态管理。
* <p>
* 对应 claude-code 的 agent 管理功能,显示运行中的子 agent/worker 信息。
* <ul>
* <li>/agent —— 列出所有活跃的 agent(任务)</li>
* <li>/agent [id] —— 查看特定 agent 详情</li>
* <li>/agent stop [id] —— 停止指定 agent</li>
* <li>/agent stop-all —— 停止所有 agent</li>
* </ul>
*/
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<String> 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) + "...";
}
}
@@ -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 命令 —— 环境诊断。
* <p>
* 对应 claude-code 的 /doctor 命令,检查运行环境是否配置正确。
* 检查项:
* <ul>
* <li>Java 版本</li>
* <li>Git 版本和配置</li>
* <li>环境变量(API key 等)</li>
* <li>配置文件状态</li>
* <li>MCP 服务器连接</li>
* <li>磁盘空间</li>
* </ul>
*/
public class DoctorCommand implements SlashCommand {
@Override
public String name() {
return "doctor";
}
@Override
public String description() {
return "Run environment diagnostics";
}
@Override
public List<String> 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;
}
}
}
@@ -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 命令 —— 会话重命名。
* <p>
* 对应 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<String> 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 <name> 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;
}
}
@@ -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 命令 —— 会话管理。
* <p>
* 显示当前会话信息,支持:
* <ul>
* <li>/session —— 显示当前会话摘要</li>
* <li>/session save [name] —— 保存会话快照</li>
* <li>/session list —— 列出保存的会话</li>
* <li>/session export —— 导出会话为 JSON</li>
* </ul>
*/
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<String> 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);
}
}
@@ -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()
);