Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Change Proposal: Extend Shell Completions

## Why

Zsh completions provide an excellent developer experience, but many developers use bash, fish, or PowerShell. Extending completion support to these shells removes friction for the majority of developers who don't use Zsh.

## What Changes

This change adds bash, fish, and PowerShell completion support following the same architectural patterns, documentation methodology, and testing rigor established for Zsh completions.

## Deltas

- **Spec:** `cli-completion`
- **Operation:** MODIFIED
- **Description:** Extend completion generation, installation, and testing requirements to support bash, fish, and PowerShell while maintaining the existing Zsh implementation and architectural patterns

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Implementation Tasks

## Phase 1: Foundation and Bash Support

- [x] Update `SupportedShell` type in `src/utils/shell-detection.ts` to include `'bash' | 'fish' | 'powershell'`
- [x] Extend shell detection logic to recognize bash, fish, and PowerShell from environment variables
- [x] Create `src/core/completions/generators/bash-generator.ts` implementing `CompletionGenerator` interface
- [x] Create `src/core/completions/installers/bash-installer.ts` implementing `CompletionInstaller` interface
- [x] Update `CompletionFactory.createGenerator()` to support bash
- [x] Update `CompletionFactory.createInstaller()` to support bash
- [x] Create test file `test/core/completions/generators/bash-generator.test.ts` mirroring zsh test structure
- [x] Create test file `test/core/completions/installers/bash-installer.test.ts` mirroring zsh test structure
- [x] Verify bash completions work manually: `openspec completion install bash && exec bash`

## Phase 2: Fish Support

- [x] Create `src/core/completions/generators/fish-generator.ts` implementing `CompletionGenerator` interface
- [x] Create `src/core/completions/installers/fish-installer.ts` implementing `CompletionInstaller` interface
- [x] Update `CompletionFactory.createGenerator()` to support fish
- [x] Update `CompletionFactory.createInstaller()` to support fish
- [x] Create test file `test/core/completions/generators/fish-generator.test.ts`
- [x] Create test file `test/core/completions/installers/fish-installer.test.ts`
- [x] Verify fish completions work manually: `openspec completion install fish`

## Phase 3: PowerShell Support

- [x] Create `src/core/completions/generators/powershell-generator.ts` implementing `CompletionGenerator` interface
- [x] Create `src/core/completions/installers/powershell-installer.ts` implementing `CompletionInstaller` interface
- [x] Update `CompletionFactory.createGenerator()` to support powershell
- [x] Update `CompletionFactory.createInstaller()` to support powershell
- [x] Create test file `test/core/completions/generators/powershell-generator.test.ts`
- [x] Create test file `test/core/completions/installers/powershell-installer.test.ts`
- [x] Verify PowerShell completions work manually on Windows or macOS PowerShell

## Phase 4: Documentation and Testing

- [x] Update `CLAUDE.md` or relevant documentation to mention all four supported shells
- [x] Add cross-shell consistency test verifying all shells support same commands
- [x] Run `pnpm test` to ensure all tests pass
- [x] Run `pnpm run build` to verify TypeScript compilation
- [x] Test all shells on different platforms (Linux for bash/fish/zsh, Windows/macOS for PowerShell)

## Phase 5: Validation and Cleanup

- [x] Run `openspec validate extend-shell-completions --strict` and resolve all issues
- [x] Update error messages to list all four supported shells
- [x] Verify `openspec completion --help` documentation is current
- [x] Test auto-detection works for all shells
- [x] Ensure uninstall works cleanly for all shells
221 changes: 183 additions & 38 deletions openspec/specs/cli-completion/spec.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/commands/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ export class CompletionCommand {
}
}

// Display warnings if present
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a bunch of zshrcConfigured checks in this file, do we need to extend for other shells here too?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly I think there's a few places where we focus on zsh heavily. For example there's a restart hint for zsh but not for other shells.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think even when uninstalling we mention zshrc

if (result.warnings && result.warnings.length > 0) {
console.log('');
for (const warning of result.warnings) {
console.log(warning);
}
}

