@fission-ai/openspec 1.8.0 → 1.10.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 (76) hide show
  1. package/dist/cli/index.d.ts +32 -0
  2. package/dist/cli/index.js +80 -9
  3. package/dist/commands/change.js +1 -0
  4. package/dist/commands/config.d.ts +5 -0
  5. package/dist/commands/config.js +19 -4
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.js +193 -28
  8. package/dist/commands/validate.d.ts +23 -0
  9. package/dist/commands/validate.js +148 -4
  10. package/dist/commands/workflow/schemas.d.ts +2 -0
  11. package/dist/commands/workflow/schemas.js +9 -2
  12. package/dist/core/archive.js +74 -10
  13. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  14. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  15. package/dist/core/artifact-graph/outputs.js +7 -0
  16. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  17. package/dist/core/artifact-graph/resolver.js +14 -0
  18. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  19. package/dist/core/command-generation/adapters/command-code.js +34 -0
  20. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  21. package/dist/core/command-generation/adapters/index.js +1 -0
  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/command-generation/registry.js +2 -0
  25. package/dist/core/completion-tip.d.ts +15 -0
  26. package/dist/core/completion-tip.js +133 -0
  27. package/dist/core/completions/command-registry.js +10 -0
  28. package/dist/core/completions/factory.d.ts +11 -0
  29. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  30. package/dist/core/completions/installers/bash-installer.js +15 -0
  31. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  32. package/dist/core/completions/installers/fish-installer.js +15 -0
  33. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  34. package/dist/core/completions/installers/powershell-installer.js +15 -0
  35. package/dist/core/config-prompts.js +19 -10
  36. package/dist/core/config-schema.d.ts +1 -0
  37. package/dist/core/config-schema.js +2 -0
  38. package/dist/core/config.d.ts +1 -0
  39. package/dist/core/config.js +17 -15
  40. package/dist/core/global-config.d.ts +2 -0
  41. package/dist/core/init.d.ts +5 -0
  42. package/dist/core/init.js +110 -14
  43. package/dist/core/legacy-cleanup.d.ts +10 -0
  44. package/dist/core/legacy-cleanup.js +48 -26
  45. package/dist/core/parsers/requirement-blocks.js +41 -9
  46. package/dist/core/parsers/requirement-text.d.ts +8 -0
  47. package/dist/core/parsers/requirement-text.js +4 -3
  48. package/dist/core/profiles.d.ts +1 -1
  49. package/dist/core/profiles.js +11 -2
  50. package/dist/core/root-selection.d.ts +1 -1
  51. package/dist/core/shared-skill-target.d.ts +15 -0
  52. package/dist/core/shared-skill-target.js +30 -0
  53. package/dist/core/specs-apply.js +11 -4
  54. package/dist/core/templates/workflows/apply-change.js +3 -0
  55. package/dist/core/templates/workflows/feedback.js +1 -0
  56. package/dist/core/templates/workflows/onboard.js +4 -4
  57. package/dist/core/templates/workflows/propose.js +2 -2
  58. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  59. package/dist/core/templates/workflows/store-selection.js +1 -1
  60. package/dist/core/templates/workflows/update-change.js +4 -4
  61. package/dist/core/update.js +49 -11
  62. package/dist/core/validation/task-numbering.d.ts +16 -0
  63. package/dist/core/validation/task-numbering.js +57 -0
  64. package/dist/core/validation/validator.d.ts +5 -2
  65. package/dist/core/validation/validator.js +57 -3
  66. package/dist/telemetry/index.d.ts +3 -1
  67. package/dist/telemetry/index.js +10 -3
  68. package/dist/utils/change-metadata.js +17 -13
  69. package/dist/utils/change-utils.js +5 -0
  70. package/dist/utils/interactive.d.ts +30 -1
  71. package/dist/utils/interactive.js +105 -2
  72. package/dist/utils/task-progress.d.ts +34 -1
  73. package/dist/utils/task-progress.js +52 -28
  74. package/package.json +3 -6
  75. package/schemas/spec-driven/schema.yaml +17 -7
  76. package/scripts/postinstall.js +0 -83
