Kode-cli/docs/develop/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

20 KiB

Custom Commands System

Overview

The Custom Commands System (src/services/customCommands.ts) enables users to create reusable markdown-based commands that extend Kode's functionality. Commands are discovered from .claude/commands/ and .kode/commands/ directories and integrated seamlessly with the built-in command system.

Architecture

System Design

interface CustomCommandSystem {
  // Discovery
  loadCustomCommands(): Promise<CustomCommandWithScope[]>
  scanMarkdownFiles(directory: string): Promise<string[]>
  
  // Parsing
  parseFrontmatter(content: string): ParsedCommand
  createCustomCommand(parsed: ParsedCommand): Command
  
  // Processing
  executeBashCommands(content: string): Promise<string>
  resolveFileReferences(content: string): Promise<string>
  
  // Management
  reloadCustomCommands(): void
  getCustomCommandDirectories(): CommandDirectories
}

Command Structure

File Format

---
name: command-name
description: Brief description of the command
aliases: [alias1, alias2]
enabled: true
hidden: false
progressMessage: Running command...
argNames: [arg1, arg2]
allowed-tools: [file_read, file_edit]
---

# Command Content

Your command prompt goes here. You can use:
- Arguments: {arg1}, {arg2}
- Official format: $ARGUMENTS
- File references: @src/file.js
- Bash execution: !`git status`

Frontmatter Schema

export interface CustomCommandFrontmatter {
  // Core properties
  name?: string              // Command name (defaults to filename)
  description?: string       // Brief description
  aliases?: string[]        // Alternative names
  
  // Behavior control
  enabled?: boolean         // Whether command is active (default: true)
  hidden?: boolean          // Hide from help output (default: false)
  
  // Execution
  progressMessage?: string  // Message shown during execution
  argNames?: string[]       // Named argument placeholders
  'allowed-tools'?: string[] // Tool restrictions
  
  // Metadata (future extensions)
  version?: string
  author?: string
  tags?: string[]
  requires?: string[]       // Dependencies
}

Discovery System

Directory Scanning

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[] = []
    
    // Scan user commands (lower priority)
    if (existsSync(this.COMMAND_DIRS.user)) {
      const userFiles = await this.scanDirectory(this.COMMAND_DIRS.user)
      files.push(...userFiles.map(f => ({ ...f, scope: 'user' })))
    }
    
    // Scan project commands (higher priority)
    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 // Prevent deep recursion
      
      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
  }
}

Namespace Support

