feat: P0功能补全 - 5命令+2工具+权限确认+Thinking显示
新增5个命令: - /diff: 显示Git未提交变更(支持--staged/--stat) - /version: 显示版本和环境信息 - /skills: 列出所有可用技能 - /memory: 查看/编辑CLAUDE.md(支持add/edit/user子命令) - /copy: 复制最近AI回复到剪贴板 新增2个工具: - WebSearchTool: DuckDuckGo搜索(免费,无需API Key) - AskUserQuestionTool: AI向用户提问(通过ToolContext回调) 权限确认机制: - 非只读工具执行前提示用户确认(Y/n/always) - 支持always一次授权全部后续操作 - 在AgentLoop.executeToolCalls中拦截 Thinking内容显示: - AgentLoop新增onThinkingContent回调 - 从ChatResponse metadata提取thinking内容 - ThinkingRenderer渲染思考过程(缩进暗色格式) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* /copy 命令 —— 将最近一次 AI 回复复制到系统剪贴板。
|
||||
* <p>
|
||||
* 从消息历史中提取最后一条 AssistantMessage 的文本内容,
|
||||
* 使用 AWT 剪贴板 API 复制。
|
||||
*/
|
||||
public class CopyCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "copy";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Copy last AI response to clipboard";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
// 从消息历史中查找最后一条助手消息
|
||||
List<Message> history = context.agentLoop().getMessageHistory();
|
||||
String lastResponse = null;
|
||||
|
||||
for (int i = history.size() - 1; i >= 0; i--) {
|
||||
Message msg = history.get(i);
|
||||
if (msg instanceof AssistantMessage assistant) {
|
||||
String text = assistant.getText();
|
||||
if (text != null && !text.isBlank()) {
|
||||
lastResponse = text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResponse == null) {
|
||||
return AnsiStyle.yellow(" ⚠ 暂无 AI 回复可复制");
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 AWT 剪贴板
|
||||
StringSelection selection = new StringSelection(lastResponse);
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection);
|
||||
|
||||
int charCount = lastResponse.length();
|
||||
int lineCount = (int) lastResponse.lines().count();
|
||||
return AnsiStyle.green(" ✓ 已复制到剪贴板")
|
||||
+ AnsiStyle.dim(" (" + charCount + " 字符, " + lineCount + " 行)");
|
||||
} catch (java.awt.HeadlessException e) {
|
||||
// 无头环境(如 SSH)无法使用 AWT 剪贴板
|
||||
return AnsiStyle.yellow(" ⚠ 当前环境不支持剪贴板(Headless 模式)\n")
|
||||
+ AnsiStyle.dim(" 提示:在有图形界面的终端中运行可使用此功能");
|
||||
} catch (Exception e) {
|
||||
return AnsiStyle.red(" ✗ 复制失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* /diff 命令 —— 显示 Git 未提交的变更。
|
||||
* <p>
|
||||
* 对应 claude-code 的 /diff 命令,展示工作区的变更内容:
|
||||
* <ul>
|
||||
* <li>无参数:显示所有未暂存变更</li>
|
||||
* <li>--staged:显示已暂存变更</li>
|
||||
* <li>--stat:仅显示文件统计(不含详细diff)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class DiffCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "diff";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Show uncommitted git changes";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||
if (!Files.isDirectory(projectDir.resolve(".git"))) {
|
||||
return AnsiStyle.yellow(" ⚠ 当前目录不是 Git 仓库");
|
||||
}
|
||||
|
||||
args = args == null ? "" : args.strip();
|
||||
|
||||
try {
|
||||
String diffOutput;
|
||||
String header;
|
||||
|
||||
if (args.contains("--staged")) {
|
||||
diffOutput = runGit(projectDir, "diff", "--staged", "--color=always");
|
||||
header = "Staged Changes";
|
||||
} else if (args.contains("--stat")) {
|
||||
diffOutput = runGit(projectDir, "diff", "--stat", "--color=always");
|
||||
header = "Changes (stat)";
|
||||
} else {
|
||||
// 默认:显示所有变更(未暂存 + 已暂存的统计 + 未跟踪文件)
|
||||
String unstaged = runGit(projectDir, "diff", "--color=always");
|
||||
String staged = runGit(projectDir, "diff", "--staged", "--stat");
|
||||
String untracked = runGit(projectDir, "ls-files", "--others", "--exclude-standard");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n").append(AnsiStyle.bold(" 📋 Git Diff\n"));
|
||||
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||
|
||||
if (!staged.isBlank()) {
|
||||
sb.append("\n").append(AnsiStyle.green(" ▸ Staged:\n"));
|
||||
staged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||
}
|
||||
|
||||
if (!unstaged.isBlank()) {
|
||||
sb.append("\n").append(AnsiStyle.yellow(" ▸ Unstaged changes:\n"));
|
||||
// 限制行数避免输出过长
|
||||
long lineCount = unstaged.lines().count();
|
||||
if (lineCount > 100) {
|
||||
unstaged.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||
sb.append(AnsiStyle.dim(" ... (共 " + lineCount + " 行,截断显示前100行)\n"));
|
||||
} else {
|
||||
unstaged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!untracked.isBlank()) {
|
||||
sb.append("\n").append(AnsiStyle.red(" ▸ Untracked files:\n"));
|
||||
untracked.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||
}
|
||||
|
||||
if (staged.isBlank() && unstaged.isBlank() && untracked.isBlank()) {
|
||||
sb.append("\n").append(AnsiStyle.green(" ✓ 工作区干净,无变更\n"));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// --staged 或 --stat 模式
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n").append(AnsiStyle.bold(" 📋 " + header + "\n"));
|
||||
sb.append(" ").append("─".repeat(50)).append("\n\n");
|
||||
|
||||
if (diffOutput.isBlank()) {
|
||||
sb.append(AnsiStyle.green(" ✓ 无变更\n"));
|
||||
} else {
|
||||
long lineCount = diffOutput.lines().count();
|
||||
if (lineCount > 100) {
|
||||
diffOutput.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||
sb.append(AnsiStyle.dim(" ... (共 " + lineCount + " 行)\n"));
|
||||
} else {
|
||||
diffOutput.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
return AnsiStyle.red(" ✗ Git diff 执行失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String runGit(Path dir, String... args) throws Exception {
|
||||
var command = new java.util.ArrayList<String>();
|
||||
command.add("git");
|
||||
command.add("--no-pager");
|
||||
command.addAll(java.util.List.of(args));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(dir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
StringBuilder output = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
process.waitFor(10, TimeUnit.SECONDS);
|
||||
return output.toString().stripTrailing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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.util.List;
|
||||
|
||||
/**
|
||||
* /memory 命令 —— 查看和编辑 CLAUDE.md 记忆文件。
|
||||
* <p>
|
||||
* 对应 claude-code 的 /memory 命令,支持:
|
||||
* <ul>
|
||||
* <li>/memory —— 显示当前 CLAUDE.md 内容</li>
|
||||
* <li>/memory add [内容] —— 追加内容到项目级 CLAUDE.md</li>
|
||||
* <li>/memory edit —— 用系统编辑器打开 CLAUDE.md</li>
|
||||
* <li>/memory user —— 查看用户级 CLAUDE.md</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class MemoryCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "memory";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "View/edit CLAUDE.md memory files";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> aliases() {
|
||||
return List.of("mem");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
args = args == null ? "" : args.strip();
|
||||
|
||||
if (args.startsWith("add ")) {
|
||||
return handleAdd(args.substring(4).strip());
|
||||
} else if (args.equals("edit")) {
|
||||
return handleEdit();
|
||||
} else if (args.equals("user")) {
|
||||
return showUserMemory();
|
||||
} else {
|
||||
return showProjectMemory();
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示项目级 CLAUDE.md */
|
||||
private String showProjectMemory() {
|
||||
Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
|
||||
return showMemoryFile(projectClaudeMd, "项目级");
|
||||
}
|
||||
|
||||
/** 显示用户级 CLAUDE.md */
|
||||
private String showUserMemory() {
|
||||
Path userClaudeMd = Path.of(System.getProperty("user.home"), ".claude", "CLAUDE.md");
|
||||
return showMemoryFile(userClaudeMd, "用户级");
|
||||
}
|
||||
|
||||
private String showMemoryFile(Path path, String level) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.bold(" 📝 CLAUDE.md (" + level + ")\n"));
|
||||
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.dim("Path: " + path)).append("\n\n");
|
||||
|
||||
if (Files.exists(path)) {
|
||||
try {
|
||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
if (content.isBlank()) {
|
||||
sb.append(AnsiStyle.dim(" (文件为空)\n"));
|
||||
} else {
|
||||
content.lines().forEach(line -> sb.append(" ").append(line).append("\n"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
sb.append(AnsiStyle.red(" ✗ 读取失败: " + e.getMessage() + "\n"));
|
||||
}
|
||||
} else {
|
||||
sb.append(AnsiStyle.dim(" (文件不存在)\n\n"));
|
||||
sb.append(AnsiStyle.dim(" 使用 /memory add <内容> 创建并添加内容\n"));
|
||||
sb.append(AnsiStyle.dim(" 或使用 /init 命令初始化\n"));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 追加内容到项目级 CLAUDE.md */
|
||||
private String handleAdd(String content) {
|
||||
if (content.isEmpty()) {
|
||||
return AnsiStyle.yellow(" ⚠ 请提供要添加的内容:/memory add <内容>");
|
||||
}
|
||||
|
||||
Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
|
||||
try {
|
||||
// 确保文件存在
|
||||
if (!Files.exists(projectClaudeMd)) {
|
||||
Files.writeString(projectClaudeMd,
|
||||
"# CLAUDE.md\n\n" + content + "\n",
|
||||
StandardCharsets.UTF_8);
|
||||
return AnsiStyle.green(" ✓ 已创建 CLAUDE.md 并添加内容");
|
||||
}
|
||||
|
||||
// 追加内容
|
||||
String existing = Files.readString(projectClaudeMd, StandardCharsets.UTF_8);
|
||||
String newContent = existing.endsWith("\n") ? existing + "\n" + content + "\n" : existing + "\n\n" + content + "\n";
|
||||
Files.writeString(projectClaudeMd, newContent, StandardCharsets.UTF_8);
|
||||
|
||||
return AnsiStyle.green(" ✓ 已追加内容到 CLAUDE.md");
|
||||
} catch (IOException e) {
|
||||
return AnsiStyle.red(" ✗ 写入失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 用系统编辑器打开 CLAUDE.md */
|
||||
private String handleEdit() {
|
||||
Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
|
||||
try {
|
||||
if (!Files.exists(projectClaudeMd)) {
|
||||
Files.writeString(projectClaudeMd, "# CLAUDE.md\n\n", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
// 尝试用系统编辑器打开
|
||||
String editor = System.getenv("EDITOR");
|
||||
if (editor == null || editor.isBlank()) {
|
||||
editor = System.getenv("VISUAL");
|
||||
}
|
||||
|
||||
if (editor != null && !editor.isBlank()) {
|
||||
ProcessBuilder pb = new ProcessBuilder(editor, projectClaudeMd.toString());
|
||||
pb.inheritIO();
|
||||
Process p = pb.start();
|
||||
p.waitFor();
|
||||
return AnsiStyle.green(" ✓ 编辑器已关闭");
|
||||
}
|
||||
|
||||
// Windows: 尝试 notepad
|
||||
if (System.getProperty("os.name").toLowerCase().contains("win")) {
|
||||
ProcessBuilder pb = new ProcessBuilder("notepad", projectClaudeMd.toString());
|
||||
pb.start(); // 不等待
|
||||
return AnsiStyle.green(" ✓ 已用记事本打开 CLAUDE.md");
|
||||
}
|
||||
|
||||
return AnsiStyle.yellow(" ⚠ 未找到编辑器。请设置 EDITOR 环境变量,或手动编辑:\n " + projectClaudeMd);
|
||||
|
||||
} catch (Exception e) {
|
||||
return AnsiStyle.red(" ✗ 打开编辑器失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
import com.claudecode.context.SkillLoader;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* /skills 命令 —— 列出所有可用的技能。
|
||||
* <p>
|
||||
* 扫描并显示从用户级、项目级和命令目录加载的技能文件。
|
||||
*/
|
||||
public class SkillsCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "skills";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "List available skills";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||
SkillLoader loader = new SkillLoader(projectDir);
|
||||
List<SkillLoader.Skill> skills = loader.loadAll();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.bold(" 🎯 Available Skills\n"));
|
||||
sb.append(" ").append("─".repeat(50)).append("\n\n");
|
||||
|
||||
if (skills.isEmpty()) {
|
||||
sb.append(AnsiStyle.dim(" (无可用技能)\n\n"));
|
||||
sb.append(AnsiStyle.dim(" 技能文件放置位置:\n"));
|
||||
sb.append(AnsiStyle.dim(" 用户级: ~/.claude/skills/*.md\n"));
|
||||
sb.append(AnsiStyle.dim(" 项目级: ./.claude/skills/*.md\n"));
|
||||
sb.append(AnsiStyle.dim(" 命令级: ./.claude/commands/*.md\n"));
|
||||
} else {
|
||||
for (SkillLoader.Skill skill : skills) {
|
||||
sb.append(" ").append(AnsiStyle.cyan("▸ ")).append(AnsiStyle.bold(skill.name()));
|
||||
|
||||
// 来源标签
|
||||
String sourceLabel = switch (skill.source()) {
|
||||
case "user" -> AnsiStyle.dim(" [用户级]");
|
||||
case "project" -> AnsiStyle.dim(" [项目级]");
|
||||
case "command" -> AnsiStyle.dim(" [命令]");
|
||||
default -> AnsiStyle.dim(" [" + skill.source() + "]");
|
||||
};
|
||||
sb.append(sourceLabel).append("\n");
|
||||
|
||||
if (!skill.description().isEmpty()) {
|
||||
sb.append(" ").append(skill.description()).append("\n");
|
||||
}
|
||||
if (!skill.whenToUse().isEmpty()) {
|
||||
sb.append(" ").append(AnsiStyle.dim("When: " + skill.whenToUse())).append("\n");
|
||||
}
|
||||
sb.append(" ").append(AnsiStyle.dim("File: " + skill.filePath())).append("\n");
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(AnsiStyle.dim(" 共 " + skills.size() + " 个技能\n"));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
/**
|
||||
* /version 命令 —— 显示版本和环境信息。
|
||||
* <p>
|
||||
* 展示 Claude Code Java 版本、运行时环境等信息。
|
||||
*/
|
||||
public class VersionCommand implements SlashCommand {
|
||||
|
||||
/** 当前版本号 */
|
||||
public static final String VERSION = "1.0.0";
|
||||
public static final String BUILD_DATE = "2025-07";
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "version";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Show version information";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.bold(" 🏷️ Claude Code Java\n"));
|
||||
sb.append(" ").append("─".repeat(40)).append("\n\n");
|
||||
|
||||
sb.append(" ").append(AnsiStyle.bold("Version: "))
|
||||
.append(AnsiStyle.cyan("v" + VERSION)).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.bold("Build: "))
|
||||
.append(BUILD_DATE).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.bold("Java: "))
|
||||
.append(System.getProperty("java.version")).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.bold("JVM: "))
|
||||
.append(System.getProperty("java.vm.name")).append(" ")
|
||||
.append(System.getProperty("java.vm.version")).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.bold("OS: "))
|
||||
.append(System.getProperty("os.name")).append(" ")
|
||||
.append(System.getProperty("os.arch")).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.bold("Spring Boot: "))
|
||||
.append(getSpringBootVersion()).append("\n");
|
||||
sb.append(" ").append(AnsiStyle.bold("Spring AI: "))
|
||||
.append("2.0.0-M4").append("\n");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String getSpringBootVersion() {
|
||||
try {
|
||||
// 从 Spring Boot 包中获取版本
|
||||
String version = org.springframework.boot.SpringBootVersion.getVersion();
|
||||
return version != null ? version : "4.1.0-M2";
|
||||
} catch (Exception e) {
|
||||
return "4.1.0-M2";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user