@@ -1,3 +1,4 @@
1
+ import { createInterface } from 'node:readline';
1
2
  /**
2
3
  * Resolves whether non-interactive mode is requested.
3
4
  * Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
@@ -38,7 +39,12 @@ export function isInteractive(value) {
38
39
  * somebody was there and chose to quit.
39
40
  *
40
41
  * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0`
41
- * and `--no-interactive` count even when a runner allocated a pty.
42
+ * and `--no-interactive` count even when a runner allocated a pty. It also
43
+ * counts a redirected stdout: `confirmPrompt` drops to the plain reader whenever
44
+ * *either* stream is not a TTY, so a stdin-TTY-but-stdout-redirected run
45
+ * (`openspec archive x > log.txt` from a terminal) that hits EOF must classify
46
+ * the same way the prompt was selected — otherwise it would leak the raw
47
+ * `ExitPromptError` instead of the `--yes` guidance.
42
48
  */
43
49
  export function isNonInteractivePromptError(error, value) {
44
50
  if (!(error instanceof Error))
@@ -48,6 +54,103 @@ export function isNonInteractivePromptError(error, value) {
48
54
  return false;
49
55
  if (error.message.includes('SIGINT'))
50
56
  return false;
51
- return !isInteractive(value);
57
+ return !isInteractive(value) || !process.stdout.isTTY;
58
+ }
59
+ /**
60
+ * Ask a yes/no question. A real terminal gets @inquirer's rich prompt;
61
+ * everything else — a pipe, a file redirect, an agent that captures stdout —
62
+ * reads one plain line instead.
63
+ *
64
+ * @inquirer renders `confirm` by writing ANSI cursor-movement escape sequences,
65
+ * and it emits them even when stdout is not a TTY. Redirected to a file those
66
+ * sequences are noise, and in some non-TTY hosts the render loop never settles
67
+ * and repeats `ESC[NNG` cursor moves until the disk fills (#1526). Reading
68
+ * the answer ourselves keeps the single piped answer @inquirer ever supported
69
+ * working (`printf 'y\n' | openspec archive ...`) without emitting any escapes.
70
+ *
71
+ * `io` overrides the streams; it exists for tests and mirrors @inquirer's own
72
+ * `{ input, output }` context. Production callers pass only the prompt.
73
+ */
74
+ export async function confirmPrompt(prompt, io = {}) {
75
+ const input = io.input ?? process.stdin;
76
+ const output = io.output ?? process.stdout;
77
+ const isTerminal = Boolean(input.isTTY) &&
78
+ Boolean(output.isTTY);
79
+ if (isTerminal) {
80
+ const { confirm } = await import('@inquirer/prompts');
81
+ return confirm(prompt);
82
+ }
83
+ return readYesNo(prompt, input, output);
84
+ }
85
+ function readYesNo(prompt, input, output) {
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ // Detach the input-stream error listener on settle: `input` is the
89
+ // long-lived process.stdin, so a leftover listener would accumulate across
90
+ // archive's successive prompts and swallow a later, unrelated stdin error.
91
+ const cleanup = () => {
92
+ input.removeListener('error', onError);
93
+ };
94
+ const blockOnNoAnswer = () => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ cleanup();
99
+ // No line could be read (stdin closed / EOF). Mirror @inquirer's failure
100
+ // so callers that classify it — isNonInteractivePromptError, the #1479
101
+ // "rerun with --yes" guidance — keep working unchanged.
102
+ const error = new Error('User force closed the prompt');
103
+ error.name = 'ExitPromptError';
104
+ reject(error);
105
+ };
106
+ // A stdin error surfaces on the interface (readline forwards input-stream
107
+ // errors since Node 16). Without a handler the promise would hang and the
108
+ // 'error' would go unhandled; settle it with the real fault instead.
109
+ const onError = (err) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ cleanup();
114
+ rl.close();
115
+ reject(err instanceof Error ? err : new Error(String(err)));
116
+ };
117
+ // An earlier prompt may have already drained stdin (only one piped answer
118
+ // was ever supported). A fresh readline over an ended stream never emits
119
+ // 'close', so guard here rather than hang and exit as a no-op.
120
+ if (input.readableEnded) {
121
+ blockOnNoAnswer();
122
+ return;
123
+ }
124
+ output.write(`${prompt.message} ${prompt.default ? '(Y/n)' : '(y/N)'} `);
125
+ // terminal:false guarantees readline never emits its own line-editing
126
+ // escapes — an escape-free read is the whole point here.
127
+ const rl = createInterface({ input, terminal: false });
128
+ input.once('error', onError);
129
+ rl.once('error', onError);
130
+ rl.once('line', (line) => {
131
+ if (settled)
132
+ return;
133
+ settled = true;
134
+ cleanup();
135
+ rl.close();
136
+ output.write('\n');
137
+ // Mirror @inquirer/confirm's parser (prefix match on y/yes and n/no,
138
+ // otherwise the default) so a piped answer resolves identically to the
139
+ // interactive prompt it replaces.
140
+ const answer = line.trim();
141
+ if (/^(y|yes)/i.test(answer)) {
142
+ resolve(true);
143
+ }
144
+ else if (/^(n|no)/i.test(answer)) {
145
+ resolve(false);
146
+ }
147
+ else {
148
+ resolve(prompt.default);
149
+ }
150
+ });
151
+ rl.once('close', () => {
152
+ blockOnNoAnswer();
153
+ });
154
+ });
52
155
  }