class CommandNamespace {
  /**
   * Generate namespaced command name from file path
   * 
   * Examples:
   * - /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
    }
    
    // Create namespace from directory structure
    const namespace = parts.slice(0, -1).join(':')
    return `${namespace}:${fileName}`
  }
  
  /**
   * Add scope prefix to command name
   * 
   * Examples:
   * - "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}`
  }
}

Parsing System

Frontmatter Parser

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()
    
    // Skip comments and empty lines
    if (!trimmed || trimmed.startsWith('#')) continue
    
    // Handle array items
    if (arrayMode && trimmed.startsWith('-')) {
      const item = trimmed.slice(1).trim().replace(/['"]/g, '')
      arrayItems.push(item)
      continue
    }
    
    // End array mode when we hit a new key
    if (arrayMode && trimmed.includes(':')) {
      if (currentKey) {
        result[currentKey as keyof CustomCommandFrontmatter] = arrayItems as any
      }
      arrayMode = false
      arrayItems = []
      currentKey = null
    }
    
    // Parse key-value pairs
    const colonIndex = trimmed.indexOf(':')
    if (colonIndex === -1) continue
    
    const key = trimmed.slice(0, colonIndex).trim()
    const value = trimmed.slice(colonIndex + 1).trim()
    
    // Handle different value types
    if (value.startsWith('[') && value.endsWith(']')) {
      // Inline array
      result[key as keyof CustomCommandFrontmatter] = this.parseInlineArray(value) as any
    } else if (value === '' || value === '[]') {
      // Multi-line array start
      currentKey = key
      arrayMode = true
      arrayItems = []
    } else if (value === 'true' || value === 'false') {
      // Boolean
      result[key as keyof CustomCommandFrontmatter] = (value === 'true') as any
    } else {
      // String (remove quotes)
      result[key as keyof CustomCommandFrontmatter] = value.replace(/['"]/g, '') as any
    }
  }
  
  // Handle final array if we ended in array mode
  if (arrayMode && currentKey) {
    result[currentKey as keyof CustomCommandFrontmatter] = arrayItems as any
  }
  
  return result
}

private parseInlineArray(value: string): string[] {
  return value
    .slice(1, -1) // Remove brackets
    .split(',')
    .map(s => s.trim().replace(/['"]/g, ''))
    .filter(s => s.length > 0)
}

Content Processing

Dynamic Content Execution

class DynamicContentProcessor {
  /**
   * Process bash command execution: !`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(`Failed to execute: ${command}`, error)
        result = result.replace(fullMatch, `(error: ${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() || '(no output)')
        }
      })
    })
  }
  
  /**
   * Process file references: @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, `(file not found: ${filePath})`)
      }
    }
    
    return result
  }
  
  private async readFileContent(filePath: string): Promise<string> {
    const fullPath = path.resolve(process.cwd(), filePath)
    
    // Security check
    if (!fullPath.startsWith(process.cwd())) {
      throw new Error('Path traversal detected')
    }
    
    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 `
## File: ${filePath}

\`\`\`${language}
${content}
\`\`\`
`
  }
}

Argument Processing

class ArgumentProcessor {
  /**
   * Process command arguments with multiple strategies
   */
  processArguments(
    content: string,
    args: string,
    argNames?: string[]
  ): string {
    let result = content
    
    // Strategy 1: Official $ARGUMENTS placeholder
    if (result.includes('$ARGUMENTS')) {
      result = result.replace(/\$ARGUMENTS/g, args || '')
    }
    
    // Strategy 2: Named placeholders {arg1}, {arg2}
    if (argNames && argNames.length > 0) {
      result = this.processNamedArguments(result, args, argNames)
    }
    
    // Strategy 3: Positional placeholders $1, $2, $3
    result = this.processPositionalArguments(result, args)
    
    // Strategy 4: Append if no placeholders used
    if (!this.hasPlaceholders(content) && args.trim()) {
      result += `\n\nAdditional context: ${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 processPositionalArguments(
    content: string,
    args: string
  ): string {
    const argValues = this.parseArguments(args)
    let result = content
    
    argValues.forEach((value, index) => {
      const placeholder = new RegExp(`\\$${index + 1}`, 'g')
      result = result.replace(placeholder, value)
    })
    
    return result
  }
  
  private parseArguments(args: string): string[] {
    // Handle quoted arguments
    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
  }
  
  private hasPlaceholders(content: string): boolean {
    return /\$ARGUMENTS|\$\d+|\{[^}]+\}/.test(content)
  }
}

Command Creation

Command Factory

function createCustomCommand(
  frontmatter: CustomCommandFrontmatter,
  content: string,
  filePath: string,
  baseDir: string
): CustomCommandWithScope | null {
  // Generate command name
  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)
  
  // Extract configuration
  const config = {
    description: frontmatter.description || `Custom command: ${finalName}`,
    enabled: frontmatter.enabled !== false,
    hidden: frontmatter.hidden === true,
    aliases: frontmatter.aliases || [],
    progressMessage: frontmatter.progressMessage || `Running ${finalName}...`,
    argNames: frontmatter.argNames,
    allowedTools: frontmatter['allowed-tools']
  }
  
  // Validate command
  if (!finalName) {
    console.warn(`No name for command in ${filePath}`)
    return null
  }
  
  // Create command object
  return {
    type: 'prompt' as const,
    name: finalName,
    ...config,
    scope,
    
    userFacingName(): string {
      return finalName
    },
    
    async getPromptForCommand(args: string): Promise<MessageParam[]> {
      let prompt = content.trim()
      
      // Process dynamic content
      const processor = new DynamicContentProcessor()
      prompt = await processor.executeBashCommands(prompt)
      prompt = await processor.resolveFileReferences(prompt)
      
      // Process arguments
      const argProcessor = new ArgumentProcessor()
      prompt = argProcessor.processArguments(prompt, args, config.argNames)
      
      // Add tool restrictions if specified
      if (config.allowedTools && config.allowedTools.length > 0) {
        prompt += `\n\nIMPORTANT: You are restricted to using only these tools: ${config.allowedTools.join(', ')}.`
      }
      
      return [{
        role: 'user',
        content: prompt
      }]
    }
  }
}

Caching and Performance

Memoized Loading

export const loadCustomCommands = memoize(
  async (): Promise<CustomCommandWithScope[]> => {
    const startTime = Date.now()
    const discovery = new CommandDiscovery()
    
    try {
      // Discover command files
      const files = await discovery.discover()
      
      // Parse and create commands in parallel
      const commands = await Promise.all(
        files.map(async (file) => {
          try {
            const content = await fs.readFile(file.path, 'utf-8')
            const { frontmatter, content: body } = parseFrontmatter(content)
            
            return createCustomCommand(
              frontmatter,
              body,
              file.path,
              file.baseDir
            )
          } catch (error) {
            console.warn(`Failed to load ${file.path}:`, error)
            return null
          }
        })
      )
      
      // Filter out failed commands and disabled ones
      const validCommands = commands
        .filter((cmd): cmd is CustomCommandWithScope => 
          cmd !== null && cmd.isEnabled
        )
      
      // Log metrics
      const duration = Date.now() - startTime
      logEvent('custom_commands_loaded', {
        total: files.length,
        valid: validCommands.length,
        duration
      })
      
      return validCommands
      
    } catch (error) {
      console.error('Failed to load custom commands:', error)
      return []
    }
  },
  // Cache key resolver
  () => {
    const dirs = getCustomCommandDirectories()
    const cacheKey = [
      process.cwd(),
      existsSync(dirs.user),
      existsSync(dirs.project),
      Math.floor(Date.now() / 60000) // 1-minute cache
    ].join(':')
    
    return cacheKey
  }
)

Cache Invalidation

export function reloadCustomCommands(): void {
  loadCustomCommands.cache.clear()
  console.log('Custom commands cache cleared')
}

// Watch for changes (optional)
class CommandWatcher {
  private watchers: FSWatcher[] = []
  
  watch(onChange: () => void): void {
    const dirs = getCustomCommandDirectories()
    
    for (const dir of Object.values(dirs)) {
      if (existsSync(dir)) {
        const watcher = watch(dir, { recursive: true }, (event, filename) => {
          if (filename?.endsWith('.md')) {
            console.log(`Command file changed: ${filename}`)
            reloadCustomCommands()
            onChange()
          }
        })
        
        this.watchers.push(watcher)
      }
    }
  }
  
  stop(): void {
    for (const watcher of this.watchers) {
      watcher.close()
    }
    this.watchers = []
  }
}

Examples

Basic Command

---
name: explain
description: Explain code or concept in detail
aliases: [exp, describe]
---

Please provide a detailed explanation of $ARGUMENTS.

Include:
- Overview and purpose
- How it works
- Key concepts
- Examples if applicable
- Common use cases

Command with File Reference

---
name: review-pr
description: Review pull request changes
progressMessage: Analyzing PR changes...
---

Review the following pull request changes:

!`git diff main...HEAD`

@.github/pull_request_template.md

Please analyze:
1. Code quality and style
2. Potential bugs or issues
3. Performance implications
4. Security considerations
5. Test coverage

Provide constructive feedback and suggestions.

Command with Arguments

---
name: scaffold
description: Generate project scaffold
argNames: [type, name, features]
---

Create a new {type} project named "{name}" with the following features: {features}

Structure:
!`ls -la`

Requirements:
- Follow best practices for {type} projects
- Include necessary configuration files
- Set up development environment
- Add basic tests
- Create comprehensive README

Current directory context:
@package.json

Command with Tool Restrictions

---
name: analyze-only
description: Analyze without making changes
allowed-tools: [file_read, grep, glob]
hidden: false
---

Analyze the codebase to understand $ARGUMENTS.

You may only read files and search for patterns.
Do not make any modifications or execute commands.

Focus on:
- Understanding the implementation
- Identifying patterns
- Documenting findings

Integration with Main System

Command Registration

export async function getCommands(): Promise<Command[]> {
  const [builtIn, mcp, custom] = await Promise.all([
    getBuiltInCommands(),
    getMCPCommands(),
    loadCustomCommands()
  ])
  
  // Merge all commands
  const allCommands = [...builtIn, ...mcp, ...custom]
  
  // Handle conflicts (custom commands override built-in)
  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') {
      // Project commands override others
      commandMap.set(name, cmd)
    }
  }
  
  return Array.from(commandMap.values())
}

Help System Integration

class HelpFormatter {
  formatCustomCommands(commands: CustomCommandWithScope[]): string {
    const grouped = this.groupByScope(commands)
    
    let output = ''
    
    if (grouped.project.length > 0) {
      output += '\n📁 Project Commands:\n'
      output += this.formatCommandList(grouped.project)
    }
    
    if (grouped.user.length > 0) {
      output += '\n👤 User Commands:\n'
      output += this.formatCommandList(grouped.user)
    }
    
    return output
  }
  
  private groupByScope(
    commands: CustomCommandWithScope[]
  ): Record<string, CustomCommandWithScope[]> {
    return commands.reduce((acc, cmd) => {
      const scope = cmd.scope || 'user'
      if (!acc[scope]) acc[scope] = []
      acc[scope].push(cmd)
      return acc
    }, {} as Record<string, CustomCommandWithScope[]>)
  }
  
  private formatCommandList(commands: CustomCommandWithScope[]): string {
    return commands
      .sort((a, b) => a.name.localeCompare(b.name))
      .map(cmd => {
        const aliases = cmd.aliases?.length 
          ? ` (${cmd.aliases.join(', ')})` 
          : ''
        return `  /${cmd.name}${aliases} - ${cmd.description}`
      })
      .join('\n')
  }
}

The Custom Commands System provides a powerful, flexible way to extend Kode with user-defined commands while maintaining security, performance, and integration with the core system.