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:
@@ -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()
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user