@fission-ai/openspec 1.1.0 → 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 (73) 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/opencode.js +4 -1
  11. package/dist/core/command-generation/adapters/pi.d.ts +14 -0
  12. package/dist/core/command-generation/adapters/pi.js +41 -0
  13. package/dist/core/command-generation/registry.js +4 -0
  14. package/dist/core/completions/command-registry.js +5 -0
  15. package/dist/core/config-schema.d.ts +10 -0
  16. package/dist/core/config-schema.js +14 -1
  17. package/dist/core/config.js +2 -0
  18. package/dist/core/global-config.d.ts +5 -0
  19. package/dist/core/global-config.js +12 -2
  20. package/dist/core/init.d.ts +5 -0
  21. package/dist/core/init.js +206 -42
  22. package/dist/core/legacy-cleanup.js +1 -0
  23. package/dist/core/migration.d.ts +23 -0
  24. package/dist/core/migration.js +108 -0
  25. package/dist/core/profile-sync-drift.d.ts +38 -0
  26. package/dist/core/profile-sync-drift.js +200 -0
  27. package/dist/core/profiles.d.ts +26 -0
  28. package/dist/core/profiles.js +40 -0
  29. package/dist/core/shared/index.d.ts +1 -1
  30. package/dist/core/shared/index.js +1 -1
  31. package/dist/core/shared/skill-generation.d.ts +16 -8
  32. package/dist/core/shared/skill-generation.js +42 -22
  33. package/dist/core/shared/tool-detection.d.ts +8 -3
  34. package/dist/core/shared/tool-detection.js +18 -0
  35. package/dist/core/templates/index.d.ts +1 -1
  36. package/dist/core/templates/index.js +2 -2
  37. package/dist/core/templates/skill-templates.d.ts +15 -118
  38. package/dist/core/templates/skill-templates.js +14 -3424
  39. package/dist/core/templates/types.d.ts +19 -0
  40. package/dist/core/templates/types.js +5 -0
  41. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  42. package/dist/core/templates/workflows/apply-change.js +308 -0
  43. package/dist/core/templates/workflows/archive-change.d.ts +10 -0
  44. package/dist/core/templates/workflows/archive-change.js +271 -0
  45. package/dist/core/templates/workflows/bulk-archive-change.d.ts +10 -0
  46. package/dist/core/templates/workflows/bulk-archive-change.js +488 -0
  47. package/dist/core/templates/workflows/continue-change.d.ts +10 -0
  48. package/dist/core/templates/workflows/continue-change.js +232 -0
  49. package/dist/core/templates/workflows/explore.d.ts +10 -0
  50. package/dist/core/templates/workflows/explore.js +461 -0
  51. package/dist/core/templates/workflows/feedback.d.ts +9 -0
  52. package/dist/core/templates/workflows/feedback.js +108 -0
  53. package/dist/core/templates/workflows/ff-change.d.ts +10 -0
  54. package/dist/core/templates/workflows/ff-change.js +198 -0
  55. package/dist/core/templates/workflows/new-change.d.ts +10 -0
  56. package/dist/core/templates/workflows/new-change.js +143 -0
  57. package/dist/core/templates/workflows/onboard.d.ts +10 -0
  58. package/dist/core/templates/workflows/onboard.js +565 -0
  59. package/dist/core/templates/workflows/propose.d.ts +10 -0
  60. package/dist/core/templates/workflows/propose.js +216 -0
  61. package/dist/core/templates/workflows/sync-specs.d.ts +10 -0
  62. package/dist/core/templates/workflows/sync-specs.js +272 -0
  63. package/dist/core/templates/workflows/verify-change.d.ts +10 -0
  64. package/dist/core/templates/workflows/verify-change.js +332 -0
  65. package/dist/core/update.d.ts +36 -1
  66. package/dist/core/update.js +292 -61
  67. package/dist/prompts/searchable-multi-select.d.ts +3 -2
  68. package/dist/prompts/searchable-multi-select.js +22 -12
  69. package/dist/utils/command-references.d.ts +18 -0
  70. package/dist/utils/command-references.js +20 -0
  71. package/dist/utils/index.d.ts +1 -0
  72. package/dist/utils/index.js +2 -0
  73. package/package.json +1 -1
@@ -2,20 +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';
13
+ import { transformToHyphenCommands } from '../utils/command-references.js';
12
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
13
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
14
- import { getConfiguredTools, getAllToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
16
+ import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
15
17
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, getToolsFromLegacyArtifacts, } from './legacy-cleanup.js';
16
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';
17
24
  const require = createRequire(import.meta.url);
18
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
+ }
19
38
  export class UpdateCommand {
20
39
  force;
21
40
  constructor(options = {}) {
@@ -28,40 +47,75 @@ export class UpdateCommand {
28
47
  if (!await FileSystemUtils.directoryExists(openspecPath)) {
29
48
  throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
30
49
  }
31
- // 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills
32
- const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath);
33
- // 3. Find configured tools
34
- 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);
35
66
  if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
36
67
  console.log(chalk.yellow('No configured tools found.'));
37
68
  console.log(chalk.dim('Run "openspec init" to set up tools.'));
38
69
  return;
39
70
  }
