@fission-ai/openspec 1.9.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 (38) hide show
  1. package/dist/cli/index.d.ts +17 -0
  2. package/dist/cli/index.js +49 -7
  3. package/dist/commands/config.js +14 -3
  4. package/dist/commands/feedback.js +28 -7
  5. package/dist/core/archive.js +64 -7
  6. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  7. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  8. package/dist/core/artifact-graph/outputs.js +7 -0
  9. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  10. package/dist/core/command-generation/adapters/opencode.js +13 -2
  11. package/dist/core/completion-tip.d.ts +15 -0
  12. package/dist/core/completion-tip.js +133 -0
  13. package/dist/core/completions/command-registry.js +5 -0
  14. package/dist/core/completions/factory.d.ts +11 -0
  15. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  16. package/dist/core/completions/installers/bash-installer.js +15 -0
  17. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  18. package/dist/core/completions/installers/fish-installer.js +15 -0
  19. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  20. package/dist/core/completions/installers/powershell-installer.js +15 -0
  21. package/dist/core/config-prompts.js +19 -10
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +2 -0
  24. package/dist/core/config.js +1 -0
  25. package/dist/core/global-config.d.ts +2 -0
  26. package/dist/core/init.d.ts +5 -0
  27. package/dist/core/init.js +91 -7
  28. package/dist/core/profiles.d.ts +1 -1
  29. package/dist/core/profiles.js +11 -2
  30. package/dist/core/templates/workflows/feedback.js +1 -0
  31. package/dist/core/templates/workflows/onboard.js +4 -4
  32. package/dist/core/update.js +15 -4
  33. package/dist/telemetry/index.js +3 -2
  34. package/dist/utils/change-metadata.js +17 -13
  35. package/dist/utils/change-utils.js +5 -0
  36. package/package.json +3 -6
  37. package/schemas/spec-driven/schema.yaml +17 -7
  38. package/scripts/postinstall.js +0 -83
@@ -20,6 +20,23 @@ export declare function getCommandPath(command: Command): string;
20
20
  * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
21
21
  */
22
22
  export declare function isJsonRun(command: Command): boolean;
23
+ /**
24
+ * True for the commands that exist to serve shell completions: the user-facing
25
+ * `openspec completion ...` group and the hidden `__complete` resolver that
26
+ * generated completion scripts call on every Tab press. Tipping either about
27
+ * completions is noise, and `__complete` would burn the one-shot tip invisibly.
28
+ */
29
+ export declare function isCompletionRun(commandPath: string): boolean;
30
+ /**
31
+ * True when the first-run completions tip must be deferred rather than shown.
32
+ *
33
+ * Deferring keeps the tip unconsumed, so it still reaches the user on a later
34
+ * run that can actually carry it. All three cases are runs nobody would read a
35
+ * hint from: JSON output, the completion machinery itself, and a stderr that is
36
+ * not a terminal — pipes and the agent-driven runs that dominate this CLI's
37
+ * usage would otherwise burn the user's one-shot tip into a log nobody opens.
38
+ */
39
+ export declare function shouldDeferCompletionTip(command: Command, stderrIsTty: boolean): boolean;
23
40
  export { program };
24
41
  export declare function runCli(argv?: string[]): void;
25
42
  //# sourceMappingURL=index.d.ts.map
package/dist/cli/index.js CHANGED
@@ -26,6 +26,7 @@ import { registerContextCommand } from '../commands/context.js';
26
26
  import { registerWorksetCommand } from '../commands/workset.js';
27
27
  import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
28
28
  import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
29
+ import { maybeShowCompletionTip } from '../core/completion-tip.js';
29
30
  import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
30
31
  import { isInteractive } from '../utils/interactive.js';
31
32
  const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
@@ -90,6 +91,27 @@ export function isJsonRun(command) {
90
91
  return (command.optsWithGlobals().json === true ||
91
92
  command.args.includes('--json'));
92
93
  }
94
+ /**
95
+ * True for the commands that exist to serve shell completions: the user-facing
96
+ * `openspec completion ...` group and the hidden `__complete` resolver that
97
+ * generated completion scripts call on every Tab press. Tipping either about
98
+ * completions is noise, and `__complete` would burn the one-shot tip invisibly.
99
+ */
100
+ export function isCompletionRun(commandPath) {
101
+ return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
102
+ }
103
+ /**
104
+ * True when the first-run completions tip must be deferred rather than shown.
105
+ *
106
+ * Deferring keeps the tip unconsumed, so it still reaches the user on a later
107
+ * run that can actually carry it. All three cases are runs nobody would read a
108
+ * hint from: JSON output, the completion machinery itself, and a stderr that is
109
+ * not a terminal — pipes and the agent-driven runs that dominate this CLI's
110
+ * usage would otherwise burn the user's one-shot tip into a log nobody opens.
111
+ */
112
+ export function shouldDeferCompletionTip(command, stderrIsTty) {
113
+ return isJsonRun(command) || isCompletionRun(getCommandPath(command)) || !stderrIsTty;
114
+ }
93
115
  program
