@fission-ai/openspec 1.9.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 (72) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +60 -9
  3. package/dist/commands/change.d.ts +24 -0
  4. package/dist/commands/change.js +223 -0
  5. package/dist/commands/config.js +14 -3
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.d.ts +5 -0
  8. package/dist/commands/schema.js +201 -35
  9. package/dist/commands/show.js +1 -1
  10. package/dist/commands/workflow/index.d.ts +1 -1
  11. package/dist/commands/workflow/index.js +1 -1
  12. package/dist/commands/workflow/status.d.ts +2 -0
  13. package/dist/commands/workflow/status.js +76 -12
  14. package/dist/core/archive.js +64 -7
  15. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  16. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  17. package/dist/core/artifact-graph/outputs.js +7 -0
  18. package/dist/core/artifact-graph/resolver.js +15 -9
  19. package/dist/core/available-tools.js +16 -1
  20. package/dist/core/command-generation/adapters/antigravity.d.ts +1 -1
  21. package/dist/core/command-generation/adapters/antigravity.js +2 -2
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/completion-tip.d.ts +15 -0
  25. package/dist/core/completion-tip.js +133 -0
  26. package/dist/core/completions/command-registry.js +20 -0
  27. package/dist/core/completions/factory.d.ts +11 -0
  28. package/dist/core/completions/generators/fish-generator.d.ts +12 -0
  29. package/dist/core/completions/generators/fish-generator.js +79 -47
  30. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  31. package/dist/core/completions/installers/bash-installer.js +15 -0
  32. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  33. package/dist/core/completions/installers/fish-installer.js +15 -0
  34. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  35. package/dist/core/completions/installers/powershell-installer.js +15 -0
  36. package/dist/core/completions/templates/fish-templates.d.ts +1 -1
  37. package/dist/core/completions/templates/fish-templates.js +107 -10
  38. package/dist/core/completions/types.d.ts +4 -0
  39. package/dist/core/config-prompts.js +19 -10
  40. package/dist/core/config-schema.d.ts +1 -0
  41. package/dist/core/config-schema.js +2 -0
  42. package/dist/core/config.js +7 -1
  43. package/dist/core/global-config.d.ts +2 -0
  44. package/dist/core/init.d.ts +5 -0
  45. package/dist/core/init.js +117 -14
  46. package/dist/core/legacy-cleanup.js +6 -0
  47. package/dist/core/migration.js +42 -12
  48. package/dist/core/parsers/requirement-blocks.d.ts +1 -0
  49. package/dist/core/parsers/requirement-blocks.js +2 -0
  50. package/dist/core/profiles.d.ts +1 -1
  51. package/dist/core/profiles.js +11 -2
  52. package/dist/core/shared/tool-detection.js +9 -3
  53. package/dist/core/shared-skill-target.d.ts +13 -0
  54. package/dist/core/shared-skill-target.js +43 -1
  55. package/dist/core/specs-apply.js +16 -8
  56. package/dist/core/templates/workflows/explore.js +79 -73
  57. package/dist/core/templates/workflows/feedback.js +1 -0
  58. package/dist/core/templates/workflows/onboard.js +4 -4
  59. package/dist/core/update.js +39 -26
  60. package/dist/core/validation/constants.d.ts +3 -0
  61. package/dist/core/validation/constants.js +12 -0
  62. package/dist/core/validation/purpose-placeholder.d.ts +45 -0
  63. package/dist/core/validation/purpose-placeholder.js +117 -0
  64. package/dist/core/validation/validator.js +16 -1
  65. package/dist/telemetry/index.js +3 -2
  66. package/dist/utils/change-metadata.js +17 -13
  67. package/dist/utils/change-utils.js +5 -0
  68. package/dist/utils/requirement-diff.d.ts +42 -0
  69. package/dist/utils/requirement-diff.js +61 -0
  70. package/package.json +4 -6
  71. package/schemas/spec-driven/schema.yaml +17 -7
  72. package/scripts/postinstall.js +0 -83