53
156
  //# sourceMappingURL=interactive.js.map
@@ -20,6 +20,27 @@ export interface TaskProgress {
20
20
  completed: number;
21
21
  }
22
22
  export declare function countTasksFromContent(content: string): TaskProgress;
23
+ /**
24
+ * Run-scoped memo mapping a schema name to its tracked-tasks `generates` glob.
25
+ * When one command resolves progress for many changes under a constant
26
+ * `projectRoot` — e.g. `validate --archived` over an append-only archive — this
27
+ * avoids re-reading and re-parsing (YAML + Zod) the same `schema.yaml` once per
28
+ * change. Keyed by schema name alone, which is safe *only* because a single run
29
+ * holds `projectRoot` constant; never reuse one cache across differing roots.
30
+ */
31
+ export type SchemaGlobCache = Map<string, string | undefined>;
32
+ /** Resolves the task files selected by the schema's apply tracking rule. */
33
+ export declare function resolveTaskFilesForChange(changeDir: string, projectRoot: string, schemaGlobCache?: SchemaGlobCache): string[];
34
+ export interface TaskProgressDetail extends TaskProgress {
35
+ /**
36
+ * Task files that exist but could not be read (any error other than ENOENT).
37
+ * `getTaskProgressForChange` discards this list to preserve its behavior;
38
+ * callers that must fail loudly on an unreadable tasks file — e.g.
39
+ * `openspec validate --archived` — read it so an unreadable file is never
40
+ * silently counted as "no tasks" (#205).
41
+ */
42
+ unreadable: string[];
43
+ }
23
44
  /**
24
45
  * Computes a change's task progress by resolving its tracked-tasks artifact and
25
46
  * counting checkboxes across every file matched by that artifact's `generates`
@@ -27,7 +48,19 @@ export declare function countTasksFromContent(content: string): TaskProgress;
27
48
  * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested
28
49
  * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly
29
50
  * as before) when the schema is unresolvable, no tracked-tasks artifact is found,
30
- * or the glob matches no file. Never throws.
51
+ * or the glob matches no file. Also reports task files that exist but could not
52
+ * be read. Per-file read errors are captured (never thrown); the only throw path
53
+ * is a malformed/unsafe schema whose glob resolution rejects (path traversal or
54
+ * a linked-directory cycle in `resolveArtifactOutputs`). Pass `schemaGlobCache`
55
+ * to memoize schema→glob resolution across many changes in one run.
56
+ */
57
+ export declare function getTaskProgressDetailForChange(changesDir: string, changeName: string, projectRoot: string, schemaGlobCache?: SchemaGlobCache): Promise<TaskProgressDetail>;
58
+ /**
59
+ * The task-completion counter `status`, `list`, and `archive` share. Delegates
60
+ * to `getTaskProgressDetailForChange` and drops the `unreadable` detail, so its
61
+ * returned totals are unchanged. Throws only on the same malformed/unsafe-schema
62
+ * glob-resolution path as that function (existing behavior; callers guard it as
63
+ * they did before).
31
64
  */