40
- // 4. Check version status for all configured tools
41
- const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION);
42
- // 5. Smart update detection
43
- const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate);
44
- const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate);
45
- 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) {
46
93
  // All tools are up to date
47
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);
48
98
  return;
49
99
  }
50
- // 6. Display update plan
100
+ // 8. Display update plan
51
101
  if (this.force) {
52
102
  console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
53
103
  }
54
104
  else {
55
- this.displayUpdatePlan(toolsNeedingUpdate, toolsUpToDate);
105
+ this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
56
106
  }
57
107
  console.log();
58
- // 7. Prepare templates
59
- const skillTemplates = getSkillTemplates();
60
- const commandContents = getCommandContents();
61
- // 8. Update tools (all if force, otherwise only those needing update)
62
- 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];
63
113
  const updatedTools = [];
64
114
  const failedTools = [];
115
+ let removedCommandCount = 0;
116
+ let removedSkillCount = 0;
117
+ let removedDeselectedCommandCount = 0;
118
+ let removedDeselectedSkillCount = 0;
65
119
  for (const toolId of toolsToUpdate) {
66
120
  const tool = AI_TOOLS.find((t) => t.value === toolId);
67
121
  if (!tool?.skillsDir)
@@ -69,22 +123,38 @@ export class UpdateCommand {
69
123
  const spinner = ora(`Updating ${tool.name}...`).start();
70
124
  try {
71
125
  const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
72
- // Update skill files
73
- for (const { template, dirName } of skillTemplates) {
74
- const skillDir = path.join(skillsDir, dirName);
75
- const skillFile = path.join(skillDir, 'SKILL.md');
76
- const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
77
- 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);
78
141
  }
79
- // Update commands
80
- const adapter = CommandAdapterRegistry.get(tool.value);
81
- if (adapter) {
82
- const generatedCommands = generateCommands(commandContents, adapter);
83
- for (const cmd of generatedCommands) {
84
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
85
- 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);
86
152
  }
87
153
  }
154
+ // Delete command files if delivery is skills-only
155
+ if (!shouldGenerateCommands) {
156
+ removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
157
+ }
88
158
  spinner.succeed(`Updated ${tool.name}`);
89
159
  updatedTools.push(tool.name);
90
160
  }
@@ -96,7 +166,7 @@ export class UpdateCommand {
96
166
  });
97
167
  }
98
168
  }
99
- // 9. Summary
169
+ // 11. Summary
100
170
  console.log();