94
116
  .name('openspec')
95
117
  .description('AI-native system for spec-driven development')
@@ -105,16 +127,32 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
105
127
  if (opts.color === false) {
106
128
  process.env.NO_COLOR = '1';
107
129
  }
108
- // Show first-run telemetry notice (if not seen). Suppress it whenever the run
109
- // asked for JSON so stdout stays a single valid JSON document (see isJsonRun).
130
+ // Show first-run telemetry notice (if not seen). It's written to stderr, so it
131
+ // never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
132
+ // very first invocation stays free of any incidental output on either stream.
110
133
  await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
111
134
  // Track command execution (use actionCommand to get the actual subcommand)
112
135
  const commandPath = getCommandPath(actionCommand);
113
136
  await trackCommand(commandPath, version);
114
137
  });
115
138
  // Shutdown telemetry after command completes
116
- program.hook('postAction', async () => {
117
- await shutdown();
139
+ program.hook('postAction', async (_thisCommand, actionCommand) => {
140
+ // Show the first-run shell-completions tip (on stderr, so piped stdout stays
141
+ // clean). postAction, not preAction: the tip trails the command's own output
142
+ // instead of pushing an error message or `init`'s setup summary down the
143
+ // screen. Deferred — not consumed — whenever nobody would read it: JSON runs,
144
+ // `openspec completion ...`, and a stderr that is not a terminal (agents and
145
+ // pipes would otherwise silently burn the user's one-shot tip).
146
+ try {
147
+ await maybeShowCompletionTip({
148
+ silent: shouldDeferCompletionTip(actionCommand, Boolean(process.stderr.isTTY)),
149
+ });
150
+ }
151
+ finally {
152
+ // The flush runs even if the hint throws: parse() is synchronous, so a
153
+ // rejection here has no catch anywhere above it.
154
+ await shutdown();
155
+ }
118
156
  });
119
157
  const availableToolIds = AI_TOOLS
120
158
  .filter((tool) => tool.skillsDir || tool.globalSkillsDir)
@@ -127,6 +165,7 @@ program
127
165
  .command('init [path]')
128
166
  .description('Initialize OpenSpec in your project')
129
167
  .option('--tools <tools>', toolsOptionDescription)
168
+ .option('--language <language>', 'Write new OpenSpec artifacts in this language')
130
169
  .option('--force', 'Auto-cleanup legacy files without prompting')
131
170
  .option('--profile <profile>', 'Override global config profile (core or custom)')
132
171
  .option('--no-animation', 'Show a static welcome screen instead of the animated one')
@@ -157,6 +196,7 @@ program
157
196
  const { InitCommand } = await import('../core/init.js');
158
197
  const initCommand = new InitCommand({
159
198
  tools: options?.tools,
199
+ language: options?.language,
160
200
  force: options?.force,
161
201
  profile: options?.profile,
162
202
  animation: options?.animation,
@@ -362,10 +402,12 @@ changeCmd
362
402
  .action(async (changeName, options) => {
363
403
  try {
364
404
  const changeCommand = new ChangeCommand();
405
+ // validate() already sets process.exitCode, and Node honours it at
406
+ // natural exit. Calling process.exit() here would skip commander's
407
+ // postAction hook — the same trap called out for `update` below — which
408
+ // kills the telemetry flush and the first-run completions tip on what is
409
+ // a routine outcome, not an error: a change that fails validation.
365
410
  await changeCommand.validate(changeName, options);
366
- if (typeof process.exitCode === 'number' && process.exitCode !== 0) {
367
- process.exit(process.exitCode);
368
- }
369
411
  }
370
412
  catch (error) {
371
413
  console.error(`Error: ${error.message}`);
@@ -453,6 +453,7 @@ export function registerConfigCommand(program) {
453
453
  delivery: currentState.delivery,
454
454
  workflows: [...currentState.workflows],
455
455
  };
456
+ let workflowSelectionChanged = false;
456
457
  if (action === 'both' || action === 'delivery') {
457
458
  const deliveryChoices = [
458
459
  {
@@ -497,8 +498,11 @@ export function registerConfigCommand(program) {
497
498
  };
498
499
  };
499
500
  const selectedWorkflows = await checkbox({
501
+ // The `instructions` option was removed in @inquirer/checkbox v5.
502
+ // Its replacement, the built-in keys help tip, renders
503
+ // "↑↓ navigate • space select • ⏎ submit" by default — a superset of
504
+ // the hint this used to pass — so no theme override is needed here.
500
505
  message: 'Select workflows to make available:',
501
- instructions: 'Space to toggle, Enter to confirm',
502
506
  pageSize: ALL_WORKFLOWS.length,
503
507
  theme: {
504
508
  icon: {
@@ -509,7 +513,12 @@ export function registerConfigCommand(program) {
509
513
  choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
510
514
  });
511
515
  nextState.workflows = selectedWorkflows;
512
- nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows);
516
+ workflowSelectionChanged =
517
+ selectedWorkflows.length !== currentState.workflows.length ||
518
+ selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
519
+ nextState.profile = workflowSelectionChanged
520
+ ? deriveProfileFromWorkflowSelection(selectedWorkflows)
521
+ : currentState.profile;
513
522
  }
514
523
  const diff = diffProfileState(currentState, nextState);
515
524
  if (!diff.hasChanges) {
@@ -524,7 +533,9 @@ export function registerConfigCommand(program) {
524
533
  console.log();
525
534
  config.profile = nextState.profile;
526
535
  config.delivery = nextState.delivery;
527
- config.workflows = nextState.workflows;
536
+ if (currentState.profile !== 'custom' || workflowSelectionChanged) {
537
+ config.workflows = nextState.workflows;
538
+ }
528
539
  saveGlobalConfig(config);
529
540
  // Check if inside an OpenSpec project
530
541
  const projectDir = process.cwd();
@@ -2,6 +2,8 @@ import { execSync, execFileSync } from 'child_process';
2
2
  import { createRequire } from 'module';
3
3
  import os from 'os';
4
4
  const require = createRequire(import.meta.url);
5
+ const MAX_TITLE_LENGTH = 72;
6
+ const TITLE_PREFIX = 'Feedback: ';
5
7
  /**
6
8
  * Check if gh CLI is installed and available in PATH
7
9
  * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
@@ -69,18 +71,37 @@ Submitted via OpenSpec CLI
69
71
  * Format the feedback title
70
72
  */
71
73
  function formatTitle(message) {
72
- return `Feedback: ${message}`;
74
+ const normalizedMessage = message.replace(/\s+/g, ' ').trim();
75
+ const title = `${TITLE_PREFIX}${normalizedMessage}`;
76
+ if (Array.from(title).length <= MAX_TITLE_LENGTH) {
77
+ return title;
78
+ }
79
+ const availableLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1;
80
+ let candidate = '';
81
+ let candidateLength = 0;
82
+ const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(normalizedMessage);
83
+ for (const { segment } of segments) {
84
+ const segmentLength = Array.from(segment).length;
85
+ if (candidateLength + segmentLength > availableLength) {
86
+ break;
87
+ }
88
+ candidate += segment;
89
+ candidateLength += segmentLength;
90
+ }
91
+ candidate = candidate.trimEnd();
92
+ const lastSpace = candidate.lastIndexOf(' ');
93
+ const summary = lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate;
94
+ return `${TITLE_PREFIX}${summary}…`;
73
95
  }
74
96
  /**
75
97
  * Format the full feedback body
76
98
  */
77
- function formatBody(bodyText) {
78
- const parts = [];
99
+ function formatBody(message, bodyText) {
100
+ const parts = ['## Summary', '', message];
79
101
  if (bodyText) {
80
- parts.push(bodyText);
81
- parts.push(''); // Empty line before metadata
102
+ parts.push('', '## Details', '', bodyText);
82
103
  }
83
- parts.push(generateMetadata());
104
+ parts.push('', generateMetadata());
84
105
  return parts.join('\n');
85
106
  }
86
107
  /**
@@ -216,7 +237,7 @@ export class FeedbackCommand {
216
237
  async execute(message, options) {
217
238
  // Format title and body once for all code paths
218
239
  const title = formatTitle(message);
219
- const body = formatBody(options?.body);
240
+ const body = formatBody(message, options?.body);
220
241
  // Check if gh CLI is installed
221
242
  if (!isGhInstalled()) {
222
243
  handleFallback(title, body, 'missing');
@@ -46,6 +46,33 @@ export async function isRetirableSpec(specName, rebuilt) {
46
46
  return (errors.length > 0 &&
47
47
  errors.every((issue) => issue.message === VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS));
48
48
  }
49
+ /**
50
+ * How much of one blocking line the abort is willing to show. A line long
51
+ * enough to fill the screen would push the way out of the abort off it.
52
+ */
53
+ const UNACCOUNTED_LINE_MAX = 200;
54
+ /**
55
+ * The first few lines a retirement would delete without being able to name
56
+ * them, quoted, with a count for the rest. Capped so a long tail cannot bury
57
+ * the rest of the abort.
58
+ *
59
+ * The lines are authored spec content printed verbatim to a terminal, so they
60
+ * get the same treatment as a change directory name (`describeChangeName`): a
61
+ * raw CR could forge a line of its own, and an ESC could redraw the screen.
62
+ * Truncation counts code points so a cut never leaves half a surrogate pair.
63
+ */
64
+ function describeUnaccountedContent(lines) {
65
+ const shown = lines
66
+ .slice(0, 3)
67
+ .map((line) => {
68
+ const safe = [...line.replace(/[\u0000-\u001f\u007f]/g, '?')];
69
+ const clipped = safe.slice(0, UNACCOUNTED_LINE_MAX).join('');
70
+ return `"${safe.length > UNACCOUNTED_LINE_MAX ? `${clipped}\u2026` : clipped}"`;
71
+ })
72
+ .join(', ');
73
+ const rest = lines.length > 3 ? `, and ${lines.length - 3} more line(s)` : '';
74
+ return `${shown}${rest}`;
75
+ }
49
76
  async function isRetirementCandidate(update, built, skipValidation) {
50
77
  return (!skipValidation &&
51
78
  built.noRequirementBlocks &&
@@ -1252,17 +1279,24 @@ export class ArchiveCommand {
1252
1279
  const specName = p.update.id;
1253
1280
  const report = await new Validator().validateSpecContent(specName, p.rebuilt);
1254
1281
  if (!report.valid) {
1282
+ // This run is what emptied the capability, and "no
1283
+ // requirements" is the only thing wrong with the spec that
1284
+ // would be written - so retiring it is what archive would do,
1285
+ // and what stands in the way of that is worth saying. Not
1286
+ // always the *only* fix: a live requirement can be hiding in a
1287
+ // second `## Requirements` section the validator never reaches,
1288
+ // and merging the sections fixes that spec without a deletion.
1289
+ const emptiedByThisRun = p.update.exists &&
1290
+ p.counts.removed > 0 &&
1291
+ p.noRequirementBlocks &&
1292
+ (await isRetirableSpec(specName, p.rebuilt));
1255
1293
  // The dead end #1302 describes: the rebuilt spec is unwritable
1256
1294
  // for exactly one reason, and retiring the capability is the
1257
1295
  // fix - but only the author can authorise deleting the spec, so
1258
1296
  // the abort names the marker instead of just rejecting. Says so
1259
1297
  // only when the marker is the ONLY thing missing, so it never
1260
1298
  // sends someone after a marker that would not have helped.
1261
- const retirementWouldFix = !retirementDeclared &&
1262
- p.update.exists &&
1263
- p.counts.removed > 0 &&
1264
- (await isRetirementCandidate(p.update, p, false));
1265
- const retirementHint = retirementWouldFix
1299
+ const retirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length === 0
1266
1300
  ? `This change removes the last requirement '${specName}' has. To retire the` +
1267
1301
  ` capability and delete its spec, add \`retire_capabilities: true\` to the` +
1268
1302
  ` change's ${METADATA_FILENAME} (alongside its \`schema:\`, which that file` +
@@ -1271,6 +1305,27 @@ export class ArchiveCommand {
1271
1305
  ? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
1272
1306
  : '')
1273
1307
  : undefined;
1308
+ // #1696: the marker is missing AND the file holds content a
1309
+ // retirement cannot account for, so this abort said nothing at
1310
+ // all - just "must have at least one requirement", with no way
1311
+ // forward. It names the content instead of the marker, on
1312
+ // purpose: the marker is only ever named when adding it would
1313
+ // really let the archive through, and here it would not. Once
1314
+ // the content is resolved the rerun names the marker.
1315
+ const blockedRetirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length > 0
1316
+ ? `This change removes the last requirement '${specName}' has, so the rebuilt ` +
1317
+ `spec has none left and cannot be written. Retiring the capability is what ` +
1318
+ `archive does instead, and it is refused while the spec holds content the ` +
1319
+ `merge cannot safely account for and deleting the file would take with it: ` +
1320
+ `${describeUnaccountedContent(p.unaccountedContent)}. ` +
1321
+ 'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand, then rerun.' +
1322
+ // Said here too, because an author looking at a marker they
1323
+ // believe authorises the deletion should not have to clear
1324
+ // the content first to find out it was never read.
1325
+ (retirementMarker.invalidReason
1326
+ ? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
1327
+ : '')
1328
+ : undefined;
1274
1329
  // The marker was set and retirement was still refused. Saying
1275
1330
  // nothing left the author who did exactly what the docs asked
1276
1331
  // back in the original dead end with no signal that their
@@ -1282,13 +1337,13 @@ export class ArchiveCommand {
1282
1337
  (await isRetirableSpec(specName, p.rebuilt))
1283
1338
  ? `'${specName}' declares retire_capabilities, but the spec holds content the merge ` +
1284
1339
  `cannot safely account for and deleting the file would take with it: ` +
1285
- `${p.unaccountedContent.slice(0, 3).map((line) => `"${line}"`).join(', ')}` +
1286
- `${p.unaccountedContent.length > 3 ? `, and ${p.unaccountedContent.length - 3} more line(s)` : ''}. ` +
1340
+ `${describeUnaccountedContent(p.unaccountedContent)}. ` +
1287
1341
  'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand.'
1288
1342
  : undefined;
1289
1343
  if (json) {
1290
1344
  throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, refusalReason ??
1291
1345
  retirementHint ??
1346
+ blockedRetirementHint ??
1292
1347
  `Run ${withStoreFlag(root, `openspec validate ${specName}`)} after fixing the change deltas.`);
1293
1348
  }
1294
1349
  console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
@@ -1300,6 +1355,8 @@ export class ArchiveCommand {
1300
1355
  }
1301
1356
  if (retirementHint)
1302
1357
  console.log(chalk.yellow(` → ${retirementHint}`));
1358
+ if (blockedRetirementHint)
1359
+ console.log(chalk.yellow(` → ${blockedRetirementHint}`));
1303
1360
  if (refusalReason)
1304
1361
  console.log(chalk.yellow(` → ${refusalReason}`));
1305
1362
  console.log('Aborted. No files were changed.');
@@ -3,7 +3,7 @@ import * as path from 'node:path';
3
3
  import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
4
4
  import { ArtifactGraph } from './graph.js';
5
5
  import { detectCompleted } from './state.js';
6
- import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
6
+ import { isSpecsArtifactPath, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
7
7
  import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
8
8
  import { FileSystemUtils } from '../../utils/file-system.js';
9
9
  import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
@@ -92,12 +92,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
92
92
  const skippedArtifacts = new Set();
93
93
  if (metadata?.skip_specs) {
94
94
  for (const artifact of graph.getAllArtifacts()) {
95
- // A schema may write generates as './specs/...' - the globs treat that
96
- // identically to 'specs/...', so the skip set must too, or validate
97
- // would honor the marker while instructions tell the agent to create
98
- // the very files the conflict gate polices.
99
- const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
100
- if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
95
+ if (isSpecsArtifactPath(artifact.generates) && !completed.has(artifact.id)) {
101
96
  completed.add(artifact.id);
102
97
  skippedArtifacts.add(artifact.id);
103
98
  }
@@ -2,6 +2,10 @@
2
2
  * Checks if a path contains glob pattern characters.
3
3
  */
4
4
  export declare function isGlobPattern(pattern: string): boolean;
5
+ /**
6
+ * Returns whether an artifact generates files under the change's specs/ tree.
7
+ */
8
+ export declare function isSpecsArtifactPath(generates: string): boolean;
5
9
  export declare function resolveArtifactOutputPath(changeDir: string, generates: string): string;
6
10
  /**
7
11
  * Resolves an artifact's output path(s) to concrete files that currently exist.
@@ -8,6 +8,13 @@ import { FileSystemUtils } from '../../utils/file-system.js';
8
8
  export function isGlobPattern(pattern) {
9
9
  return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
10
10
  }
11
+ /**
12
+ * Returns whether an artifact generates files under the change's specs/ tree.
13
+ */
14
+ export function isSpecsArtifactPath(generates) {
15
+ const normalized = path.posix.normalize(FileSystemUtils.toPosixPath(generates));
16
+ return normalized.startsWith('specs/');
17
+ }
11
18
  export function resolveArtifactOutputPath(changeDir, generates) {
12
19
  const outputPath = path.join(changeDir, generates);
13
20
  FileSystemUtils.assertPathWithin(changeDir, outputPath);
@@ -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',
@@ -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
@@ -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
  *