Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e89f79e
added CLI completions support for: bash, fish and powershell
noameron Dec 15, 2025
ad53abc
Add bash/fish/powershell completions
noameron Dec 23, 2025
07cad96
Archive extend-shell-completions
noameron Dec 23, 2025
0bac2e3
Archive extend-shell-completions
noameron Dec 23, 2025
5524026
Merge branch 'main' into feature/bash_fish_power_shells_completions
noameron Dec 23, 2025
9dd9962
Fix canWriteFile control flow and add tests
noameron Dec 24, 2025
f69feab
Merge branch 'main' into feature/bash_fish_power_shells_completions
noameron Dec 27, 2025
d9b4ae6
Fix bash completion fallback and security escaping
noameron Dec 27, 2025
71f434b
Merge remote-tracking branch 'origin/feature/bash_fish_power_shells_c…
noameron Dec 27, 2025
aaf65a2
Merge branch 'main' into feature/bash_fish_power_shells_completions
noameron Jan 1, 2026
f624d3f
refactor: extract completion templates and standardize naming
noameron Jan 4, 2026
fadb83b
docs: update spec to reflect multi-shell support
noameron Jan 4, 2026
311b99d
fix: add all shells to zsh completion suggestions
noameron Jan 4, 2026
a597dc9
feat: add --yes flag to completion uninstall
noameron Jan 4, 2026
b9255cf
fix: remove bash-completion dependency from fallback
noameron Jan 9, 2026
f47632a
fix: use printf instead of echo for Fish tab output
noameron Jan 9, 2026
291d0ee
fix: make UX messages shell-aware
noameron Jan 9, 2026
f192da5
fix: add Homebrew paths for bash-completion detection
noameron Jan 9, 2026
28f47db
fix: support both PowerShell Core and Windows PS 5.1
noameron Jan 9, 2026
94123dd
fix: preserve colon handling in bash completion
noameron Jan 9, 2026
6e6903a
fix: update completion tests to match implementation changes
noameron Jan 9, 2026
3322198
Merge branch 'main' into feature/bash_fish_power_shells_completions
noameron Jan 9, 2026
21641b5
Merge branch 'main' into feature/bash_fish_power_shells_completions
TabishB Jan 9, 2026
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
214 changes: 214 additions & 0 deletions src/core/completions/generators/bash-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.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 {
const script: string[] = [];

// Header comment
script.push('# Bash completion script for OpenSpec CLI');
script.push('# Auto-generated - do not edit manually');
script.push('');

// Main completion function
script.push('_openspec_completion() {');
script.push(' local cur prev words cword');
script.push('');
script.push(' # Use _init_completion if available (from bash-completion package)');
script.push(' # Otherwise, fall back to manual initialization');
script.push(' if declare -F _init_completion >/dev/null 2>&1; then');
script.push(' _init_completion || return');
script.push(' else');
script.push(' # Manual fallback when bash-completion is not installed');
script.push(' COMPREPLY=()');
script.push(' _get_comp_words_by_ref -n : cur prev words cword 2>/dev/null || {');
script.push(' cur="${COMP_WORDS[COMP_CWORD]}"');
script.push(' prev="${COMP_WORDS[COMP_CWORD-1]}"');
script.push(' words=("${COMP_WORDS[@]}")');
script.push(' cword=$COMP_CWORD');
script.push(' }');
script.push(' fi');
script.push('');
script.push(' local cmd="${words[1]}"');
script.push(' local subcmd="${words[2]}"');
script.push('');

// Top-level commands
script.push(' # Top-level commands');
script.push(' if [[ $cword -eq 1 ]]; then');
script.push(' local commands="' + commands.map(c => this.escapeCommandName(c.name)).join(' ') + '"');
script.push(' COMPREPLY=($(compgen -W "$commands" -- "$cur"))');
script.push(' return 0');
script.push(' fi');
script.push('');

// Command-specific completion
script.push(' # Command-specific completion');
script.push(' case "$cmd" in');

for (const cmd of commands) {
script.push(` ${cmd.name})`);
script.push(...this.generateCommandCase(cmd, ' '));
script.push(' ;;');
}

script.push(' esac');
script.push('');
script.push(' return 0');
script.push('}');
script.push('');

// Helper functions for dynamic completions
script.push(...this.generateDynamicCompletionHelpers());

// Register the completion function
script.push('complete -F _openspec_completion openspec');
script.push('');

return script.join('\n');
}

/**
* 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;
}
Comment on lines +81 to +107
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 | 🟠 Major

Bug: parent flags are not completable for commands with subcommands (e.g., openspec config --scope ...).
generateCommandCase() enters the “has subcommands” branch (Line 83) and, at cword -eq 2, only offers subcommands (Line 84-88). It never offers cmd.flags at the parent level in that flow. This undermines “same commands/flags across shells” expectations for commands like config that have both flags and subcommands.

Proposed fix (complete parent flags at word 2 before subcommand)
   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`);
+      if (cmd.flags.length > 0) {
+        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}  if [[ "$cur" == -* ]]; then`);
+        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(`${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`);

Also applies to: 110-135

🤖 Prompt for AI Agents
In @src/core/completions/generators/bash-generator.ts around lines 79 - 105,
generateCommandCase currently, when cmd.subcommands exists, only completes
subcommands at cword == 2 and never offers parent-level flags; modify
generateCommandCase so that before emitting the subcommands list for cword == 2
it also builds and offers parent flags (cmd.flags) combined with subcommands (or
offers flags first), by including cmd.flags (properly escaped) in the local
completions string or by calling generateArgumentCompletion(cmd, indent) for the
parent at that cword check; ensure you reference cmd.flags and reuse
escapeCommandName and generateArgumentCompletion so parent flags like --scope
are offered at word 2 while keeping the per-subcommand case block intact.


/**
* 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;
}

/**
* Generate dynamic completion helper functions
*/
private generateDynamicCompletionHelpers(): string[] {
const lines: string[] = [];

lines.push('# Dynamic completion helpers');
lines.push('');

// Helper for completing change IDs
lines.push('_openspec_complete_changes() {');
lines.push(' local changes');
lines.push(' changes=$(openspec __complete changes 2>/dev/null | cut -f1)');
lines.push(' COMPREPLY=($(compgen -W "$changes" -- "$cur"))');
lines.push('}');
lines.push('');

// Helper for completing spec IDs
lines.push('_openspec_complete_specs() {');
lines.push(' local specs');
lines.push(' specs=$(openspec __complete specs 2>/dev/null | cut -f1)');
lines.push(' COMPREPLY=($(compgen -W "$specs" -- "$cur"))');
lines.push('}');
lines.push('');

// Helper for completing both changes and specs
lines.push('_openspec_complete_items() {');
lines.push(' local items');
lines.push(' items=$(openspec __complete changes 2>/dev/null | cut -f1; openspec __complete specs 2>/dev/null | cut -f1)');
lines.push(' COMPREPLY=($(compgen -W "$items" -- "$cur"))');
lines.push('}');
lines.push('');

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