@@ -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 `---
@@ -7,7 +7,8 @@ import type { ToolCommandAdapter } from '../types.js';
7
7
  /**
8
8
  * OpenCode adapter for command generation.
9
9
  * File path: .opencode/commands/opsx-<id>.md
10
- * Frontmatter: description
10
+ * Frontmatter: description. $ARGUMENTS is injected after the complete input
11
+ * contract because OpenCode only passes arguments through explicit placeholders.
11
12
  */
12
13
  export declare const opencodeAdapter: ToolCommandAdapter;
13
14
  //# sourceMappingURL=opencode.d.ts.map
@@ -5,10 +5,21 @@
5
5
  */
6
6
  import path from 'path';
7
7
  import { escapeYamlValue } from '../yaml.js';
8
+ const OPENCODE_INPUT_BLOCK = /^\*\*Input\*\*:[^\r\n]*(?:\r?\n(?!\r?\n)[^\r\n]*)*/m;
9
+ const OPENCODE_NO_INPUT = /^\*\*Input\*\*:\s*None required\b/im;
10
+ const OPENCODE_ARGUMENT_PLACEHOLDER = /\$(?:ARGUMENTS\b|[1-9]\d*\b)/;
11
+ function injectOpenCodeArgs(body) {
12
+ if (OPENCODE_ARGUMENT_PLACEHOLDER.test(body) || OPENCODE_NO_INPUT.test(body)) {
13
+ return body;
14
+ }
15
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
16
+ return body.replace(OPENCODE_INPUT_BLOCK, (input) => `${input}${eol}**Provided arguments**: $ARGUMENTS`);
17
+ }
8
18
  /**
9
19
  * OpenCode adapter for command generation.
10
20
  * File path: .opencode/commands/opsx-<id>.md
11
- * Frontmatter: description
21
+ * Frontmatter: description. $ARGUMENTS is injected after the complete input
22
+ * contract because OpenCode only passes arguments through explicit placeholders.
12
23
  */
13
24
  export const opencodeAdapter = {
14
25
  toolId: 'opencode',
@@ -20,7 +31,7 @@ export const opencodeAdapter = {
20
31
  description: ${escapeYamlValue(content.description)}
21
32
  ---
22
33
 
23
- ${content.body}
34
+ ${injectOpenCodeArgs(content.body)}
24
35
  `;
25
36
  },
26
37
  };
