Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .claude/tasks/done/05-extend-autocompletion.md
Original file line number Diff line number Diff line change
@@ -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 <TAB>` should autocomplete to the list of all available tasks (run `emb tasks` to find them in this repo).
`emb tasks run dep<TAB>` should autocomplete to `emb tasks run dependent` since it's the only possible match
`emb tasks run test<TAB>` 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 <TAB> → all task IDs (dependent, greet, prereq, buildargs:test, ...)
emb run <TAB> → all task IDs (alias for tasks:run)
emb tasks run dep<TAB> → dependent, dependent:test
emb tasks run test<TAB> → buildargs:test, dependent:test, frontend:test
```
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
166 changes: 166 additions & 0 deletions src/cli/hooks/postrun.ts
Original file line number Diff line number Diff line change
@@ -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;
61 changes: 61 additions & 0 deletions tests/integration/cli/autocomplete/bash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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 <TAB>"', () => {
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 <TAB>" (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<TAB>"', () => {
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<TAB>"', () => {
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');
});
});
Loading