32
65
  export declare function getTaskProgressForChange(changesDir: string, changeName: string, projectRoot: string): Promise<TaskProgress>;
33
66
  export declare function formatTaskStatus(progress: TaskProgress): string;
@@ -64,24 +64,43 @@ function findTrackedTasksArtifact(schema) {
64
64
  * when the schema cannot be resolved or no tracked-tasks artifact exists.
65
65
  * `resolveSchema` throws on an unresolvable/misnamed schema; we swallow that so
66
66
  * the caller falls back to a single top-level `tasks.md` and never crashes.
67
+ * A `schemaGlobCache`, when supplied, memoizes the schema-name → glob lookup for
68
+ * the duration of one run.
67
69
  */
68
- function resolveTrackedTasksGlob(changeDir, projectRoot) {
70
+ function resolveTrackedTasksGlob(changeDir, projectRoot, schemaGlobCache) {
69
71
  try {
70
72
  const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot);
73
+ if (schemaGlobCache?.has(schemaName))
74
+ return schemaGlobCache.get(schemaName);
71
75
  const schema = resolveSchema(schemaName, projectRoot);
72
- return findTrackedTasksArtifact(schema)?.generates;
76
+ const generates = findTrackedTasksArtifact(schema)?.generates;
77
+ schemaGlobCache?.set(schemaName, generates);
78
+ return generates;
73
79
  }
74
80
  catch {
75
81
  return undefined;
76
82
  }
77
83
  }
78
- async function countSingleTopLevelTasksFile(changeDir) {
79
- const tasksPath = path.join(changeDir, 'tasks.md');
84
+ /** Resolves the task files selected by the schema's apply tracking rule. */
85
+ export function resolveTaskFilesForChange(changeDir, projectRoot, schemaGlobCache) {
86
+ const generates = resolveTrackedTasksGlob(changeDir, projectRoot, schemaGlobCache);
87
+ return generates ? resolveArtifactOutputs(changeDir, generates) : [];
88
+ }
89
+ /**
90
+ * Reads one task file and counts its checkboxes. ENOENT (a glob file that
91
+ * vanished between resolve and read, or the absent single top-level `tasks.md`)
92
+ * means zero tasks, exactly as before. Any other error (permissions, I/O,
93
+ * ENOTDIR) is recorded in `unreadable` so a caller can surface it; the count
94
+ * still contributes zero, so existing callers see no change.
95
+ */
96
+ async function countTaskFile(file, unreadable) {
80
97
  try {
81
- const content = await fs.readFile(tasksPath, 'utf-8');
98
+ const content = await fs.readFile(file, 'utf-8');
82
99
  return countTasksFromContent(content);
83
100
  }
84
- catch {
101
+ catch (error) {
102
+ if (error?.code !== 'ENOENT')
103
+ unreadable.push(file);
85
104
  return { total: 0, completed: 0 };
86
105
  }
87
106
  }
@@ -92,31 +111,36 @@ async function countSingleTopLevelTasksFile(changeDir) {
92
111
  * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested
93
112
  * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly
94
113
  * as before) when the schema is unresolvable, no tracked-tasks artifact is found,
95
- * or the glob matches no file. Never throws.
114
+ * or the glob matches no file. Also reports task files that exist but could not
115
+ * be read. Per-file read errors are captured (never thrown); the only throw path
116
+ * is a malformed/unsafe schema whose glob resolution rejects (path traversal or
117
+ * a linked-directory cycle in `resolveArtifactOutputs`). Pass `schemaGlobCache`
118
+ * to memoize schema→glob resolution across many changes in one run.
96
119
  */
97
- export async function getTaskProgressForChange(changesDir, changeName, projectRoot) {
120
+ export async function getTaskProgressDetailForChange(changesDir, changeName, projectRoot, schemaGlobCache) {
98
121
  const changeDir = path.join(changesDir, changeName);
99
- const generates = resolveTrackedTasksGlob(changeDir, projectRoot);
100
- if (generates) {
101
- const files = resolveArtifactOutputs(changeDir, generates);
102
- if (files.length > 0) {
103
- let total = 0;
104
- let completed = 0;
105
- for (const file of files) {
106
- try {
107
- const content = await fs.readFile(file, 'utf-8');
108
- const progress = countTasksFromContent(content);
109
- total += progress.total;
110
- completed += progress.completed;
111
- }
112
- catch {
113
- // Swallow files that vanish between glob and read, as before.
114
- }
115
- }
116
- return { total, completed };
117
- }
122
+ const files = resolveTaskFilesForChange(changeDir, projectRoot, schemaGlobCache);
123
+ const targets = files.length > 0 ? files : [path.join(changeDir, 'tasks.md')];
124
+ const unreadable = [];
125
+ let total = 0;
126
+ let completed = 0;
127
+ for (const file of targets) {
128
+ const progress = await countTaskFile(file, unreadable);
129
+ total += progress.total;
130
+ completed += progress.completed;
118
131
  }
119
- return countSingleTopLevelTasksFile(changeDir);
132
+ return { total, completed, unreadable };
133
+ }
134
+ /**
135
+ * The task-completion counter `status`, `list`, and `archive` share. Delegates
136
+ * to `getTaskProgressDetailForChange` and drops the `unreadable` detail, so its
137
+ * returned totals are unchanged. Throws only on the same malformed/unsafe-schema
138
+ * glob-resolution path as that function (existing behavior; callers guard it as
139
+ * they did before).
140
+ */
141
+ export async function getTaskProgressForChange(changesDir, changeName, projectRoot) {
142
+ const { total, completed } = await getTaskProgressDetailForChange(changesDir, changeName, projectRoot);
143
+ return { total, completed };
120
144
  }
121
145
  export function formatTaskStatus(progress) {
122
146
  if (progress.total === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",
@@ -33,7 +33,6 @@
33
33
  "dist",
34
34
  "bin",
35
35
  "schemas",
36
- "scripts/postinstall.js",
37
36
  "!dist/**/*.test.js",
38
37
  "!dist/**/__tests__",
39
38
  "!dist/**/*.map"
@@ -53,8 +52,8 @@
53
52
  "vitest": "^3.2.6"
54
53
  },
55
54
  "dependencies": {
56
- "@inquirer/core": "^10.3.2",
57
- "@inquirer/prompts": "^7.10.1",
55
+ "@inquirer/core": "^11.2.1",
56
+ "@inquirer/prompts": "^8.5.2",
58
57
  "chalk": "^5.6.2",
59
58
  "commander": "^14.0.0",
60
59
  "cross-spawn": "7.0.6",
@@ -74,8 +73,6 @@
74
73
  "test:watch": "vitest",
75
74
  "test:ui": "vitest --ui",
76
75
  "test:coverage": "vitest --coverage",
77
- "test:postinstall": "node scripts/postinstall.js",
78
- "postinstall": "node scripts/postinstall.js",
79
76
  "check:pack-version": "node scripts/pack-version-check.mjs",
80
77
  "release": "pnpm run release:ci",
81
78
  "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
@@ -91,10 +91,16 @@ artifacts:
91
91
  by hand. Do NOT add `## Purpose` to a delta for an existing capability -
92
92
  that spec already has one and the delta's is ignored. To change an
93
93
  existing capability's Purpose - including a leftover `TBD` placeholder -
94
- edit `openspec/specs/<capability-path>/spec.md` directly.
94
+ edit `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
95
+ directly. `planningHome.root` comes from the `openspec instructions ...
96
+ --json` response. Always use it rather than a repo-relative path: it
97
+ resolves to the store whenever the change lives in one - whether that
98
+ came from `--store`, a project `store:` pointer, or a global default
99
+ store - and to the current repository otherwise. Do not try to work out
100
+ which case applies; the field already has.
95
101
 
96
102
  MODIFIED requirements workflow:
97
- 1. Locate the existing requirement in openspec/specs/<capability-path>/spec.md
103
+ 1. Locate the existing requirement in `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (the same store-aware root as above)
98
104
  2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)
99
105
  3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior
100
106
  4. Ensure header text matches exactly (whitespace-insensitive)
@@ -182,22 +188,26 @@ artifacts:
182
188
  - Each task MUST be a checkbox: `- [ ] X.Y Task description`
183
189
  - Tasks should be small enough to complete in one session
184
190
  - Order tasks by dependency (what must be done first?)
191
+ - Each task MUST state how to verify completion (a test, command,
192
+ observable behavior, or delivered artifact). Put the verification in
193
+ that task's checkbox description. Use a separate verification task only
194
+ when it checks broader integration or system behavior that spans
195
+ multiple implementation tasks.
185
196
 
186
197
  Example:
187
198
  ```
188
199
  ## 1. Setup
189
200
 
190
- - [ ] 1.1 Create new module structure
191
- - [ ] 1.2 Add dependencies to package.json
201
+ - [ ] 1.1 Create new module structure and verify expected files are present
202
+ - [ ] 1.2 Add dependencies to package.json and verify package installation succeeds
192
203
 
193
204
  ## 2. Core Implementation
194
205
 
195
- - [ ] 2.1 Implement data export function
196
- - [ ] 2.2 Add CSV formatting utilities
206
+ - [ ] 2.1 Implement data export function and verify the export test passes
207
+ - [ ] 2.2 Add CSV formatting utilities and verify unit tests cover quoting and delimiters
197
208
  ```
198
209
 
199
210
  Reference specs for what needs to be built, design for how to build it.
200
- Each task should be verifiable - you know when it's done.
201
211
  requires:
202
212
  - specs
203
213
  - design
@@ -1,83 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Postinstall script that hints about shell completions
5
- *
6
- * Completion installation is opt-in: the user must run
7
- * `openspec completion install` explicitly. This script only
8
- * prints a one-line tip after npm install.
9
- *
10
- * The tip is suppressed when:
11
- * - CI=true environment variable is set
12
- * - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
13
- * - dist/ directory doesn't exist (dev setup scenario)
14
- *
15
- * The script never fails npm install - all errors are caught and handled gracefully.
16
- */
17
-
18
- import { promises as fs } from 'fs';
19
- import path from 'path';
20
- import { fileURLToPath } from 'url';
21
-
22
- const __filename = fileURLToPath(import.meta.url);
23
- const __dirname = path.dirname(__filename);
24
-
25
- /**
26
- * Check if we should skip installation
27
- */
28
- function shouldSkipInstallation() {
29
- // Skip in CI environments
30
- if (process.env.CI === 'true' || process.env.CI === '1') {
31
- return { skip: true, reason: 'CI environment detected' };
32
- }
33
-
34
- // Skip if user opted out
35
- if (process.env.OPENSPEC_NO_COMPLETIONS === '1') {
36
- return { skip: true, reason: 'OPENSPEC_NO_COMPLETIONS=1 set' };
37
- }
38
-
39
- return { skip: false };
40
- }
41
-
42
- /**
43
- * Check if dist/ directory exists
44
- */
45
- async function distExists() {
46
- const distPath = path.join(__dirname, '..', 'dist');
47
- try {
48
- const stat = await fs.stat(distPath);
49
- return stat.isDirectory();
50
- } catch {
51
- return false;
52
- }
53
- }
54
-
55
- /**
56
- * Main function
57
- */
58
- async function main() {
59
- try {
60
- // Check if we should skip
61
- const skipCheck = shouldSkipInstallation();
62
- if (skipCheck.skip) {
63
- // Silent skip - no output
64
- return;
65
- }
66
-
67
- // Check if dist/ exists (skip silently if not - expected during dev setup)
68
- if (!(await distExists())) {
69
- return;
70
- }
71
-
72
- // Completions are opt-in — just print a hint
73
- console.log(`\nTip: Run 'openspec completion install' for shell completions`);
74
- } catch (error) {
75
- // Fail gracefully - never break npm install
76
- }
77
- }
78
-
79
- // Run main and handle any unhandled errors
80
- main().catch(() => {
81
- // Silent failure - never break npm install
82
- process.exit(0);
83
- });