@@ -0,0 +1,15 @@
1
+ export declare const COMPLETION_TIP_MESSAGE = "Tip: Run 'openspec completion install' for shell completions";
2
+ export interface CompletionTipOptions {
3
+ /**
4
+ * Skip printing without marking the tip as seen, so it still appears on the
5
+ * user's first later run that can safely carry it. Used for runs nobody would
6
+ * read the tip from: JSON output, and stderr that is not a terminal.
7
+ */
8
+ silent?: boolean;
9
+ }
10
+ /**
11
+ * Print the completion tip once, the first time the CLI runs.
12
+ * Never throws — a hint must not break a command.
13
+ */
14
+ export declare function maybeShowCompletionTip(options?: CompletionTipOptions): Promise<void>;
15
+ //# sourceMappingURL=completion-tip.d.ts.map
@@ -0,0 +1,133 @@
1
+ /**
2
+ * First-run hint pointing users at opt-in shell completions.
3
+ *
4
+ * This hint used to be an npm `postinstall` script. Printing it from the CLI
5
+ * instead lets the package ship with no install scripts at all, so `npm install`
6
+ * no longer emits an `allow-scripts` warning. Completions stay opt-in: the tip
7
+ * only names the command, it never installs anything.
8
+ *
9
+ * The tip goes to stderr, never stdout, so it cannot contaminate piped command
10
+ * output.
11
+ *
12
+ * The tip is suppressed when:
13
+ * - CI is set (any value npm/telemetry would treat as CI)
14
+ * - OPENSPEC_NO_COMPLETIONS=1
15
+ * - completions are already installed, or the shell is one the installer would
16
+ * reject
17
+ * - the caller passes `silent` — JSON runs, `openspec completion ...`, and
18
+ * non-TTY runs, which are deferred rather than consumed (see `silent`)
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { getGlobalConfigPath } from './global-config.js';
23
+ import { isCiEnvironment } from '../utils/ci.js';
24
+ import { detectShell } from '../utils/shell-detection.js';
25
+ import { CompletionFactory } from './completions/factory.js';
26
+ export const COMPLETION_TIP_MESSAGE = "Tip: Run 'openspec completion install' for shell completions";
27
+ function isSuppressedByEnv() {
28
+ // isCiEnvironment, not a CI==='true' string check: providers set CI to "True",
29
+ // "yes", "on", and the tip should be as quiet in those builds as telemetry is.
30
+ return isCiEnvironment() || process.env.OPENSPEC_NO_COMPLETIONS === '1';
31
+ }
32
+ /**
33
+ * Whether the tip is worth showing, once we know it is owed and readable.
34
+ *
35
+ * "retire" consumes the tip without printing: the user either already has
36
+ * completions, or is on a shell `openspec completion install` would refuse.
37
+ *
38
+ * Without the installed check the tip tells people to install completions they
39
+ * installed long ago — including on the run right after `completion install`,
40
+ * whose own run only defers the tip. An undetected or unsupported shell retires
41
+ * it too: `completion install` exits 1 for those users, so pointing them at it
42
+ * is a dead end, and this tip is the only message about completions they would
43
+ * ever get.
44
+ *
45
+ * Not free: detectShell() forks `ps` to read the parent process (except on
46
+ * Windows), so this costs a spawn plus a stat. It runs only on interactive runs
47
+ * that still owe the tip, which is normally exactly one — but a config that
48
+ * cannot be written never records the flag, and then every interactive run pays
49
+ * it. On any unexpected error we show the tip rather than swallow it.
50
+ */
51
+ async function decideTip() {
52
+ try {
53
+ const { shell } = detectShell();
54
+ if (!shell) {
55
+ return 'retire';
56
+ }
57
+ return (await CompletionFactory.createInstaller(shell).isInstalled())
58
+ ? 'retire'
59
+ : 'show';
60
+ }
61
+ catch {
62
+ return 'show';
63
+ }
64
+ }
65
+ /**
66
+ * Read the global config exactly as it sits on disk.
67
+ *
68
+ * Deliberately NOT `getGlobalConfig()`: that merges in defaults, and writing the
69
+ * merged result back would stamp `profile`/`delivery` into a file the user never
70
+ * set them in. `migrateIfNeeded` treats a raw `profile` as "already migrated",
71
+ * so that stamp would permanently suppress the one-time profile migration and
72
+ * cost users their installed workflow skills.
73
+ *
74
+ * Returns null when the file exists but cannot be read or parsed — a config we
75
+ * cannot understand is left strictly alone rather than overwritten.
76
+ */
77
+ function readRawConfig() {
78
+ const configPath = getGlobalConfigPath();
79
+ if (!fs.existsSync(configPath)) {
80
+ return {};
81
+ }
82
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
83
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
84
+ return null;
85
+ }
86
+ return parsed;
87
+ }
88
+ /**
89
+ * Record the flag, re-reading the config first and replacing the file by rename.
90
+ *
91
+ * Deciding whether to show the tip costs a `ps` spawn and a stat, and a sibling
92
+ * `openspec` process can write the same file in that window — on a first run
93
+ * that is exactly when telemetry mints `anonymousId`. Re-reading here keeps the
94
+ * write down to this one key, and the rename keeps a reader from ever seeing a
95
+ * half-written config.
96
+ */
97
+ function markTipSeen() {
98
+ const configPath = getGlobalConfigPath();
99
+ const current = readRawConfig() ?? {};
100
+ const tempPath = `${configPath}.${process.pid}.tmp`;
101
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
102
+ fs.writeFileSync(tempPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n', 'utf-8');
103
+ fs.renameSync(tempPath, configPath);
104
+ }
105
+ /**
106
+ * Print the completion tip once, the first time the CLI runs.
107
+ * Never throws — a hint must not break a command.
108
+ */
109
+ export async function maybeShowCompletionTip(options = {}) {
110
+ if (isSuppressedByEnv()) {
111
+ return;
112
+ }
113
+ try {
114
+ const raw = readRawConfig();
115
+ if (raw === null || raw.completionTipSeen === true) {
116
+ return;
117
+ }
118
+ if (options.silent) {
119
+ return;
120
+ }
121
+ const decision = await decideTip();
122
+ // Record before printing: if the flag cannot be persisted, staying quiet
123
+ // beats reprinting the tip on every future run.
124
+ markTipSeen();
125
+ if (decision === 'show') {
126
+ console.error(`\n${COMPLETION_TIP_MESSAGE}`);
127
+ }
128
+ }
129
+ catch {
130
+ // Silent failure - a hint should never break the CLI.
131
+ }
132
+ }
133
+ //# sourceMappingURL=completion-tip.js.map
@@ -12,6 +12,11 @@ export const COMMAND_REGISTRY = [
12
12
  description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)',
13
13
  takesValue: true,
14
14
  },
