- 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
20 KiB
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.