diff --git a/.claude/tasks/done/05-extend-autocompletion.md b/.claude/tasks/done/05-extend-autocompletion.md new file mode 100644 index 0000000..934118e --- /dev/null +++ b/.claude/tasks/done/05-extend-autocompletion.md @@ -0,0 +1,83 @@ +# Problem to solve + +I would like to find ways to extend the autocompletion of the CLI to autocomplete task names on `emb tasks run` and its equivalent shortcuts `emb run`. + +Let's start with a first step: Please write a series of integration tests on the autocompletion to expect the following: + +`emb tasks run ` should autocomplete to the list of all available tasks (run `emb tasks` to find them in this repo). +`emb tasks run dep` should autocomplete to `emb tasks run dependent` since it's the only possible match +`emb tasks run test` should autocomplete to the options 'buildargs:test' 'dependent:test' since it's ambiguous + +This should give us a red tests suite, once we have that we will then proceed to the implementation. + +## Current Status + +### Bash completion +- [x] Write failing integration tests for task name completion +- [x] Implement task name completion in bash autocomplete script +- [x] Run tests and verify they pass (all 13 tests passing) + +### Zsh completion +- [x] Write integration tests for zsh command/flag completion (equivalent to bash coverage) +- [x] Write failing integration tests for zsh task name completion +- [x] Implement task name completion in zsh autocomplete script +- [x] Run tests and verify they pass (all 25 tests passing: 13 bash + 12 zsh) + +## Technical Analysis + +### Challenge + +oclif's `@oclif/plugin-autocomplete` does NOT support argument completion - only commands and flags. This is a known limitation (GitHub issue #1, closed as "not planned" after 6 years). + +The generated bash completion script has a hardcoded list of commands with their flags: +```bash +local commands=" +clean --json --verbose --force +tasks --json --verbose +tasks:run --json --verbose --executor --all-matching +..." +``` + +For argument positions (like task names after `tasks run`), it returns empty completions. + +### Solution Approach + +Extend the bash completion function to detect when we're completing arguments for `tasks:run` or `run` commands, and dynamically fetch available task names by calling `emb tasks --json`. + +The completion function needs to: +1. Detect if the command is `tasks:run` or `run` +2. Check if we're in argument position (not flag position) +3. Call `emb tasks --json` to get available task IDs +4. Filter by prefix if partial input provided +5. Return matching task names + +### Files Modified + +- `tests/integration/cli/autocomplete/bash.spec.ts` - 13 tests for bash command/flag/task completion +- `tests/integration/cli/autocomplete/zsh.spec.ts` - 12 tests for zsh command/flag/task completion +- `src/cli/hooks/postrun.ts` - Postrun hook that patches both bash and zsh completion scripts +- `package.json` - Registered the postrun hook + +### Implementation Details + +The solution uses oclif's `postrun` hook to patch the generated completion scripts after `emb autocomplete` runs: + +#### Bash +1. **Task completion function** (`_emb_complete_tasks`): Calls `emb tasks --json` to get available tasks, then filters by substring match +2. **Injection point**: Injected right before the `if [[ -z "$normalizedCommand" ]]` check +3. **Pattern matching**: Detects `tasks:run:*` or `run:*` commands and calls the task completion function +4. **Substring matching**: Unlike typical prefix-only completion, supports substring matching (e.g., "test" matches "buildargs:test") + +#### Zsh +1. **Task completion function** (`_emb_complete_tasks`): Uses zsh's `_describe` to add task completions +2. **Injection point**: Replaces `"*: :_files"` with `"*: :_emb_complete_tasks"` in the run command sections +3. **Coverage**: Both `tasks run` and the `run` alias use task completion + +## Test Cases + +``` +emb tasks run → all task IDs (dependent, greet, prereq, buildargs:test, ...) +emb run → all task IDs (alias for tasks:run) +emb tasks run dep → dependent, dependent:test +emb tasks run test → buildargs:test, dependent:test, frontend:test +``` \ No newline at end of file diff --git a/package.json b/package.json index 8571846..2aaef5f 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,8 @@ "commands": "./dist/src/cli/commands", "hooks": { "init": "./dist/src/cli/hooks/init", - "command_not_found": "./dist/src/cli/hooks/not-found" + "command_not_found": "./dist/src/cli/hooks/not-found", + "postrun": "./dist/src/cli/hooks/postrun" }, "macos": { "identifier": "dev.enspirit.emb" diff --git a/src/cli/hooks/postrun.ts b/src/cli/hooks/postrun.ts new file mode 100644 index 0000000..fb2961a --- /dev/null +++ b/src/cli/hooks/postrun.ts @@ -0,0 +1,166 @@ +import { Hook } from '@oclif/core'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Task completion function to inject into the bash completion script. + * This function is called when completing arguments for `tasks run` or `run`. + */ +const TASK_COMPLETION_FUNCTION = ` +# EMB: Task name completion for 'tasks run' and 'run' commands +_emb_complete_tasks() { + local cur="\${1:-}" + local tasks + + # Get task IDs from emb tasks --json, extract the id field + tasks=$(emb tasks --json 2>/dev/null | grep -o '"id": *"[^"]*"' | sed 's/"id": *"//g' | sed 's/"//g') + + if [[ -z "$tasks" ]]; then + return + fi + + # Filter by substring match if provided (more flexible than prefix-only) + if [[ -n "$cur" ]]; then + local matches="" + for task in $tasks; do + if [[ "$task" == *"$cur"* ]]; then + matches="$matches $task" + fi + done + COMPREPLY=($matches) + else + COMPREPLY=($tasks) + fi +} +`; + +/** + * Enhanced completion logic to inject after normalizedCommand is calculated. + * Checks if we're completing task arguments for 'tasks run' or 'run'. + */ +const TASK_COMPLETION_LOGIC = ` + # EMB: Check if we're completing task names for 'tasks run' or 'run' + if [[ "$normalizedCommand" == tasks:run:* ]] || [[ "$normalizedCommand" == run:* ]] || [[ "$normalizedCommand" == "tasks:run" ]] || [[ "$normalizedCommand" == "run" ]]; then + _emb_complete_tasks "$cur" + return + fi +`; + +/** + * Zsh task completion function to inject. + * Uses zsh's compadd to add task completions. + * Note: We use compadd instead of _describe because task IDs contain colons + * (e.g., "frontend:test") and _describe uses colons as value:description delimiter. + */ +const ZSH_TASK_COMPLETION_FUNCTION = ` +# EMB: Task name completion for 'tasks run' and 'run' commands +_emb_complete_tasks() { + local tasks + # Get task IDs from emb tasks --json, extract the id field + tasks=(\${(f)"$(emb tasks --json 2>/dev/null | grep -o '"id": *"[^"]*"' | sed 's/"id": *"//g' | sed 's/"//g')"}) + + if [[ \${#tasks[@]} -gt 0 ]]; then + # Use compadd instead of _describe because task IDs contain colons + # which _describe interprets as value:description separators + compadd -a tasks + fi +} +`; + +/** + * Patches the bash completion script. + */ +function patchBashCompletion(): void { + const possiblePaths = [ + join(homedir(), 'Library/Caches/emb/autocomplete/functions/bash/emb.bash'), + join(homedir(), '.cache/emb/autocomplete/functions/bash/emb.bash'), + ]; + + const scriptPath = possiblePaths.find((p) => existsSync(p)); + if (!scriptPath) { + return; + } + + const content = readFileSync(scriptPath, 'utf8'); + + // Check if already patched + if (content.includes('_emb_complete_tasks')) { + return; + } + + // Insert task completion function after the join_by function + let patched = content.replace( + /function join_by \{[^}]+\}/, + (match) => `${match}\n${TASK_COMPLETION_FUNCTION}`, + ); + + // Insert task completion logic right before the "if [[ -z "$normalizedCommand" ]]" check + patched = patched.replace( + 'if [[ -z "$normalizedCommand" ]]; then', + `${TASK_COMPLETION_LOGIC.trim()}\n\n if [[ -z "$normalizedCommand" ]]; then`, + ); + + writeFileSync(scriptPath, patched, 'utf8'); +} + +/** + * Patches the zsh completion script. + */ +function patchZshCompletion(): void { + const possiblePaths = [ + join(homedir(), 'Library/Caches/emb/autocomplete/functions/zsh/_emb'), + join(homedir(), '.cache/emb/autocomplete/functions/zsh/_emb'), + ]; + + const scriptPath = possiblePaths.find((p) => existsSync(p)); + if (!scriptPath) { + return; + } + + const content = readFileSync(scriptPath, 'utf8'); + + // Check if already patched + if (content.includes('_emb_complete_tasks')) { + return; + } + + // Insert task completion function after #compdef line + let patched = content.replace( + '#compdef emb', + `#compdef emb\n${ZSH_TASK_COMPLETION_FUNCTION}`, + ); + + // Replace _files with _emb_complete_tasks in run command sections + // Pattern 1: Inside _emb_tasks for "tasks run" + patched = patched.replace( + /("run"\)\s*\n\s*_arguments -S[\s\S]*?)"?\*: :_files"(\s*;;)/, + '$1"*: :_emb_complete_tasks"$2', + ); + + // Pattern 2: Top-level run) command (the alias) + patched = patched.replace( + /(^run\)\n_arguments -S[\s\S]*?)"?\*: :_files"(\s*;;)/m, + '$1"*: :_emb_complete_tasks"$2', + ); + + writeFileSync(scriptPath, patched, 'utf8'); +} + +/** + * Postrun hook that patches completion scripts after autocomplete generation. + * This adds task name completion for the 'tasks run' and 'run' commands. + */ +const hook: Hook.Postrun = async function (options) { + const commandId = options.Command?.id; + + // Only patch after autocomplete commands + if (commandId !== 'autocomplete' && commandId !== 'autocomplete:create') { + return; + } + + patchBashCompletion(); + patchZshCompletion(); +}; + +export default hook; diff --git a/tests/integration/cli/autocomplete/bash.spec.ts b/tests/integration/cli/autocomplete/bash.spec.ts index e3cc6d1..11107f5 100644 --- a/tests/integration/cli/autocomplete/bash.spec.ts +++ b/tests/integration/cli/autocomplete/bash.spec.ts @@ -50,7 +50,11 @@ describe('CLI - bash autocomplete', () => { * @returns Array of completion suggestions */ function getCompletions(words: string[]): string[] { + // Define emb as a function that calls ./bin/run.js + // This ensures task completion works even when emb isn't globally installed const script = ` + emb() { ./bin/run.js "$@"; } + export -f emb source "${completionScriptPath}" COMP_WORDS=(${words.map((w) => `"${w}"`).join(' ')}) COMP_CWORD=${words.length - 1} @@ -160,4 +164,61 @@ describe('CLI - bash autocomplete', () => { expect(completions).not.to.include('--force'); expect(completions).not.to.include('--json'); }); + + // Task name completion tests - these should fail until we implement + // custom argument completion for the tasks:run command + + test('completes task names for "emb tasks run "', () => { + const completions = getCompletions(['emb', 'tasks', 'run', '']); + + // Should include all available tasks + expect(completions).to.include('dependent'); + expect(completions).to.include('greet'); + expect(completions).to.include('prereq'); + expect(completions).to.include('buildargs:test'); + expect(completions).to.include('dependent:test'); + expect(completions).to.include('frontend:fail'); + expect(completions).to.include('frontend:test'); + expect(completions).to.include('simple:confirm'); + expect(completions).to.include('simple:inspect'); + expect(completions).to.include('simple:sudo'); + expect(completions).to.include('utils:release'); + }); + + test('completes task names for "emb run " (alias)', () => { + const completions = getCompletions(['emb', 'run', '']); + + // Should include all available tasks (same as tasks:run) + expect(completions).to.include('dependent'); + expect(completions).to.include('greet'); + expect(completions).to.include('prereq'); + expect(completions).to.include('buildargs:test'); + expect(completions).to.include('frontend:test'); + }); + + test('filters task names for "emb tasks run dep"', () => { + const completions = getCompletions(['emb', 'tasks', 'run', 'dep']); + + // Should include tasks starting with "dep" + expect(completions).to.include('dependent'); + expect(completions).to.include('dependent:test'); + + // Should NOT include unrelated tasks + expect(completions).not.to.include('greet'); + expect(completions).not.to.include('prereq'); + expect(completions).not.to.include('frontend:test'); + }); + + test('filters task names for "emb tasks run test"', () => { + const completions = getCompletions(['emb', 'tasks', 'run', 'test']); + + // Should include all tasks containing "test" + expect(completions).to.include('buildargs:test'); + expect(completions).to.include('dependent:test'); + expect(completions).to.include('frontend:test'); + + // Should NOT include unrelated tasks + expect(completions).not.to.include('dependent'); + expect(completions).not.to.include('greet'); + }); }); diff --git a/tests/integration/cli/autocomplete/zsh.spec.ts b/tests/integration/cli/autocomplete/zsh.spec.ts new file mode 100644 index 0000000..874b1e5 --- /dev/null +++ b/tests/integration/cli/autocomplete/zsh.spec.ts @@ -0,0 +1,187 @@ +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, test } from 'vitest'; + +/** + * Integration tests for zsh tab completion. + * + * Zsh completion testing is more complex than bash because zsh's completion + * system uses special functions (_arguments, _values, compadd) that only work + * in a proper completion context. + * + * These tests verify: + * 1. The completion script is generated correctly + * 2. Expected commands and subcommands are defined + * 3. Flags are properly configured + * 4. Task name completion is available (once implemented) + */ +describe('CLI - zsh autocomplete', () => { + let completionScriptPath: string; + let completionScript: string; + + beforeAll(() => { + // Generate the autocomplete cache + execSync('./bin/run.js autocomplete zsh', { + cwd: process.cwd(), + stdio: 'pipe', + }); + + // Find the completion script path (macOS vs Linux cache location) + const possiblePaths = [ + join(homedir(), 'Library/Caches/emb/autocomplete/functions/zsh/_emb'), // macOS + join(homedir(), '.cache/emb/autocomplete/functions/zsh/_emb'), // Linux + ]; + + completionScriptPath = possiblePaths.find((p) => existsSync(p)) || ''; + + if (!completionScriptPath) { + throw new Error( + 'Could not find zsh completion script. Checked:\n' + + possiblePaths.join('\n'), + ); + } + + completionScript = readFileSync(completionScriptPath, 'utf8'); + }); + + /** + * Extract commands from the zsh completion script. + * Commands are defined in _values calls like: "command[description]" + */ + function extractCommands(): string[] { + const matches = completionScript.match(/"([a-z_-]+)\[/g) || []; + return [...new Set(matches.map((m) => m.slice(1, -1)))]; + } + + /** + * Check if a specific command definition exists in the script. + */ + function hasCommandDefinition(command: string): boolean { + return completionScript.includes(`"${command}[`); + } + + // ==================== Command Completion Tests ==================== + + test('script defines top-level commands', () => { + const commands = extractCommands(); + + // Main commands + expect(commands).to.include('up'); + expect(commands).to.include('down'); + expect(commands).to.include('start'); + expect(commands).to.include('stop'); + expect(commands).to.include('restart'); + expect(commands).to.include('clean'); + expect(commands).to.include('ps'); + + // Topic commands + expect(commands).to.include('tasks'); + expect(commands).to.include('components'); + expect(commands).to.include('containers'); + expect(commands).to.include('images'); + expect(commands).to.include('resources'); + expect(commands).to.include('kubernetes'); + expect(commands).to.include('config'); + + // Utility commands + expect(commands).to.include('help'); + expect(commands).to.include('autocomplete'); + }); + + test('script defines tasks subcommands', () => { + expect(hasCommandDefinition('run')).to.equal(true); + }); + + test('script defines images subcommands', () => { + expect(hasCommandDefinition('delete')).to.equal(true); + expect(hasCommandDefinition('prune')).to.equal(true); + expect(hasCommandDefinition('push')).to.equal(true); + }); + + test('script defines components subcommands', () => { + expect(hasCommandDefinition('logs')).to.equal(true); + expect(hasCommandDefinition('shell')).to.equal(true); + }); + + // ==================== Flag Completion Tests ==================== + + test('script defines flags for up command', () => { + // Find the up command section + const upSection = completionScript.match( + /^up\)\n_arguments[\s\S]*?;;\s*$/m, + ); + expect(upSection).not.to.equal(null); + expect(upSection![0]).to.include('--flavor'); + expect(upSection![0]).to.include('--force'); + expect(upSection![0]).to.include('--json'); + expect(upSection![0]).to.include('--verbose'); + }); + + test('script defines flags for tasks command', () => { + // The tasks flags are in _emb_tasks_flags + expect(completionScript).to.include('_emb_tasks_flags'); + const tasksFlagsSection = completionScript.match( + /_emb_tasks_flags\(\) \{[\s\S]*?\}/, + ); + expect(tasksFlagsSection).not.to.equal(null); + expect(tasksFlagsSection![0]).to.include('--json'); + expect(tasksFlagsSection![0]).to.include('--verbose'); + }); + + test('script defines flags for tasks run command', () => { + // Find the tasks run section within _emb_tasks + const tasksRunSection = completionScript.match( + /"run"\)\s*\n\s*_arguments[\s\S]*?;;/, + ); + expect(tasksRunSection).not.to.equal(null); + expect(tasksRunSection![0]).to.include('--executor'); + expect(tasksRunSection![0]).to.include('--all-matching'); + expect(tasksRunSection![0]).to.include('--json'); + expect(tasksRunSection![0]).to.include('--verbose'); + }); + + // ==================== Structural Tests ==================== + + test('script has proper zsh completion header', () => { + expect(completionScript).to.match(/^#compdef emb/); + }); + + test('script defines main _emb function', () => { + expect(completionScript).to.include('_emb() {'); + }); + + test('script calls _emb at the end', () => { + expect(completionScript.trim()).to.match(/_emb$/); + }); + + // ==================== Task Name Completion Tests ==================== + // These tests verify task name completion for 'emb tasks run' and 'emb run' + // They should fail until we implement zsh task completion + + test('completes task names for "emb tasks run "', () => { + // Check if the completion script has task completion logic + const hasTaskCompletion = + completionScript.includes('_emb_complete_tasks') || + completionScript.includes('emb tasks --json'); + + expect(hasTaskCompletion).to.equal(true); + }); + + test('completes task names for "emb run " (alias)', () => { + // The run command should also have task completion + const runSection = completionScript.match( + /^run\)\n_arguments[\s\S]*?;;\s*/m, + ); + expect(runSection).not.to.equal(null); + + // Check if it has task completion instead of just _files + const hasTaskCompletion = + runSection![0].includes('_emb_complete_tasks') || + runSection![0].includes('emb tasks --json') || + !runSection![0].includes('"*: :_files"'); + + expect(hasTaskCompletion).to.equal(true); + }); +});