Skip to content

Commit d033217

Browse files
committed
fix(completions): suppress filesystem fallback in Fish completions
1 parent 1b06fdd commit d033217

6 files changed

Lines changed: 270 additions & 66 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@fission-ai/openspec': patch
3+
---
4+
5+
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.

src/core/completions/command-registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
245245
name: 'store-path',
246246
description: 'Existing local context store root for --initiative',
247247
takesValue: true,
248+
completionType: 'path',
248249
},
249250
{
250251
name: 'schema',
@@ -282,6 +283,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
282283
name: 'store-path',
283284
description: 'Existing local context store root for --initiative',
284285
takesValue: true,
286+
completionType: 'path',
285287
},
286288
COMMON_FLAGS.json,
287289
],
@@ -430,6 +432,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
430432
name: 'store-path',
431433
description: 'Existing local context store root for --initiative',
432434
takesValue: true,
435+
completionType: 'path',
433436
},
434437
{
435438
name: 'agent',
@@ -471,6 +474,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
471474
name: 'path',
472475
description: 'Directory to use for the context store',
473476
takesValue: true,
477+
completionType: 'path',
474478
},
475479
{
476480
name: 'init-git',
@@ -564,6 +568,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
564568
name: 'store-path',
565569
description: 'Existing local context store root',
566570
takesValue: true,
571+
completionType: 'path',
567572
},
568573
{
569574
name: 'title',
@@ -593,6 +598,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
593598
name: 'store-path',
594599
description: 'Existing local context store root',
595600
takesValue: true,
601+
completionType: 'path',
596602
},
597603
COMMON_FLAGS.json,
598604
],
@@ -610,6 +616,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
610616
name: 'store-path',
611617
description: 'Existing local context store root',
612618
takesValue: true,
619+
completionType: 'path',
613620
},
614621
COMMON_FLAGS.json,
615622
],
@@ -627,6 +634,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
627634
name: 'store-path',
628635
description: 'Existing local context store root',
629636
takesValue: true,
637+
completionType: 'path',
630638
},
631639
COMMON_FLAGS.json,
632640
],

src/core/completions/generators/fish-generator.ts