// Print instructions (only shown if .zshrc wasn't auto-configured)
if (result.instructions && result.instructions.length > 0) {
console.log('');
Expand Down
42 changes: 37 additions & 5 deletions src/core/completions/factory.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { CompletionGenerator } from './types.js';
import { ZshGenerator } from './generators/zsh-generator.js';
import { ZshInstaller, InstallationResult } from './installers/zsh-installer.js';
import { BashGenerator } from './generators/bash-generator.js';
import { FishGenerator } from './generators/fish-generator.js';
import { PowerShellGenerator } from './generators/powershell-generator.js';
import { ZshInstaller } from './installers/zsh-installer.js';
import { BashInstaller } from './installers/bash-installer.js';
import { FishInstaller } from './installers/fish-installer.js';
import { PowerShellInstaller } from './installers/powershell-installer.js';
import { SupportedShell } from '../../utils/shell-detection.js';

/**
* Common installation result interface
*/
export interface InstallationResult {
success: boolean;
installedPath?: string;
backupPath?: string;
message: string;
instructions?: string[];
warnings?: string[];
// Shell-specific optional fields
isOhMyZsh?: boolean;
zshrcConfigured?: boolean;
bashrcConfigured?: boolean;
profileConfigured?: boolean;
}

/**
* Interface for completion installers
*/
Expand All @@ -11,15 +34,12 @@ export interface CompletionInstaller {
uninstall(): Promise<{ success: boolean; message: string }>;
}

// Re-export InstallationResult for convenience
export type { InstallationResult };

/**
* Factory for creating completion generators and installers
* This design makes it easy to add support for additional shells
*/
export class CompletionFactory {
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh'];
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell'];

/**
* Create a completion generator for the specified shell
Expand All @@ -32,6 +52,12 @@ export class CompletionFactory {
switch (shell) {
case 'zsh':
return new ZshGenerator();
case 'bash':
return new BashGenerator();
case 'fish':
return new FishGenerator();
case 'powershell':
return new PowerShellGenerator();
default:
throw new Error(`Unsupported shell: ${shell}`);
}
Expand All @@ -48,6 +74,12 @@ export class CompletionFactory {
switch (shell) {
case 'zsh':
return new ZshInstaller();
case 'bash':
return new BashInstaller();
case 'fish':
return new FishInstaller();
case 'powershell':
return new PowerShellInstaller();
default:
throw new Error(`Unsupported shell: ${shell}`);
}
Expand Down
175 changes: 175 additions & 0 deletions src/core/completions/generators/bash-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
import { BASH_DYNAMIC_HELPERS } from '../templates/bash-templates.js';

/**
* Generates Bash completion scripts for the OpenSpec CLI.
* Follows Bash completion conventions using complete builtin and COMPREPLY array.
*/
export class BashGenerator implements CompletionGenerator {
readonly shell = 'bash' as const;

/**
* Generate a Bash completion script
*
* @param commands - Command definitions to generate completions for
* @returns Bash completion script as a string
*/
generate(commands: CommandDefinition[]): string {
// Build command list for top-level completions
const commandList = commands.map(c => this.escapeCommandName(c.name)).join(' ');

// Build command cases using push() for loop clarity
const caseLines: string[] = [];
for (const cmd of commands) {
caseLines.push(` ${cmd.name})`);
caseLines.push(...this.generateCommandCase(cmd, ' '));
caseLines.push(' ;;');
}
const commandCases = caseLines.join('\n');

// Dynamic completion helpers from template
const helpers = BASH_DYNAMIC_HELPERS;

// Assemble final script with template literal
return `# Bash completion script for OpenSpec CLI
# Auto-generated - do not edit manually
_openspec_completion() {
local cur prev words cword
# Use _init_completion if available (from bash-completion package)
# Otherwise, fall back to manual initialization
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion || return
else
# Manual fallback when bash-completion is not installed
COMPREPLY=()
_get_comp_words_by_ref -n : cur prev words cword 2>/dev/null || {
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
words=("\${COMP_WORDS[@]}")
cword=$COMP_CWORD
}
fi
Comment on lines +40 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fallback logic still depends on bash-completion.

The fallback block attempts to handle missing _init_completion but then calls _get_comp_words_by_ref (line 47), which is also provided by the bash-completion package. If bash-completion is not installed, this fallback will fail with the same error.

🔎 Proposed fix for proper fallback
   # Use _init_completion if available (from bash-completion package)
   # Otherwise, fall back to manual initialization
   if declare -F _init_completion >/dev/null 2>&1; then
     _init_completion || return
   else
     # Manual fallback when bash-completion is not installed
     COMPREPLY=()
-    _get_comp_words_by_ref -n : cur prev words cword 2>/dev/null || {
-      cur="\${COMP_WORDS[COMP_CWORD]}"
-      prev="\${COMP_WORDS[COMP_CWORD-1]}"
-      words=("\${COMP_WORDS[@]}")
-      cword=$COMP_CWORD
-    }
+    cur="\${COMP_WORDS[COMP_CWORD]}"
+    prev="\${COMP_WORDS[COMP_CWORD-1]:-}"
+    words=("\${COMP_WORDS[@]}")
+    cword=$COMP_CWORD
   fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Use _init_completion if available (from bash-completion package)
# Otherwise, fall back to manual initialization
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion || return
else
# Manual fallback when bash-completion is not installed
COMPREPLY=()
_get_comp_words_by_ref -n : cur prev words cword 2>/dev/null || {
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
words=("\${COMP_WORDS[@]}")
cword=$COMP_CWORD
}
fi
# Use _init_completion if available (from bash-completion package)
# Otherwise, fall back to manual initialization
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion || return
else
# Manual fallback when bash-completion is not installed
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]:-}"
words=("\${COMP_WORDS[@]}")
cword=$COMP_CWORD
fi
🤖 Prompt for AI Agents
In src/core/completions/generators/bash-generator.ts around lines 40 to 53, the
fallback branch still calls _get_comp_words_by_ref (provided by bash-completion)
which defeats the purpose of the fallback; replace the fallback with pure-Bash
initialization that does not call any bash-completion helpers: set COMPREPLY=(),
then directly populate cur="${COMP_WORDS[COMP_CWORD]}",
prev="${COMP_WORDS[COMP_CWORD-1]}", words=("${COMP_WORDS[@]}") and
cword=$COMP_CWORD (and remove the _get_comp_words_by_ref call and its
surrounding construct) so the completion works when bash-completion is not
installed.

local cmd="\${words[1]}"
local subcmd="\${words[2]}"
# Top-level commands
if [[ $cword -eq 1 ]]; then
local commands="${commandList}"
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return 0
fi
# Command-specific completion
case "$cmd" in
${commandCases}
esac
return 0
}
${helpers}
complete -F _openspec_completion openspec
`;
}

/**
* Generate completion case logic for a command
*/
private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {
const lines: string[] = [];

// Handle subcommands
if (cmd.subcommands && cmd.subcommands.length > 0) {
lines.push(`${indent}if [[ $cword -eq 2 ]]; then`);
lines.push(`${indent} local subcommands="` + cmd.subcommands.map(s => this.escapeCommandName(s.name)).join(' ') + '"');
lines.push(`${indent} COMPREPLY=($(compgen -W "$subcommands" -- "$cur"))`);
lines.push(`${indent} return 0`);
lines.push(`${indent}fi`);
lines.push('');
lines.push(`${indent}case "$subcmd" in`);

for (const subcmd of cmd.subcommands) {
lines.push(`${indent} ${subcmd.name})`);
lines.push(...this.generateArgumentCompletion(subcmd, indent + ' '));
lines.push(`${indent} ;;`);
}

lines.push(`${indent}esac`);
} else {
// No subcommands, just complete arguments
lines.push(...this.generateArgumentCompletion(cmd, indent));
}

return lines;
}

/**
* Generate argument completion (flags and positional arguments)
*/
private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {
const lines: string[] = [];

// Check for flag completion
if (cmd.flags.length > 0) {
lines.push(`${indent}if [[ "$cur" == -* ]]; then`);
const flags = cmd.flags.map(f => {
const parts: string[] = [];
if (f.short) parts.push(`-${f.short}`);
parts.push(`--${f.name}`);
return parts.join(' ');
}).join(' ');
lines.push(`${indent} local flags="${flags}"`);
lines.push(`${indent} COMPREPLY=($(compgen -W "$flags" -- "$cur"))`);
lines.push(`${indent} return 0`);
lines.push(`${indent}fi`);
lines.push('');
}

// Handle positional completions
if (cmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));
}

return lines;
}

/**
* Generate positional argument completion based on type
*/
private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {
const lines: string[] = [];

switch (positionalType) {
case 'change-id':
lines.push(`${indent}_openspec_complete_changes`);
break;
case 'spec-id':
lines.push(`${indent}_openspec_complete_specs`);
break;
case 'change-or-spec-id':
lines.push(`${indent}_openspec_complete_items`);
break;
case 'shell':
lines.push(`${indent}local shells="zsh bash fish powershell"`);
lines.push(`${indent}COMPREPLY=($(compgen -W "$shells" -- "$cur"))`);
break;
case 'path':
lines.push(`${indent}COMPREPLY=($(compgen -f -- "$cur"))`);
break;
}

return lines;
}


/**
* Escape command/subcommand names for safe use in Bash scripts
*/
private escapeCommandName(name: string): string {
// Escape shell metacharacters to prevent command injection
return name.replace(/["\$`\\]/g, '\\$&');
}
}
Loading
Loading