15
+ {
16
+ name: 'language',
17
+ description: 'Write new OpenSpec artifacts in this language',
18
+ takesValue: true,
19
+ },
15
20
  {
16
21
  name: 'force',
17
22
  description: 'Auto-cleanup legacy files without prompting',
@@ -131,6 +136,10 @@ export const COMMAND_REGISTRY = [
131
136
  name: 'requirements-only',
132
137
  description: 'Alias for --deltas-only (deprecated, change-specific)',
133
138
  },
139
+ {
140
+ name: 'diff',
141
+ description: 'Show per-requirement diffs for delta specs (change-specific)',
142
+ },
134
143
  {
135
144
  name: 'requirements',
136
145
  description: 'Show only requirements, exclude scenarios (JSON only, spec-specific)',
@@ -184,6 +193,10 @@ export const COMMAND_REGISTRY = [
184
193
  description: 'Change name to show status for',
185
194
  takesValue: true,
186
195
  },
196
+ {
197
+ name: 'all',
198
+ description: 'Show status for all active changes',
199
+ },
187
200
  {
188
201
  name: 'schema',
189
202
  description: 'Schema override',
@@ -280,6 +293,7 @@ export const COMMAND_REGISTRY = [
280
293
  name: 'path',
281
294
  description: 'Directory to use for the store',
282
295
  takesValue: true,
296
+ completionType: 'path',
283
297
  },
284
298
  {
285
299
  name: 'init-git',
@@ -372,6 +386,7 @@ export const COMMAND_REGISTRY = [
372
386
  name: 'code-workspace',
373
387
  description: 'Also write a VS Code workspace file for the set',
374
388
  takesValue: true,
389
+ completionType: 'path',
375
390
  },
376
391
  {
377
392
  name: 'force',
@@ -402,6 +417,7 @@ export const COMMAND_REGISTRY = [
402
417
  name: 'member',
403
418
  description: 'Member folder as <path> or <name>=<path>; repeatable, first is the primary',
404
419
  takesValue: true,
420
+ completionType: 'path',
405
421
  },
406
422
  {
407
423
  name: 'tool',
@@ -483,6 +499,10 @@ export const COMMAND_REGISTRY = [
483
499
  name: 'requirements-only',
484
500
  description: 'Alias for --deltas-only (deprecated)',
485
501
  },
502
+ {
503
+ name: 'diff',
504
+ description: 'Show per-requirement diffs for delta specs',
505
+ },
486
506
  COMMON_FLAGS.noInteractive,
487
507
  ],
488
508
  },
@@ -24,6 +24,17 @@ export interface CompletionInstaller {
24
24
  success: boolean;
25
25
  message: string;
26
26
  }>;
27
+ /**
28
+ * True when a completion script file is present at the install path.
29
+ *
30
+ * Deliberately just the script: bash and PowerShell also need a sourcing
31
+ * line in the user's profile, and `install()` adds that on a best-effort
32
+ * basis (it is skipped by OPENSPEC_NO_AUTO_CONFIG=1 or an unwritable
33
+ * profile, printing manual instructions instead). Someone in that state has
34
+ * already met the installer, so callers that use this to decide whether to
35
+ * *advertise* completions should not advertise again.
36
+ */
37
+ isInstalled(): Promise<boolean>;
27
38
  }
28
39
  /**
29
40
  * Factory for creating completion generators and installers
@@ -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
@@ -22,6 +22,13 @@ export declare class BashInstaller {
22
22
  * @returns Installation path
23
23
  */
24
24
  getInstallationPath(): Promise<string>;
25
+ /**
26
+ * Check if a completion script is currently installed.
27
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
28
+ *
29
+ * @returns true if the completion script exists
30
+ */
31
+ isInstalled(): Promise<boolean>;
25
32
  /**
26
33
  * Backup an existing completion file if it exists
27
34
  *
@@ -55,6 +55,21 @@ export class BashInstaller {
55
55
  // For user installation, use local directory
56
56
  return path.join(localCompletionDir, 'openspec');
57
57
  }
58
+ /**
59
+ * Check if a completion script is currently installed.
60
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
61
+ *
62
+ * @returns true if the completion script exists
63
+ */
64
+ async isInstalled() {
65
+ try {
66
+ // stat, not access: a directory at the install path is not a script.
67
+ return (await fs.stat(await this.getInstallationPath())).isFile();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
58
73
  /**
59
74
  * Backup an existing completion file if it exists
60
75
  *
@@ -12,6 +12,13 @@ export declare class FishInstaller {
12
12
  * @returns Installation path
13
13
  */
14
14
  getInstallationPath(): string;
15
+ /**
16
+ * Check if a completion script is currently installed.
17
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
18
+ *
19
+ * @returns true if the completion script exists
20
+ */
21
+ isInstalled(): Promise<boolean>;
15
22
  /**
16
23
  * Backup an existing completion file if it exists
17
24
  *
@@ -19,6 +19,21 @@ export class FishInstaller {
19
19
  getInstallationPath() {
20
20
  return path.join(this.homeDir, '.config', 'fish', 'completions', 'openspec.fish');
21
21
  }
22
+ /**
23
+ * Check if a completion script is currently installed.
24
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
25
+ *
26
+ * @returns true if the completion script exists
27
+ */
28
+ async isInstalled() {
29
+ try {
30
+ // stat, not access: a directory at the install path is not a script.
31
+ return (await fs.stat(this.getInstallationPath())).isFile();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
22
37
  /**
23
38
  * Backup an existing completion file if it exists
24
39
  *
@@ -43,6 +43,13 @@ export declare class PowerShellInstaller {
43
43
  * @returns Installation path
44
44
  */
45
45
  getInstallationPath(): string;
46
+ /**
47
+ * Check if a completion script is currently installed.
48
+ * Mirrors ZshInstaller.isInstalled so callers can ask any installer.
49
+ *
50
+ * @returns true if the completion script exists
51
+ */
52
+ isInstalled(): Promise<boolean>;
46
53
  /**
47
54
  * Backup an existing completion file if it exists
48
55
  *