Lines changed: 92 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,25 @@ export class FishGenerator implements CompletionGenerator {
1515
* @returns Fish completion script as a string
1616
*/
1717
generate(commands: CommandDefinition[]): string {
18-
// Build top-level commands using push() for loop clarity
1918
const topLevelLines: string[] = [];
2019
for (const cmd of commands) {
2120
topLevelLines.push(`# ${cmd.name} command`);
2221
topLevelLines.push(
23-
`complete -c openspec -n '__fish_openspec_no_subcommand' -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`
22+
`complete -c openspec -n '__fish_openspec_no_subcommand' -f -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`
2423
);
2524
}
2625
const topLevelCommands = topLevelLines.join('\n');
2726

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

36-
// Static helper functions from template
3734
const helperFunctions = FISH_STATIC_HELPERS;
38-
39-
// Dynamic completion helpers from template
4035
const dynamicHelpers = FISH_DYNAMIC_HELPERS;
4136

42-
// Assemble final script with template literal
4337
return `# Fish completion script for OpenSpec CLI
4438
# Auto-generated - do not edit manually
4539
@@ -56,42 +50,63 @@ ${commandCompletions}`;
5650
private generateCommandCompletions(cmd: CommandDefinition): string[] {
5751
const lines: string[] = [];
5852

59-
// If command has subcommands
6053
if (cmd.subcommands && cmd.subcommands.length > 0) {
61-
// Add subcommand completions
6254
for (const subcmd of cmd.subcommands) {
6355
lines.push(
64-
`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)}'`
56+
`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)}'`
6557
);
6658
}
6759
lines.push('');
6860

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

74-
// Add completions for each subcommand
7565
for (const subcmd of cmd.subcommands) {
7666
lines.push(`# ${cmd.name} ${subcmd.name} flags`);
7767
for (const flag of subcmd.flags) {
78-
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
68+
lines.push(
69+
...this.generateFlagCompletion(
70+
flag,
71+
`__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`
72+
)
73+
);
7974
}
8075

81-
// Add positional completions for subcommand
82-
if (subcmd.acceptsPositional) {
83-
lines.push(...this.generatePositionalCompletion(subcmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
76+
if (subcmd.positionals?.length) {
77+
lines.push(
78+
...this.generateIndexedPositionalCompletions(
79+
subcmd.positionals,
80+
`__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`,
81+
this.collectValueFlags(cmd.flags, subcmd.flags),
82+
2
83+
)
84+
);
85+
} else if (subcmd.acceptsPositional) {
86+
lines.push(
87+
...this.generatePositionalCompletion(
88+
subcmd.positionalType,
89+
`__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`
90+
)
91+
);
8492
}
8593
}
8694
} else {
87-
// Command without subcommands
8895
lines.push(`# ${cmd.name} flags`);
8996
for (const flag of cmd.flags) {
9097
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
9198
}
9299

93-
// Add positional completions
94-
if (cmd.acceptsPositional) {
100+
if (cmd.positionals?.length) {
101+
lines.push(
102+
...this.generateIndexedPositionalCompletions(
103+
cmd.positionals,
104+
`__fish_openspec_using_subcommand ${cmd.name}`,
105+
this.collectValueFlags(cmd.flags),
106+
1
107+
)
108+
);
109+
} else if (cmd.acceptsPositional) {
95110
lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}`));
96111
}
97112
}
@@ -104,44 +119,23 @@ ${commandCompletions}`;
104119
*/
105120
private generateFlagCompletion(flag: FlagDefinition, condition: string): string[] {
106121
const lines: string[] = [];
107-
const longFlag = `--${flag.name}`;
108-
const shortFlag = flag.short ? `-${flag.short}` : undefined;
122+
const description = this.escapeDescription(flag.description);
123+
const shortFlag = flag.short ? `-s ${flag.short} ` : '';
124+
const flagOptions = `${shortFlag}-l ${flag.name}`;
125+
const fileFallback = flag.completionType === 'path' ? '-r' : '-r -f';
109126

110127
if (flag.takesValue && flag.values) {
111-
// Flag with enum values
112128
for (const value of flag.values) {
113-
if (shortFlag) {
114-
lines.push(
115-
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
116-
);
117-
} else {
118-
lines.push(
119-
`complete -c openspec -n '${condition}' -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
120-
);
121-
}
122-
}
123-
} else if (flag.takesValue) {
124-
// Flag that takes a value but no specific values defined
125-
if (shortFlag) {
126-
lines.push(
127-
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
128-
);
129-
} else {
130129
lines.push(
131-
`complete -c openspec -n '${condition}' -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
130+
`complete -c openspec -n '${condition}' ${flagOptions} -f -a '${value}' -d '${description}'`
132131
);
133132
}
133+
} else if (flag.takesValue) {
134+
lines.push(
135+
`complete -c openspec -n '${condition}' ${flagOptions} ${fileFallback} -d '${description}'`
136+
);
134137
} else {
135-
// Boolean flag
136-
if (shortFlag) {
137-
lines.push(
138-
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
139-
);
140-
} else {
141-
lines.push(
142-
`complete -c openspec -n '${condition}' -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
143-
);
144-
}
138+
lines.push(`complete -c openspec -n '${condition}' ${flagOptions} -f -d '${description}'`);
145139
}
146140

147141
return lines;
@@ -170,22 +164,65 @@ ${commandCompletions}`;
170164
lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`);
171165
break;
172166
case 'path':
173-
// Fish automatically completes files, no need to specify
167+
// Fish automatically completes files for path arguments; no rule needed.
168+
break;
169+
default:
170+
lines.push(`complete -c openspec -n '${condition}' -f`);
174171
break;
175172
}
176173

177174
return lines;
178175
}
179176

177+
/**
178+
* Generate indexed positional completions.
179+
*/
180+
private generateIndexedPositionalCompletions(
181+
positionals: NonNullable<CommandDefinition['positionals']>,
182+
condition: string,
183+
valueFlags: string[],
184+
depth: number
185+
): string[] {
186+
const lines: string[] = [];
187+
188+
for (const [index, positional] of positionals.entries()) {
189+
const indexCondition = `${condition}; and __fish_openspec_positional_index ${index} ${depth}${valueFlags.length ? ` ${valueFlags.join(' ')}` : ''}`;
190+
lines.push(...this.generatePositionalCompletion(positional.type, indexCondition));
191+
}
192+
193+
return lines;
194+
}
195+
196+
/**
197+
* Collect the long and short names for flags that consume the next token.
198+
*/
199+
private collectValueFlags(...flagGroups: FlagDefinition[][]): string[] {
200+
const flags = new Set<string>();
201+
202+
for (const group of flagGroups) {
203+
for (const flag of group) {
204+
if (!flag.takesValue) {
205+
continue;
206+
}
207+
208+
flags.add(`--${flag.name}`);
209+
if (flag.short) {
210+
flags.add(`-${flag.short}`);
211+
}
212+
}
213+
}
214+
215+
return [...flags];
216+
}
180217

181218
/**
182219
* Escape description text for Fish
183220
*/
184221
private escapeDescription(description: string): string {
185222
return description
186-
.replace(/\\/g, '\\\\') // Backslashes first
187-
.replace(/'/g, "\\'") // Single quotes
188-
.replace(/\$/g, '\\$') // Dollar signs (prevents $())
189-
.replace(/`/g, '\\`'); // Backticks
223+
.replace(/\\/g, '\\\\') // Backslashes first
224+
.replace(/'/g, "\\'") // Single quotes
225+
.replace(/\$/g, '\\$') // Dollar signs (prevents $())
226+
.replace(/`/g, '\\`'); // Backticks
190227
}
191228
}

src/core/completions/templates/fish-templates.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,31 @@ end
1818
function __fish_openspec_no_subcommand
1919
set -l cmd (commandline -opc)
2020
test (count $cmd) -eq 1
21+
end
22+
23+
function __fish_openspec_positional_index
24+
set -l target $argv[1]
25+
set -l depth $argv[2]
26+
set -l value_flags $argv[3..]
27+
set -l tokens (commandline -opc)
28+
set -e tokens[1]
29+
set -l count 0
30+
set -l skip 0
31+
for token in $tokens
32+
if test $skip -eq 1
33+
set skip 0
34+
continue
35+
end
36+
if contains -- $token $value_flags
37+
set skip 1
38+
continue
39+
end
40+
if string match -q -- '-*' $token
41+
continue
42+
end
43+
set count (math $count + 1)
44+
end
45+
test $count -eq (math $target + $depth)
2146
end`;
2247

2348
export const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers

src/core/completions/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export interface FlagDefinition {
2424
*/
2525
takesValue?: boolean;
2626

27+
/**
28+
* Completion type for the flag value.
29+
*/
30+
completionType?: PositionalType;
31+
2732
/**
2833
* Possible values for the flag (for completion suggestions)
2934
*/

0 commit comments

Comments
 (0)