- CommandUtils: shared formatting (header, separator, formatBytes, formatDuration, progressBar, truncate) - ToolValidator: input validation (requireString, getString, getInt, getBoolean, validatePathInWorkDir) - ProcessExecutor: consolidated ProcessBuilder with timeout, cleanup, shell execution - AbstractReadOnlyTool: base class for read-only tools - Split AppConfig -> ToolConfiguration + CommandConfiguration + AppConfig (core) - Applied CommandUtils to 16 command files, removed duplicated private methods - Applied ToolValidator + ProcessExecutor to NotificationTool Net: -403 lines removed, 87 tests still passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
5929f50b3a
commit
80f43480c1
@ -0,0 +1,159 @@ |
||||
package com.claudecode.command; |
||||
|
||||
import com.claudecode.console.AnsiStyle; |
||||
|
||||
/** |
||||
* 命令输出格式化工具 —— 消除命令实现中的重复格式化代码。 |
||||
* <p> |
||||
* 提供标准化的命令输出格式:标题、分隔线、成功/失败消息、 |
||||
* 字节/时间格式化、进度条等。 |
||||
*/ |
||||
public final class CommandUtils { |
||||
|
||||
private CommandUtils() {} |
||||
|
||||
// ==================== 标题和分隔线 ====================
|
||||
|
||||
/** |
||||
* 格式化命令标题行。 |
||||
* 输出: "\n ⚡ Title\n ──────────\n\n" |
||||
*/ |
||||
public static String header(String emoji, String title) { |
||||
return "\n" + AnsiStyle.bold(" " + emoji + " " + title + "\n") |
||||
+ separator(40) + "\n\n"; |
||||
} |
||||
|
||||
/** |
||||
* 格式化命令标题行(无 emoji)。 |
||||
*/ |
||||
public static String header(String title) { |
||||
return "\n" + AnsiStyle.bold(" " + title + "\n") |
||||
+ separator(40) + "\n\n"; |
||||
} |
||||
|
||||
/** |
||||
* 生成分隔线。 |
||||
*/ |
||||
public static String separator(int width) { |
||||
return " " + "─".repeat(width); |
||||
} |
||||
|
||||
// ==================== 状态消息 ====================
|
||||
|
||||
/** |
||||
* 成功消息(绿色 ✓)。 |
||||
*/ |
||||
public static String success(String message) { |
||||
return " " + AnsiStyle.green("✓") + " " + message; |
||||
} |
||||
|
||||
/** |
||||
* 错误消息(红色 ✗)。 |
||||
*/ |
||||
public static String error(String message) { |
||||
return " " + AnsiStyle.red("✗") + " " + message; |
||||
} |
||||
|
||||
/** |
||||
* 警告消息(黄色 ⚠)。 |
||||
*/ |
||||
public static String warning(String message) { |
||||
return " " + AnsiStyle.yellow("⚠") + " " + message; |
||||
} |
||||
|
||||
/** |
||||
* 信息消息(蓝色 ℹ)。 |
||||
*/ |
||||
public static String info(String message) { |
||||
return " " + AnsiStyle.cyan("ℹ") + " " + message; |
||||
} |
||||
|
||||
/** |
||||
* 子标题(粗体缩进)。 |
||||
*/ |
||||
public static String subtitle(String title) { |
||||
return AnsiStyle.bold(" " + title); |
||||
} |
||||
|
||||
/** |
||||
* 键值对行。 |
||||
*/ |
||||
public static String keyValue(String key, Object value) { |
||||
return String.format(" %-12s %s", key + ":", value); |
||||
} |
||||
|
||||
/** |
||||
* 键值对行(自定义宽度)。 |
||||
*/ |
||||
public static String keyValue(String key, Object value, int keyWidth) { |
||||
return String.format(" %-" + keyWidth + "s %s", key + ":", value); |
||||
} |
||||
|
||||
// ==================== 格式化工具 ====================
|
||||
|
||||
/** |
||||
* 格式化字节数为人类可读形式。 |
||||
*/ |
||||
public static String formatBytes(long bytes) { |
||||
if (bytes < 0) return "N/A"; |
||||
if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); |
||||
if (bytes >= 1_048_576) return String.format("%.1fMB", bytes / 1_048_576.0); |
||||
if (bytes >= 1_024) return String.format("%.0fKB", bytes / 1_024.0); |
||||
return bytes + "B"; |
||||
} |
||||
|
||||
/** |
||||
* 格式化秒数为人类可读时长。 |
||||
*/ |
||||
public static String formatDuration(long seconds) { |
||||
if (seconds < 0) return "N/A"; |
||||
if (seconds < 60) return seconds + "s"; |
||||
if (seconds < 3600) return (seconds / 60) + "m " + (seconds % 60) + "s"; |
||||
return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m"; |
||||
} |
||||
|
||||
/** |
||||
* 格式化毫秒为人类可读时长。 |
||||
*/ |
||||
public static String formatMillis(long ms) { |
||||
if (ms < 1000) return ms + "ms"; |
||||
return formatDuration(ms / 1000); |
||||
} |
||||
|
||||
/** |
||||
* 截断字符串到最大长度。 |
||||
*/ |
||||
public static String truncate(String text, int maxLen) { |
||||
if (text == null) return ""; |
||||
if (text.length() <= maxLen) return text; |
||||
return text.substring(0, maxLen - 3) + "..."; |
||||
} |
||||
|
||||
// ==================== 进度条 ====================
|
||||
|
||||
/** |
||||
* 生成带颜色的进度条。 |
||||
*/ |
||||
public static String progressBar(double ratio, int width) { |
||||
ratio = Math.max(0, Math.min(1.0, ratio)); |
||||
int filled = (int) (ratio * width); |
||||
String bar = "█".repeat(filled); |
||||
String empty = "░".repeat(width - filled); |
||||
|
||||
String colored; |
||||
if (ratio > 0.8) colored = AnsiStyle.red(bar); |
||||
else if (ratio > 0.5) colored = AnsiStyle.yellow(bar); |
||||
else colored = AnsiStyle.green(bar); |
||||
|
||||
return "[" + colored + empty + "] " + String.format("%.0f%%", ratio * 100); |
||||
} |
||||
|
||||
// ==================== 参数解析 ====================
|
||||
|
||||
/** |
||||
* 安全解析命令参数(去空、null→空字符串)。 |
||||
*/ |
||||
public static String parseArgs(String args) { |
||||
return (args == null) ? "" : args.strip(); |
||||
} |
||||
} |
||||
@ -0,0 +1,89 @@ |
||||
package com.claudecode.config; |
||||
|
||||
import com.claudecode.command.CommandRegistry; |
||||
import com.claudecode.command.impl.*; |
||||
import com.claudecode.core.TaskManager; |
||||
import com.claudecode.permission.PermissionSettings; |
||||
import com.claudecode.plugin.PluginManager; |
||||
import org.springframework.context.annotation.Bean; |
||||
import org.springframework.context.annotation.Configuration; |
||||
|
||||
/** |
||||
* 命令注册配置 —— 从 AppConfig 拆分出来,专注于 SlashCommand 注册。 |
||||
*/ |
||||
@Configuration |
||||
public class CommandConfiguration { |
||||
|
||||
@Bean |
||||
public CommandRegistry commandRegistry(PluginManager pluginManager, PermissionSettings permissionSettings, |
||||
TaskManager taskManager) { |
||||
ConfigCommand configCommand = new ConfigCommand(permissionSettings); |
||||
CommandRegistry registry = new CommandRegistry(); |
||||
registry.registerAll( |
||||
// 基础命令
|
||||
new HelpCommand(), |
||||
new ClearCommand(), |
||||
new CompactCommand(), |
||||
new CostCommand(), |
||||
new ModelCommand(), |
||||
new StatusCommand(), |
||||
new ContextCommand(), |
||||
new InitCommand(), |
||||
configCommand, |
||||
new HistoryCommand(), |
||||
// 文件/Git 命令
|
||||
new DiffCommand(), |
||||
new VersionCommand(), |
||||
new SkillsCommand(), |
||||
new MemoryCommand(), |
||||
new CopyCommand(), |
||||
new ResumeCommand(), |
||||
new ExportCommand(), |
||||
new CommitCommand(), |
||||
new FilesCommand(), |
||||
new PermissionsCommand(permissionSettings), |
||||
new TasksCommand(taskManager), |
||||
new PlanCommand(permissionSettings), |
||||
// 协作/审查命令
|
||||
new HooksCommand(), |
||||
new ReviewCommand(), |
||||
new StatsCommand(), |
||||
new BranchCommand(), |
||||
new RewindCommand(), |
||||
new TagCommand(), |
||||
new SecurityReviewCommand(), |
||||
new McpCommand(), |
||||
new PluginCommand(), |
||||
// 会话/Agent 命令
|
||||
new DoctorCommand(), |
||||
new SessionCommand(), |
||||
new AgentCommand(), |
||||
new RenameCommand(), |
||||
// UX 命令
|
||||
new BriefCommand(), |
||||
new VimCommand(), |
||||
new ThemeCommand(), |
||||
new UsageCommand(), |
||||
new TipsCommand(), |
||||
new OutputStyleCommand(), |
||||
new EnvCommand(), |
||||
new PerformanceCommand(), |
||||
new PrivacyCommand(), |
||||
new FeedbackCommand(), |
||||
new ReleaseNotesCommand(), |
||||
new KeybindingsCommand(), |
||||
// 调试命令
|
||||
new DebugCommand(), |
||||
new HeapdumpCommand(), |
||||
new TraceCommand(), |
||||
new ContextVizCommand(), |
||||
new ResetLimitsCommand(), |
||||
new SandboxCommand(), |
||||
// Exit
|
||||
new ExitCommand() |
||||
); |
||||
|
||||
pluginManager.registerCommands(registry); |
||||
return registry; |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@ |
||||
package com.claudecode.config; |
||||
|
||||
import com.claudecode.mcp.McpManager; |
||||
import com.claudecode.tool.ToolContext; |
||||
import com.claudecode.tool.ToolRegistry; |
||||
import com.claudecode.tool.impl.*; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.context.annotation.Bean; |
||||
import org.springframework.context.annotation.Configuration; |
||||
import com.claudecode.core.TaskManager; |
||||
import com.claudecode.permission.PermissionSettings; |
||||
|
||||
/** |
||||
* 工具注册配置 —— 从 AppConfig 拆分出来,专注于 Tool 注册。 |
||||
*/ |
||||
@Configuration |
||||
public class ToolConfiguration { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ToolConfiguration.class); |
||||
|
||||
@Bean |
||||
public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager, |
||||
ToolContext toolContext, PermissionSettings permissionSettings) { |
||||
toolContext.set("TASK_MANAGER", taskManager); |
||||
toolContext.set("MCP_MANAGER", mcpManager); |
||||
toolContext.set("PERMISSION_SETTINGS", permissionSettings); |
||||
|
||||
ToolRegistry registry = new ToolRegistry(); |
||||
registry.registerAll( |
||||
// 核心工具
|
||||
new BashTool(), |
||||
new FileReadTool(), |
||||
new FileWriteTool(), |
||||
new FileEditTool(), |
||||
new GlobTool(), |
||||
new GrepTool(), |
||||
new ListFilesTool(), |
||||
new WebFetchTool(), |
||||
new TodoWriteTool(), |
||||
new AgentTool(), |
||||
new NotebookEditTool(), |
||||
new WebSearchTool(), |
||||
new AskUserQuestionTool(), |
||||
// 任务管理工具
|
||||
new TaskCreateTool(), |
||||
new TaskGetTool(), |
||||
new TaskListTool(), |
||||
new TaskUpdateTool(), |
||||
new TaskStopTool(), |
||||
new TaskOutputTool(), |
||||
// 配置/实用工具
|
||||
new ConfigTool(), |
||||
new SleepTool(), |
||||
new ToolSearchTool(), |
||||
new EnterPlanModeTool(), |
||||
new ExitPlanModeTool(), |
||||
new SkillTool(), |
||||
new SendMessageTool(), |
||||
new ListMcpResourcesTool(), |
||||
new ReadMcpResourceTool(), |
||||
new EnterWorktreeTool(), |
||||
new ExitWorktreeTool(), |
||||
// 高级工具
|
||||
new LSPTool(), |
||||
new BriefTool(), |
||||
new NotificationTool() |
||||
); |
||||
|
||||
// MCP 工具桥接
|
||||
for (var client : mcpManager.getClients().values()) { |
||||
for (var mcpTool : client.getTools()) { |
||||
registry.register(new McpToolBridge(client.getServerName(), mcpTool)); |
||||
} |
||||
} |
||||
|
||||
toolContext.set("TOOL_REGISTRY", registry); |
||||
return registry; |
||||
} |
||||
} |
||||
@ -0,0 +1,53 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* 只读工具抽象基类 —— 18+ 只读工具共享的基础实现。 |
||||
* <p> |
||||
* 提供: |
||||
* <ul> |
||||
* <li>{@link #isReadOnly()} 固定返回 true</li> |
||||
* <li>{@link #isEnabled()} 默认返回 true</li> |
||||
* <li>{@link #checkPermission} 默认 ALLOW</li> |
||||
* <li>{@link #errorResult(String)} 标准化错误格式</li> |
||||
* </ul> |
||||
* 子类只需实现 name/description/inputSchema/execute。 |
||||
*/ |
||||
public abstract class AbstractReadOnlyTool implements Tool { |
||||
|
||||
@Override |
||||
public final boolean isReadOnly() { |
||||
return true; |
||||
} |
||||
|
||||
@Override |
||||
public boolean isEnabled() { |
||||
return true; |
||||
} |
||||
|
||||
@Override |
||||
public PermissionResult checkPermission(Map<String, Object> input, ToolContext context) { |
||||
return PermissionResult.ALLOW; |
||||
} |
||||
|
||||
@Override |
||||
public String activityDescription(Map<String, Object> input) { |
||||
return "Reading " + name() + "..."; |
||||
} |
||||
|
||||
/** |
||||
* 标准化错误结果。 |
||||
*/ |
||||
protected String errorResult(String message) { |
||||
return "Error: " + message; |
||||
} |
||||
|
||||
/** |
||||
* 从 input 中获取必填 String 参数,缺失则返回 null 并可由调用方提前 return errorResult。 |
||||
*/ |
||||
protected String requireParam(Map<String, Object> input, String paramName) { |
||||
String err = ToolValidator.requireString(input, paramName); |
||||
return err == null ? input.get(paramName).toString() : null; |
||||
} |
||||
} |
||||
@ -0,0 +1,90 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
import java.nio.file.Path; |
||||
|
||||
/** |
||||
* 工具输入验证器 —— 消除 Tool 实现中的重复验证代码。 |
||||
* <p> |
||||
* 所有 validate* 方法:返回 null 表示验证通过,返回 String 表示错误消息。 |
||||
* 典型用法: |
||||
* <pre> |
||||
* String err = ToolValidator.requireString(input, "file_path"); |
||||
* if (err != null) return err; |
||||
* </pre> |
||||
*/ |
||||
public final class ToolValidator { |
||||
|
||||
private ToolValidator() {} |
||||
|
||||
/** |
||||
* 验证必填 String 参数(非 null、非空白)。 |
||||
* @return null 表示通过,否则返回错误消息 |
||||
*/ |
||||
public static String requireString(java.util.Map<String, Object> input, String paramName) { |
||||
Object value = input.get(paramName); |
||||
if (value == null) { |
||||
return "Error: '" + paramName + "' is required."; |
||||
} |
||||
if (value instanceof String s && s.isBlank()) { |
||||
return "Error: '" + paramName + "' must not be empty."; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* 获取 String 参数(带默认值)。 |
||||
*/ |
||||
public static String getString(java.util.Map<String, Object> input, String paramName, String defaultValue) { |
||||
Object value = input.get(paramName); |
||||
if (value instanceof String s && !s.isBlank()) return s; |
||||
return defaultValue; |
||||
} |
||||
|
||||
/** |
||||
* 安全获取 int 参数(带默认值,防止 ClassCastException)。 |
||||
*/ |
||||
public static int getInt(java.util.Map<String, Object> input, String paramName, int defaultValue) { |
||||
Object value = input.get(paramName); |
||||
if (value instanceof Number n) return n.intValue(); |
||||
if (value instanceof String s) { |
||||
try { return Integer.parseInt(s); } catch (NumberFormatException e) { /* fall through */ } |
||||
} |
||||
return defaultValue; |
||||
} |
||||
|
||||
/** |
||||
* 安全获取 boolean 参数(带默认值)。 |
||||
*/ |
||||
public static boolean getBoolean(java.util.Map<String, Object> input, String paramName, boolean defaultValue) { |
||||
Object value = input.get(paramName); |
||||
if (value instanceof Boolean b) return b; |
||||
if (value instanceof String s) return Boolean.parseBoolean(s); |
||||
return defaultValue; |
||||
} |
||||
|
||||
/** |
||||
* 验证文件路径在工作目录内(防止路径遍历)。 |
||||
* @return null 表示通过,否则返回错误消息 |
||||
*/ |
||||
public static String validatePathInWorkDir(String filePath, Path workDir) { |
||||
if (filePath == null || filePath.isBlank()) { |
||||
return "Error: file path is required."; |
||||
} |
||||
Path resolved = workDir.resolve(filePath).normalize(); |
||||
if (!resolved.startsWith(workDir.normalize())) { |
||||
return "Error: Path traversal not allowed. Path must be within the working directory."; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* 解析并验证路径(合并 resolve + normalize + traversal check)。 |
||||
* @return 解析后的路径,如验证失败则为 null(错误通过 errorHolder 返回) |
||||
*/ |
||||
public static Path resolveSafePath(String filePath, Path workDir) { |
||||
if (filePath == null || filePath.isBlank()) return null; |
||||
Path resolved = workDir.resolve(filePath).normalize(); |
||||
if (!resolved.startsWith(workDir.normalize())) return null; |
||||
return resolved; |
||||
} |
||||
} |
||||
@ -0,0 +1,115 @@ |
||||
package com.claudecode.tool.util; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.Path; |
||||
import java.util.*; |
||||
import java.util.concurrent.TimeUnit; |
||||
|
||||
/** |
||||
* 进程执行器 —— 消除 BashTool、GrepTool、NotificationTool 中的重复 ProcessBuilder 模式。 |
||||
* <p> |
||||
* 封装进程创建、超时管理、输出捕获和资源清理。 |
||||
*/ |
||||
public final class ProcessExecutor { |
||||
|
||||
private ProcessExecutor() {} |
||||
|
||||
/** |
||||
* 进程执行结果。 |
||||
*/ |
||||
public record Result(int exitCode, String stdout, String stderr, boolean timedOut) { |
||||
|
||||
public boolean isSuccess() { |
||||
return exitCode == 0 && !timedOut; |
||||
} |
||||
|
||||
public String output() { |
||||
return stdout != null ? stdout : ""; |
||||
} |
||||
|
||||
public String combinedOutput() { |
||||
StringBuilder sb = new StringBuilder(); |
||||
if (stdout != null && !stdout.isEmpty()) sb.append(stdout); |
||||
if (stderr != null && !stderr.isEmpty()) { |
||||
if (!sb.isEmpty()) sb.append("\n"); |
||||
sb.append(stderr); |
||||
} |
||||
return sb.toString(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 在指定工作目录执行命令,带超时和资源清理。 |
||||
*/ |
||||
public static Result execute(List<String> command, Path workDir, long timeoutMs) { |
||||
return execute(command, workDir, timeoutMs, null); |
||||
} |
||||
|
||||
/** |
||||
* 在指定工作目录执行命令,带超时、环境变量和资源清理。 |
||||
*/ |
||||
public static Result execute(List<String> command, Path workDir, long timeoutMs, Map<String, String> env) { |
||||
ProcessBuilder pb = new ProcessBuilder(command); |
||||
if (workDir != null) pb.directory(workDir.toFile()); |
||||
pb.redirectErrorStream(false); |
||||
|
||||
if (env != null && !env.isEmpty()) { |
||||
pb.environment().putAll(env); |
||||
} |
||||
|
||||
Process process = null; |
||||
try { |
||||
process = pb.start(); |
||||
|
||||
String stdout; |
||||
String stderr; |
||||
try (var stdoutStream = process.getInputStream(); |
||||
var stderrStream = process.getErrorStream()) { |
||||
stdout = new String(stdoutStream.readAllBytes()); |
||||
stderr = new String(stderrStream.readAllBytes()); |
||||
} |
||||
|
||||
boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS); |
||||
if (!finished) { |
||||
process.destroyForcibly(); |
||||
process.waitFor(2, TimeUnit.SECONDS); |
||||
return new Result(-1, stdout, stderr, true); |
||||
} |
||||
|
||||
return new Result(process.exitValue(), stdout, stderr, false); |
||||
} catch (IOException e) { |
||||
return new Result(-1, "", "IOException: " + e.getMessage(), false); |
||||
} catch (InterruptedException e) { |
||||
Thread.currentThread().interrupt(); |
||||
return new Result(-1, "", "Interrupted", false); |
||||
} finally { |
||||
if (process != null && process.isAlive()) { |
||||
process.destroyForcibly(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 快速检查命令是否可用(which/where)。 |
||||
*/ |
||||
public static boolean isCommandAvailable(String command) { |
||||
boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); |
||||
String checker = isWindows ? "where" : "which"; |
||||
Result result = execute(List.of(checker, command), null, 5000); |
||||
return result.isSuccess(); |
||||
} |
||||
|
||||
/** |
||||
* 执行 shell 命令(通过 sh -c 或 cmd /c)。 |
||||
*/ |
||||
public static Result executeShell(String shellCommand, Path workDir, long timeoutMs) { |
||||
boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); |
||||
List<String> command; |
||||
if (isWindows) { |
||||
command = List.of("cmd.exe", "/c", shellCommand); |
||||
} else { |
||||
command = List.of("/bin/sh", "-c", shellCommand); |
||||
} |
||||
return execute(command, workDir, timeoutMs); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue