@fission-ai/openspec 1.14.3 → 1.15.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 (48) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/feedback.js +33 -20
  4. package/dist/commands/install-standalone.d.ts +27 -0
  5. package/dist/commands/install-standalone.js +150 -0
  6. package/dist/commands/schema.js +10 -3
  7. package/dist/commands/validate.js +10 -3
  8. package/dist/commands/workflow/instructions.js +15 -4
  9. package/dist/commands/workflow/new-change.js +12 -4
  10. package/dist/commands/workflow/status.js +5 -2
  11. package/dist/commands/workflow/templates.js +5 -2
  12. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  13. package/dist/core/artifact-graph/resolver.js +11 -0
  14. package/dist/core/available-tools.d.ts +2 -1
  15. package/dist/core/available-tools.js +8 -28
  16. package/dist/core/completion-tip.d.ts +23 -0
  17. package/dist/core/completion-tip.js +4 -4
  18. package/dist/core/completions/command-registry.js +15 -0
  19. package/dist/core/config-schema.d.ts +1 -0
  20. package/dist/core/config-schema.js +1 -0
  21. package/dist/core/file-view.d.ts +13 -0
  22. package/dist/core/file-view.js +14 -0
  23. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  24. package/dist/core/github-copilot/cloud-agent.js +13 -9
  25. package/dist/core/global-config.d.ts +4 -0
  26. package/dist/core/global-config.js +5 -1
  27. package/dist/core/legacy-cleanup.d.ts +11 -1
  28. package/dist/core/legacy-cleanup.js +15 -18
  29. package/dist/core/migration.d.ts +27 -1
  30. package/dist/core/migration.js +77 -29
  31. package/dist/core/profile-sync-drift.d.ts +4 -3
  32. package/dist/core/profile-sync-drift.js +19 -16
  33. package/dist/core/shared/tool-detection.d.ts +12 -4
  34. package/dist/core/shared/tool-detection.js +29 -27
  35. package/dist/core/shared-skill-target.d.ts +12 -5
  36. package/dist/core/shared-skill-target.js +34 -23
  37. package/dist/core/standalone-tip.d.ts +6 -0
  38. package/dist/core/standalone-tip.js +38 -0
  39. package/dist/core/templates/workflows/verify-change.js +4 -4
  40. package/dist/core/update-plan.d.ts +127 -0
  41. package/dist/core/update-plan.js +390 -0
  42. package/dist/core/update.d.ts +45 -1
  43. package/dist/core/update.js +279 -137
  44. package/dist/core/version-check.d.ts +8 -1
  45. package/dist/core/version-check.js +48 -5
  46. package/dist/utils/executable-path.d.ts +5 -0
  47. package/dist/utils/executable-path.js +44 -0
  48. package/package.json +4 -3
package/README.md CHANGED
@@ -117,20 +117,22 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
117
117
 
118
118
  ## Quick Start
119
119
 
120
- **Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency.
120
+ Install OpenSpec. It is a single file and needs no Node.js (on Alpine Linux, first run `apk add libstdc++ libgcc`).
121
121
 
122
- Install OpenSpec globally:
122
+ macOS and Linux:
123
123
 
124
124
  ```bash
125
- npm install -g @fission-ai/openspec@latest
125
+ curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh
126
126
  ```
127
127
 
128
- Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux:
128
+ Windows (PowerShell):
129
129
 
130
- ```bash
131
- brew install openspec
130
+ ```powershell
131
+ irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex
132
132
  ```
133
133
 
134
+ Prefer a package manager? `npm install -g @fission-ai/openspec@latest` (needs Node.js 20.19.0 or higher) or the official [Homebrew formula](https://formulae.brew.sh/formula/openspec), `brew install openspec`.
135
+
134
136
  Then navigate to your project directory and initialize:
135
137
 
136
138
  ```bash
@@ -152,7 +154,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
152
154
  > [!NOTE]
153
155
  > Not sure if your tool is supported? [View the full list](https://openspec.dev/docs/supported-tools) – we support 30+ tools and growing.
154
156
  >
155
- > Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
157
+ > Also works with npm, Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
156
158
 
157
159
  ## Docs
158
160
 
@@ -192,7 +194,9 @@ AI coding assistants are powerful but unpredictable when requirements live only
192
194
 
193
195
  ## Updating OpenSpec
194
196
 
195
- **Upgrade the package**
197
+ **Upgrade the CLI**
198
+
199
+ With the standalone install, run `openspec install`. If you installed with npm:
196
200
 
197
201
  ```bash
198
202
  npm install -g @fission-ai/openspec@latest
@@ -220,7 +224,7 @@ openspec update
220
224
 
221
225
  ## Contributing
222
226
 
223
- Open a discussion (for core design changes) or an issue before you open a PR, and link the issue or discussion from the PR. New features, significant refactors, and architectural changes need an OpenSpec change proposal first.
227
+ Every change starts as an issue. We discuss it there, and if we agree to pursue it, a maintainer implements the change or invites you to build it with us.
224
228
 
225
229
  → **[CONTRIBUTING.md](CONTRIBUTING.md)**: the full process, from first issue to merged PR
226
230
 
package/dist/cli/index.js CHANGED
@@ -277,8 +277,29 @@ program
277
277
  .command('update [path]')
278
278
  .description('Update OpenSpec instruction files')
279
279
  .option('--force', 'Force update even when tools are up to date')
280
+ .option('--dry-run', 'Report what the update would change without writing anything')
281
+ .option('--json', 'With --dry-run, print the planned changes as JSON')
280
282
  .action(async (targetPath = '.', options) => {
281
283
  try {
284
+ if (options?.json && !options?.dryRun) {
285
+ console.error('--json is only available with --dry-run.');
286
+ process.exitCode = 1;
287
+ return;
288
+ }
289
+ // A dry run reports the plan and nothing else: no network check, no
290
+ // upgrade prompt, no output that would break --json.
291
+ if (options?.dryRun) {
292
+ const [{ UpdateCommand }, { formatUpdatePlan }] = await Promise.all([
293
+ import('../core/update.js'),
294
+ import('../core/update-plan.js'),
295
+ ]);
296
+ const plan = await new UpdateCommand({
297
+ force: options?.force,
298
+ dryRun: true,
299
+ }).plan(targetPath);
300
+ console.log(options?.json ? JSON.stringify(plan, null, 2) : formatUpdatePlan(plan));
301
+ return;
302
+ }
282
303
  const [{ getInstallDir, isSourceCheckout, getAvailableCliUpdate, shouldOfferUpgrade, displayCliUpdateNote, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, }, { isInteractive }, { UpdateCommand },] = await Promise.all([
283
304
  import('../core/version-check.js'),
284
305
  import('../utils/interactive.js'),
@@ -333,6 +354,32 @@ program
333
354
  else if (latestVersion) {
334
355
  displayCliUpdateNote(latestVersion, targetPath);
335
356
  }
357
+ // Once per machine, a package-manager user hears the standalone install
358
+ // exists. Offered, never pushed; deferred when stderr is not a terminal.
359
+ const { maybeShowStandaloneTip } = await import('../core/standalone-tip.js');
360
+ await maybeShowStandaloneTip({ silent: !process.stderr.isTTY });
361
+ }
362
+ catch (error) {
363
+ // The --json dry run follows the agent contract: exactly one JSON
364
+ // document on stdout, even on failure.
365
+ // failWithError sets the exit code. Returning instead of process.exit()
366
+ // lets piped stdout drain - an exit can cut the JSON document short where
367
+ // pipes are asynchronous (macOS, Windows) - and keeps commander's
368
+ // postAction telemetry flush, as the upgrade paths above already do.
369
+ await failWithError(error, {
370
+ enabled: options?.dryRun && options?.json,
371
+ payload: { changes: [], notes: [], failures: [] },
372
+ fallbackCode: 'update_error',
373
+ });
374
+ }
375
+ });
376
+ program
377
+ .command('install [version]')
378
+ .description('Install the standalone openspec, which runs without Node.js (latest release unless [version] is given)')
379
+ .action(async (version) => {
380
+ try {
381
+ const { installStandalone } = await import('../commands/install-standalone.js');
382
+ await installStandalone({ version });
336
383
  }
337
384
  catch (error) {
338
385
  await failWithError(error);
@@ -4,6 +4,7 @@ import os from 'os';
4
4
  const require = createRequire(import.meta.url);
5
5
  const MAX_TITLE_LENGTH = 72;
6
6
  const TITLE_PREFIX = 'Feedback: ';
7
+ const REPRO_PROMPT = '_Not provided — filed with `openspec feedback`. If this is a bug, please add the shortest steps that reproduce it from a fresh project._';
7
8
  /**
8
9
  * Check if gh CLI is installed and available in PATH
9
10
  * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
@@ -97,24 +98,37 @@ function formatTitle(message) {
97
98
  }
98
99
  /**
99
100
  * Format the full feedback body
101
+ *
102
+ * The headings mirror the bug form's labels (.github/ISSUE_TEMPLATE/bug_report.yml)
103
+ * so a CLI-filed issue reads like one filed through the form. `gh issue create
104
+ * --body` cannot fill an issue form, so the repro prompt is spelled out instead
105
+ * of being left silently empty.
100
106
  */
101
107
  function formatBody(message, bodyText) {
102
- const parts = ['## Summary', '', message];
108
+ const parts = ['### What happened', '', message];
103
109
  if (bodyText) {
104
- parts.push('', '## Details', '', bodyText);
110
+ parts.push('', '### Details', '', bodyText);
105
111
  }
112
+ parts.push('', '### Minimal steps to reproduce', '', REPRO_PROMPT);
106
113
  parts.push('', generateMetadata());
107
114
  return parts.join('\n');
108
115
  }
109
116
  /**
110
117
  * Generate a pre-filled GitHub issue URL for manual submission
118
+ *
119
+ * Opens the bug form itself. The query keys are the form's own field ids, which
120
+ * is how GitHub prefills an issue form; the fields it leaves empty are the
121
+ * required ones only the reporter can answer.
111
122
  */
112
- function generateManualSubmissionUrl(title, body) {
113
- const repo = 'Fission-AI/OpenSpec';
114
- const encodedTitle = encodeURIComponent(title);
115
- const encodedBody = encodeURIComponent(body);
116
- const encodedLabels = encodeURIComponent('feedback');
117
- return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;
123
+ function generateManualSubmissionUrl(title, message, bodyText) {
124
+ const params = new URLSearchParams({
125
+ template: 'bug_report.yml',
126
+ title,
127
+ what_happened: bodyText ? `${message}\n\n${bodyText}` : message,
128
+ version: getVersion(),
129
+ environment: `${getPlatform()}, Node ${process.version}`,
130
+ });
131
+ return `https://github.com/Fission-AI/OpenSpec/issues/new?${params.toString()}`;
118
132
  }
119
133
  /**
120
134
  * Display formatted feedback content for manual submission
@@ -145,7 +159,7 @@ function isMissingLabelError(error) {
145
159
  * network, rate limit, ...), so show the same manual-submission path the
146
160
  * missing-gh and unauthenticated flows get instead of discarding the text.
147
161
  */
148
- function reportGhFailure(error, title, body) {
162
+ function reportGhFailure(error, title, body, url) {
149
163
  // Display the error output from gh CLI
150
164
  if (error.stderr) {
151
165
  console.error(error.stderr.toString());
@@ -154,9 +168,8 @@ function reportGhFailure(error, title, body) {
154
168
  console.error(error.message);
155
169
  }
156
170
  displayFormattedFeedback(title, body);
157
- const manualUrl = generateManualSubmissionUrl(title, body);
158
171
  console.log('Please submit your feedback manually:');
159
- console.log(manualUrl);
172
+ console.log(url);
160
173
  // Exit with the same code as gh CLI
161
174
  process.exit(error.status ?? 1);
162
175
  }
@@ -184,7 +197,7 @@ function createIssue(title, body, labels) {
184
197
  /**
185
198
  * Submit feedback via gh CLI
186
199
  */
187
- function submitViaGhCli(title, body) {
200
+ function submitViaGhCli(title, body, url) {
188
201
  let issueUrl;
189
202
  let labelApplied = true;
190
203
  try {
@@ -192,7 +205,7 @@ function submitViaGhCli(title, body) {
192
205
  }
193
206
  catch (error) {
194
207
  if (!isMissingLabelError(error)) {
195
- reportGhFailure(error, title, body);
208
+ reportGhFailure(error, title, body, url);
196
209
  return;
197
210
  }
198
211
  // The repository does not define the 'feedback' label. Nothing was
@@ -202,7 +215,7 @@ function submitViaGhCli(title, body) {
202
215
  labelApplied = false;
203
216
  }
204
217
  catch (retryError) {
205
- reportGhFailure(retryError, title, body);
218
+ reportGhFailure(retryError, title, body, url);
206
219
  return;
207
220
  }
208
221
  }
@@ -215,7 +228,7 @@ function submitViaGhCli(title, body) {
215
228
  /**
216
229
  * Handle fallback when gh CLI is not available or not authenticated
217
230
  */
218
- function handleFallback(title, body, reason) {
231
+ function handleFallback(title, body, url, reason) {
219
232
  if (reason === 'missing') {
220
233
  console.log('⚠️ GitHub CLI not found. Manual submission required.');
221
234
  }
@@ -223,9 +236,8 @@ function handleFallback(title, body, reason) {
223
236
  console.log('⚠️ GitHub authentication required. Manual submission required.');
224
237
  }
225
238
  displayFormattedFeedback(title, body);
226
- const manualUrl = generateManualSubmissionUrl(title, body);
227
239
  console.log('Please submit your feedback manually:');
228
- console.log(manualUrl);
240
+ console.log(url);
229
241
  if (reason === 'unauthenticated') {
230
242
  console.log('\nTo auto-submit in the future: gh auth login');
231
243
  }
@@ -240,18 +252,19 @@ export class FeedbackCommand {
240
252
  // Format title and body once for all code paths
241
253
  const title = formatTitle(message);
242
254
  const body = formatBody(message, options?.body);
255
+ const url = generateManualSubmissionUrl(title, message, options?.body);
243
256
  // Check if gh CLI is installed
244
257
  if (!isGhInstalled()) {
245
- handleFallback(title, body, 'missing');
258
+ handleFallback(title, body, url, 'missing');
246
259
  return;
247
260
  }
248
261
  // Check if gh CLI is authenticated
249
262
  if (!isGhAuthenticated()) {
250
- handleFallback(title, body, 'unauthenticated');
263
+ handleFallback(title, body, url, 'unauthenticated');
251
264
  return;
252
265
  }
253
266
  // Submit via gh CLI
254
- submitViaGhCli(title, body);
267
+ submitViaGhCli(title, body, url);
255
268
  }
256
269
  }
257
270
  //# sourceMappingURL=feedback.js.map
@@ -0,0 +1,27 @@
1
+ import { type CliInstallInfo } from '../core/version-check.js';
2
+ export interface InstallTarget {
3
+ dir: string;
4
+ name: string;
5
+ }
6
+ /**
7
+ * Where the installer will put the binary. Mirrors install.sh and install.ps1,
8
+ * and is passed to them explicitly so both sides agree. The standalone binary
9
+ * reinstalls itself where it is, under its own name.
10
+ */
11
+ export declare function resolveInstallTarget(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, standalonePath?: string | null): InstallTarget;
12
+ /** The command that removes a package-manager install, or null when there is nothing to remove. */
13
+ export declare function removeCommandFor(install: CliInstallInfo): string | null;
14
+ /**
15
+ * Checks a download source given through the environment. Release files and
16
+ * SHA256SUMS come from the same place, so a remote plain-HTTP source could
17
+ * swap both: only https, or http to this machine. The host is parsed, not
18
+ * prefix-matched, and a user part (http://localhost:80@evil.example/) is
19
+ * refused.
20
+ */
21
+ export declare function checkSource(label: string, value: string): string;
22
+ /** Where the installer script comes from: OPENSPEC_DOWNLOAD_URL when set (mirrors, tests), else main. */
23
+ export declare function installerBase(env?: NodeJS.ProcessEnv): string;
24
+ export declare function installStandalone(options?: {
25
+ version?: string;
26
+ }): Promise<void>;
27
+ //# sourceMappingURL=install-standalone.d.ts.map
@@ -0,0 +1,150 @@
1
+ import { spawn } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import chalk from 'chalk';
5
+ import { findCopiesOnPath, samePath } from '../utils/executable-path.js';
6
+ import { getCliInstallInfo, getStandaloneBinaryPath, } from '../core/version-check.js';
7
+ /**
8
+ * `openspec install`: installs the standalone binary (no Node.js needed) by
9
+ * running the same release installer the docs give users, then says how to
10
+ * retire the package-manager copy. It never removes that copy itself: switching
11
+ * is the user's choice, and so is when.
12
+ */
13
+ // The installers come from main, which always has them; they then pick the
14
+ // newest release whose files are all attached (see install.sh).
15
+ const INSTALLER_BASE = 'https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary';
16
+ const RELEASE_VERSION = /^v?\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?$/;
17
+ /**
18
+ * Where the installer will put the binary. Mirrors install.sh and install.ps1,
19
+ * and is passed to them explicitly so both sides agree. The standalone binary
20
+ * reinstalls itself where it is, under its own name.
21
+ */
22
+ export function resolveInstallTarget(env = process.env, platform = process.platform, standalonePath = getStandaloneBinaryPath()) {
23
+ const defaultName = platform === 'win32' ? 'openspec.exe' : 'openspec';
24
+ if (env.OPENSPEC_INSTALL_DIR) {
25
+ return { dir: path.resolve(env.OPENSPEC_INSTALL_DIR), name: env.OPENSPEC_INSTALL_NAME || defaultName };
26
+ }
27
+ if (standalonePath) {
28
+ return { dir: path.dirname(standalonePath), name: path.basename(standalonePath) };
29
+ }
30
+ if (platform === 'win32') {
31
+ const localAppData = env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
32
+ return { dir: path.join(localAppData, 'Programs', 'openspec'), name: defaultName };
33
+ }
34
+ return { dir: path.join(os.homedir(), '.local', 'bin'), name: defaultName };
35
+ }
36
+ /** The command that removes a package-manager install, or null when there is nothing to remove. */
37
+ export function removeCommandFor(install) {
38
+ if (install.scope !== 'global')
39
+ return null;
40
+ // The Homebrew formula keeps its own npm tree under Cellar; brew owns it.
41
+ if (install.location && /[\\/]Cellar[\\/]openspec[\\/]/i.test(install.location)) {
42
+ return 'brew uninstall openspec';
43
+ }
44
+ switch (install.packageManager) {
45
+ case 'npm':
46
+ return 'npm uninstall -g @fission-ai/openspec';
47
+ case 'pnpm':
48
+ return 'pnpm remove -g @fission-ai/openspec';
49
+ case 'yarn':
50
+ return 'yarn global remove @fission-ai/openspec';
51
+ case 'bun':
52
+ return 'bun remove -g @fission-ai/openspec';
53
+ case 'volta':
54
+ return 'volta uninstall @fission-ai/openspec';
55
+ default:
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * Checks a download source given through the environment. Release files and
61
+ * SHA256SUMS come from the same place, so a remote plain-HTTP source could
62
+ * swap both: only https, or http to this machine. The host is parsed, not
63
+ * prefix-matched, and a user part (http://localhost:80@evil.example/) is
64
+ * refused.
65
+ */
66
+ export function checkSource(label, value) {
67
+ let url;
68
+ try {
69
+ url = new URL(value);
70
+ }
71
+ catch {
72
+ throw new Error(`${label} is not a URL: ${value}`);
73
+ }
74
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
75
+ if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))) {
76
+ throw new Error(`${label} must use https (or http to localhost): ${value}`);
77
+ }
78
+ return url.href.replace(/\/+$/, '');
79
+ }
80
+ /** Where the installer script comes from: OPENSPEC_DOWNLOAD_URL when set (mirrors, tests), else main. */
81
+ export function installerBase(env = process.env) {
82
+ if (env.OPENSPEC_RELEASES_URL)
83
+ checkSource('OPENSPEC_RELEASES_URL', env.OPENSPEC_RELEASES_URL);
84
+ return env.OPENSPEC_DOWNLOAD_URL ? checkSource('OPENSPEC_DOWNLOAD_URL', env.OPENSPEC_DOWNLOAD_URL) : INSTALLER_BASE;
85
+ }
86
+ // Fixed scripts: the installer URL reaches them as data (an argument, or an
87
+ // environment variable), never as part of the command text.
88
+ const POSIX_RUNNER = 'f=$(mktemp) || exit 1; { curl -fsSL "$1" -o "$f" 2>/dev/null || wget -q -O "$f" "$1"; } && sh "$f"; s=$?; rm -f "$f"; exit $s';
89
+ const WINDOWS_RUNNER = 'irm $env:OPENSPEC_INSTALLER_URL | iex';
90
+ /**
91
+ * Downloads and runs the installer with the same tools as the documented
92
+ * one-liners (curl or wget; PowerShell's irm), so proxies and certificates
93
+ * behave as they do for those. Saved to a file first on macOS and Linux:
94
+ * piped straight into sh, a failed download would look like a successful run.
95
+ */
96
+ function runInstaller(scriptUrl, env) {
97
+ const windows = process.platform === 'win32';
98
+ const command = windows ? 'powershell.exe' : 'sh';
99
+ const args = windows
100
+ ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_RUNNER]
101
+ : ['-c', POSIX_RUNNER, 'openspec-install', scriptUrl];
102
+ return new Promise((resolve, reject) => {
103
+ const child = spawn(command, args, {
104
+ stdio: 'inherit',
105
+ env: windows ? { ...env, OPENSPEC_INSTALLER_URL: scriptUrl } : env,
106
+ });
107
+ child.on('error', reject);
108
+ child.on('close', (code) => resolve(code ?? 1));
109
+ });
110
+ }
111
+ export async function installStandalone(options = {}) {
112
+ const { version } = options;
113
+ if (version && version !== 'latest' && !RELEASE_VERSION.test(version)) {
114
+ throw new Error(`Not a release version: ${version}. Use one like 1.15.0, or leave it out for the latest.`);
115
+ }
116
+ const scriptUrl = `${installerBase()}/${process.platform === 'win32' ? 'install.ps1' : 'install.sh'}`;
117
+ const target = resolveInstallTarget();
118
+ const exitCode = await runInstaller(scriptUrl, {
119
+ ...process.env,
120
+ OPENSPEC_INSTALL_DIR: target.dir,
121
+ OPENSPEC_INSTALL_NAME: target.name,
122
+ ...(version && version !== 'latest' ? { OPENSPEC_VERSION: version } : {}),
123
+ });
124
+ if (exitCode !== 0) {
125
+ throw new Error(`The standalone install did not finish (installer: ${scriptUrl}).`);
126
+ }
127
+ const installedPath = path.join(target.dir, target.name);
128
+ const defaultName = process.platform === 'win32' ? 'openspec.exe' : 'openspec';
129
+ // A custom file name is never what `openspec` on PATH finds, so skip that check.
130
+ const first = target.name === defaultName ? findCopiesOnPath()[0] : undefined;
131
+ const runsStandalone = first !== undefined && samePath(first, installedPath);
132
+ // Run from a package manager, the old copy stays until the user removes it.
133
+ const remove = getStandaloneBinaryPath() ? null : removeCommandFor(getCliInstallInfo());
134
+ console.log();
135
+ if (runsStandalone) {
136
+ console.log('openspec now runs the standalone copy.');
137
+ }
138
+ else if (first) {
139
+ console.log(`${first} comes first on your PATH, so it still runs.`);
140
+ }
141
+ else if (target.name === defaultName) {
142
+ console.log('Open a new terminal so it picks up the new PATH.');
143
+ }
144
+ if (remove) {
145
+ console.log(runsStandalone ? 'To remove your package-manager copy:' : 'To switch, remove your package-manager copy:');
146
+ console.log(chalk.cyan(` ${remove}`));
147
+ }
148
+ console.log(chalk.dim("Check with 'openspec version'; it shows (standalone)."));
149
+ }
150
+ //# sourceMappingURL=install-standalone.js.map
@@ -1,7 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
- import ora from 'ora';
5
4
  import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml';
6
5
  import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
7
6
  import { findApplyTracksWarning, parseSchema, SchemaValidationError, } from '../core/artifact-graph/schema.js';
@@ -602,7 +601,11 @@ export async function schemaValidateCommand(name, options) {
602
601
  }
603
602
  }
604
603
  export async function schemaForkCommand(source, name, options) {
605
- const spinner = options?.json ? null : ora();
604
+ let spinner = null;
605
+ if (!options?.json) {
606
+ const { default: ora } = await import('ora');
607
+ spinner = ora();
608
+ }
606
609
  try {
607
610
  const projectRoot = process.cwd();
608
611
  const destinationName = name || `${source}-custom`;
@@ -829,7 +832,11 @@ export async function schemaForkCommand(source, name, options) {
829
832
  }
830
833
  }
831
834
  export async function schemaInitCommand(name, options) {
832
- const spinner = options?.json ? null : ora();
835
+ let spinner = null;
836
+ if (!options?.json) {
837
+ const { default: ora } = await import('ora');
838
+ spinner = ora();
839
+ }
833
840
  try {
834
841
  const projectRoot = process.cwd();
835
842
  // Validate name
@@ -1,4 +1,3 @@
1
- import ora from 'ora';
2
1
  import path from 'path';
3
2
  import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER, } from '../utils/nested-change.js';
4
3
  import { Validator } from '../core/validation/validator.js';
@@ -356,7 +355,11 @@ export class ValidateCommand {
356
355
  }
357
356
  }
358
357
  async runBulkValidation(root, scope, opts) {
359
- const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating...').start() : undefined;
358
+ let spinner;
359
+ if (!opts.json && isInteractive(opts) && canRenderSpinner()) {
360
+ const { default: ora } = await import('ora');
361
+ spinner = ora('Validating...').start();
362
+ }
360
363
  const [changeIds, specIds] = await Promise.all([
361
364
  scope.changes ? this.listChangeIds(root) : Promise.resolve([]),
362
365
  scope.specs ? getSpecIds(root.path) : Promise.resolve([]),
@@ -527,7 +530,11 @@ export class ValidateCommand {
527
530
  // List first (may throw on a real archive-read failure), then start the
528
531
  // spinner so a thrown error never leaves a spinner spinning.
529
532
  const ids = await this.listArchivedChangeIds(root);
530
- const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating archived changes...').start() : undefined;
533
+ let spinner;
534
+ if (!opts.json && isInteractive(opts) && canRenderSpinner()) {
535
+ const { default: ora } = await import('ora');
536
+ spinner = ora('Validating archived changes...').start();
537
+ }
531
538
  // The archive is append-only and can hold thousands of changes; a single
532
539
  // run resolves them all under one constant projectRoot (root.path), so
533
540
  // memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
@@ -4,7 +4,6 @@
4
4
  * Generates enriched instructions for creating artifacts or applying tasks.
5
5
  * Includes both artifact instructions and apply instructions.
6
6
  */
7
- import ora from 'ora';
8
7
  import path from 'path';
9
8
  import * as fs from 'fs';
10
9
  import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
@@ -50,7 +49,11 @@ export async function instructionsCommand(artifactId, options) {
50
49
  if (!root) {
51
50
  return;
52
51
  }
53
- const spinner = options.json ? undefined : ora('Generating instructions...').start();
52
+ let spinner;
53
+ if (!options.json) {
54
+ const { default: ora } = await import('ora');
55
+ spinner = ora('Generating instructions...').start();
56
+ }
54
57
  try {
55
58
  const planningHome = toPlanningHome(root);
56
59
  const projectRoot = root.path;
@@ -534,7 +537,11 @@ export async function applyInstructionsCommand(options) {
534
537
  if (!root) {
535
538
  return;
536
539
  }
537
- const spinner = options.json ? undefined : ora('Generating apply instructions...').start();
540
+ let spinner;
541
+ if (!options.json) {
542
+ const { default: ora } = await import('ora');
543
+ spinner = ora('Generating apply instructions...').start();
544
+ }
538
545
  try {
539
546
  const planningHome = toPlanningHome(root);
540
547
  const projectRoot = root.path;
@@ -639,7 +646,11 @@ export async function archiveInstructionsCommand(options) {
639
646
  if (!root) {
640
647
  return;
641
648
  }
642
- const spinner = options.json ? undefined : ora('Loading archive inputs...').start();
649
+ let spinner;
650
+ if (!options.json) {
651
+ const { default: ora } = await import('ora');
652
+ spinner = ora('Loading archive inputs...').start();
653
+ }
643
654
  try {
644
655
  const changeName = await validateChangeExists(options.change, root.path, root.changesDir, { newChangeHint: withStoreFlag(root, 'openspec new change <name>') });
645
656
  const projectConfig = readProjectConfig(root.path);
@@ -7,11 +7,11 @@
7
7
  * this command.
8
8
  */
9
9
  import chalk from 'chalk';
10
- import ora from 'ora';
11
10
  import path from 'path';
12
11
  import { createChange, validateChangeName } from '../../utils/change-utils.js';
13
12
  import { formatChangeLocation } from '../../core/planning-home.js';
14
13
  import { resolveRootForCommand, RootSelectionError, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../../core/root-selection.js';
14
+ import { readProjectConfig } from '../../core/project-config.js';
15
15
  import { printJson, statusFromError, validateSchemaExists } from './shared.js';
16
16
  // -----------------------------------------------------------------------------
17
17
  // Command Implementation
@@ -53,7 +53,11 @@ function printImplicitRootNotice(root) {
53
53
  console.log(chalk.dim('Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project.'));
54
54
  }
55
55
  export async function newChangeCommand(name, options) {
56
- const spinner = options.json ? undefined : ora();
56
+ let spinner;
57
+ if (!options.json) {
58
+ const { default: ora } = await import('ora');
59
+ spinner = ora();
60
+ }
57
61
  try {
58
62
  if (!name) {
59
63
  throw new Error('Missing required argument <name>');
@@ -75,12 +79,16 @@ export async function newChangeCommand(name, options) {
75
79
  if (options.schema) {
76
80
  validateSchemaExists(options.schema, projectRoot);
77
81
  }
78
- const resolvedSchema = options.schema ?? root.defaultSchema;
82
+ // Resolve the schema once (--schema, then config, then the root default)
83
+ // and hand it to createChange, so the progress line names the schema the
84
+ // change gets and config warnings print only once. An empty --schema is
85
+ // absent, matching createChange's `if (options.schema)`.
86
+ const resolvedSchema = options.schema || (readProjectConfig(projectRoot)?.schema ?? root.defaultSchema);
79
87
  if (spinner) {
80
88
  spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`);
81
89
  }
82
90
  const result = await createChange(projectRoot, name, {
83
- schema: options.schema,
91
+ schema: resolvedSchema,
84
92
  defaultSchema: root.defaultSchema,
85
93
  changesDir: root.changesDir,
86
94
  metadata: {
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Displays artifact completion status for a change.
5
5
  */
6
- import ora from 'ora';
7
6
  import chalk from 'chalk';
8
7
  import { getChangeDir } from '../../core/planning-home.js';
9
8
  import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, findDeclaringProjectRoot, } from '../../core/root-selection.js';
@@ -33,7 +32,11 @@ export async function statusCommand(options) {
33
32
  if (!root) {
34
33
  return;
35
34
  }
36
- const spinner = options.json ? undefined : ora('Loading change status...').start();
35
+ let spinner;
36
+ if (!options.json) {
37
+ const { default: ora } = await import('ora');
38
+ spinner = ora('Loading change status...').start();
39
+ }
37
40
  try {
38
41
  const planningHome = toPlanningHome(root);
39
42
  const projectRoot = root.path;
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Shows resolved template paths for all artifacts in a schema.
5
5
  */
6
- import ora from 'ora';
7
6
  import path from 'path';
8
7
  import { resolveSchema, getSchemaDir, ArtifactGraph, } from '../../core/artifact-graph/index.js';
9
8
  import { FileSystemUtils } from '../../utils/file-system.js';
@@ -12,7 +11,11 @@ import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
12
11
  // Command Implementation
13
12
  // -----------------------------------------------------------------------------
14
13
  export async function templatesCommand(options) {
15
- const spinner = options.json ? undefined : ora('Loading templates...').start();
14
+ let spinner;
15
+ if (!options.json) {
16
+ const { default: ora } = await import('ora');
17
+ spinner = ora('Loading templates...').start();
18
+ }
16
19
  try {
17
20
  const projectRoot = process.cwd();
18
21
  const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
@@ -8,6 +8,12 @@ export declare class SchemaLoadError extends Error {
8
8
  readonly cause?: Error | undefined;
9
9
  constructor(message: string, schemaPath: string, cause?: Error | undefined);
10
10
  }
11
+ /**
12
+ * Points the built-in schemas at another directory. The standalone binary has
13
+ * no package directory on disk, so it passes a function that extracts its
14
+ * embedded schemas; it runs only when a command first needs them.
15
+ */
16
+ export declare function setPackageSchemasDir(resolveDir: () => string): void;
11
17
  /**
12
18
  * Gets the package's built-in schemas directory path.
13
19
  * Uses import.meta.url to resolve relative to the current module.