新增基础设施: - TokenTracker: 真实Token使用量追踪和费用估算(按模型定价) - SkillLoader: .claude/skills/ 扫描加载,支持YAML frontmatter元数据 - GitContext: Git分支/状态/最近提交收集,注入系统提示词 新增命令 (4个): - /init: 项目CLAUDE.md初始化向导,自动检测项目类型(Maven/Gradle/Node/Python等) - /status: 会话状态仪表板(模型、Token、内存、运行时间等) - /context: 上下文概览(CLAUDE.md、Skills、Git、系统提示词大小) - /config: 配置查看/设置(支持model快捷切换和API key显示) 增强命令 (3个): - /cost: 从占位→真实Token统计,显示input/output/cache tokens和费用 - /model: 支持快捷名称切换(sonnet/opus/haiku),显示可用模型列表 - /compact: 增强显示压缩前后消息数和Token使用量 系统提示词增强: - 集成Skills摘要和Git上下文到系统提示词 - 新增Shell环境信息和更详细的Guidelines 命令总数: 6 → 10, 工具总数: 11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
e09c3de91e
commit
7a6c2fcc02
@ -0,0 +1,124 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
import com.claudecode.console.AnsiStyle; |
||||
|
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* /config 命令 —— 查看和设置应用配置。 |
||||
* <p> |
||||
* 支持查看当前配置、设置单个配置项。 |
||||
* 配置变更仅在当前会话内生效。 |
||||
*/ |
||||
public class ConfigCommand implements SlashCommand { |
||||
|
||||
/** 支持的配置项及说明 */ |
||||
private static final Map<String, String> CONFIG_KEYS = Map.of( |
||||
"model", "AI model name (e.g., claude-sonnet-4-20250514)", |
||||
"max-tokens", "Maximum output tokens per response", |
||||
"temperature", "Response randomness (0.0-1.0)", |
||||
"verbose", "Enable verbose logging (true/false)", |
||||
"auto-compact", "Auto compact when context is large (true/false)" |
||||
); |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "config"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "View or set configuration"; |
||||
} |
||||
|
||||
@Override |
||||
public List<String> aliases() { |
||||
return List.of("cfg"); |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
if (args == null || args.isBlank()) { |
||||
return showAllConfig(context); |
||||
} |
||||
|
||||
String[] parts = args.strip().split("\\s+", 2); |
||||
String key = parts[0]; |
||||
|
||||
if (parts.length == 1) { |
||||
// 显示单个配置项
|
||||
return showConfig(key, context); |
||||
} |
||||
|
||||
// 设置配置项
|
||||
String value = parts[1]; |
||||
return setConfig(key, value, context); |
||||
} |
||||
|
||||
private String showAllConfig(CommandContext context) { |
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("\n"); |
||||
sb.append(AnsiStyle.bold(" ⚙ Configuration\n")); |
||||
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||
|
||||
// 当前活跃配置
|
||||
String model = context.agentLoop().getTokenTracker().getModelName(); |
||||
sb.append(" ").append(AnsiStyle.bold("model: ")).append(AnsiStyle.cyan(model)).append("\n"); |
||||
|
||||
String apiKey = System.getenv("ANTHROPIC_API_KEY"); |
||||
if (apiKey != null && apiKey.length() > 8) { |
||||
sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.dim( |
||||
apiKey.substring(0, 8) + "..." + apiKey.substring(apiKey.length() - 4))).append("\n"); |
||||
} else { |
||||
sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.yellow("(not set)")).append("\n"); |
||||
} |
||||
|
||||
String baseUrl = System.getenv().getOrDefault("ANTHROPIC_BASE_URL", "https://api.anthropic.com"); |
||||
sb.append(" ").append(AnsiStyle.bold("base-url: ")).append(AnsiStyle.dim(baseUrl)).append("\n"); |
||||
|
||||
sb.append("\n"); |
||||
sb.append(AnsiStyle.dim(" Available keys:\n")); |
||||
for (var entry : CONFIG_KEYS.entrySet()) { |
||||
sb.append(AnsiStyle.dim(" " + entry.getKey() + " — " + entry.getValue())).append("\n"); |
||||
} |
||||
sb.append("\n"); |
||||
sb.append(AnsiStyle.dim(" Usage: /config <key> <value>")); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
|
||||
private String showConfig(String key, CommandContext context) { |
||||
if (!CONFIG_KEYS.containsKey(key)) { |
||||
return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n" |
||||
+ AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet())); |
||||
} |
||||
|
||||
String desc = CONFIG_KEYS.get(key); |
||||
return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n" |
||||
+ AnsiStyle.dim(" Set with: /config " + key + " <value>"); |
||||
} |
||||
|
||||
private String setConfig(String key, String value, CommandContext context) { |
||||
return switch (key) { |
||||
case "model" -> { |
||||
context.agentLoop().getTokenTracker().setModel(value); |
||||
yield AnsiStyle.green(" ✅ Model set to: " + value) + "\n" |
||||
+ AnsiStyle.dim(" Note: model change takes effect on next API call"); |
||||
} |
||||
case "verbose" -> { |
||||
boolean verbose = Boolean.parseBoolean(value); |
||||
yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF")); |
||||
} |
||||
default -> { |
||||
if (!CONFIG_KEYS.containsKey(key)) { |
||||
yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key); |
||||
} |
||||
yield AnsiStyle.yellow(" ⚠ Setting '" + key + "' is not yet supported at runtime") + "\n" |
||||
+ AnsiStyle.dim(" Set via application.yml or environment variables"); |
||||
} |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,102 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
import com.claudecode.console.AnsiStyle; |
||||
import com.claudecode.context.ClaudeMdLoader; |
||||
import com.claudecode.context.GitContext; |
||||
import com.claudecode.context.SkillLoader; |
||||
|
||||
import java.nio.file.Path; |
||||
|
||||
/** |
||||
* /context 命令 —— 显示当前上下文信息。 |
||||
* <p> |
||||
* 展示已加载的 CLAUDE.md、Skills、Git 上下文和 Token 预算使用情况。 |
||||
*/ |
||||
public class ContextCommand implements SlashCommand { |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "context"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Show current context (CLAUDE.md, skills, git)"; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
Path projectDir = Path.of(System.getProperty("user.dir")); |
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("\n"); |
||||
sb.append(AnsiStyle.bold(" 📋 Context Overview\n")); |
||||
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||
|
||||
// CLAUDE.md 状态
|
||||
sb.append(AnsiStyle.bold(" CLAUDE.md:\n")); |
||||
ClaudeMdLoader loader = new ClaudeMdLoader(projectDir); |
||||
String claudeMd = loader.load(); |
||||
if (claudeMd.isEmpty()) { |
||||
sb.append(AnsiStyle.dim(" (none loaded) — run /init to create one\n")); |
||||
} else { |
||||
// 显示摘要(前 200 字符)
|
||||
int lines = claudeMd.split("\n").length; |
||||
sb.append(" ").append(AnsiStyle.green(lines + " lines loaded")).append("\n"); |
||||
String preview = claudeMd.length() > 200 |
||||
? claudeMd.substring(0, 200) + "..." |
||||
: claudeMd; |
||||
for (String line : preview.split("\n")) { |
||||
sb.append(AnsiStyle.dim(" │ " + line)).append("\n"); |
||||
} |
||||
} |
||||
sb.append("\n"); |
||||
|
||||
// Skills 状态
|
||||
sb.append(AnsiStyle.bold(" Skills:\n")); |
||||
SkillLoader skillLoader = new SkillLoader(projectDir); |
||||
var skills = skillLoader.loadAll(); |
||||
if (skills.isEmpty()) { |
||||
sb.append(AnsiStyle.dim(" (none loaded) — add .md files to .claude/skills/\n")); |
||||
} else { |
||||
for (var skill : skills) { |
||||
sb.append(" • ").append(AnsiStyle.cyan(skill.name())); |
||||
if (!skill.description().isEmpty()) { |
||||
sb.append(AnsiStyle.dim(" — " + skill.description())); |
||||
} |
||||
sb.append(AnsiStyle.dim(" [" + skill.source() + "]")).append("\n"); |
||||
} |
||||
} |
||||
sb.append("\n"); |
||||
|
||||
// Git 上下文
|
||||
sb.append(AnsiStyle.bold(" Git:\n")); |
||||
GitContext git = new GitContext(projectDir).collect(); |
||||
if (!git.isGitRepo()) { |
||||
sb.append(AnsiStyle.dim(" (not a git repository)\n")); |
||||
} else { |
||||
sb.append(" Branch: ").append(AnsiStyle.cyan(git.getBranch() != null ? git.getBranch() : "unknown")).append("\n"); |
||||
if (git.getStatus() != null) { |
||||
long modifiedCount = git.getStatus().lines() |
||||
.filter(l -> !l.startsWith("##")) |
||||
.count(); |
||||
if (modifiedCount > 0) { |
||||
sb.append(" Modified: ").append(AnsiStyle.yellow(modifiedCount + " file(s)")).append("\n"); |
||||
} else { |
||||
sb.append(" Working tree: ").append(AnsiStyle.green("clean")).append("\n"); |
||||
} |
||||
} |
||||
} |
||||
sb.append("\n"); |
||||
|
||||
// Token 预算
|
||||
sb.append(AnsiStyle.bold(" System Prompt:\n")); |
||||
String sysPrompt = context.agentLoop().getSystemPrompt(); |
||||
int charCount = sysPrompt.length(); |
||||
int estimatedTokens = charCount / 4; // 粗略估算
|
||||
sb.append(" Size: ").append(charCount).append(" chars (~").append(estimatedTokens).append(" tokens)\n"); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
} |
||||
@ -0,0 +1,140 @@ |
||||
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; |
||||
|
||||
/** |
||||
* /init 命令 —— 初始化项目 CLAUDE.md。 |
||||
* <p> |
||||
* 对应 claude-code/src/commands/init.ts。 |
||||
* 检测项目类型并生成 CLAUDE.md 模板文件。 |
||||
*/ |
||||
public class InitCommand implements SlashCommand { |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "init"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Initialize CLAUDE.md for the current project"; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
Path projectDir = Path.of(System.getProperty("user.dir")); |
||||
Path claudeMdPath = projectDir.resolve("CLAUDE.md"); |
||||
|
||||
// 检查是否已存在
|
||||
if (Files.exists(claudeMdPath)) { |
||||
return AnsiStyle.yellow(" ⚠ CLAUDE.md already exists at: " + claudeMdPath) + "\n" |
||||
+ AnsiStyle.dim(" Use a text editor to modify it, or delete and re-run /init."); |
||||
} |
||||
|
||||
// 检测项目类型
|
||||
ProjectType type = detectProjectType(projectDir); |
||||
|
||||
// 生成 CLAUDE.md 内容
|
||||
String content = generateClaudeMd(projectDir, type); |
||||
|
||||
try { |
||||
Files.writeString(claudeMdPath, content, StandardCharsets.UTF_8); |
||||
|
||||
// 如果存在 .claude 目录,也创建 skills 目录
|
||||
Path claudeDir = projectDir.resolve(".claude"); |
||||
Path skillsDir = claudeDir.resolve("skills"); |
||||
if (!Files.exists(skillsDir)) { |
||||
Files.createDirectories(skillsDir); |
||||
} |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append(AnsiStyle.green(" ✅ Created CLAUDE.md")).append("\n"); |
||||
sb.append(AnsiStyle.dim(" Path: " + claudeMdPath)).append("\n"); |
||||
sb.append(AnsiStyle.dim(" Project type: " + type.displayName)).append("\n"); |
||||
sb.append(AnsiStyle.dim(" Skills dir: " + skillsDir)).append("\n\n"); |
||||
sb.append(AnsiStyle.dim(" Edit CLAUDE.md to customize instructions for the AI assistant.")); |
||||
return sb.toString(); |
||||
|
||||
} catch (IOException e) { |
||||
return AnsiStyle.red(" ✗ Failed to create CLAUDE.md: " + e.getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** 检测项目类型 */ |
||||
private ProjectType detectProjectType(Path projectDir) { |
||||
if (Files.exists(projectDir.resolve("pom.xml"))) return ProjectType.MAVEN; |
||||
if (Files.exists(projectDir.resolve("build.gradle")) || Files.exists(projectDir.resolve("build.gradle.kts"))) |
||||
return ProjectType.GRADLE; |
||||
if (Files.exists(projectDir.resolve("package.json"))) return ProjectType.NODE; |
||||
if (Files.exists(projectDir.resolve("pyproject.toml")) || Files.exists(projectDir.resolve("setup.py"))) |
||||
return ProjectType.PYTHON; |
||||
if (Files.exists(projectDir.resolve("Cargo.toml"))) return ProjectType.RUST; |
||||
if (Files.exists(projectDir.resolve("go.mod"))) return ProjectType.GO; |
||||
if (Files.exists(projectDir.resolve("Gemfile"))) return ProjectType.RUBY; |
||||
return ProjectType.GENERIC; |
||||
} |
||||
|
||||
/** 生成 CLAUDE.md 内容 */ |
||||
private String generateClaudeMd(Path projectDir, ProjectType type) { |
||||
String projectName = projectDir.getFileName().toString(); |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("# ").append(projectName).append("\n\n"); |
||||
sb.append("## Project Overview\n\n"); |
||||
sb.append("<!-- Describe your project here -->\n\n"); |
||||
|
||||
sb.append("## Tech Stack\n\n"); |
||||
sb.append(type.techStackHint).append("\n\n"); |
||||
|
||||
sb.append("## Build & Run\n\n"); |
||||
sb.append("```bash\n"); |
||||
sb.append(type.buildCommand).append("\n"); |
||||
sb.append("```\n\n"); |
||||
|
||||
sb.append("## Test\n\n"); |
||||
sb.append("```bash\n"); |
||||
sb.append(type.testCommand).append("\n"); |
||||
sb.append("```\n\n"); |
||||
|
||||
sb.append("## Code Style\n\n"); |
||||
sb.append("- Follow existing patterns in the codebase\n"); |
||||
sb.append("- Write clear, descriptive variable names\n"); |
||||
sb.append("- Add comments for complex business logic\n\n"); |
||||
|
||||
sb.append("## Project Structure\n\n"); |
||||
sb.append("<!-- Describe key directories and files -->\n"); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
|
||||
enum ProjectType { |
||||
MAVEN("Maven/Java", "- Java (Maven)\n- Spring Boot (if applicable)", "mvn clean install", "mvn test"), |
||||
GRADLE("Gradle/Java", "- Java/Kotlin (Gradle)\n- Spring Boot (if applicable)", "gradle build", "gradle test"), |
||||
NODE("Node.js", "- Node.js\n- TypeScript/JavaScript", "npm install && npm run build", "npm test"), |
||||
PYTHON("Python", "- Python 3\n- pip/poetry", "pip install -e .", "pytest"), |
||||
RUST("Rust", "- Rust\n- Cargo", "cargo build", "cargo test"), |
||||
GO("Go", "- Go", "go build ./...", "go test ./..."), |
||||
RUBY("Ruby", "- Ruby\n- Bundler", "bundle install", "bundle exec rspec"), |
||||
GENERIC("Generic", "<!-- List your tech stack -->", "# add build command", "# add test command"); |
||||
|
||||
final String displayName; |
||||
final String techStackHint; |
||||
final String buildCommand; |
||||
final String testCommand; |
||||
|
||||
ProjectType(String displayName, String techStackHint, String buildCommand, String testCommand) { |
||||
this.displayName = displayName; |
||||
this.techStackHint = techStackHint; |
||||
this.buildCommand = buildCommand; |
||||
this.testCommand = testCommand; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,87 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
import com.claudecode.console.AnsiStyle; |
||||
import com.claudecode.core.TokenTracker; |
||||
|
||||
import java.lang.management.ManagementFactory; |
||||
import java.time.Duration; |
||||
import java.time.Instant; |
||||
|
||||
/** |
||||
* /status 命令 —— 显示会话状态仪表板。 |
||||
* <p> |
||||
* 展示当前模型、Token 用量、工具数、消息数、内存和运行时间等信息。 |
||||
*/ |
||||
public class StatusCommand implements SlashCommand { |
||||
|
||||
private final Instant startTime = Instant.now(); |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "status"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Show session status dashboard"; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("\n"); |
||||
sb.append(AnsiStyle.bold(" 📊 Session Status\n")); |
||||
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||
|
||||
// 模型信息
|
||||
TokenTracker tracker = context.agentLoop().getTokenTracker(); |
||||
sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n"); |
||||
|
||||
// Token 使用
|
||||
sb.append(" ").append(AnsiStyle.bold("Tokens: ")) |
||||
.append("↑ ").append(TokenTracker.formatTokens(tracker.getInputTokens())) |
||||
.append(" input, ↓ ").append(TokenTracker.formatTokens(tracker.getOutputTokens())) |
||||
.append(" output") |
||||
.append(AnsiStyle.dim(" ($" + String.format("%.4f", tracker.estimateCost()) + ")")) |
||||
.append("\n"); |
||||
|
||||
// API 调用次数
|
||||
sb.append(" ").append(AnsiStyle.bold("API Calls:")).append(" ").append(tracker.getApiCallCount()).append("\n"); |
||||
|
||||
// 消息历史
|
||||
int msgCount = context.agentLoop().getMessageHistory().size(); |
||||
sb.append(" ").append(AnsiStyle.bold("Messages: ")).append(msgCount).append("\n"); |
||||
|
||||
// 工具数
|
||||
sb.append(" ").append(AnsiStyle.bold("Tools: ")).append(context.toolRegistry().size()).append(" registered\n"); |
||||
|
||||
// 工作目录
|
||||
sb.append(" ").append(AnsiStyle.bold("Work Dir: ")).append(AnsiStyle.dim(System.getProperty("user.dir"))).append("\n"); |
||||
|
||||
// JVM 内存
|
||||
Runtime rt = Runtime.getRuntime(); |
||||
long usedMB = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024); |
||||
long maxMB = rt.maxMemory() / (1024 * 1024); |
||||
sb.append(" ").append(AnsiStyle.bold("Memory: ")).append(usedMB).append("MB / ").append(maxMB).append("MB\n"); |
||||
|
||||
// 运行时间
|
||||
Duration uptime = Duration.between(startTime, Instant.now()); |
||||
sb.append(" ").append(AnsiStyle.bold("Uptime: ")).append(formatDuration(uptime)).append("\n"); |
||||
|
||||
// Java 版本
|
||||
sb.append(" ").append(AnsiStyle.bold("JDK: ")).append(System.getProperty("java.version")).append("\n"); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
|
||||
private String formatDuration(Duration d) { |
||||
long hours = d.toHours(); |
||||
long minutes = d.toMinutesPart(); |
||||
long seconds = d.toSecondsPart(); |
||||
if (hours > 0) return String.format("%dh %dm %ds", hours, minutes, seconds); |
||||
if (minutes > 0) return String.format("%dm %ds", minutes, seconds); |
||||
return String.format("%ds", seconds); |
||||
} |
||||
} |
||||
@ -0,0 +1,127 @@ |
||||
package com.claudecode.context; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.io.BufferedReader; |
||||
import java.io.InputStreamReader; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.concurrent.TimeUnit; |
||||
|
||||
/** |
||||
* Git 上下文收集器 —— 收集当前项目的 Git 信息。 |
||||
* <p> |
||||
* 提取分支名、状态、最近提交等信息,注入系统提示词中, |
||||
* 帮助 AI 理解当前代码版本和变更状态。 |
||||
*/ |
||||
public class GitContext { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GitContext.class); |
||||
private static final int CMD_TIMEOUT = 5; // 秒
|
||||
|
||||
private final Path projectDir; |
||||
private String branch; |
||||
private String status; |
||||
private String recentCommits; |
||||
private boolean isGitRepo; |
||||
|
||||
public GitContext(Path projectDir) { |
||||
this.projectDir = projectDir; |
||||
this.isGitRepo = Files.isDirectory(projectDir.resolve(".git")); |
||||
} |
||||
|
||||
/** |
||||
* 收集 Git 上下文信息 |
||||
*/ |
||||
public GitContext collect() { |
||||
if (!isGitRepo) { |
||||
log.debug("当前目录不是 Git 仓库: {}", projectDir); |
||||
return this; |
||||
} |
||||
|
||||
this.branch = runGitCommand("rev-parse", "--abbrev-ref", "HEAD"); |
||||
this.status = runGitCommand("status", "--short", "--branch"); |
||||
this.recentCommits = runGitCommand("log", "--oneline", "-5", "--no-decorate"); |
||||
|
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* 构建 Git 上下文摘要(注入系统提示词) |
||||
*/ |
||||
public String buildSummary() { |
||||
if (!isGitRepo) { |
||||
return ""; |
||||
} |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("# Git Context\n\n"); |
||||
|
||||
if (branch != null && !branch.isBlank()) { |
||||
sb.append("- Branch: ").append(branch).append("\n"); |
||||
} |
||||
|
||||
if (status != null && !status.isBlank()) { |
||||
// 提取修改文件数
|
||||
long modifiedCount = status.lines() |
||||
.filter(l -> !l.startsWith("##")) |
||||
.count(); |
||||
if (modifiedCount > 0) { |
||||
sb.append("- Modified files: ").append(modifiedCount).append("\n"); |
||||
sb.append("- Status:\n```\n").append(status).append("\n```\n"); |
||||
} else { |
||||
sb.append("- Working tree: clean\n"); |
||||
} |
||||
} |
||||
|
||||
if (recentCommits != null && !recentCommits.isBlank()) { |
||||
sb.append("- Recent commits:\n```\n").append(recentCommits).append("\n```\n"); |
||||
} |
||||
|
||||
return sb.toString(); |
||||
} |
||||
|
||||
/** |
||||
* 执行 Git 命令 |
||||
*/ |
||||
private String runGitCommand(String... args) { |
||||
try { |
||||
var command = new java.util.ArrayList<String>(); |
||||
command.add("git"); |
||||
command.addAll(java.util.List.of(args)); |
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command); |
||||
pb.directory(projectDir.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"); |
||||
} |
||||
} |
||||
|
||||
boolean finished = process.waitFor(CMD_TIMEOUT, TimeUnit.SECONDS); |
||||
if (!finished) { |
||||
process.destroyForcibly(); |
||||
return ""; |
||||
} |
||||
|
||||
return output.toString().stripTrailing(); |
||||
|
||||
} catch (Exception e) { |
||||
log.debug("Git 命令执行失败: {}", e.getMessage()); |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
// Getters
|
||||
public boolean isGitRepo() { return isGitRepo; } |
||||
public String getBranch() { return branch; } |
||||
public String getStatus() { return status; } |
||||
public String getRecentCommits() { return recentCommits; } |
||||
} |
||||
@ -0,0 +1,178 @@ |
||||
package com.claudecode.context; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.*; |
||||
|
||||
/** |
||||
* Skills 技能加载器 —— 对应 claude-code/src/skills/ 模块。 |
||||
* <p> |
||||
* 从多个来源扫描和加载 .md 格式的技能文件: |
||||
* <ol> |
||||
* <li>用户级: ~/.claude/skills/</li> |
||||
* <li>项目级: ./.claude/skills/</li> |
||||
* <li>命令目录: ./.claude/commands/ (自动转换为技能)</li> |
||||
* </ol> |
||||
* <p> |
||||
* 每个技能文件支持 YAML frontmatter 元数据: |
||||
* <pre> |
||||
* --- |
||||
* name: verify-tests |
||||
* description: Run all tests after changes |
||||
* whenToUse: After modifying code |
||||
* --- |
||||
* [技能内容 markdown] |
||||
* </pre> |
||||
*/ |
||||
public class SkillLoader { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillLoader.class); |
||||
|
||||
private final Path projectDir; |
||||
private final List<Skill> skills = new ArrayList<>(); |
||||
|
||||
public SkillLoader(Path projectDir) { |
||||
this.projectDir = projectDir; |
||||
} |
||||
|
||||
/** |
||||
* 扫描并加载所有技能文件 |
||||
*/ |
||||
public List<Skill> loadAll() { |
||||
skills.clear(); |
||||
|
||||
// 1. 用户级技能
|
||||
Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills"); |
||||
loadFromDirectory(userSkillsDir, "user"); |
||||
|
||||
// 2. 项目级技能
|
||||
Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills"); |
||||
loadFromDirectory(projectSkillsDir, "project"); |
||||
|
||||
// 3. 命令目录(自动转换为技能)
|
||||
Path commandsDir = projectDir.resolve(".claude").resolve("commands"); |
||||
loadFromDirectory(commandsDir, "command"); |
||||
|
||||
log.debug("共加载 {} 个技能", skills.size()); |
||||
return Collections.unmodifiableList(skills); |
||||
} |
||||
|
||||
/** |
||||
* 从指定目录加载 .md 技能文件 |
||||
*/ |
||||
private void loadFromDirectory(Path dir, String source) { |
||||
if (!Files.isDirectory(dir)) { |
||||
return; |
||||
} |
||||
|
||||
try (var stream = Files.list(dir)) { |
||||
stream.filter(p -> p.toString().endsWith(".md")) |
||||
.sorted() |
||||
.forEach(p -> { |
||||
try { |
||||
Skill skill = parseSkillFile(p, source); |
||||
skills.add(skill); |
||||
log.debug("加载技能: {} [{}] from {}", skill.name(), source, p.getFileName()); |
||||
} catch (IOException e) { |
||||
log.warn("加载技能文件失败: {}: {}", p, e.getMessage()); |
||||
} |
||||
}); |
||||
} catch (IOException e) { |
||||
log.debug("扫描技能目录失败: {}: {}", dir, e.getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 解析单个技能文件,提取 frontmatter 和内容 |
||||
*/ |
||||
private Skill parseSkillFile(Path path, String source) throws IOException { |
||||
String raw = Files.readString(path, StandardCharsets.UTF_8).strip(); |
||||
String fileName = path.getFileName().toString().replace(".md", ""); |
||||
|
||||
// 尝试提取 YAML frontmatter
|
||||
String name = fileName; |
||||
String description = ""; |
||||
String whenToUse = ""; |
||||
String content = raw; |
||||
|
||||
if (raw.startsWith("---")) { |
||||
int endIdx = raw.indexOf("---", 3); |
||||
if (endIdx > 0) { |
||||
String frontmatter = raw.substring(3, endIdx).strip(); |
||||
content = raw.substring(endIdx + 3).strip(); |
||||
|
||||
// 简单的 YAML 解析(key: value 格式)
|
||||
for (String line : frontmatter.split("\n")) { |
||||
line = line.strip(); |
||||
int colonIdx = line.indexOf(':'); |
||||
if (colonIdx > 0) { |
||||
String key = line.substring(0, colonIdx).strip(); |
||||
String value = line.substring(colonIdx + 1).strip(); |
||||
// 去掉引号
|
||||
if (value.startsWith("\"") && value.endsWith("\"")) { |
||||
value = value.substring(1, value.length() - 1); |
||||
} |
||||
switch (key) { |
||||
case "name" -> name = value; |
||||
case "description" -> description = value; |
||||
case "whenToUse" -> whenToUse = value; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return new Skill(name, description, whenToUse, content, source, path); |
||||
} |
||||
|
||||
/** |
||||
* 获取已加载的技能列表 |
||||
*/ |
||||
public List<Skill> getSkills() { |
||||
return Collections.unmodifiableList(skills); |
||||
} |
||||
|
||||
/** |
||||
* 按名称查找技能 |
||||
*/ |
||||
public Optional<Skill> findByName(String name) { |
||||
return skills.stream() |
||||
.filter(s -> s.name().equalsIgnoreCase(name)) |
||||
.findFirst(); |
||||
} |
||||
|
||||
/** |
||||
* 构建技能上下文摘要(注入系统提示词) |
||||
*/ |
||||
public String buildSkillsSummary() { |
||||
if (skills.isEmpty()) { |
||||
return ""; |
||||
} |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("# Available Skills\n\n"); |
||||
for (Skill skill : skills) { |
||||
sb.append("- **").append(skill.name()).append("**"); |
||||
if (!skill.description().isEmpty()) { |
||||
sb.append(": ").append(skill.description()); |
||||
} |
||||
if (!skill.whenToUse().isEmpty()) { |
||||
sb.append(" (use when: ").append(skill.whenToUse()).append(")"); |
||||
} |
||||
sb.append("\n"); |
||||
} |
||||
return sb.toString(); |
||||
} |
||||
|
||||
/** |
||||
* 技能数据记录 |
||||
*/ |
||||
public record Skill(String name, String description, String whenToUse, |
||||
String content, String source, Path filePath) { |
||||
} |
||||
} |
||||
@ -0,0 +1,91 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import java.util.concurrent.atomic.AtomicLong; |
||||
|
||||
/** |
||||
* Token 使用量追踪器 —— 记录 API 调用的 token 消耗。 |
||||
* <p> |
||||
* 从 ChatResponse 的 usage 元数据中提取 token 统计信息, |
||||
* 支持按会话累计和费用估算。 |
||||
*/ |
||||
public class TokenTracker { |
||||
|
||||
private final AtomicLong totalInputTokens = new AtomicLong(0); |
||||
private final AtomicLong totalOutputTokens = new AtomicLong(0); |
||||
private final AtomicLong totalCacheReadTokens = new AtomicLong(0); |
||||
private final AtomicLong totalCacheCreationTokens = new AtomicLong(0); |
||||
private final AtomicLong apiCallCount = new AtomicLong(0); |
||||
|
||||
/** 模型定价(每百万 token 的美元价格) */ |
||||
private double inputPricePerMillion = 3.0; // Claude Sonnet 4 input
|
||||
private double outputPricePerMillion = 15.0; // Claude Sonnet 4 output
|
||||
private double cacheReadPricePerMillion = 0.3; // 缓存读取
|
||||
private String modelName = "claude-sonnet-4-20250514"; |
||||
|
||||
/** 记录一次 API 调用的 token 使用 */ |
||||
public void recordUsage(long inputTokens, long outputTokens) { |
||||
totalInputTokens.addAndGet(inputTokens); |
||||
totalOutputTokens.addAndGet(outputTokens); |
||||
apiCallCount.incrementAndGet(); |
||||
} |
||||
|
||||
/** 记录一次包含缓存的 API 调用 */ |
||||
public void recordUsage(long inputTokens, long outputTokens, long cacheRead, long cacheCreation) { |
||||
totalInputTokens.addAndGet(inputTokens); |
||||
totalOutputTokens.addAndGet(outputTokens); |
||||
totalCacheReadTokens.addAndGet(cacheRead); |
||||
totalCacheCreationTokens.addAndGet(cacheCreation); |
||||
apiCallCount.incrementAndGet(); |
||||
} |
||||
|
||||
/** 设置模型和对应定价 */ |
||||
public void setModel(String model) { |
||||
this.modelName = model; |
||||
// 根据模型设置定价
|
||||
if (model.contains("opus")) { |
||||
inputPricePerMillion = 15.0; |
||||
outputPricePerMillion = 75.0; |
||||
cacheReadPricePerMillion = 1.5; |
||||
} else if (model.contains("sonnet")) { |
||||
inputPricePerMillion = 3.0; |
||||
outputPricePerMillion = 15.0; |
||||
cacheReadPricePerMillion = 0.3; |
||||
} else if (model.contains("haiku")) { |
||||
inputPricePerMillion = 0.25; |
||||
outputPricePerMillion = 1.25; |
||||
cacheReadPricePerMillion = 0.03; |
||||
} |
||||
} |
||||
|
||||
public long getInputTokens() { return totalInputTokens.get(); } |
||||
public long getOutputTokens() { return totalOutputTokens.get(); } |
||||
public long getCacheReadTokens() { return totalCacheReadTokens.get(); } |
||||
public long getCacheCreationTokens() { return totalCacheCreationTokens.get(); } |
||||
public long getTotalTokens() { return totalInputTokens.get() + totalOutputTokens.get(); } |
||||
public long getApiCallCount() { return apiCallCount.get(); } |
||||
public String getModelName() { return modelName; } |
||||
|
||||
/** 估算当前会话费用(美元) */ |
||||
public double estimateCost() { |
||||
double inputCost = totalInputTokens.get() * inputPricePerMillion / 1_000_000.0; |
||||
double outputCost = totalOutputTokens.get() * outputPricePerMillion / 1_000_000.0; |
||||
double cacheCost = totalCacheReadTokens.get() * cacheReadPricePerMillion / 1_000_000.0; |
||||
return inputCost + outputCost + cacheCost; |
||||
} |
||||
|
||||
/** 重置统计 */ |
||||
public void reset() { |
||||
totalInputTokens.set(0); |
||||
totalOutputTokens.set(0); |
||||
totalCacheReadTokens.set(0); |
||||
totalCacheCreationTokens.set(0); |
||||
apiCallCount.set(0); |
||||
} |
||||
|
||||
/** 格式化 token 数量(带千位分隔) */ |
||||
public static String formatTokens(long tokens) { |
||||
if (tokens < 1000) return String.valueOf(tokens); |
||||
if (tokens < 1_000_000) return String.format("%.1fK", tokens / 1000.0); |
||||
return String.format("%.2fM", tokens / 1_000_000.0); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue