@fission-ai/openspec 1.1.1 → 1.2.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.js +2 -0
- package/dist/commands/config.d.ts +28 -0
- package/dist/commands/config.js +359 -5
- package/dist/core/available-tools.d.ts +16 -0
- package/dist/core/available-tools.js +30 -0
- package/dist/core/command-generation/adapters/index.d.ts +2 -0
- package/dist/core/command-generation/adapters/index.js +2 -0
- package/dist/core/command-generation/adapters/kiro.d.ts +13 -0
- package/dist/core/command-generation/adapters/kiro.js +26 -0
- package/dist/core/command-generation/adapters/pi.d.ts +14 -0
- package/dist/core/command-generation/adapters/pi.js +41 -0
- package/dist/core/command-generation/registry.js +4 -0
- package/dist/core/completions/command-registry.js +5 -0
- package/dist/core/config-schema.d.ts +10 -0
- package/dist/core/config-schema.js +14 -1
- package/dist/core/config.js +2 -0
- package/dist/core/global-config.d.ts +5 -0
- package/dist/core/global-config.js +12 -2
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +205 -44
- package/dist/core/legacy-cleanup.js +1 -0
- package/dist/core/migration.d.ts +23 -0
- package/dist/core/migration.js +108 -0
- package/dist/core/profile-sync-drift.d.ts +38 -0
- package/dist/core/profile-sync-drift.js +200 -0
- package/dist/core/profiles.d.ts +26 -0
- package/dist/core/profiles.js +40 -0
- package/dist/core/shared/index.d.ts +1 -1
- package/dist/core/shared/index.js +1 -1
- package/dist/core/shared/skill-generation.d.ts +14 -7
- package/dist/core/shared/skill-generation.js +36 -20
- package/dist/core/shared/tool-detection.d.ts +8 -3
- package/dist/core/shared/tool-detection.js +18 -0
- package/dist/core/templates/index.d.ts +1 -1
- package/dist/core/templates/index.js +2 -2
- package/dist/core/templates/skill-templates.d.ts +15 -118
- package/dist/core/templates/skill-templates.js +14 -3424
- package/dist/core/templates/types.d.ts +19 -0
- package/dist/core/templates/types.js +5 -0
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +308 -0
- package/dist/core/templates/workflows/archive-change.d.ts +10 -0
- package/dist/core/templates/workflows/archive-change.js +271 -0
- package/dist/core/templates/workflows/bulk-archive-change.d.ts +10 -0
- package/dist/core/templates/workflows/bulk-archive-change.js +488 -0
- package/dist/core/templates/workflows/continue-change.d.ts +10 -0
- package/dist/core/templates/workflows/continue-change.js +232 -0
- package/dist/core/templates/workflows/explore.d.ts +10 -0
- package/dist/core/templates/workflows/explore.js +461 -0
- package/dist/core/templates/workflows/feedback.d.ts +9 -0
- package/dist/core/templates/workflows/feedback.js +108 -0
- package/dist/core/templates/workflows/ff-change.d.ts +10 -0
- package/dist/core/templates/workflows/ff-change.js +198 -0
- package/dist/core/templates/workflows/new-change.d.ts +10 -0
- package/dist/core/templates/workflows/new-change.js +143 -0
- package/dist/core/templates/workflows/onboard.d.ts +10 -0
- package/dist/core/templates/workflows/onboard.js +565 -0
- package/dist/core/templates/workflows/propose.d.ts +10 -0
- package/dist/core/templates/workflows/propose.js +216 -0
- package/dist/core/templates/workflows/sync-specs.d.ts +10 -0
- package/dist/core/templates/workflows/sync-specs.js +272 -0
- package/dist/core/templates/workflows/verify-change.d.ts +10 -0
- package/dist/core/templates/workflows/verify-change.js +332 -0
- package/dist/core/update.d.ts +36 -1
- package/dist/core/update.js +291 -65
- package/dist/prompts/searchable-multi-select.d.ts +3 -2
- package/dist/prompts/searchable-multi-select.js +22 -12
- package/package.json +1 -1
package/dist/core/update.js
CHANGED
|
@@ -2,21 +2,39 @@
|
|
|
2
2
|
* Update Command
|
|
3
3
|
*
|
|
4
4
|
* Refreshes OpenSpec skills and commands for configured tools.
|
|
5
|
-
* Supports
|
|
5
|
+
* Supports profile-aware updates, delivery changes, migration, and smart update detection.
|
|
6
6
|
*/
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import ora from 'ora';
|
|
10
|
+
import * as fs from 'fs';
|
|
10
11
|
import { createRequire } from 'module';
|
|
11
12
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
12
13
|
import { transformToHyphenCommands } from '../utils/command-references.js';
|
|
13
14
|
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
|
|
14
15
|
import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
|
|
15
|
-
import {
|
|
16
|
+
import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
|
|
16
17
|
import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, getToolsFromLegacyArtifacts, } from './legacy-cleanup.js';
|
|
17
18
|
import { isInteractive } from '../utils/interactive.js';
|
|
19
|
+
import { getGlobalConfig } from './global-config.js';
|
|
20
|
+
import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
|
|
21
|
+
import { getAvailableTools } from './available-tools.js';
|
|
22
|
+
import { WORKFLOW_TO_SKILL_DIR, getCommandConfiguredTools, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
|
|
23
|
+
import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js';
|
|
18
24
|
const require = createRequire(import.meta.url);
|
|
19
25
|
const { version: OPENSPEC_VERSION } = require('../../package.json');
|
|
26
|
+
/**
|
|
27
|
+
* Scans installed workflow artifacts (skills and managed commands) across all configured tools.
|
|
28
|
+
* Returns the union of detected workflow IDs that match ALL_WORKFLOWS.
|
|
29
|
+
*
|
|
30
|
+
* Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs.
|
|
31
|
+
*/
|
|
32
|
+
export function scanInstalledWorkflows(projectPath, toolIds) {
|
|
33
|
+
const tools = toolIds
|
|
34
|
+
.map((id) => AI_TOOLS.find((t) => t.value === id))
|
|
35
|
+
.filter((t) => t != null);
|
|
36
|
+
return scanInstalledWorkflowsShared(projectPath, tools);
|
|
37
|
+
}
|
|
20
38
|
export class UpdateCommand {
|
|
21
39
|
force;
|
|
22
40
|
constructor(options = {}) {
|
|
@@ -29,40 +47,75 @@ export class UpdateCommand {
|
|
|
29
47
|
if (!await FileSystemUtils.directoryExists(openspecPath)) {
|
|
30
48
|
throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
|
|
31
49
|
}
|
|
32
|
-
// 2.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
// 2. Perform one-time migration if needed before any legacy upgrade generation.
|
|
51
|
+
// Use detected tool directories to preserve existing opsx skills/commands.
|
|
52
|
+
const detectedTools = getAvailableTools(resolvedProjectPath);
|
|
53
|
+
migrateIfNeededShared(resolvedProjectPath, detectedTools);
|
|
54
|
+
// 3. Read global config for profile/delivery
|
|
55
|
+
const globalConfig = getGlobalConfig();
|
|
56
|
+
const profile = globalConfig.profile ?? 'core';
|
|
57
|
+
const delivery = globalConfig.delivery ?? 'both';
|
|
58
|
+
const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows);
|
|
59
|
+
const desiredWorkflows = profileWorkflows.filter((workflow) => ALL_WORKFLOWS.includes(workflow));
|
|
60
|
+
const shouldGenerateSkills = delivery !== 'commands';
|
|
61
|
+
const shouldGenerateCommands = delivery !== 'skills';
|
|
62
|
+
// 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config
|
|
63
|
+
const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath, desiredWorkflows, delivery);
|
|
64
|
+
// 5. Find configured tools
|
|
65
|
+
const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);
|
|
36
66
|
if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
|
|
37
67
|
console.log(chalk.yellow('No configured tools found.'));
|
|
38
68
|
console.log(chalk.dim('Run "openspec init" to set up tools.'));
|
|
39
69
|
return;
|
|
40
70
|
}
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
// 6. Check version status for all configured tools
|
|
72
|
+
const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath);
|
|
73
|
+
const commandConfiguredSet = new Set(commandConfiguredTools);
|
|
74
|
+
const toolStatuses = configuredTools.map((toolId) => {
|
|
75
|
+
const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION);
|
|
76
|
+
if (!status.configured && commandConfiguredSet.has(toolId)) {
|
|
77
|
+
return { ...status, configured: true };
|
|
78
|
+
}
|
|
79
|
+
return status;
|
|
80
|
+
});
|
|
81
|
+
const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status]));
|
|
82
|
+
// 7. Smart update detection
|
|
83
|
+
const toolsNeedingVersionUpdate = toolStatuses
|
|
84
|
+
.filter((s) => s.needsUpdate)
|
|
85
|
+
.map((s) => s.toolId);
|
|
86
|
+
const toolsNeedingConfigSync = getToolsNeedingProfileSync(resolvedProjectPath, desiredWorkflows, delivery, configuredTools);
|
|
87
|
+
const toolsToUpdateSet = new Set([
|
|
88
|
+
...toolsNeedingVersionUpdate,
|
|
89
|
+
...toolsNeedingConfigSync,
|
|
90
|
+
]);
|
|
91
|
+
const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId));
|
|
92
|
+
if (!this.force && toolsToUpdateSet.size === 0) {
|
|
47
93
|
// All tools are up to date
|
|
48
94
|
this.displayUpToDateMessage(toolStatuses);
|
|
95
|
+
// Still check for new tool directories and extra workflows
|
|
96
|
+
this.detectNewTools(resolvedProjectPath, configuredTools);
|
|
97
|
+
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
|
|
49
98
|
return;
|
|
50
99
|
}
|
|
51
|
-
//
|
|
100
|
+
// 8. Display update plan
|
|
52
101
|
if (this.force) {
|
|
53
102
|
console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
|
|
54
103
|
}
|
|
55
104
|
else {
|
|
56
|
-
this.displayUpdatePlan(
|
|
105
|
+
this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
|
|
57
106
|
}
|
|
58
107
|
console.log();
|
|
59
|
-
//
|
|
60
|
-
const skillTemplates = getSkillTemplates();
|
|
61
|
-
const commandContents = getCommandContents();
|
|
62
|
-
//
|
|
63
|
-
const toolsToUpdate = this.force ? configuredTools :
|
|
108
|
+
// 9. Determine what to generate based on delivery
|
|
109
|
+
const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];
|
|
110
|
+
const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];
|
|
111
|
+
// 10. Update tools (all if force, otherwise only those needing update)
|
|
112
|
+
const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
|
|
64
113
|
const updatedTools = [];
|
|
65
114
|
const failedTools = [];
|
|
115
|
+
let removedCommandCount = 0;
|
|
116
|
+
let removedSkillCount = 0;
|
|
117
|
+
let removedDeselectedCommandCount = 0;
|
|
118
|
+
let removedDeselectedSkillCount = 0;
|
|
66
119
|
for (const toolId of toolsToUpdate) {
|
|
67
120
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
68
121
|
if (!tool?.skillsDir)
|
|
@@ -70,24 +123,38 @@ export class UpdateCommand {
|
|
|
70
123
|
const spinner = ora(`Updating ${tool.name}...`).start();
|
|
71
124
|
try {
|
|
72
125
|
const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
126
|
+
// Generate skill files if delivery includes skills
|
|
127
|
+
if (shouldGenerateSkills) {
|
|
128
|
+
for (const { template, dirName } of skillTemplates) {
|
|
129
|
+
const skillDir = path.join(skillsDir, dirName);
|
|
130
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
131
|
+
// Use hyphen-based command references for OpenCode
|
|
132
|
+
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
|
|
133
|
+
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
|
|
134
|
+
await FileSystemUtils.writeFile(skillFile, skillContent);
|
|
135
|
+
}
|
|
136
|
+
removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
|
|
137
|
+
}
|
|
138
|
+
// Delete skill directories if delivery is commands-only
|
|
139
|
+
if (!shouldGenerateSkills) {
|
|
140
|
+
removedSkillCount += await this.removeSkillDirs(skillsDir);
|
|
81
141
|
}
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
142
|
+
// Generate commands if delivery includes commands
|
|
143
|
+
if (shouldGenerateCommands) {
|
|
144
|
+
const adapter = CommandAdapterRegistry.get(tool.value);
|
|
145
|
+
if (adapter) {
|
|
146
|
+
const generatedCommands = generateCommands(commandContents, adapter);
|
|
147
|
+
for (const cmd of generatedCommands) {
|
|
148
|
+
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
|
|
149
|
+
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
|
150
|
+
}
|
|
151
|
+
removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, desiredWorkflows);
|
|
89
152
|
}
|
|
90
153
|
}
|
|
154
|
+
// Delete command files if delivery is skills-only
|
|
155
|
+
if (!shouldGenerateCommands) {
|
|
156
|
+
removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
|
|
157
|
+
}
|
|
91
158
|
spinner.succeed(`Updated ${tool.name}`);
|
|
92
159
|
updatedTools.push(tool.name);
|
|
93
160
|
}
|
|
@@ -99,7 +166,7 @@ export class UpdateCommand {
|
|
|
99
166
|
});
|
|
100
167
|
}
|
|
101
168
|
}
|
|
102
|
-
//
|
|
169
|
+
// 11. Summary
|
|
103
170
|
console.log();
|
|
104
171
|
if (updatedTools.length > 0) {
|
|
105
172
|
console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
|
|
@@ -107,7 +174,19 @@ export class UpdateCommand {
|
|
|
107
174
|
if (failedTools.length > 0) {
|
|
108
175
|
console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
|
|
109
176
|
}
|
|
110
|
-
|
|
177
|
+
if (removedCommandCount > 0) {
|
|
178
|
+
console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
|
|
179
|
+
}
|
|
180
|
+
if (removedSkillCount > 0) {
|
|
181
|
+
console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
|
|
182
|
+
}
|
|
183
|
+
if (removedDeselectedCommandCount > 0) {
|
|
184
|
+
console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
|
|
185
|
+
}
|
|
186
|
+
if (removedDeselectedSkillCount > 0) {
|
|
187
|
+
console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
|
|
188
|
+
}
|
|
189
|
+
// 12. Show onboarding message for newly configured tools from legacy upgrade
|
|
111
190
|
if (newlyConfiguredTools.length > 0) {
|
|
112
191
|
console.log();
|
|
113
192
|
console.log(chalk.bold('Getting started:'));
|
|
@@ -117,6 +196,16 @@ export class UpdateCommand {
|
|
|
117
196
|
console.log();
|
|
118
197
|
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
|
|
119
198
|
}
|
|
199
|
+
const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
|
|
200
|
+
// 13. Detect new tool directories not currently configured
|
|
201
|
+
this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
|
|
202
|
+
// 14. Display note about extra workflows not in profile
|
|
203
|
+
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
|
|
204
|
+
// 15. List affected tools
|
|
205
|
+
if (updatedTools.length > 0) {
|
|
206
|
+
const toolDisplayNames = updatedTools;
|
|
207
|
+
console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
|
|
208
|
+
}
|
|
120
209
|
console.log();
|
|
121
210
|
console.log(chalk.dim('Restart your IDE for changes to take effect.'));
|
|
122
211
|
}
|
|
@@ -128,28 +217,159 @@ export class UpdateCommand {
|
|
|
128
217
|
console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
|
|
129
218
|
console.log(chalk.dim(` Tools: ${toolNames.join(', ')}`));
|
|
130
219
|
console.log();
|
|
131
|
-
console.log(chalk.dim('Use --force to refresh
|
|
220
|
+
console.log(chalk.dim('Use --force to refresh files anyway.'));
|
|
132
221
|
}
|
|
133
222
|
/**
|
|
134
223
|
* Display the update plan showing which tools need updating.
|
|
135
224
|
*/
|
|
136
|
-
displayUpdatePlan(
|
|
137
|
-
const updates =
|
|
138
|
-
const
|
|
139
|
-
|
|
225
|
+
displayUpdatePlan(toolsToUpdate, statusByTool, upToDate) {
|
|
226
|
+
const updates = toolsToUpdate.map((toolId) => {
|
|
227
|
+
const status = statusByTool.get(toolId);
|
|
228
|
+
if (status?.needsUpdate) {
|
|
229
|
+
const fromVersion = status.generatedByVersion ?? 'unknown';
|
|
230
|
+
return `${status.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;
|
|
231
|
+
}
|
|
232
|
+
return `${toolId} (config sync)`;
|
|
140
233
|
});
|
|
141
|
-
console.log(`Updating ${
|
|
234
|
+
console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);
|
|
142
235
|
if (upToDate.length > 0) {
|
|
143
236
|
const upToDateNames = upToDate.map((s) => s.toolId);
|
|
144
237
|
console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
|
|
145
238
|
}
|
|
146
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Detects new tool directories that aren't currently configured and displays a hint.
|
|
242
|
+
*/
|
|
243
|
+
detectNewTools(projectPath, configuredTools) {
|
|
244
|
+
const availableTools = getAvailableTools(projectPath);
|
|
245
|
+
const configuredSet = new Set(configuredTools);
|
|
246
|
+
const newTools = availableTools.filter((t) => !configuredSet.has(t.value));
|
|
247
|
+
if (newTools.length > 0) {
|
|
248
|
+
const newToolNames = newTools.map((tool) => tool.name);
|
|
249
|
+
const isSingleTool = newToolNames.length === 1;
|
|
250
|
+
const toolNoun = isSingleTool ? 'tool' : 'tools';
|
|
251
|
+
const pronoun = isSingleTool ? 'it' : 'them';
|
|
252
|
+
console.log();
|
|
253
|
+
console.log(chalk.yellow(`Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Displays a note about extra workflows installed that aren't in the current profile.
|
|
258
|
+
*/
|
|
259
|
+
displayExtraWorkflowsNote(projectPath, configuredTools, profileWorkflows) {
|
|
260
|
+
const installedWorkflows = scanInstalledWorkflows(projectPath, configuredTools);
|
|
261
|
+
const profileSet = new Set(profileWorkflows);
|
|
262
|
+
const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));
|
|
263
|
+
if (extraWorkflows.length > 0) {
|
|
264
|
+
console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Removes skill directories for workflows when delivery changed to commands-only.
|
|
269
|
+
* Returns the number of directories removed.
|
|
270
|
+
*/
|
|
271
|
+
async removeSkillDirs(skillsDir) {
|
|
272
|
+
let removed = 0;
|
|
273
|
+
for (const workflow of ALL_WORKFLOWS) {
|
|
274
|
+
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
275
|
+
if (!dirName)
|
|
276
|
+
continue;
|
|
277
|
+
const skillDir = path.join(skillsDir, dirName);
|
|
278
|
+
try {
|
|
279
|
+
if (fs.existsSync(skillDir)) {
|
|
280
|
+
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
|
281
|
+
removed++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Ignore errors
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return removed;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Removes skill directories for workflows that are no longer selected in the active profile.
|
|
292
|
+
* Returns the number of directories removed.
|
|
293
|
+
*/
|
|
294
|
+
async removeUnselectedSkillDirs(skillsDir, desiredWorkflows) {
|
|
295
|
+
const desiredSet = new Set(desiredWorkflows);
|
|
296
|
+
let removed = 0;
|
|
297
|
+
for (const workflow of ALL_WORKFLOWS) {
|
|
298
|
+
if (desiredSet.has(workflow))
|
|
299
|
+
continue;
|
|
300
|
+
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
301
|
+
if (!dirName)
|
|
302
|
+
continue;
|
|
303
|
+
const skillDir = path.join(skillsDir, dirName);
|
|
304
|
+
try {
|
|
305
|
+
if (fs.existsSync(skillDir)) {
|
|
306
|
+
await fs.promises.rm(skillDir, { recursive: true, force: true });
|
|
307
|
+
removed++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Ignore errors
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return removed;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Removes command files for workflows when delivery changed to skills-only.
|
|
318
|
+
* Returns the number of files removed.
|
|
319
|
+
*/
|
|
320
|
+
async removeCommandFiles(projectPath, toolId) {
|
|
321
|
+
let removed = 0;
|
|
322
|
+
const adapter = CommandAdapterRegistry.get(toolId);
|
|
323
|
+
if (!adapter)
|
|
324
|
+
return 0;
|
|
325
|
+
for (const workflow of ALL_WORKFLOWS) {
|
|
326
|
+
const cmdPath = adapter.getFilePath(workflow);
|
|
327
|
+
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
|
328
|
+
try {
|
|
329
|
+
if (fs.existsSync(fullPath)) {
|
|
330
|
+
await fs.promises.unlink(fullPath);
|
|
331
|
+
removed++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Ignore errors
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return removed;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Removes command files for workflows that are no longer selected in the active profile.
|
|
342
|
+
* Returns the number of files removed.
|
|
343
|
+
*/
|
|
344
|
+
async removeUnselectedCommandFiles(projectPath, toolId, desiredWorkflows) {
|
|
345
|
+
let removed = 0;
|
|
346
|
+
const adapter = CommandAdapterRegistry.get(toolId);
|
|
347
|
+
if (!adapter)
|
|
348
|
+
return 0;
|
|
349
|
+
const desiredSet = new Set(desiredWorkflows);
|
|
350
|
+
for (const workflow of ALL_WORKFLOWS) {
|
|
351
|
+
if (desiredSet.has(workflow))
|
|
352
|
+
continue;
|
|
353
|
+
const cmdPath = adapter.getFilePath(workflow);
|
|
354
|
+
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
|
355
|
+
try {
|
|
356
|
+
if (fs.existsSync(fullPath)) {
|
|
357
|
+
await fs.promises.unlink(fullPath);
|
|
358
|
+
removed++;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// Ignore errors
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return removed;
|
|
366
|
+
}
|
|
147
367
|
/**
|
|
148
368
|
* Detect and handle legacy OpenSpec artifacts.
|
|
149
369
|
* Unlike init, update warns but continues if legacy files found in non-interactive mode.
|
|
150
370
|
* Returns array of tool IDs that were newly configured during legacy upgrade.
|
|
151
371
|
*/
|
|
152
|
-
async handleLegacyCleanup(projectPath) {
|
|
372
|
+
async handleLegacyCleanup(projectPath, desiredWorkflows, delivery) {
|
|
153
373
|
// Detect legacy artifacts
|
|
154
374
|
const detection = await detectLegacyArtifacts(projectPath);
|
|
155
375
|
if (!detection.hasLegacyArtifacts) {
|
|
@@ -164,7 +384,7 @@ export class UpdateCommand {
|
|
|
164
384
|
// --force flag: proceed with cleanup automatically
|
|
165
385
|
await this.performLegacyCleanup(projectPath, detection);
|
|
166
386
|
// Then upgrade legacy tools to new skills
|
|
167
|
-
return this.upgradeLegacyTools(projectPath, detection, canPrompt);
|
|
387
|
+
return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
|
|
168
388
|
}
|
|
169
389
|
if (!canPrompt) {
|
|
170
390
|
// Non-interactive mode without --force: warn and continue
|
|
@@ -182,7 +402,7 @@ export class UpdateCommand {
|
|
|
182
402
|
if (shouldCleanup) {
|
|
183
403
|
await this.performLegacyCleanup(projectPath, detection);
|
|
184
404
|
// Then upgrade legacy tools to new skills
|
|
185
|
-
return this.upgradeLegacyTools(projectPath, detection, canPrompt);
|
|
405
|
+
return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
|
|
186
406
|
}
|
|
187
407
|
else {
|
|
188
408
|
console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
|
|
@@ -208,14 +428,14 @@ export class UpdateCommand {
|
|
|
208
428
|
* Upgrade legacy tools to new skills system.
|
|
209
429
|
* Returns array of tool IDs that were newly configured.
|
|
210
430
|
*/
|
|
211
|
-
async upgradeLegacyTools(projectPath, detection, canPrompt) {
|
|
431
|
+
async upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery) {
|
|
212
432
|
// Get tools that had legacy artifacts
|
|
213
433
|
const legacyTools = getToolsFromLegacyArtifacts(detection);
|
|
214
434
|
if (legacyTools.length === 0) {
|
|
215
435
|
return [];
|
|
216
436
|
}
|
|
217
437
|
// Get currently configured tools
|
|
218
|
-
const configuredTools =
|
|
438
|
+
const configuredTools = getConfiguredToolsForProfileSync(projectPath);
|
|
219
439
|
const configuredSet = new Set(configuredTools);
|
|
220
440
|
// Filter to tools that aren't already configured
|
|
221
441
|
const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
|
|
@@ -265,10 +485,12 @@ export class UpdateCommand {
|
|
|
265
485
|
return [];
|
|
266
486
|
}
|
|
267
487
|
}
|
|
268
|
-
// Create skills for selected tools
|
|
488
|
+
// Create skills/commands for selected tools using effective profile+delivery.
|
|
269
489
|
const newlyConfigured = [];
|
|
270
|
-
const
|
|
271
|
-
const
|
|
490
|
+
const shouldGenerateSkills = delivery !== 'commands';
|
|
491
|
+
const shouldGenerateCommands = delivery !== 'skills';
|
|
492
|
+
const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];
|
|
493
|
+
const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];
|
|
272
494
|
for (const toolId of selectedTools) {
|
|
273
495
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
274
496
|
if (!tool?.skillsDir)
|
|
@@ -276,22 +498,26 @@ export class UpdateCommand {
|
|
|
276
498
|
const spinner = ora(`Setting up ${tool.name}...`).start();
|
|
277
499
|
try {
|
|
278
500
|
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
|
|
279
|
-
// Create skill files
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
501
|
+
// Create skill files when delivery includes skills
|
|
502
|
+
if (shouldGenerateSkills) {
|
|
503
|
+
for (const { template, dirName } of skillTemplates) {
|
|
504
|
+
const skillDir = path.join(skillsDir, dirName);
|
|
505
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
506
|
+
// Use hyphen-based command references for OpenCode
|
|
507
|
+
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
|
|
508
|
+
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
|
|
509
|
+
await FileSystemUtils.writeFile(skillFile, skillContent);
|
|
510
|
+
}
|
|
287
511
|
}
|
|
288
|
-
// Create commands
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
512
|
+
// Create commands when delivery includes commands
|
|
513
|
+
if (shouldGenerateCommands) {
|
|
514
|
+
const adapter = CommandAdapterRegistry.get(tool.value);
|
|
515
|
+
if (adapter) {
|
|
516
|
+
const generatedCommands = generateCommands(commandContents, adapter);
|
|
517
|
+
for (const cmd of generatedCommands) {
|
|
518
|
+
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
|
|
519
|
+
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
|
520
|
+
}
|
|
295
521
|
}
|
|
296
522
|
}
|
|
297
523
|
spinner.succeed(`Setup complete for ${tool.name}`);
|
|
@@ -3,6 +3,7 @@ interface Choice {
|
|
|
3
3
|
value: string;
|
|
4
4
|
description?: string;
|
|
5
5
|
configured?: boolean;
|
|
6
|
+
detected?: boolean;
|
|
6
7
|
configuredLabel?: string;
|
|
7
8
|
preSelected?: boolean;
|
|
8
9
|
}
|
|
@@ -18,9 +19,9 @@ interface Config {
|
|
|
18
19
|
*
|
|
19
20
|
* - Type to filter choices
|
|
20
21
|
* - ↑↓ to navigate
|
|
21
|
-
* -
|
|
22
|
+
* - Space to toggle highlighted item selection
|
|
22
23
|
* - Backspace to remove last selected item (or delete search char)
|
|
23
|
-
* -
|
|
24
|
+
* - Enter to confirm selections
|
|
24
25
|
*/
|
|
25
26
|
export declare function searchableMultiSelect(config: Config): Promise<string[]>;
|
|
26
27
|
export default searchableMultiSelect;
|
|
@@ -26,8 +26,8 @@ async function createSearchableMultiSelect() {
|
|
|
26
26
|
useKeypress((key) => {
|
|
27
27
|
if (status === 'done')
|
|
28
28
|
return;
|
|
29
|
-
//
|
|
30
|
-
if (key
|
|
29
|
+
// Enter to confirm/submit
|
|
30
|
+
if (isEnterKey(key)) {
|
|
31
31
|
if (validate) {
|
|
32
32
|
const result = validate(selectedValues);
|
|
33
33
|
if (result !== true) {
|
|
@@ -39,13 +39,16 @@ async function createSearchableMultiSelect() {
|
|
|
39
39
|
done(selectedValues);
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
|
-
//
|
|
43
|
-
if (
|
|
42
|
+
// Space to toggle selection
|
|
43
|
+
if (key.name === 'space') {
|
|
44
44
|
const choice = filteredChoices[cursor];
|
|
45
|
-
if (choice
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
if (choice) {
|
|
46
|
+
if (selectedSet.has(choice.value)) {
|
|
47
|
+
setSelectedValues(selectedValues.filter(v => v !== choice.value));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
setSelectedValues([...selectedValues, choice.value]);
|
|
51
|
+
}
|
|
49
52
|
}
|
|
50
53
|
return;
|
|
51
54
|
}
|
|
@@ -95,7 +98,7 @@ async function createSearchableMultiSelect() {
|
|
|
95
98
|
// Search box
|
|
96
99
|
lines.push(` Search: ${chalk.yellow('[')}${searchText || chalk.dim('type to filter')}${chalk.yellow(']')}`);
|
|
97
100
|
// Instructions
|
|
98
|
-
lines.push(` ${chalk.cyan('↑↓')} navigate • ${chalk.cyan('
|
|
101
|
+
lines.push(` ${chalk.cyan('↑↓')} navigate • ${chalk.cyan('Space')} toggle • ${chalk.cyan('Backspace')} remove • ${chalk.cyan('Enter')} confirm`);
|
|
99
102
|
// List
|
|
100
103
|
if (filteredChoices.length === 0) {
|
|
101
104
|
lines.push(chalk.yellow(' No matches'));
|
|
@@ -114,9 +117,16 @@ async function createSearchableMultiSelect() {
|
|
|
114
117
|
const arrow = isActive ? chalk.cyan('›') : ' ';
|
|
115
118
|
const name = isActive ? chalk.cyan(item.name) : item.name;
|
|
116
119
|
const isRefresh = selected && item.configured;
|
|
120
|
+
const statusLabel = !selected
|
|
121
|
+
? item.configured
|
|
122
|
+
? ' (configured)'
|
|
123
|
+
: item.detected
|
|
124
|
+
? ' (detected)'
|
|
125
|
+
: ''
|
|
126
|
+
: '';
|
|
117
127
|
const suffix = selected
|
|
118
128
|
? chalk.dim(isRefresh ? ' (refresh)' : ' (selected)')
|
|
119
|
-
:
|
|
129
|
+
: chalk.dim(statusLabel);
|
|
120
130
|
lines.push(` ${arrow} ${icon} ${name}${suffix}`);
|
|
121
131
|
}
|
|
122
132
|
// Show pagination indicator if needed
|
|
@@ -137,9 +147,9 @@ async function createSearchableMultiSelect() {
|
|
|
137
147
|
*
|
|
138
148
|
* - Type to filter choices
|
|
139
149
|
* - ↑↓ to navigate
|
|
140
|
-
* -
|
|
150
|
+
* - Space to toggle highlighted item selection
|
|
141
151
|
* - Backspace to remove last selected item (or delete search char)
|
|
142
|
-
* -
|
|
152
|
+
* - Enter to confirm selections
|
|
143
153
|
*/
|
|
144
154
|
export async function searchableMultiSelect(config) {
|
|
145
155
|
const prompt = await createSearchableMultiSelect();
|