@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.
Files changed (68) hide show
  1. package/dist/cli/index.js +2 -0
  2. package/dist/commands/config.d.ts +28 -0
  3. package/dist/commands/config.js +359 -5
  4. package/dist/core/available-tools.d.ts +16 -0
  5. package/dist/core/available-tools.js +30 -0
  6. package/dist/core/command-generation/adapters/index.d.ts +2 -0
  7. package/dist/core/command-generation/adapters/index.js +2 -0
  8. package/dist/core/command-generation/adapters/kiro.d.ts +13 -0
  9. package/dist/core/command-generation/adapters/kiro.js +26 -0
  10. package/dist/core/command-generation/adapters/pi.d.ts +14 -0
  11. package/dist/core/command-generation/adapters/pi.js +41 -0
  12. package/dist/core/command-generation/registry.js +4 -0
  13. package/dist/core/completions/command-registry.js +5 -0
  14. package/dist/core/config-schema.d.ts +10 -0
  15. package/dist/core/config-schema.js +14 -1
  16. package/dist/core/config.js +2 -0
  17. package/dist/core/global-config.d.ts +5 -0
  18. package/dist/core/global-config.js +12 -2
  19. package/dist/core/init.d.ts +5 -0
  20. package/dist/core/init.js +205 -44
  21. package/dist/core/legacy-cleanup.js +1 -0
  22. package/dist/core/migration.d.ts +23 -0
  23. package/dist/core/migration.js +108 -0
  24. package/dist/core/profile-sync-drift.d.ts +38 -0
  25. package/dist/core/profile-sync-drift.js +200 -0
  26. package/dist/core/profiles.d.ts +26 -0
  27. package/dist/core/profiles.js +40 -0
  28. package/dist/core/shared/index.d.ts +1 -1
  29. package/dist/core/shared/index.js +1 -1
  30. package/dist/core/shared/skill-generation.d.ts +14 -7
  31. package/dist/core/shared/skill-generation.js +36 -20
  32. package/dist/core/shared/tool-detection.d.ts +8 -3
  33. package/dist/core/shared/tool-detection.js +18 -0
  34. package/dist/core/templates/index.d.ts +1 -1
  35. package/dist/core/templates/index.js +2 -2
  36. package/dist/core/templates/skill-templates.d.ts +15 -118
  37. package/dist/core/templates/skill-templates.js +14 -3424
  38. package/dist/core/templates/types.d.ts +19 -0
  39. package/dist/core/templates/types.js +5 -0
  40. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  41. package/dist/core/templates/workflows/apply-change.js +308 -0
  42. package/dist/core/templates/workflows/archive-change.d.ts +10 -0
  43. package/dist/core/templates/workflows/archive-change.js +271 -0
  44. package/dist/core/templates/workflows/bulk-archive-change.d.ts +10 -0
  45. package/dist/core/templates/workflows/bulk-archive-change.js +488 -0
  46. package/dist/core/templates/workflows/continue-change.d.ts +10 -0
  47. package/dist/core/templates/workflows/continue-change.js +232 -0
  48. package/dist/core/templates/workflows/explore.d.ts +10 -0
  49. package/dist/core/templates/workflows/explore.js +461 -0
  50. package/dist/core/templates/workflows/feedback.d.ts +9 -0
  51. package/dist/core/templates/workflows/feedback.js +108 -0
  52. package/dist/core/templates/workflows/ff-change.d.ts +10 -0
  53. package/dist/core/templates/workflows/ff-change.js +198 -0
  54. package/dist/core/templates/workflows/new-change.d.ts +10 -0
  55. package/dist/core/templates/workflows/new-change.js +143 -0
  56. package/dist/core/templates/workflows/onboard.d.ts +10 -0
  57. package/dist/core/templates/workflows/onboard.js +565 -0
  58. package/dist/core/templates/workflows/propose.d.ts +10 -0
  59. package/dist/core/templates/workflows/propose.js +216 -0
  60. package/dist/core/templates/workflows/sync-specs.d.ts +10 -0
  61. package/dist/core/templates/workflows/sync-specs.js +272 -0
  62. package/dist/core/templates/workflows/verify-change.d.ts +10 -0
  63. package/dist/core/templates/workflows/verify-change.js +332 -0
  64. package/dist/core/update.d.ts +36 -1
  65. package/dist/core/update.js +291 -65
  66. package/dist/prompts/searchable-multi-select.d.ts +3 -2
  67. package/dist/prompts/searchable-multi-select.js +22 -12
  68. package/package.json +1 -1
