diff --git a/apps/mcp-server/src/cli/cli.spec.ts b/apps/mcp-server/src/cli/cli.spec.ts index 375883f9..cb652788 100644 --- a/apps/mcp-server/src/cli/cli.spec.ts +++ b/apps/mcp-server/src/cli/cli.spec.ts @@ -88,6 +88,20 @@ describe('cli', () => { expect(result.options.projectRoot).toBe('/custom/path'); }); + it('should parse completion command with shell type', () => { + const result = parseArgs(['completion', 'bash']); + + expect(result.command).toBe('completion'); + expect(result.options.shell).toBe('bash'); + }); + + it('should parse completion command without shell type', () => { + const result = parseArgs(['completion']); + + expect(result.command).toBe('completion'); + expect(result.options.shell).toBeUndefined(); + }); + it('should parse plugins command', () => { const result = parseArgs(['plugins']); diff --git a/apps/mcp-server/src/cli/cli.ts b/apps/mcp-server/src/cli/cli.ts index f35bb2bf..df95801b 100644 --- a/apps/mcp-server/src/cli/cli.ts +++ b/apps/mcp-server/src/cli/cli.ts @@ -16,6 +16,7 @@ import type { SearchOptions, UpdateOptions, } from './cli.types'; +import type { CompletionOptions } from './completion'; /** * Parsed command line arguments @@ -30,6 +31,7 @@ export interface ParsedArgs { | 'update' | 'mcp' | 'tui' + | 'completion' | 'help' | 'version'; options: Partial & @@ -37,7 +39,8 @@ export interface ParsedArgs { Partial & Partial & Partial & - Partial; + Partial & + Partial; } /** @@ -109,6 +112,14 @@ export function parseArgs(args: string[]): ParsedArgs { }; } + if (command === 'completion') { + const shell = args[1]; + return { + command: 'completion', + options: { ...options, shell }, + }; + } + if (command !== 'init') { return { command: 'help', options }; } @@ -149,6 +160,7 @@ Usage: codingbuddy mcp Start MCP server (stdio mode) codingbuddy tui Monitor agent execution in real-time codingbuddy tui --restart Restart TUI client (fixes blank screen) + codingbuddy completion Generate shell completions (bash, zsh, fish) codingbuddy --help Show this help codingbuddy --version Show version @@ -312,6 +324,22 @@ export async function main(args: string[] = process.argv.slice(2)): Promise\n', + ); + process.exitCode = 1; + break; + } + const completionResult = runCompletion({ shell: options.shell }); + if (!completionResult.success) { + process.exitCode = 1; + } + break; + } + case 'init': { const result = await runInit(options as InitOptions); if (!result.success) { diff --git a/apps/mcp-server/src/cli/completion/completion.spec.ts b/apps/mcp-server/src/cli/completion/completion.spec.ts new file mode 100644 index 00000000..de8b7910 --- /dev/null +++ b/apps/mcp-server/src/cli/completion/completion.spec.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + COMMANDS, + GLOBAL_FLAGS, + COMMAND_FLAGS, + getPluginNames, + generateBashCompletion, + generateZshCompletion, + generateFishCompletion, + generateCompletion, + runCompletion, +} from './completion'; + +// ============================================================================ +// Completion Data Structure +// ============================================================================ + +describe('completion data', () => { + it('should export all CLI commands', () => { + expect(COMMANDS).toContain('init'); + expect(COMMANDS).toContain('install'); + expect(COMMANDS).toContain('search'); + expect(COMMANDS).toContain('plugins'); + expect(COMMANDS).toContain('update'); + expect(COMMANDS).toContain('uninstall'); + expect(COMMANDS).toContain('mcp'); + expect(COMMANDS).toContain('tui'); + expect(COMMANDS).toContain('completion'); + }); + + it('should export global flags', () => { + expect(GLOBAL_FLAGS).toContain('--help'); + expect(GLOBAL_FLAGS).toContain('--version'); + }); + + it('should export command-specific flags', () => { + expect(COMMAND_FLAGS.init).toContain('--force'); + expect(COMMAND_FLAGS.init).toContain('--yes'); + expect(COMMAND_FLAGS.init).toContain('--api-key'); + expect(COMMAND_FLAGS.install).toContain('--force'); + expect(COMMAND_FLAGS.uninstall).toContain('--yes'); + }); +}); + +// ============================================================================ +// Dynamic Plugin Names +// ============================================================================ + +describe('getPluginNames', () => { + beforeEach(() => { + vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return empty array when plugins.json does not exist', async () => { + const fs = await import('fs'); + (fs.existsSync as ReturnType).mockReturnValue(false); + + const names = getPluginNames('/project'); + + expect(names).toEqual([]); + }); + + it('should return plugin names from plugins.json', async () => { + const fs = await import('fs'); + (fs.existsSync as ReturnType).mockReturnValue(true); + (fs.readFileSync as ReturnType).mockReturnValue( + JSON.stringify({ + plugins: [ + { name: 'plugin-a', version: '1.0.0' }, + { name: 'plugin-b', version: '2.0.0' }, + ], + }), + ); + + const names = getPluginNames('/project'); + + expect(names).toEqual(['plugin-a', 'plugin-b']); + }); + + it('should return empty array on parse error', async () => { + const fs = await import('fs'); + (fs.existsSync as ReturnType).mockReturnValue(true); + (fs.readFileSync as ReturnType).mockReturnValue('invalid json'); + + const names = getPluginNames('/project'); + + expect(names).toEqual([]); + }); +}); + +// ============================================================================ +// Bash Completion +// ============================================================================ + +describe('generateBashCompletion', () => { + it('should generate valid bash completion script', () => { + const script = generateBashCompletion(); + + expect(script).toContain('_codingbuddy_completions'); + expect(script).toContain('complete -F _codingbuddy_completions codingbuddy'); + expect(script).toContain('COMPREPLY'); + }); + + it('should include all commands in bash script', () => { + const script = generateBashCompletion(); + + for (const cmd of COMMANDS) { + expect(script).toContain(cmd); + } + }); + + it('should include command-specific flags', () => { + const script = generateBashCompletion(); + + expect(script).toContain('--force'); + expect(script).toContain('--yes'); + expect(script).toContain('--api-key'); + }); + + it('should include dynamic plugin completion for update/uninstall', () => { + const script = generateBashCompletion(); + + expect(script).toContain('plugins.json'); + }); +}); + +// ============================================================================ +// Zsh Completion +// ============================================================================ + +describe('generateZshCompletion', () => { + it('should generate valid zsh completion script', () => { + const script = generateZshCompletion(); + + expect(script).toContain('#compdef codingbuddy'); + expect(script).toContain('_codingbuddy'); + expect(script).toContain('compadd'); + }); + + it('should include all commands in zsh script', () => { + const script = generateZshCompletion(); + + for (const cmd of COMMANDS) { + expect(script).toContain(cmd); + } + }); + + it('should include command descriptions', () => { + const script = generateZshCompletion(); + + expect(script).toContain('Initialize'); + expect(script).toContain('Install'); + expect(script).toContain('completion'); + }); + + it('should include dynamic plugin completion', () => { + const script = generateZshCompletion(); + + expect(script).toContain('plugins.json'); + }); +}); + +// ============================================================================ +// Fish Completion +// ============================================================================ + +describe('generateFishCompletion', () => { + it('should generate valid fish completion script', () => { + const script = generateFishCompletion(); + + expect(script).toContain('complete -c codingbuddy'); + }); + + it('should include all commands in fish script', () => { + const script = generateFishCompletion(); + + for (const cmd of COMMANDS) { + expect(script).toContain(cmd); + } + }); + + it('should include command descriptions', () => { + const script = generateFishCompletion(); + + expect(script).toContain('Initialize'); + expect(script).toContain('Install'); + }); + + it('should include dynamic plugin completion', () => { + const script = generateFishCompletion(); + + expect(script).toContain('plugins.json'); + }); +}); + +// ============================================================================ +// generateCompletion dispatcher +// ============================================================================ + +describe('generateCompletion', () => { + it('should return bash script for "bash" shell', () => { + const script = generateCompletion('bash'); + + expect(script).toContain('complete -F _codingbuddy_completions'); + }); + + it('should return zsh script for "zsh" shell', () => { + const script = generateCompletion('zsh'); + + expect(script).toContain('#compdef codingbuddy'); + }); + + it('should return fish script for "fish" shell', () => { + const script = generateCompletion('fish'); + + expect(script).toContain('complete -c codingbuddy'); + }); + + it('should return null for unsupported shell', () => { + const script = generateCompletion('powershell'); + + expect(script).toBeNull(); + }); +}); + +// ============================================================================ +// runCompletion command handler +// ============================================================================ + +describe('runCompletion', () => { + let stdoutWrite: ReturnType; + let stderrWrite: ReturnType; + + beforeEach(() => { + stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should output bash completion and return success', () => { + const result = runCompletion({ shell: 'bash' }); + + expect(result.success).toBe(true); + expect(stdoutWrite).toHaveBeenCalled(); + const output = stdoutWrite.mock.calls.map((c: unknown[]) => c[0]).join(''); + expect(output).toContain('complete -F _codingbuddy_completions'); + }); + + it('should output zsh completion and return success', () => { + const result = runCompletion({ shell: 'zsh' }); + + expect(result.success).toBe(true); + const output = stdoutWrite.mock.calls.map((c: unknown[]) => c[0]).join(''); + expect(output).toContain('#compdef codingbuddy'); + }); + + it('should output fish completion and return success', () => { + const result = runCompletion({ shell: 'fish' }); + + expect(result.success).toBe(true); + const output = stdoutWrite.mock.calls.map((c: unknown[]) => c[0]).join(''); + expect(output).toContain('complete -c codingbuddy'); + }); + + it('should return failure for unsupported shell', () => { + const result = runCompletion({ shell: 'powershell' }); + + expect(result.success).toBe(false); + expect(stderrWrite).toHaveBeenCalled(); + const output = stderrWrite.mock.calls.map((c: unknown[]) => c[0]).join(''); + expect(output).toContain('Unsupported shell'); + }); + + it('should show usage when no shell is specified', () => { + const result = runCompletion({ shell: undefined as unknown as string }); + + expect(result.success).toBe(false); + expect(stderrWrite).toHaveBeenCalled(); + }); +}); diff --git a/apps/mcp-server/src/cli/completion/completion.ts b/apps/mcp-server/src/cli/completion/completion.ts new file mode 100644 index 00000000..a7e90835 --- /dev/null +++ b/apps/mcp-server/src/cli/completion/completion.ts @@ -0,0 +1,367 @@ +/** + * Shell Completion Command + * + * Generates shell-specific completion scripts for bash, zsh, and fish. + * Supports dynamic plugin name completion from .codingbuddy/plugins.json. + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +// ============================================================================ +// Completion Data +// ============================================================================ + +export const COMMANDS = [ + 'init', + 'install', + 'search', + 'plugins', + 'update', + 'uninstall', + 'mcp', + 'tui', + 'completion', +] as const; + +export const GLOBAL_FLAGS = ['--help', '--version'] as const; + +export const COMMAND_FLAGS: Record = { + init: ['--force', '--yes', '--api-key'], + install: ['--force'], + uninstall: ['--yes'], + tui: ['--restart'], + completion: ['bash', 'zsh', 'fish'], +} as const; + +const COMMAND_DESCRIPTIONS: Record = { + init: 'Initialize configuration', + install: 'Install a plugin from git repository', + search: 'Search plugins in the registry', + plugins: 'List installed plugins', + update: 'Check and update outdated plugins', + uninstall: 'Uninstall a plugin', + mcp: 'Start MCP server (stdio mode)', + tui: 'Monitor agent execution in real-time', + completion: 'Generate shell completion script', +}; + +// ============================================================================ +// Dynamic Plugin Names +// ============================================================================ + +/** + * Read plugin names from .codingbuddy/plugins.json. + */ +export function getPluginNames(projectRoot: string): string[] { + const pluginsPath = join(projectRoot, '.codingbuddy', 'plugins.json'); + + if (!existsSync(pluginsPath)) { + return []; + } + + try { + const raw = readFileSync(pluginsPath, 'utf-8'); + const data = JSON.parse(raw) as { plugins?: Array<{ name: string }> }; + return (data.plugins ?? []).map(p => p.name); + } catch { + return []; + } +} + +// ============================================================================ +// Bash Completion +// ============================================================================ + +export function generateBashCompletion(): string { + const cmds = COMMANDS.join(' '); + const globalFlags = GLOBAL_FLAGS.join(' '); + const initFlags = (COMMAND_FLAGS.init ?? []).join(' '); + const installFlags = (COMMAND_FLAGS.install ?? []).join(' '); + const uninstallFlags = (COMMAND_FLAGS.uninstall ?? []).join(' '); + const tuiFlags = (COMMAND_FLAGS.tui ?? []).join(' '); + + return `#!/bin/bash +# codingbuddy bash completion +# Install: codingbuddy completion bash >> ~/.bashrc +# or: codingbuddy completion bash > /etc/bash_completion.d/codingbuddy + +_codingbuddy_get_plugin_names() { + local plugins_file=".codingbuddy/plugins.json" + if [[ -f "$plugins_file" ]]; then + grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$plugins_file" | sed 's/"name"[[:space:]]*:[[:space:]]*"\\([^"]*\\)"/\\1/' + fi +} + +_codingbuddy_completions() { + local cur prev commands + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + commands="${cmds}" + + # Complete commands at position 1 + if [[ \${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "$commands ${globalFlags}" -- "$cur") ) + return + fi + + # Complete based on command + case "\${COMP_WORDS[1]}" in + init) + COMPREPLY=( $(compgen -W "${initFlags}" -- "$cur") ) + ;; + install) + COMPREPLY=( $(compgen -W "${installFlags}" -- "$cur") ) + ;; + uninstall) + if [[ \${COMP_CWORD} -eq 2 ]]; then + local plugins=$(_codingbuddy_get_plugin_names) + COMPREPLY=( $(compgen -W "$plugins ${uninstallFlags}" -- "$cur") ) + else + COMPREPLY=( $(compgen -W "${uninstallFlags}" -- "$cur") ) + fi + ;; + update) + if [[ \${COMP_CWORD} -eq 2 ]]; then + local plugins=$(_codingbuddy_get_plugin_names) + COMPREPLY=( $(compgen -W "$plugins" -- "$cur") ) + fi + ;; + tui) + COMPREPLY=( $(compgen -W "${tuiFlags}" -- "$cur") ) + ;; + completion) + COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) + ;; + esac +} + +complete -F _codingbuddy_completions codingbuddy +`; +} + +// ============================================================================ +// Zsh Completion +// ============================================================================ + +export function generateZshCompletion(): string { + const cmdEntries = COMMANDS.map(cmd => ` '${cmd}:${COMMAND_DESCRIPTIONS[cmd] ?? cmd}'`).join( + '\n', + ); + const initFlags = (COMMAND_FLAGS.init ?? []) + .map( + f => + ` '${f}[${f === '--force' ? 'Overwrite existing config' : f === '--yes' ? 'Accept defaults' : 'Anthropic API key'}]'`, + ) + .join('\n'); + const installFlags = (COMMAND_FLAGS.install ?? []) + .map(f => ` '${f}[Force install]'`) + .join('\n'); + const uninstallFlags = (COMMAND_FLAGS.uninstall ?? []) + .map(f => ` '${f}[Skip confirmation]'`) + .join('\n'); + + return `#compdef codingbuddy +# codingbuddy zsh completion +# Install: codingbuddy completion zsh > ~/.zsh/completions/_codingbuddy +# then add to ~/.zshrc: fpath=(~/.zsh/completions $fpath) + +_codingbuddy_get_plugin_names() { + local plugins_file=".codingbuddy/plugins.json" + if [[ -f "$plugins_file" ]]; then + grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$plugins_file" | sed 's/"name"[[:space:]]*:[[:space:]]*"\\([^"]*\\)"/\\1/' + fi +} + +_codingbuddy() { + local -a commands + commands=( +${cmdEntries} + ) + + _arguments -C \\ + '1:command:->command' \\ + '*::arg:->args' + + case "$state" in + command) + _describe 'command' commands + compadd -- --help --version + ;; + args) + case "\${words[1]}" in + init) + _arguments \\ +${initFlags} + ;; + install) + _arguments \\ +${installFlags} + ;; + uninstall) + if (( CURRENT == 2 )); then + local -a plugins + plugins=(\${(f)"$(_codingbuddy_get_plugin_names)"}) + compadd -a plugins + fi + _arguments \\ +${uninstallFlags} + ;; + update) + if (( CURRENT == 2 )); then + local -a plugins + plugins=(\${(f)"$(_codingbuddy_get_plugin_names)"}) + compadd -a plugins + fi + ;; + tui) + _arguments \\ + '--restart[Restart TUI client]' + ;; + completion) + compadd bash zsh fish + ;; + esac + ;; + esac +} + +_codingbuddy "$@" +`; +} + +// ============================================================================ +// Fish Completion +// ============================================================================ + +export function generateFishCompletion(): string { + const noSubcommandCond = + '__fish_seen_subcommand_from init install search plugins update uninstall mcp tui completion'; + + const lines: string[] = [ + '# codingbuddy fish completion', + '# Install: codingbuddy completion fish > ~/.config/fish/completions/codingbuddy.fish', + '', + '# Helper: get plugin names from plugins.json', + 'function __codingbuddy_plugin_names', + ' set -l plugins_file ".codingbuddy/plugins.json"', + ' if test -f $plugins_file', + ' string match -rg \'"name"\\s*:\\s*"([^"]*)"\' < $plugins_file', + ' end', + 'end', + '', + '# Disable file completions by default', + 'complete -c codingbuddy -f', + '', + '# Commands (only when no subcommand given)', + ]; + + for (const cmd of COMMANDS) { + const desc = COMMAND_DESCRIPTIONS[cmd] ?? cmd; + lines.push(`complete -c codingbuddy -n "not ${noSubcommandCond}" -a "${cmd}" -d "${desc}"`); + } + + lines.push(''); + lines.push('# Global flags'); + lines.push(`complete -c codingbuddy -n "not ${noSubcommandCond}" -l help -d "Show help"`); + lines.push(`complete -c codingbuddy -n "not ${noSubcommandCond}" -l version -d "Show version"`); + + lines.push(''); + lines.push('# init flags'); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from init" -l force -s f -d "Overwrite existing config"', + ); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from init" -l yes -s y -d "Accept defaults"', + ); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from init" -l api-key -d "Anthropic API key"', + ); + + lines.push(''); + lines.push('# install flags'); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from install" -l force -s f -d "Force install"', + ); + + lines.push(''); + lines.push('# uninstall - dynamic plugin names + flags'); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from uninstall" -a "(__codingbuddy_plugin_names)" -d "Plugin name"', + ); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from uninstall" -l yes -s y -d "Skip confirmation"', + ); + + lines.push(''); + lines.push('# update - dynamic plugin names'); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from update" -a "(__codingbuddy_plugin_names)" -d "Plugin name"', + ); + + lines.push(''); + lines.push('# tui flags'); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from tui" -l restart -d "Restart TUI client"', + ); + + lines.push(''); + lines.push('# completion subcommands'); + lines.push( + 'complete -c codingbuddy -n "__fish_seen_subcommand_from completion" -a "bash zsh fish" -d "Shell type"', + ); + lines.push(''); + + return lines.join('\n'); +} + +// ============================================================================ +// Dispatcher +// ============================================================================ + +export function generateCompletion(shell: string): string | null { + switch (shell) { + case 'bash': + return generateBashCompletion(); + case 'zsh': + return generateZshCompletion(); + case 'fish': + return generateFishCompletion(); + default: + return null; + } +} + +// ============================================================================ +// Command Handler +// ============================================================================ + +export interface CompletionOptions { + shell: string; +} + +export interface CompletionResult { + success: boolean; + error?: string; +} + +export function runCompletion(options: CompletionOptions): CompletionResult { + if (!options.shell) { + process.stderr.write( + 'Error: Missing shell type. Usage: codingbuddy completion \n', + ); + return { success: false, error: 'Missing shell type' }; + } + + const script = generateCompletion(options.shell); + + if (!script) { + process.stderr.write( + `Error: Unsupported shell "${options.shell}". Supported: bash, zsh, fish\n`, + ); + return { success: false, error: `Unsupported shell: ${options.shell}` }; + } + + process.stdout.write(script); + return { success: true }; +} diff --git a/apps/mcp-server/src/cli/completion/index.ts b/apps/mcp-server/src/cli/completion/index.ts new file mode 100644 index 00000000..7492f35c --- /dev/null +++ b/apps/mcp-server/src/cli/completion/index.ts @@ -0,0 +1,2 @@ +export { runCompletion, generateCompletion } from './completion'; +export type { CompletionOptions, CompletionResult } from './completion';