- /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>pull/1/head
parent
5a6798540a
commit
a926b31e34
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue