Kode-cli/docs/develop-zh/modules/custom-commands.md
CrazyBoyM 7a3c4a7baa Refactor project structure and update documentation
- 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
2025-08-11 21:31:18 +08:00

15 KiB
Raw Blame History

自定义命令系统

概述

自定义命令系统(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同时保持与核心系统的安全性、性能和集成。