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
5 changes: 5 additions & 0 deletions .changeset/fish-completion-no-file-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Improve Fish completions so command, subcommand, flag, and indexed positional completions no longer fall back to filesystem suggestions unless the target is a real path.
8 changes: 8 additions & 0 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root for --initiative',
takesValue: true,
completionType: 'path',
},
{
name: 'schema',
Expand Down Expand Up @@ -282,6 +283,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root for --initiative',
takesValue: true,
completionType: 'path',
},
COMMON_FLAGS.json,
],
Expand Down Expand Up @@ -430,6 +432,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root for --initiative',
takesValue: true,
completionType: 'path',
},
{
name: 'agent',
Expand Down Expand Up @@ -471,6 +474,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'path',
description: 'Directory to use for the context store',
takesValue: true,
completionType: 'path',
},
{
name: 'init-git',
Expand Down Expand Up @@ -564,6 +568,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root',
takesValue: true,
completionType: 'path',
},
{
name: 'title',
Expand Down Expand Up @@ -593,6 +598,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root',
takesValue: true,
completionType: 'path',
},
COMMON_FLAGS.json,
],
Expand All @@ -610,6 +616,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root',
takesValue: true,
completionType: 'path',
},
COMMON_FLAGS.json,
],
Expand All @@ -627,6 +634,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'store-path',
description: 'Existing local context store root',
takesValue: true,
completionType: 'path',
},
COMMON_FLAGS.json,
],
Expand Down
148 changes: 93 additions & 55 deletions src/core/completions/generators/fish-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,25 @@ export class FishGenerator implements CompletionGenerator {
* @returns Fish completion script as a string
*/
generate(commands: CommandDefinition[]): string {
// Build top-level commands using push() for loop clarity
const topLevelLines: string[] = [];
for (const cmd of commands) {
topLevelLines.push(`# ${cmd.name} command`);
topLevelLines.push(
`complete -c openspec -n '__fish_openspec_no_subcommand' -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`
`complete -c openspec -n '__fish_openspec_no_subcommand' -f -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`
);
}
const topLevelCommands = topLevelLines.join('\n');

// Build command-specific completions using push() for loop clarity
const commandCompletionLines: string[] = [];
for (const cmd of commands) {
commandCompletionLines.push(...this.generateCommandCompletions(cmd));
commandCompletionLines.push('');
}
const commandCompletions = commandCompletionLines.join('\n');

// Static helper functions from template
const helperFunctions = FISH_STATIC_HELPERS;

// Dynamic completion helpers from template
const dynamicHelpers = FISH_DYNAMIC_HELPERS;

// Assemble final script with template literal
return `# Fish completion script for OpenSpec CLI
# Auto-generated - do not edit manually

Expand All @@ -56,42 +50,63 @@ ${commandCompletions}`;
private generateCommandCompletions(cmd: CommandDefinition): string[] {
const lines: string[] = [];

// If command has subcommands
if (cmd.subcommands && cmd.subcommands.length > 0) {
// Add subcommand completions
for (const subcmd of cmd.subcommands) {
lines.push(
`complete -c openspec -n '__fish_openspec_using_subcommand ${cmd.name}; and not __fish_openspec_using_subcommand ${subcmd.name}' -a '${subcmd.name}' -d '${this.escapeDescription(subcmd.description)}'`
`complete -c openspec -n '__fish_openspec_using_subcommand ${cmd.name}; and not __fish_openspec_using_subcommand ${subcmd.name}' -f -a '${subcmd.name}' -d '${this.escapeDescription(subcmd.description)}'`
);
}
lines.push('');

// Add flags for parent command
for (const flag of cmd.flags) {
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
}

// Add completions for each subcommand
for (const subcmd of cmd.subcommands) {
lines.push(`# ${cmd.name} ${subcmd.name} flags`);
for (const flag of subcmd.flags) {
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
lines.push(
...this.generateFlagCompletion(
flag,
`__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`
)
);
}

// Add positional completions for subcommand
if (subcmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(subcmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
if (subcmd.positionals?.length) {
lines.push(
...this.generateIndexedPositionalCompletions(
subcmd.positionals,
`__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`,
this.collectValueFlags(cmd.flags, subcmd.flags),
2
)
);
} else if (subcmd.acceptsPositional) {
lines.push(
...this.generatePositionalCompletion(
subcmd.positionalType,
`__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`
)
);
}
}
} else {
// Command without subcommands
lines.push(`# ${cmd.name} flags`);
for (const flag of cmd.flags) {
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
}

// Add positional completions
if (cmd.acceptsPositional) {
if (cmd.positionals?.length) {
lines.push(
...this.generateIndexedPositionalCompletions(
cmd.positionals,
`__fish_openspec_using_subcommand ${cmd.name}`,
this.collectValueFlags(cmd.flags),
1
)
);
} else if (cmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}`));
}
}
Expand All @@ -104,44 +119,23 @@ ${commandCompletions}`;
*/
private generateFlagCompletion(flag: FlagDefinition, condition: string): string[] {
const lines: string[] = [];
const longFlag = `--${flag.name}`;
const shortFlag = flag.short ? `-${flag.short}` : undefined;
const description = this.escapeDescription(flag.description);
const shortFlag = flag.short ? `-s ${flag.short} ` : '';
const flagOptions = `${shortFlag}-l ${flag.name}`;
const fileFallback = flag.completionType === 'path' ? '-r' : '-r -f';

if (flag.takesValue && flag.values) {
// Flag with enum values
for (const value of flag.values) {
if (shortFlag) {
lines.push(
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
);
} else {
lines.push(
`complete -c openspec -n '${condition}' -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
);
}
}
} else if (flag.takesValue) {
// Flag that takes a value but no specific values defined
if (shortFlag) {
lines.push(
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
);
} else {
lines.push(
`complete -c openspec -n '${condition}' -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
`complete -c openspec -n '${condition}' ${flagOptions} -f -a '${value}' -d '${description}'`
);
}
} else if (flag.takesValue) {
lines.push(
`complete -c openspec -n '${condition}' ${flagOptions} ${fileFallback} -d '${description}'`
);
} else {
// Boolean flag
if (shortFlag) {
lines.push(
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
);
} else {
lines.push(
`complete -c openspec -n '${condition}' -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
);
}
lines.push(`complete -c openspec -n '${condition}' ${flagOptions} -f -d '${description}'`);
}

return lines;
Expand Down Expand Up @@ -170,22 +164,66 @@ ${commandCompletions}`;
lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`);
break;
case 'path':
// Fish automatically completes files, no need to specify
// Emit the rule without -f so Fish can offer filesystem completions.
lines.push(`complete -c openspec -n '${condition}'`);
break;
default:
lines.push(`complete -c openspec -n '${condition}' -f`);
break;
}

return lines;
}

/**
* Generate indexed positional completions.
*/
private generateIndexedPositionalCompletions(
positionals: NonNullable<CommandDefinition['positionals']>,
condition: string,
valueFlags: string[],
depth: number
): string[] {
const lines: string[] = [];

for (const [index, positional] of positionals.entries()) {
const indexCondition = `${condition}; and __fish_openspec_positional_index ${index} ${depth}${valueFlags.length ? ` ${valueFlags.join(' ')}` : ''}`;
lines.push(...this.generatePositionalCompletion(positional.type, indexCondition));
}

return lines;
}

/**
* Collect the long and short names for flags that consume the next token.
*/
private collectValueFlags(...flagGroups: FlagDefinition[][]): string[] {
const flags = new Set<string>();

for (const group of flagGroups) {
for (const flag of group) {
if (!flag.takesValue) {
continue;
}

flags.add(`--${flag.name}`);
if (flag.short) {
flags.add(`-${flag.short}`);
}
}
}

return [...flags];
}

/**
* Escape description text for Fish
*/
private escapeDescription(description: string): string {
return description
.replace(/\\/g, '\\\\') // Backslashes first
.replace(/'/g, "\\'") // Single quotes
.replace(/\$/g, '\\$') // Dollar signs (prevents $())
.replace(/`/g, '\\`'); // Backticks
.replace(/\\/g, '\\\\') // Backslashes first
.replace(/'/g, "\\'") // Single quotes
.replace(/\$/g, '\\$') // Dollar signs (prevents $())
.replace(/`/g, '\\`'); // Backticks
}
}
25 changes: 25 additions & 0 deletions src/core/completions/templates/fish-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ end
function __fish_openspec_no_subcommand
set -l cmd (commandline -opc)
test (count $cmd) -eq 1
end

function __fish_openspec_positional_index
set -l target $argv[1]
set -l depth $argv[2]
set -l value_flags $argv[3..]
set -l tokens (commandline -opc)
set -e tokens[1]
set -l count 0
set -l skip 0
for token in $tokens
if test $skip -eq 1
set skip 0
continue
end
if contains -- $token $value_flags
set skip 1
continue
end
if string match -q -- '-*' $token
continue
end
set count (math $count + 1)
end
test $count -eq (math $target + $depth)
end`;

export const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers
Expand Down
5 changes: 5 additions & 0 deletions src/core/completions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface FlagDefinition {
*/
takesValue?: boolean;

/**
* Completion type for the flag value.
*/
completionType?: PositionalType;

/**
* Possible values for the flag (for completion suggestions)
*/
Expand Down
Loading