@fission-ai/openspec 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/index.js +11 -2
  2. package/dist/commands/change.d.ts +24 -0
  3. package/dist/commands/change.js +223 -0
  4. package/dist/commands/schema.d.ts +5 -0
  5. package/dist/commands/schema.js +201 -35
  6. package/dist/commands/show.js +1 -1
  7. package/dist/commands/workflow/index.d.ts +1 -1
  8. package/dist/commands/workflow/index.js +1 -1
  9. package/dist/commands/workflow/status.d.ts +2 -0
  10. package/dist/commands/workflow/status.js +76 -12
  11. package/dist/core/artifact-graph/resolver.js +15 -9
  12. package/dist/core/available-tools.js +16 -1
  13. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  14. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  15. package/dist/core/completions/command-registry.js +15 -0
  16. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  17. package/dist/core/completions/generators/fish-generator.js +79 -47
  18. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  19. package/dist/core/completions/templates/fish-templates.js +107 -10
  20. package/dist/core/completions/types.d.ts +4 -0
  21. package/dist/core/config.js +6 -1
  22. package/dist/core/init.js +50 -31
  23. package/dist/core/legacy-cleanup.js +6 -0
  24. package/dist/core/migration.js +42 -12
  25. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  26. package/dist/core/parsers/requirement-blocks.js +2 -0
  27. package/dist/core/shared/tool-detection.js +9 -3
  28. package/dist/core/shared-skill-target.d.ts +13 -0
  29. package/dist/core/shared-skill-target.js +43 -1
  30. package/dist/core/specs-apply.js +16 -8
  31. package/dist/core/templates/workflows/explore.js +79 -73
  32. package/dist/core/update.js +24 -22
  33. package/dist/core/validation/constants.d.ts +3 -0
  34. package/dist/core/validation/constants.js +12 -0
  35. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  36. package/dist/core/validation/purpose-placeholder.js +117 -0
  37. package/dist/core/validation/validator.js +16 -1
  38. package/dist/utils/requirement-diff.d.ts +42 -0
  39. package/dist/utils/requirement-diff.js +61 -0
  40. package/package.json +2 -1
@@ -6,11 +6,13 @@
6
6
  import { type ChangeStatus } from '../../core/artifact-graph/index.js';
7
7
  export interface StatusOptions {
8
8
  change?: string;
9
+ all?: boolean;
9
10
  schema?: string;
10
11
  store?: string;
11
12
  storePath?: string;
12
13
  json?: boolean;
13
14
  }
15
+ export declare const BATCH_STATUS_FAILURE_PAYLOAD: Record<string, unknown>;
14
16
  export declare function statusCommand(options: StatusOptions): Promise<void>;
15
17
  export declare function printStatusText(status: ChangeStatus): void;
16
18
  //# sourceMappingURL=status.d.ts.map
@@ -8,14 +8,26 @@ import chalk from 'chalk';
8
8
  import { getChangeDir } from '../../core/planning-home.js';
9
9
  import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../../core/root-selection.js';
10
10
  import { loadChangeContext, formatChangeStatus, } from '../../core/artifact-graph/index.js';
11
+ import { asStatus } from '../shared-output.js';
11
12
  import { validateChangeExists, validateSchemaExists, getAvailableChanges, getStatusIndicator, getStatusColor, } from './shared.js';
