- Update project branding from claude-cli to Kode - Reorganize documentation with new development guides - Add CONTRIBUTING.md and Chinese README - Remove worktree_merge command and relocate system-design.md - Update dependencies and package configuration - Improve custom commands service with better error handling - Clean up storage utilities and debug logging
15 KiB
15 KiB
自定义命令系统
概述
自定义命令系统(src/services/customCommands.ts)使用户能够创建可重用的基于 Markdown 的命令来扩展 Kode 的功能。命令从 .claude/commands/ 和 .kode/commands/ 目录中发现并与内置命令系统无缝集成。
架构
系统设计
interface CustomCommandSystem {
// 发现
loadCustomCommands(): Promise<CustomCommandWithScope[]>
scanMarkdownFiles(directory: string): Promise<string[]>
// 解析
parseFrontmatter(content: string): ParsedCommand
createCustomCommand(parsed: ParsedCommand): Command
// 处理
executeBashCommands(content: string): Promise<string>
resolveFileReferences(content: string): Promise<string>
// 管理
reloadCustomCommands(): void
getCustomCommandDirectories(): CommandDirectories
}
命令结构
文件格式
---
name: command-name
description: 命令的简要描述
aliases: [alias1, alias2]
enabled: true
hidden: false
progressMessage: 运行命令中...
argNames: [arg1, arg2]
allowed-tools: [file_read, file_edit]
---
# 命令内容
您的命令提示在这里。您可以使用:
- 参数:{arg1}、{arg2}
- 官方格式:$ARGUMENTS
- 文件引用:@src/file.js
- Bash 执行:!`git status`
Frontmatter 模式
export interface CustomCommandFrontmatter {
// 核心属性
name?: string // 命令名称(默认为文件名)
description?: string // 简要描述
aliases?: string[] // 替代名称
// 行为控制
enabled?: boolean // 命令是否激活(默认:true)
hidden?: boolean // 从帮助输出中隐藏(默认:false)
// 执行
progressMessage?: string // 执行期间显示的消息
argNames?: string[] // 命名参数占位符
'allowed-tools'?: string[] // 工具限制
// 元数据(未来扩展)
version?: string
author?: string
tags?: string[]
requires?: string[] // 依赖项
}
发现系统
目录扫描
class CommandDiscovery {
private readonly COMMAND_DIRS = {
user: path.join(homedir(), '.claude', 'commands'),
project: path.join(process.cwd(), '.claude', 'commands')
}
async discover(): Promise<CommandFile[]> {
const files: CommandFile[] = []
// 扫描用户命令(较低优先级)
if (existsSync(this.COMMAND_DIRS.user)) {
const userFiles = await this.scanDirectory(this.COMMAND_DIRS.user)
files.push(...userFiles.map(f => ({ ...f, scope: 'user' })))
}
// 扫描项目命令(较高优先级)
if (existsSync(this.COMMAND_DIRS.project)) {
const projectFiles = await this.scanDirectory(this.COMMAND_DIRS.project)
files.push(...projectFiles.map(f => ({ ...f, scope: 'project' })))
}
return files
}
private async scanDirectory(dir: string): Promise<string[]> {
const files: string[] = []
async function scan(currentDir: string, depth = 0) {
if (depth > 5) return // 防止深度递归
const entries = await fs.readdir(currentDir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name)
if (entry.isDirectory() && !entry.name.startsWith('.')) {
await scan(fullPath, depth + 1)
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath)
}
}
}
await scan(dir)
return files
}
}
命名空间支持
class CommandNamespace {
/**
* 从文件路径生成命名空间命令名称
*
* 示例:
* - /commands/test.md → "test"
* - /commands/dev/build.md → "dev:build"
* - /commands/ci/github/deploy.md → "ci:github:deploy"
*/
static fromPath(filePath: string, baseDir: string): string {
const relative = path.relative(baseDir, filePath)
const parts = relative.split(path.sep)
const fileName = parts[parts.length - 1].replace('.md', '')
if (parts.length === 1) {
return fileName
}
// 从目录结构创建命名空间
const namespace = parts.slice(0, -1).join(':')
return `${namespace}:${fileName}`
}
/**
* 为命令名称添加范围前缀
*
* 示例:
* - "test" + "user" → "user:test"
* - "dev:build" + "project" → "project:dev:build"
*/
static addScope(name: string, scope: 'user' | 'project'): string {
if (name.startsWith(`${scope}:`)) {
return name
}
return `${scope}:${name}`
}
}
解析系统
Frontmatter 解析器
export function parseFrontmatter(content: string): {
frontmatter: CustomCommandFrontmatter
content: string
} {
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/
const match = content.match(FRONTMATTER_REGEX)
if (!match) {
return { frontmatter: {}, content }
}
const yamlContent = match[1] || ''
const markdownContent = content.slice(match[0].length)
const frontmatter = this.parseYAML(yamlContent)
return { frontmatter, content: markdownContent }
}
private parseYAML(yaml: string): CustomCommandFrontmatter {
const result: CustomCommandFrontmatter = {}
const lines = yaml.split('\n')
let currentKey: string | null = null
let arrayMode = false
let arrayItems: string[] = []
for (const line of lines) {
const trimmed = line.trim()
// 跳过注释和空行
if (!trimmed || trimmed.startsWith('#')) continue
// 处理数组项
if (arrayMode && trimmed.startsWith('-')) {
const item = trimmed.slice(1).trim().replace(/['"]/g, '')
arrayItems.push(item)
continue
}
// 当遇到新键时结束数组模式
if (arrayMode && trimmed.includes(':')) {
if (currentKey) {
result[currentKey as keyof CustomCommandFrontmatter] = arrayItems as any
}
arrayMode = false
arrayItems = []
currentKey = null
}
// 解析键值对
const colonIndex = trimmed.indexOf(':')
if (colonIndex === -1) continue
const key = trimmed.slice(0, colonIndex).trim()
const value = trimmed.slice(colonIndex + 1).trim()
// 处理不同的值类型
if (value.startsWith('[') && value.endsWith(']')) {
// 内联数组
result[key as keyof CustomCommandFrontmatter] = this.parseInlineArray(value) as any
} else if (value === '' || value === '[]') {
// 多行数组开始
currentKey = key
arrayMode = true
arrayItems = []
} else if (value === 'true' || value === 'false') {
// 布尔值
result[key as keyof CustomCommandFrontmatter] = (value === 'true') as any
} else {
// 字符串(删除引号)
result[key as keyof CustomCommandFrontmatter] = value.replace(/['"]/g, '') as any
}
}
// 处理最终数组(如果以数组模式结束)
if (arrayMode && currentKey) {
result[currentKey as keyof CustomCommandFrontmatter] = arrayItems as any
}
return result
}
内容处理
动态内容执行
class DynamicContentProcessor {
/**
* 处理 bash 命令执行:!`command`
*/
async executeBashCommands(content: string): Promise<string> {
const BASH_REGEX = /!\`([^`]+)\`/g
const matches = [...content.matchAll(BASH_REGEX)]
if (matches.length === 0) return content
let result = content
for (const match of matches) {
const fullMatch = match[0]
const command = match[1].trim()
try {
const output = await this.executeBashCommand(command)
result = result.replace(fullMatch, output)
} catch (error) {
console.warn(`执行失败:${command}`, error)
result = result.replace(fullMatch, `(错误:${error.message})`)
}
}
return result
}
private async executeBashCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, {
cwd: process.cwd(),
timeout: 5000,
maxBuffer: 1024 * 1024 // 1MB
}, (error, stdout, stderr) => {
if (error) {
reject(error)
} else {
resolve(stdout.trim() || stderr.trim() || '(无输出)')
}
})
})
}
/**
* 处理文件引用:@filepath
*/
async resolveFileReferences(content: string): Promise<string> {
const FILE_REGEX = /@([a-zA-Z0-9/._-]+(?:\.[a-zA-Z0-9]+)?)/g
const matches = [...content.matchAll(FILE_REGEX)]
if (matches.length === 0) return content
let result = content
for (const match of matches) {
const fullMatch = match[0]
const filePath = match[1]
try {
const fileContent = await this.readFileContent(filePath)
const formatted = this.formatFileContent(filePath, fileContent)
result = result.replace(fullMatch, formatted)
} catch (error) {
result = result.replace(fullMatch, `(文件未找到:${filePath})`)
}
}
return result
}
private async readFileContent(filePath: string): Promise<string> {
const fullPath = path.resolve(process.cwd(), filePath)
// 安全检查
if (!fullPath.startsWith(process.cwd())) {
throw new Error('检测到路径遍历')
}
return fs.readFile(fullPath, 'utf-8')
}
private formatFileContent(filePath: string, content: string): string {
const ext = path.extname(filePath).slice(1)
const language = this.detectLanguage(ext)
return `
## 文件:${filePath}
\`\`\`${language}
${content}
\`\`\`
`
}
}
参数处理
class ArgumentProcessor {
/**
* 使用多种策略处理命令参数
*/
processArguments(
content: string,
args: string,
argNames?: string[]
): string {
let result = content
// 策略 1:官方 $ARGUMENTS 占位符
if (result.includes('$ARGUMENTS')) {
result = result.replace(/\$ARGUMENTS/g, args || '')
}
// 策略 2:命名占位符 {arg1}、{arg2}
if (argNames && argNames.length > 0) {
result = this.processNamedArguments(result, args, argNames)
}
// 策略 3:位置占位符 $1、$2、$3
result = this.processPositionalArguments(result, args)
// 策略 4:如果没有使用占位符则追加
if (!this.hasPlaceholders(content) && args.trim()) {
result += `\n\n附加上下文:${args}`
}
return result
}
private processNamedArguments(
content: string,
args: string,
argNames: string[]
): string {
const argValues = this.parseArguments(args)
let result = content
argNames.forEach((name, index) => {
const value = argValues[index] || ''
const placeholder = new RegExp(`\\{${name}\\}`, 'g')
result = result.replace(placeholder, value)
})
return result
}
private parseArguments(args: string): string[] {
// 处理带引号的参数
const regex = /[^\s"']+|"([^"]*)"|'([^']*)'/g
const matches: string[] = []
let match
while ((match = regex.exec(args)) !== null) {
matches.push(match[1] || match[2] || match[0])
}
return matches
}
}
命令创建
命令工厂
function createCustomCommand(
frontmatter: CustomCommandFrontmatter,
content: string,
filePath: string,
baseDir: string
): CustomCommandWithScope | null {
// 生成命令名称
const namespace = CommandNamespace.fromPath(filePath, baseDir)
const scope = (baseDir.includes('.claude/commands') || baseDir.includes('.kode/commands')) ? 'project' : 'user'
const finalName = frontmatter.name ||
CommandNamespace.addScope(namespace, scope)
// 提取配置
const config = {
description: frontmatter.description || `自定义命令:${finalName}`,
enabled: frontmatter.enabled !== false,
hidden: frontmatter.hidden === true,
aliases: frontmatter.aliases || [],
progressMessage: frontmatter.progressMessage || `运行 ${finalName}...`,
argNames: frontmatter.argNames,
allowedTools: frontmatter['allowed-tools']
}
// 验证命令
if (!finalName) {
console.warn(`${filePath} 中的命令没有名称`)
return null
}
// 创建命令对象
return {
type: 'prompt' as const,
name: finalName,
...config,
scope,
userFacingName(): string {
return finalName
},
async getPromptForCommand(args: string): Promise<MessageParam[]> {
let prompt = content.trim()
// 处理动态内容
const processor = new DynamicContentProcessor()
prompt = await processor.executeBashCommands(prompt)
prompt = await processor.resolveFileReferences(prompt)
// 处理参数
const argProcessor = new ArgumentProcessor()
prompt = argProcessor.processArguments(prompt, args, config.argNames)
// 添加工具限制(如果指定)
if (config.allowedTools && config.allowedTools.length > 0) {
prompt += `\n\n重要:您仅限于使用这些工具:${config.allowedTools.join('、')}。`
}
return [{
role: 'user',
content: prompt
}]
}
}
}
示例
基本命令
---
name: explain
description: 详细解释代码或概念
aliases: [exp, describe]
---
请详细解释 $ARGUMENTS。
包括:
- 概述和目的
- 工作原理
- 关键概念
- 适用示例
- 常见用例
带文件引用的命令
---
name: review-pr
description: 审查拉取请求更改
progressMessage: 分析 PR 更改...
---
审查以下拉取请求更改:
!`git diff main...HEAD`
@.github/pull_request_template.md
请分析:
1. 代码质量和风格
2. 潜在的错误或问题
3. 性能影响
4. 安全考虑
5. 测试覆盖率
提供建设性的反馈和建议。
带参数的命令
---
name: scaffold
description: 生成项目脚手架
argNames: [type, name, features]
---
创建一个名为"{name}"的新 {type} 项目,具有以下功能:{features}
结构:
!`ls -la`
要求:
- 遵循 {type} 项目的最佳实践
- 包括必要的配置文件
- 设置开发环境
- 添加基本测试
- 创建全面的 README
当前目录上下文:
@package.json
带工具限制的命令
---
name: analyze-only
description: 分析而不进行更改
allowed-tools: [file_read, grep, glob]
hidden: false
---
分析代码库以理解 $ARGUMENTS。
您只能读取文件和搜索模式。
不要进行任何修改或执行命令。
专注于:
- 理解实现
- 识别模式
- 记录发现
与主系统的集成
命令注册
export async function getCommands(): Promise<Command[]> {
const [builtIn, mcp, custom] = await Promise.all([
getBuiltInCommands(),
getMCPCommands(),
loadCustomCommands()
])
// 合并所有命令
const allCommands = [...builtIn, ...mcp, ...custom]
// 处理冲突(自定义命令覆盖内置)
const commandMap = new Map<string, Command>()
for (const cmd of allCommands) {
const name = cmd.userFacingName()
if (!commandMap.has(name)) {
commandMap.set(name, cmd)
} else if (cmd.scope === 'project') {
// 项目命令覆盖其他
commandMap.set(name, cmd)
}
}
return Array.from(commandMap.values())
}
自定义命令系统提供了一种强大、灵活的方式来使用用户定义的命令扩展 Kode,同时保持与核心系统的安全性、性能和集成。