Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
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 @@ -150,6 +150,14 @@ export class CompletionCommand {
}
}

// Display warnings if present
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
192 changes: 192 additions & 0 deletions src/core/completions/generators/bash-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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(' _init_completion || return');
script.push('');
Comment on lines +25 to +28
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the bash-generator.ts file to understand the complete context
fd -t f bash-generator.ts -x wc -l {}

Repository: Fission-AI/OpenSpec

Length of output: 118


🏁 Script executed:

# Search for _init_completion and bash-completion references
rg -n "_init_completion|bash-completion" --type=ts -A 2 -B 2

Repository: Fission-AI/OpenSpec

Length of output: 7773


🏁 Script executed:

# Check if there are other shell generators for comparison
fd -t f -path "*/generators/*" ".*-generator\.ts$"

Repository: Fission-AI/OpenSpec

Length of output: 231


🏁 Script executed:

# Look for any documentation about dependencies or setup
fd -t f "README|INSTALL|SETUP|package\.json" -x grep -l "bash-completion" {} \;

Repository: Fission-AI/OpenSpec

Length of output: 45


🏁 Script executed:

# Let's look at the full context of bash-generator.ts around lines 25-28
sed -n '1,50p' src/core/completions/generators/bash-generator.ts

Repository: Fission-AI/OpenSpec

Length of output: 1791


🏁 Script executed:

# Check the bash-installer.ts warnings about bash-completion
sed -n '210,275p' src/core/completions/installers/bash-installer.ts

Repository: Fission-AI/OpenSpec

Length of output: 2577


🏁 Script executed:

# Look at the beginning of bash-generator.ts to see if there's any documentation
head -n 30 src/core/completions/generators/bash-generator.ts

Repository: Fission-AI/OpenSpec

Length of output: 1115


Document bash-completion requirement in generated script header.

The _init_completion function depends on the bash-completion package. While the BashInstaller already checks for this dependency and warns users during installation if it's missing, the generated script header could document this requirement to make the dependency explicit in the script itself.

Consider adding a comment in the generated script header (e.g., # Requires: bash-completion package) for clarity.

🤖 Prompt for AI Agents
In src/core/completions/generators/bash-generator.ts around lines 25 to 28, the
generated completion script calls _init_completion but does not document that
this requires the bash-completion package; add a one-line comment at the top of
the generated script header such as "# Requires: bash-completion package" (or
similar) immediately before or after the function declaration to make the
dependency explicit in the output script.

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 => 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 => 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;
}

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