@@ -2,21 +2,39 @@
2
2
  * Update Command
3
3
  *
4
4
  * Refreshes OpenSpec skills and commands for configured tools.
5
- * Supports smart update detection to skip updates when already current.
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 { getConfiguredTools, getAllToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
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. Detect and handle legacy artifacts + upgrade legacy tools to new skills
33
- const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath);
34
- // 3. Find configured tools
35
- const configuredTools = getConfiguredTools(resolvedProjectPath);
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
- // 4. Check version status for all configured tools
42
- const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION);
43
- // 5. Smart update detection
44
- const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate);
45
- const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate);
46
- if (!this.force && toolsNeedingUpdate.length === 0) {
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
- // 6. Display update plan
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(toolsNeedingUpdate, toolsUpToDate);
105
+ this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
57
106
  }
58
107
  console.log();
59
- // 7. Prepare templates
60
- const skillTemplates = getSkillTemplates();
61
- const commandContents = getCommandContents();
62
- // 8. Update tools (all if force, otherwise only those needing update)
63
- const toolsToUpdate = this.force ? configuredTools : toolsNeedingUpdate.map((s) => s.toolId);
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
- // Update skill files
74
- for (const { template, dirName } of skillTemplates) {
75
- const skillDir = path.join(skillsDir, dirName);
76
- const skillFile = path.join(skillDir, 'SKILL.md');
77
- // Use hyphen-based command references for OpenCode
78
- const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
79
- const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
80
- await FileSystemUtils.writeFile(skillFile, skillContent);
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
- // Update commands
83
- const adapter = CommandAdapterRegistry.get(tool.value);
84
- if (adapter) {
85
- const generatedCommands = generateCommands(commandContents, adapter);
86
- for (const cmd of generatedCommands) {
87
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
88
- await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
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
- // 9. Summary
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
- // 10. Show onboarding message for newly configured tools from legacy upgrade
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 skills anyway.'));
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(needingUpdate, upToDate) {
137
- const updates = needingUpdate.map((s) => {
138
- const fromVersion = s.generatedByVersion ?? 'unknown';
139
- return `${s.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;
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 ${needingUpdate.length} tool(s): ${updates.join(', ')}`);
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 = getConfiguredTools(projectPath);
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 skillTemplates = getSkillTemplates();
271
- const commandContents = getCommandContents();
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
- for (const { template, dirName } of skillTemplates) {
281
- const skillDir = path.join(skillsDir, dirName);
282
- const skillFile = path.join(skillDir, 'SKILL.md');
283
- // Use hyphen-based command references for OpenCode
284
- const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
285
- const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
286
- await FileSystemUtils.writeFile(skillFile, skillContent);
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
- const adapter = CommandAdapterRegistry.get(tool.value);
290
- if (adapter) {
291
- const generatedCommands = generateCommands(commandContents, adapter);
292
- for (const cmd of generatedCommands) {
293
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
294
- await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
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
- * - Enter to add highlighted item
22
+ * - Space to toggle highlighted item selection
22
23
  * - Backspace to remove last selected item (or delete search char)
23
- * - Tab to confirm selections
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
- // Tab to confirm
30
- if (key.name === 'tab') {
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
- // Enter to add item
43
- if (isEnterKey(key)) {
42
+ // Space to toggle selection
43
+ if (key.name === 'space') {
44
44
  const choice = filteredChoices[cursor];
45
- if (choice && !selectedSet.has(choice.value)) {
46
- setSelectedValues([...selectedValues, choice.value]);
47
- setSearchText('');
48
- setCursor(0);
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('Enter')} add • ${chalk.cyan('Backspace')} remove • ${chalk.cyan('Tab')} confirm`);
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
- * - Enter to add highlighted item
150
+ * - Space to toggle highlighted item selection
141
151
  * - Backspace to remove last selected item (or delete search char)
142
- * - Tab to confirm selections
152
+ * - Enter to confirm selections
143
153
  */
144
154
  export async function searchableMultiSelect(config) {
145
155
  const prompt = await createSearchableMultiSelect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",