12
- // -----------------------------------------------------------------------------
13
- // Command Implementation
14
- // -----------------------------------------------------------------------------
13
+ // The --all --json failure null-shape. Root-selection failures (handled in
14
+ // resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must
15
+ // emit the same shape, so both call sites reference this one constant.
16
+ export const BATCH_STATUS_FAILURE_PAYLOAD = {
17
+ changes: [],
18
+ root: null,
19
+ };
15
20
  export async function statusCommand(options) {
21
+ if (options.all && options.change) {
22
+ throw new Error('The --all and --change options are mutually exclusive.');
23
+ }
16
24
  // The root resolves (and the store banner prints) before the spinner starts
17
- // so the two do not fight over stderr.
18
- const root = await resolveRootForCommand(options, { json: options.json });
25
+ // so the two do not fight over stderr. The batch null-shape rides along so
26
+ // a root-selection failure under --all --json still carries `changes: []`.
27
+ const root = await resolveRootForCommand(options, {
28
+ json: options.json,
29
+ failurePayload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
30
+ });
19
31
  if (!root) {
20
32
  return;
21
33
  }
@@ -25,9 +37,20 @@ export async function statusCommand(options) {
25
37
  const projectRoot = root.path;
26
38
  const rootOutput = toRootOutput(root);
27
39
  const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
40
+ // Single definition of "load one change's status" so the batch and
41
+ // single-change payloads can never drift apart.
42
+ const loadStatus = (changeName) => formatChangeStatus(loadChangeContext(projectRoot, changeName, options.schema, {
43
+ changeDir: getChangeDir(planningHome, changeName),
44
+ planningHome,
45
+ }), isStoreSelectedRoot(root) ? { storeId: root.storeId } : {});
28
46
  // Handle no-changes case gracefully — status is informational,
29
47
  // so "no changes" is a valid state, not an error.
30
48
  if (!options.change) {
49
+ // Validate before the no-changes early return so a bogus --schema
50
+ // fails the same way whether or not any change exists yet.
51
+ if (options.all && options.schema) {
52
+ validateSchemaExists(options.schema, projectRoot);
53
+ }
31
54
  const available = await getAvailableChanges(projectRoot, root.changesDir);
32
55
  if (available.length === 0) {
33
56
  spinner?.stop();
@@ -38,9 +61,54 @@ export async function statusCommand(options) {
38
61
  console.log(`No active changes. Create one with: ${newChangeHint}`);
39
62
  return;
40
63
  }
41
- // Changes exist but --change not provided
64
+ if (options.all) {
65
+ // readdir order is platform-dependent; sort for deterministic output,
66
+ // with the same comparator validate --all uses so the two batch
67
+ // commands order a given change set identically.
68
+ const entries = [];
69
+ for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
70
+ try {
71
+ entries.push(loadStatus(changeName));
72
+ }
73
+ catch (error) {
74
+ // One malformed change must not blank the sweep; carry its
75
+ // diagnostic in place and keep going.
76
+ entries.push({ changeName, status: [asStatus(error, 'change_error')] });
77
+ }
78
+ }
79
+ spinner?.stop();
80
+ const failed = entries.some((entry) => !('artifacts' in entry));
81
+ if (options.json) {
82
+ console.log(JSON.stringify({ changes: entries, root: rootOutput }, null, 2));
83
+ if (failed) {
84
+ process.exitCode = 1;
85
+ }
86
+ return;
87
+ }
88
+ entries.forEach((entry, index) => {
89
+ if (index > 0) {
90
+ console.log();
91
+ }
92
+ if ('artifacts' in entry) {
93
+ printStatusText(entry);
94
+ }
95
+ else {
96
+ console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
97
+ }
98
+ });
99
+ // A partial load is still a failed command in both output modes;
100
+ // JSON callers can parse the complete envelope independently of
101
+ // the process exit code.
102
+ if (failed) {
103
+ process.exitCode = 1;
104
+ }
105
+ return;
106
+ }
107
+ // Changes exist but neither --change nor --all provided. Name --all
108
+ // here too: it is the other way to answer this prompt, and a caller
109
+ // who wants every change should not have to find it in --help.
42
110
  spinner?.stop();
43
- throw new Error(`Missing required option --change. Available changes:\n ${available.join('\n ')}`);
111
+ throw new Error(`Missing required option --change (or --all for every active change). Available changes:\n ${available.join('\n ')}`);
44
112
  }
45
113
  const changeName = await validateChangeExists(options.change, projectRoot, root.changesDir, { newChangeHint });
46
114
  // Validate schema if explicitly provided
@@ -48,11 +116,7 @@ export async function statusCommand(options) {
48
116
  validateSchemaExists(options.schema, projectRoot);
49
117
  }
50
118
  // loadChangeContext will auto-detect schema from metadata if not provided
51
- const context = loadChangeContext(projectRoot, changeName, options.schema, {
52
- changeDir: getChangeDir(planningHome, changeName),
53
- planningHome,
54
- });
55
- const status = formatChangeStatus(context, isStoreSelectedRoot(root) ? { storeId: root.storeId } : {});
119
+ const status = loadStatus(changeName);
56
120
  spinner?.stop();
57
121
  if (options.json) {
58
122
  console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2));
@@ -53,18 +53,24 @@ export function getProjectSchemasDir(projectRoot) {
53
53
  * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
54
54
  */
55
55
  /**
56
- * Directories `schema fork` creates transiently while swapping a fork into
57
- * place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
58
- * backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
59
- * can briefly coexist with real schemas in the schemas dir, so discovery must
60
- * never surface them. Real schema names are kebab-case (no dots), so excluding
61
- * these dot-bearing temp names can never hide a legitimate schema.
56
+ * Directories `schema fork` and `schema init` create transiently while swapping
57
+ * a schema into place: a staging copy (`.fork-staging-<rand>` /
58
+ * `.init-staging-<rand>`, created via mkdtemp) and a backup of the previous
59
+ * destination (`<name>.fork-backup-<pid>-<ts>` /
60
+ * `<name>.init-backup-<pid>-<ts>`). Either can briefly coexist with real
61
+ * schemas in the schemas dir, and a backup outlives the run when its cleanup is
62
+ * blocked, so discovery must never surface them. Real schema names are
63
+ * kebab-case (no dots), so excluding these dot-bearing temp names can never
64
+ * hide a legitimate schema.
62
65
  */
63
- function isOwnedForkTempDir(name) {
64
- return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
66
+ function isOwnedTransientSchemaDir(name) {
67
+ return (name.startsWith('.fork-staging-') ||
68
+ name.includes('.fork-backup-') ||
69
+ name.startsWith('.init-staging-') ||
70
+ name.includes('.init-backup-'));
65
71
  }
66
72
  export function isSchemaDir(parentDir, entry) {
67
- if (isOwnedForkTempDir(entry.name)) {
73
+ if (isOwnedTransientSchemaDir(entry.name)) {
68
74
  return false;
69
75
  }
70
76
  if (entry.isDirectory()) {
@@ -49,6 +49,21 @@ export function getAvailableTools(projectPath) {
49
49
  }
50
50
  });
51
51
  const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
52
- return available.filter((tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value));
52
+ const hasIndependentDetectionPath = (tool) => (tool.detectionPaths ?? []).some((detectionPath) => {
53
+ // Skill roots still go through managed-content reconciliation below;
54
+ // their mere existence is not an independent tool signal.
55
+ if (detectionPath.endsWith('/skills'))
56
+ return false;
57
+ try {
58
+ fs.statSync(path.join(projectPath, detectionPath));
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ });
65
+ return available.filter((tool) => tool.globalSkillsDir ||
66
+ hasIndependentDetectionPath(tool) ||
67
+ activeProjectTools.has(tool.value));
53
68
  }
54
69
  //# sourceMappingURL=available-tools.js.map
@@ -6,7 +6,7 @@
6
6
  import type { ToolCommandAdapter } from '../types.js';
7
7
  /**
8
8
  * Antigravity adapter for command generation.
9
- * File path: .agent/workflows/opsx-<id>.md
9
+ * File path: .agents/workflows/opsx-<id>.md
10
10
  * Frontmatter: description
11
11
  */
12
12
  export declare const antigravityAdapter: ToolCommandAdapter;
@@ -7,13 +7,13 @@ import path from 'path';
7
7
  import { escapeYamlValue } from '../yaml.js';
8
8
  /**
9
9
  * Antigravity adapter for command generation.
10
- * File path: .agent/workflows/opsx-<id>.md
10
+ * File path: .agents/workflows/opsx-<id>.md
11
11
  * Frontmatter: description
12
12
  */
13
13
  export const antigravityAdapter = {
14
14
  toolId: 'antigravity',
15
15
  getFilePath(commandId) {
16
- return path.join('.agent', 'workflows', `opsx-${commandId}.md`);
16
+ return path.join('.agents', 'workflows', `opsx-${commandId}.md`);
17
17
  },
18
18
  formatFile(content) {
19
19
  return `---
@@ -136,6 +136,10 @@ export const COMMAND_REGISTRY = [
136
136
  name: 'requirements-only',
137
137
  description: 'Alias for --deltas-only (deprecated, change-specific)',
138
138
  },
139
+ {
140
+ name: 'diff',
141
+ description: 'Show per-requirement diffs for delta specs (change-specific)',
142
+ },
139
143
  {
140
144
  name: 'requirements',
141
145
  description: 'Show only requirements, exclude scenarios (JSON only, spec-specific)',
@@ -189,6 +193,10 @@ export const COMMAND_REGISTRY = [
189
193
  description: 'Change name to show status for',
190
194
  takesValue: true,
191
195
  },
196
+ {
197
+ name: 'all',
198
+ description: 'Show status for all active changes',
199
+ },
192
200
  {
193
201
  name: 'schema',
194
202
  description: 'Schema override',
@@ -285,6 +293,7 @@ export const COMMAND_REGISTRY = [
285
293
  name: 'path',
286
294
  description: 'Directory to use for the store',
287
295
  takesValue: true,
296
+ completionType: 'path',
288
297
  },
289
298
  {
290
299
  name: 'init-git',
@@ -377,6 +386,7 @@ export const COMMAND_REGISTRY = [
377
386
  name: 'code-workspace',
378
387
  description: 'Also write a VS Code workspace file for the set',
379
388
  takesValue: true,
389
+ completionType: 'path',
380
390
  },
381
391
  {
382
392
  name: 'force',
@@ -407,6 +417,7 @@ export const COMMAND_REGISTRY = [
407
417
  name: 'member',
408
418
  description: 'Member folder as <path> or <name>=<path>; repeatable, first is the primary',
409
419
  takesValue: true,
420
+ completionType: 'path',
410
421
  },
411
422
  {
412
423
  name: 'tool',
@@ -488,6 +499,10 @@ export const COMMAND_REGISTRY = [
488
499
  name: 'requirements-only',
489
500
  description: 'Alias for --deltas-only (deprecated)',
490
501
  },
502
+ {
503
+ name: 'diff',
504
+ description: 'Show per-requirement diffs for delta specs',
505
+ },
491
506
  COMMON_FLAGS.noInteractive,
492
507
  ],
493
508
  },
@@ -24,6 +24,18 @@ export declare class FishGenerator implements CompletionGenerator {
24
24
  * Generate positional argument completion
25
25
  */
26
26
  private generatePositionalCompletion;
27
+ /**
28
+ * Generate indexed positional completions.
29
+ */
30
+ private generateIndexedPositionalCompletions;
31
+ /**
32
+ * Collect the long and short names for flags that consume the next token.
33
+ */
34
+ private collectValueFlags;
35
+ /**
36
+ * Build a Fish condition that matches command words in their actual slots.
37
+ */
38
+ private commandPathCondition;
27
39
  /**
28
40
  * Escape description text for Fish
29
41
  */
@@ -12,30 +12,26 @@ export class FishGenerator {
12
12
  * @returns Fish completion script as a string
13
13
  */
14
14
  generate(commands) {
15
- // Build top-level commands using push() for loop clarity
16
15
  const topLevelLines = [];
17
16
  for (const cmd of commands) {
18
17
  topLevelLines.push(`# ${cmd.name} command`);
19
- topLevelLines.push(`complete -c openspec -n '__fish_openspec_no_subcommand' -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`);
18
+ topLevelLines.push(`complete -c openspec -n '__fish_openspec_no_subcommand' -f -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`);
20
19
  }
21
20
  const topLevelCommands = topLevelLines.join('\n');
22
- // Build command-specific completions using push() for loop clarity
23
21
  const commandCompletionLines = [];
24
22
  for (const cmd of commands) {
25
23
  commandCompletionLines.push(...this.generateCommandCompletions(cmd));
26
24
  commandCompletionLines.push('');
27
25
  }
28
26
  const commandCompletions = commandCompletionLines.join('\n');
29
- // Static helper functions from template
30
27
  const helperFunctions = FISH_STATIC_HELPERS;
31
- // Dynamic completion helpers from template
32
28
  const dynamicHelpers = FISH_DYNAMIC_HELPERS;
33
- // Assemble final script with template literal
34
29
  return `# Fish completion script for OpenSpec CLI
35
30
  # Auto-generated - do not edit manually
36
31
 
37
32
  ${helperFunctions}
38
33
  ${dynamicHelpers}
34
+ complete -c openspec -l no-color -f -d 'Disable color output'
39
35
  ${topLevelCommands}
40
36
 
41
37
  ${commandCompletions}`;
@@ -45,38 +41,45 @@ ${commandCompletions}`;
45
41
  */
46
42
  generateCommandCompletions(cmd) {
47
43
  const lines = [];
48
- // If command has subcommands
49
44
  if (cmd.subcommands && cmd.subcommands.length > 0) {
50
- // Add subcommand completions
45
+ const commandCondition = this.commandPathCondition([cmd.name]);
46
+ const parentValueFlags = this.collectValueFlags(cmd.flags);
47
+ const noSubcommandCondition = cmd.subcommands
48
+ .map((subcmd) => `not ${this.commandPathCondition([cmd.name, subcmd.name], parentValueFlags)}`)
49
+ .join('; and ');
51
50
  for (const subcmd of cmd.subcommands) {
52
- 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)}'`);
51
+ lines.push(`complete -c openspec -n '${commandCondition}; and ${noSubcommandCondition}' -f -a '${subcmd.name}' -d '${this.escapeDescription(subcmd.description)}'`);
53
52
  }
54
53
  lines.push('');
55
- // Add flags for parent command
56
54
  for (const flag of cmd.flags) {
57
- lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
55
+ lines.push(...this.generateFlagCompletion(flag, commandCondition));
58
56
  }
59
- // Add completions for each subcommand
60
57
  for (const subcmd of cmd.subcommands) {
58
+ const subcommandCondition = this.commandPathCondition([cmd.name, subcmd.name], parentValueFlags);
61
59
  lines.push(`# ${cmd.name} ${subcmd.name} flags`);
60
+ lines.push(`complete -c openspec -n '${subcommandCondition}' -f`);
62
61
  for (const flag of subcmd.flags) {
63
- lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
62
+ lines.push(...this.generateFlagCompletion(flag, subcommandCondition));
64
63
  }
65
- // Add positional completions for subcommand
66
- if (subcmd.acceptsPositional) {
67
- lines.push(...this.generatePositionalCompletion(subcmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
64
+ if (subcmd.positionals?.length) {
65
+ lines.push(...this.generateIndexedPositionalCompletions(subcmd.positionals, subcommandCondition, this.collectValueFlags(cmd.flags, subcmd.flags), 2));
66
+ }
67
+ else if (subcmd.acceptsPositional) {
68
+ lines.push(...this.generatePositionalCompletion(subcmd.positionalType, subcommandCondition));
68
69
  }
69
70
  }
70
71
  }
71
72
  else {
72
- // Command without subcommands
73
73
  lines.push(`# ${cmd.name} flags`);
74
+ lines.push(`complete -c openspec -n '__fish_openspec_using_command_path ${cmd.name}' -f`);
74
75
  for (const flag of cmd.flags) {
75
- lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
76
+ lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_command_path ${cmd.name}`));
77
+ }
78
+ if (cmd.positionals?.length) {
79
+ lines.push(...this.generateIndexedPositionalCompletions(cmd.positionals, `__fish_openspec_using_command_path ${cmd.name}`, this.collectValueFlags(cmd.flags), 1));
76
80
  }
77
- // Add positional completions
78
- if (cmd.acceptsPositional) {
79
- lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}`));
81
+ else if (cmd.acceptsPositional) {
82
+ lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_command_path ${cmd.name}`));
80
83
  }
81
84
  }
82
85
  return lines;
@@ -86,36 +89,26 @@ ${commandCompletions}`;
86
89
  */
87
90
  generateFlagCompletion(flag, condition) {
88
91
  const lines = [];
89
- const longFlag = `--${flag.name}`;
90
- const shortFlag = flag.short ? `-${flag.short}` : undefined;
92
+ const description = this.escapeDescription(flag.description);
93
+ const shortFlag = flag.short ? `-s ${flag.short} ` : '';
94
+ const flagOptions = `${shortFlag}-l ${flag.name}`;
91
95
  if (flag.takesValue && flag.values) {
92
- // Flag with enum values
93
96
  for (const value of flag.values) {
94
- if (shortFlag) {
95
- lines.push(`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`);
96
- }
97
- else {
98
- lines.push(`complete -c openspec -n '${condition}' -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`);
99
- }
97
+ lines.push(`complete -c openspec -n '${condition}' ${flagOptions} -r -f -a '${value}' -d '${description}'`);
100
98
  }
101
99
  }
102
100
  else if (flag.takesValue) {
103
- // Flag that takes a value but no specific values defined
104
- if (shortFlag) {
105
- lines.push(`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`);
106
- }
107
- else {
108
- lines.push(`complete -c openspec -n '${condition}' -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`);
101
+ lines.push(`complete -c openspec -n '${condition}' ${flagOptions} -r -f -d '${description}'`);
102
+ if (flag.completionType === 'path') {
103
+ const optionNames = [`--${flag.name}`, ...(flag.short ? [`-${flag.short}`] : [])];
104
+ lines.push(`complete -c openspec -n '${condition}; and __fish_openspec_completing_option_value ${optionNames.join(' ')}' ${flagOptions} -r -F -d '${description}'`);
105
+ if (flag.short) {
106
+ lines.push(`complete -c openspec -n '${condition}' ${flagOptions} -r -f -a '(__fish_openspec_complete_attached_short_path -${flag.short})' -d '${description}'`);
107
+ }
109
108
  }
110
109
  }
111
110
  else {
112
- // Boolean flag
113
- if (shortFlag) {
114
- lines.push(`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`);
115
- }
116
- else {
117
- lines.push(`complete -c openspec -n '${condition}' -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`);
118
- }
111
+ lines.push(`complete -c openspec -n '${condition}' ${flagOptions} -f -d '${description}'`);
119
112
  }
120
113
  return lines;
121
114
  }
@@ -141,20 +134,59 @@ ${commandCompletions}`;
141
134
  lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`);
142
135
  break;
143
136
  case 'path':
144
- // Fish automatically completes files, no need to specify
137
+ // -F re-enables filesystem completion: sibling rules in the same
138
+ // context carry -f, and Fish never restores files without --force-files.
139
+ lines.push(`complete -c openspec -n '${condition}' -F`);
140
+ break;
141
+ default:
142
+ lines.push(`complete -c openspec -n '${condition}' -f`);
145
143
  break;
146
144
  }
147
145
  return lines;
148
146
  }
147
+ /**
148
+ * Generate indexed positional completions.
149
+ */
150
+ generateIndexedPositionalCompletions(positionals, condition, valueFlags, depth) {
151
+ const lines = [];
152
+ for (const [index, positional] of positionals.entries()) {
153
+ const indexCondition = `${condition}; and __fish_openspec_positional_index ${index} ${depth}${valueFlags.length ? ` ${valueFlags.join(' ')}` : ''}`;
154
+ lines.push(...this.generatePositionalCompletion(positional.type, indexCondition));
155
+ }
156
+ return lines;
157
+ }
158
+ /**
159
+ * Collect the long and short names for flags that consume the next token.
160
+ */
161
+ collectValueFlags(...flagGroups) {
162
+ const flags = new Set();
163
+ for (const group of flagGroups) {
164
+ for (const flag of group) {
165
+ if (!flag.takesValue) {
166
+ continue;
167
+ }
168
+ flags.add(`--${flag.name}`);
169
+ if (flag.short) {
170
+ flags.add(`-${flag.short}`);
171
+ }
172
+ }
173
+ }
174
+ return [...flags];
175
+ }
176
+ /**
177
+ * Build a Fish condition that matches command words in their actual slots.
178
+ */
179
+ commandPathCondition(path, valueFlags = []) {
180
+ const valueFlagArguments = valueFlags.length ? ` -- ${valueFlags.join(' ')}` : '';
181
+ return `__fish_openspec_using_command_path ${path.join(' ')}${valueFlagArguments}`;
182
+ }
149
183
  /**
150
184
  * Escape description text for Fish
151
185
  */
152
186
  escapeDescription(description) {
153
187
  return description
154
188
  .replace(/\\/g, '\\\\') // Backslashes first
155
- .replace(/'/g, "\\'") // Single quotes
156
- .replace(/\$/g, '\\$') // Dollar signs (prevents $())
157
- .replace(/`/g, '\\`'); // Backticks
189
+ .replace(/'/g, "\\'"); // Single quotes
158
190
  }
159
191
  }
160
192
  //# sourceMappingURL=fish-generator.js.map
@@ -2,6 +2,6 @@
2
2
  * Static template strings for Fish completion scripts.
3
3
  * These are Fish-specific helper functions that never change.
4
4
  */
5
- export declare const FISH_STATIC_HELPERS = "# Helper function to check if a subcommand is present\nfunction __fish_openspec_using_subcommand\n set -l cmd (commandline -opc)\n set -e cmd[1]\n for i in $argv\n if contains -- $i $cmd\n return 0\n end\n end\n return 1\nend\n\nfunction __fish_openspec_no_subcommand\n set -l cmd (commandline -opc)\n test (count $cmd) -eq 1\nend";
5
+ export declare const FISH_STATIC_HELPERS = "# Helper function to match the command path at the start of the invocation\nfunction __fish_openspec_using_command_path\n set -l expected\n set -l value_flags\n set -l reading_value_flags 0\n for argument in $argv\n if test \"$argument\" = --\n set reading_value_flags 1\n continue\n end\n if test $reading_value_flags -eq 1\n set -a value_flags $argument\n else\n set -a expected $argument\n end\n end\n\n set -l tokens (commandline -opc)\n set -e tokens[1]\n set -l path\n set -l skip 0\n for token in $tokens\n if test $skip -eq 1\n set skip 0\n continue\n end\n if test \"$token\" = --no-color\n continue\n end\n if contains -- $token $value_flags\n set skip 1\n continue\n end\n if string match -q -- '-*' $token\n continue\n end\n set -a path $token\n if test (count $path) -eq (count $expected)\n break\n end\n end\n test (count $path) -eq (count $expected); or return 1\n for index in (seq (count $expected))\n test \"$path[$index]\" = \"$expected[$index]\"; or return 1\n end\n return 0\nend\n\nfunction __fish_openspec_no_subcommand\n set -l tokens (commandline -opc)\n set -e tokens[1]\n for token in $tokens\n if test \"$token\" != --no-color\n return 1\n end\n end\n return 0\nend\n\nfunction __fish_openspec_completing_option_value\n set -l current (commandline -ct)\n for option in $argv\n if string match -q -- \"$option=*\" \"$current\"\n return 0\n end\n end\n\n set -l tokens (commandline -opc)\n test (count $tokens) -gt 0; or return 1\n contains -- $tokens[-1] $argv\nend\n\nfunction __fish_openspec_complete_attached_short_path\n set -l option $argv[1]\n set -l current (commandline -ct)\n test \"$current\" != \"$option\"; or return 1\n string match -q -- \"$option*\" \"$current\"; or return 1\n set -l value (string sub -s (math (string length -- \"$option\") + 1) -- \"$current\")\n __fish_complete_path \"$value\"\nend\n\nfunction __fish_openspec_positional_index\n set -l target $argv[1]\n set -l depth $argv[2]\n set -l value_flags $argv[3..]\n set -l tokens (commandline -opc)\n set -e tokens[1]\n set -l count 0\n set -l options 1\n set -l skip 0\n for token in $tokens\n if test $skip -eq 1\n set skip 0\n continue\n end\n if test $options -eq 1\n if test \"$token\" = --\n set options 0\n continue\n end\n if contains -- $token $value_flags\n set skip 1\n continue\n end\n if string match -q -- '-*' $token\n continue\n end\n end\n set count (math $count + 1)\n end\n test $skip -eq 0; or return 1\n test $count -eq (math $target + $depth)\nend";
6
6
  export declare const FISH_DYNAMIC_HELPERS = "# Dynamic completion helpers\n\nfunction __fish_openspec_changes\n openspec __complete changes 2>/dev/null | while read -l id desc\n printf '%s\\t%s\\n' \"$id\" \"$desc\"\n end\nend\n\nfunction __fish_openspec_specs\n openspec __complete specs 2>/dev/null | while read -l id desc\n printf '%s\\t%s\\n' \"$id\" \"$desc\"\n end\nend\n\nfunction __fish_openspec_items\n __fish_openspec_changes\n __fish_openspec_specs\nend\n\nfunction __fish_openspec_schemas\n openspec __complete schemas 2>/dev/null | while read -l id desc\n printf '%s\\t%s\\n' \"$id\" \"$desc\"\n end\nend";
7
7
  //# sourceMappingURL=fish-templates.d.ts.map
@@ -2,21 +2,118 @@
2
2
  * Static template strings for Fish completion scripts.
3
3
  * These are Fish-specific helper functions that never change.
4
4
  */
5
- export const FISH_STATIC_HELPERS = `# Helper function to check if a subcommand is present
6
- function __fish_openspec_using_subcommand
7
- set -l cmd (commandline -opc)
8
- set -e cmd[1]
9
- for i in $argv
10
- if contains -- $i $cmd
11
- return 0
5
+ export const FISH_STATIC_HELPERS = `# Helper function to match the command path at the start of the invocation
6
+ function __fish_openspec_using_command_path
7
+ set -l expected
8
+ set -l value_flags
9
+ set -l reading_value_flags 0
10
+ for argument in $argv
11
+ if test "$argument" = --
12
+ set reading_value_flags 1
13
+ continue
14
+ end
15
+ if test $reading_value_flags -eq 1
16
+ set -a value_flags $argument
17
+ else
18
+ set -a expected $argument
12
19
  end
13
20
  end
14
- return 1
21
+
22
+ set -l tokens (commandline -opc)
23
+ set -e tokens[1]
24
+ set -l path
25
+ set -l skip 0
26
+ for token in $tokens
27
+ if test $skip -eq 1
28
+ set skip 0
29
+ continue
30
+ end
31
+ if test "$token" = --no-color
32
+ continue
33
+ end
34
+ if contains -- $token $value_flags
35
+ set skip 1
36
+ continue
37
+ end
38
+ if string match -q -- '-*' $token
39
+ continue
40
+ end
41
+ set -a path $token
42
+ if test (count $path) -eq (count $expected)
43
+ break
44
+ end
45
+ end
46
+ test (count $path) -eq (count $expected); or return 1
47
+ for index in (seq (count $expected))
48
+ test "$path[$index]" = "$expected[$index]"; or return 1
49
+ end
50
+ return 0
15
51
  end
16
52
 
17
53
  function __fish_openspec_no_subcommand
18
- set -l cmd (commandline -opc)
19
- test (count $cmd) -eq 1
54
+ set -l tokens (commandline -opc)
55
+ set -e tokens[1]
56
+ for token in $tokens
57
+ if test "$token" != --no-color
58
+ return 1
59
+ end
60
+ end
61
+ return 0
62
+ end
63
+
64
+ function __fish_openspec_completing_option_value
65
+ set -l current (commandline -ct)
66
+ for option in $argv
67
+ if string match -q -- "$option=*" "$current"
68
+ return 0
69
+ end
70
+ end
71
+
72
+ set -l tokens (commandline -opc)
73
+ test (count $tokens) -gt 0; or return 1
74
+ contains -- $tokens[-1] $argv
75
+ end
76
+
77
+ function __fish_openspec_complete_attached_short_path
78
+ set -l option $argv[1]
79
+ set -l current (commandline -ct)
80
+ test "$current" != "$option"; or return 1
81
+ string match -q -- "$option*" "$current"; or return 1
82
+ set -l value (string sub -s (math (string length -- "$option") + 1) -- "$current")
83
+ __fish_complete_path "$value"
84
+ end
85
+
86
+ function __fish_openspec_positional_index
87
+ set -l target $argv[1]
88
+ set -l depth $argv[2]
89
+ set -l value_flags $argv[3..]
90
+ set -l tokens (commandline -opc)
91
+ set -e tokens[1]
92
+ set -l count 0
93
+ set -l options 1
94
+ set -l skip 0
95
+ for token in $tokens
96
+ if test $skip -eq 1
97
+ set skip 0
98
+ continue
99
+ end
100
+ if test $options -eq 1
101
+ if test "$token" = --
102
+ set options 0
103
+ continue
104
+ end
105
+ if contains -- $token $value_flags
106
+ set skip 1
107
+ continue
108
+ end
109
+ if string match -q -- '-*' $token
110
+ continue
111
+ end
112
+ end
113
+ set count (math $count + 1)
114
+ end
115
+ test $skip -eq 0; or return 1
116
+ test $count -eq (math $target + $depth)
20
117
  end`;
21
118
  export const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers
22
119