@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.
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.js +49 -7
- package/dist/commands/config.js +14 -3
- package/dist/commands/feedback.js +28 -7
- package/dist/core/archive.js +64 -7
- package/dist/core/artifact-graph/instruction-loader.js +2 -7
- package/dist/core/artifact-graph/outputs.d.ts +4 -0
- package/dist/core/artifact-graph/outputs.js +7 -0
- package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +5 -0
- package/dist/core/completions/factory.d.ts +11 -0
- package/dist/core/completions/installers/bash-installer.d.ts +7 -0
- package/dist/core/completions/installers/bash-installer.js +15 -0
- package/dist/core/completions/installers/fish-installer.d.ts +7 -0
- package/dist/core/completions/installers/fish-installer.js +15 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
- package/dist/core/completions/installers/powershell-installer.js +15 -0
- package/dist/core/config-prompts.js +19 -10
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/config.js +1 -0
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +91 -7
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/update.js +15 -4
- package/dist/telemetry/index.js +3 -2
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/package.json +3 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- package/scripts/postinstall.js +0 -83
package/dist/cli/index.d.ts
CHANGED
|
@@ -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).
|
|
109
|
-
//
|
|
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
|
-
|
|
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}`);
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/dist/core/archive.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
*
|