101
171
  if (updatedTools.length > 0) {
102
172
  console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
@@ -104,7 +174,19 @@ export class UpdateCommand {
104
174
  if (failedTools.length > 0) {
105
175
  console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
106
176
  }
107
- // 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
108
190
  if (newlyConfiguredTools.length > 0) {
109
191
  console.log();
110
192
  console.log(chalk.bold('Getting started:'));
@@ -114,6 +196,16 @@ export class UpdateCommand {
114
196
  console.log();
115
197
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
116
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
+ }
117
209
  console.log();
118
210
  console.log(chalk.dim('Restart your IDE for changes to take effect.'));
119
211
  }
@@ -125,28 +217,159 @@ export class UpdateCommand {
125
217
  console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
126
218
  console.log(chalk.dim(` Tools: ${toolNames.join(', ')}`));
127
219
  console.log();
128
- console.log(chalk.dim('Use --force to refresh skills anyway.'));
220
+ console.log(chalk.dim('Use --force to refresh files anyway.'));
129
221
  }
130
222
  /**
131
223
  * Display the update plan showing which tools need updating.
132
224
  */
133
- displayUpdatePlan(needingUpdate, upToDate) {
134
- const updates = needingUpdate.map((s) => {
135
- const fromVersion = s.generatedByVersion ?? 'unknown';
136
- 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)`;
137
233
  });
138
- console.log(`Updating ${needingUpdate.length} tool(s): ${updates.join(', ')}`);
234
+ console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);
139
235
  if (upToDate.length > 0) {
140
236
  const upToDateNames = upToDate.map((s) => s.toolId);
141
237
  console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
142
238
  }
143
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
+ }
144
367
  /**
145
368
  * Detect and handle legacy OpenSpec artifacts.
146
369
  * Unlike init, update warns but continues if legacy files found in non-interactive mode.
147
370
  * Returns array of tool IDs that were newly configured during legacy upgrade.
148
371
  */
149
- async handleLegacyCleanup(projectPath) {
372
+ async handleLegacyCleanup(projectPath, desiredWorkflows, delivery) {
150
373
  // Detect legacy artifacts
151
374
  const detection = await detectLegacyArtifacts(projectPath);
152
375
  if (!detection.hasLegacyArtifacts) {
@@ -161,7 +384,7 @@ export class UpdateCommand {
161
384
  // --force flag: proceed with cleanup automatically
162
385
  await this.performLegacyCleanup(projectPath, detection);
163
386
  // Then upgrade legacy tools to new skills
164
- return this.upgradeLegacyTools(projectPath, detection, canPrompt);
387
+ return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
165
388
  }
166
389
  if (!canPrompt) {
167
390
  // Non-interactive mode without --force: warn and continue
@@ -179,7 +402,7 @@ export class UpdateCommand {
179
402
  if (shouldCleanup) {
180
403
  await this.performLegacyCleanup(projectPath, detection);
181
404
  // Then upgrade legacy tools to new skills
182
- return this.upgradeLegacyTools(projectPath, detection, canPrompt);
405
+ return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
183
406
  }
184
407
  else {
185
408
  console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
@@ -205,14 +428,14 @@ export class UpdateCommand {
205
428
  * Upgrade legacy tools to new skills system.
206
429
  * Returns array of tool IDs that were newly configured.
207
430
  */
208
- async upgradeLegacyTools(projectPath, detection, canPrompt) {
431
+ async upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery) {
209
432
  // Get tools that had legacy artifacts
210
433
  const legacyTools = getToolsFromLegacyArtifacts(detection);
211
434
  if (legacyTools.length === 0) {
212
435
  return [];
213
436
  }
214
437
  // Get currently configured tools
215
- const configuredTools = getConfiguredTools(projectPath);
438
+ const configuredTools = getConfiguredToolsForProfileSync(projectPath);
216
439
  const configuredSet = new Set(configuredTools);
217
440
  // Filter to tools that aren't already configured
218
441
  const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
@@ -262,10 +485,12 @@ export class UpdateCommand {
262
485
  return [];
263
486
  }
264
487
  }
265
- // Create skills for selected tools
488
+ // Create skills/commands for selected tools using effective profile+delivery.
266
489
  const newlyConfigured = [];
267
- const skillTemplates = getSkillTemplates();
268
- 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) : [];
269
494
  for (const toolId of selectedTools) {
270
495
  const tool = AI_TOOLS.find((t) => t.value === toolId);
271
496
  if (!tool?.skillsDir)
@@ -273,20 +498,26 @@ export class UpdateCommand {
273
498
  const spinner = ora(`Setting up ${tool.name}...`).start();
274
499
  try {
275
500
  const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
276
- // Create skill files
277
- for (const { template, dirName } of skillTemplates) {
278
- const skillDir = path.join(skillsDir, dirName);
279
- const skillFile = path.join(skillDir, 'SKILL.md');
280
- const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
281
- 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
+ }
282
511
  }
283
- // Create commands
284
- const adapter = CommandAdapterRegistry.get(tool.value);
285
- if (adapter) {
286
- const generatedCommands = generateCommands(commandContents, adapter);
287
- for (const cmd of generatedCommands) {
288
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
289
- 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
+ }
290
521
  }
291
522
  }
292
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();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Command Reference Utilities
3
+ *
4
+ * Utilities for transforming command references to tool-specific formats.
5
+ */
6
+ /**
7
+ * Transforms colon-based command references to hyphen-based format.
8
+ * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax.
9
+ *
10
+ * @param text - The text containing command references
11
+ * @returns Text with command references transformed to hyphen format
12
+ *
13
+ * @example
14
+ * transformToHyphenCommands('/opsx:new') // returns '/opsx-new'
15
+ * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement'
16
+ */
17
+ export declare function transformToHyphenCommands(text: string): string;
18
+ //# sourceMappingURL=command-references.d.ts.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Command Reference Utilities
3
+ *
4
+ * Utilities for transforming command references to tool-specific formats.
5
+ */
6
+ /**
7
+ * Transforms colon-based command references to hyphen-based format.
8
+ * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax.
9
+ *
10
+ * @param text - The text containing command references
11
+ * @returns Text with command references transformed to hyphen format
12
+ *
13
+ * @example
14
+ * transformToHyphenCommands('/opsx:new') // returns '/opsx-new'
15
+ * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement'
16
+ */
17
+ export function transformToHyphenCommands(text) {
18
+ return text.replace(/\/opsx:/g, '/opsx-');
19
+ }
20
+ //# sourceMappingURL=command-references.js.map
@@ -2,4 +2,5 @@ export { validateChangeName, createChange } from './change-utils.js';
2
2
  export type { ValidationResult, CreateChangeOptions } from './change-utils.js';
3
3
  export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, validateSchemaName, ChangeMetadataError, } from './change-metadata.js';
4
4
  export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
5
+ export { transformToHyphenCommands } from './command-references.js';
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,6 @@ export { validateChangeName, createChange } from './change-utils.js';
4
4
  export { readChangeMetadata, writeChangeMetadata, resolveSchemaForChange, validateSchemaName, ChangeMetadataError, } from './change-metadata.js';
5
5
  // File system utilities
6
6
  export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
7
+ // Command reference utilities
8
+ export { transformToHyphenCommands } from './command-references.js';
7
9
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",