@fission-ai/openspec 1.6.0 → 1.8.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 (208) hide show
  1. package/README.md +31 -2
  2. package/dist/cli/index.js +79 -8
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +105 -51
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +110 -31
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +49 -29
  11. package/dist/commands/validate.js +20 -5
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +121 -33
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +9 -4
  19. package/dist/commands/workflow/templates.js +15 -5
  20. package/dist/core/archive.d.ts +14 -1
  21. package/dist/core/archive.js +1332 -155
  22. package/dist/core/artifact-graph/graph.d.ts +16 -0
  23. package/dist/core/artifact-graph/graph.js +30 -6
  24. package/dist/core/artifact-graph/index.d.ts +1 -1
  25. package/dist/core/artifact-graph/index.js +1 -1
  26. package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
  27. package/dist/core/artifact-graph/instruction-loader.js +70 -12
  28. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  29. package/dist/core/artifact-graph/outputs.js +67 -5
  30. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  31. package/dist/core/artifact-graph/resolver.js +69 -15
  32. package/dist/core/artifact-graph/types.js +21 -3
  33. package/dist/core/available-tools.d.ts +2 -2
  34. package/dist/core/available-tools.js +14 -3
  35. package/dist/core/change-metadata/schema.d.ts +2 -0
  36. package/dist/core/change-metadata/schema.js +13 -0
  37. package/dist/core/change-status-policy.d.ts +1 -1
  38. package/dist/core/change-status-policy.js +2 -2
  39. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  40. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  41. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  42. package/dist/core/command-generation/adapters/auggie.js +2 -1
  43. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  44. package/dist/core/command-generation/adapters/bob.js +6 -5
  45. package/dist/core/command-generation/adapters/claude.js +1 -8
  46. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  47. package/dist/core/command-generation/adapters/continue.js +3 -2
  48. package/dist/core/command-generation/adapters/costrict.js +2 -1
  49. package/dist/core/command-generation/adapters/crush.js +5 -5
  50. package/dist/core/command-generation/adapters/cursor.js +2 -2
  51. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  52. package/dist/core/command-generation/adapters/devin.js +36 -0
  53. package/dist/core/command-generation/adapters/factory.js +2 -1
  54. package/dist/core/command-generation/adapters/gemini.js +37 -2
  55. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  56. package/dist/core/command-generation/adapters/iflow.js +5 -4
  57. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  58. package/dist/core/command-generation/adapters/index.js +2 -2
  59. package/dist/core/command-generation/adapters/junie.js +2 -1
  60. package/dist/core/command-generation/adapters/kiro.js +2 -1
  61. package/dist/core/command-generation/adapters/lingma.js +5 -5
  62. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  63. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  64. package/dist/core/command-generation/adapters/opencode.js +3 -5
  65. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  66. package/dist/core/command-generation/adapters/pi.js +3 -6
  67. package/dist/core/command-generation/adapters/qoder.js +5 -5
  68. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  69. package/dist/core/command-generation/adapters/qwen.js +12 -7
  70. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  71. package/dist/core/command-generation/adapters/roocode.js +4 -4
  72. package/dist/core/command-generation/adapters/trae.js +1 -21
  73. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  74. package/dist/core/command-generation/adapters/zcode.js +33 -0
  75. package/dist/core/command-generation/generator.d.ts +8 -0
  76. package/dist/core/command-generation/generator.js +15 -1
  77. package/dist/core/command-generation/index.d.ts +1 -1
  78. package/dist/core/command-generation/index.js +1 -1
  79. package/dist/core/command-generation/invocation.d.ts +73 -0
  80. package/dist/core/command-generation/invocation.js +77 -0
  81. package/dist/core/command-generation/registry.js +4 -4
  82. package/dist/core/command-generation/types.d.ts +9 -2
  83. package/dist/core/command-generation/yaml.d.ts +20 -6
  84. package/dist/core/command-generation/yaml.js +31 -21
  85. package/dist/core/command-surface.d.ts +15 -0
  86. package/dist/core/command-surface.js +33 -0
  87. package/dist/core/completions/command-registry.js +16 -2
  88. package/dist/core/completions/generators/powershell-generator.js +15 -8
  89. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  90. package/dist/core/completions/installers/zsh-installer.js +20 -4
  91. package/dist/core/config-prompts.d.ts +1 -1
  92. package/dist/core/config-prompts.js +14 -1
  93. package/dist/core/config-schema.d.ts +10 -1
  94. package/dist/core/config-schema.js +67 -1
  95. package/dist/core/config.d.ts +15 -0
  96. package/dist/core/config.js +45 -5
  97. package/dist/core/file-state.js +56 -24
  98. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  99. package/dist/core/github-copilot/cloud-agent.js +497 -0
  100. package/dist/core/global-config.d.ts +16 -0
  101. package/dist/core/init.d.ts +58 -0
  102. package/dist/core/init.js +450 -69
  103. package/dist/core/legacy-cleanup.d.ts +59 -0
  104. package/dist/core/legacy-cleanup.js +253 -24
  105. package/dist/core/list.js +5 -7
  106. package/dist/core/migration.d.ts +73 -1
  107. package/dist/core/migration.js +360 -11
  108. package/dist/core/onboarding-commands.d.ts +30 -0
  109. package/dist/core/onboarding-commands.js +38 -0
  110. package/dist/core/parsers/change-parser.d.ts +17 -2
  111. package/dist/core/parsers/change-parser.js +33 -20
  112. package/dist/core/parsers/code-fence.d.ts +15 -0
  113. package/dist/core/parsers/code-fence.js +49 -0
  114. package/dist/core/parsers/markdown-parser.js +2 -1
  115. package/dist/core/parsers/requirement-blocks.d.ts +18 -0
  116. package/dist/core/parsers/requirement-blocks.js +120 -35
  117. package/dist/core/parsers/requirement-text.d.ts +1 -8
  118. package/dist/core/parsers/requirement-text.js +4 -42
  119. package/dist/core/parsers/spec-structure.d.ts +1 -1
  120. package/dist/core/parsers/spec-structure.js +20 -30
  121. package/dist/core/profile-sync-drift.d.ts +0 -8
  122. package/dist/core/profile-sync-drift.js +48 -49
  123. package/dist/core/project-config.d.ts +28 -6
  124. package/dist/core/project-config.js +108 -8
  125. package/dist/core/references.js +42 -4
  126. package/dist/core/relationship-health.d.ts +8 -0
  127. package/dist/core/relationship-health.js +13 -0
  128. package/dist/core/root-selection.d.ts +9 -6
  129. package/dist/core/root-selection.js +42 -5
  130. package/dist/core/shared/index.d.ts +1 -0
  131. package/dist/core/shared/index.js +1 -0
  132. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  133. package/dist/core/shared/skill-content-equivalence.js +41 -0
  134. package/dist/core/shared/skill-paths.d.ts +13 -0
  135. package/dist/core/shared/skill-paths.js +23 -0
  136. package/dist/core/shared/tool-detection.d.ts +25 -5
  137. package/dist/core/shared/tool-detection.js +166 -36
  138. package/dist/core/shared-skill-target.d.ts +16 -0
  139. package/dist/core/shared-skill-target.js +156 -0
  140. package/dist/core/specs-apply.d.ts +80 -33
  141. package/dist/core/specs-apply.js +659 -171
  142. package/dist/core/store/git.d.ts +13 -0
  143. package/dist/core/store/git.js +24 -0
  144. package/dist/core/templates/skill-templates.d.ts +1 -1
  145. package/dist/core/templates/skill-templates.js +1 -1
  146. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  147. package/dist/core/templates/workflows/apply-change.js +48 -158
  148. package/dist/core/templates/workflows/archive-change.js +170 -42
  149. package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
  150. package/dist/core/templates/workflows/continue-change.js +38 -46
  151. package/dist/core/templates/workflows/explore.js +54 -18
  152. package/dist/core/templates/workflows/ff-change.js +46 -30
  153. package/dist/core/templates/workflows/new-change.js +2 -2
  154. package/dist/core/templates/workflows/onboard.js +13 -7
  155. package/dist/core/templates/workflows/propose.js +116 -46
  156. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  157. package/dist/core/templates/workflows/store-selection.js +1 -1
  158. package/dist/core/templates/workflows/sync-specs.js +260 -32
  159. package/dist/core/templates/workflows/update-change.js +26 -16
  160. package/dist/core/templates/workflows/verify-change.js +16 -10
  161. package/dist/core/update.d.ts +40 -5
  162. package/dist/core/update.js +389 -93
  163. package/dist/core/validation/constants.d.ts +4 -1
  164. package/dist/core/validation/constants.js +4 -1
  165. package/dist/core/validation/validator.d.ts +22 -8
  166. package/dist/core/validation/validator.js +222 -50
  167. package/dist/core/version-check.d.ts +152 -0
  168. package/dist/core/version-check.js +731 -0
  169. package/dist/core/view.js +12 -17
  170. package/dist/prompts/searchable-multi-select.js +1 -1
  171. package/dist/telemetry/config.d.ts +3 -4
  172. package/dist/telemetry/index.d.ts +10 -5
  173. package/dist/telemetry/index.js +75 -41
  174. package/dist/ui/welcome-screen.d.ts +9 -1
  175. package/dist/ui/welcome-screen.js +91 -38
  176. package/dist/utils/change-metadata.d.ts +43 -0
  177. package/dist/utils/change-metadata.js +123 -7
  178. package/dist/utils/change-utils.d.ts +10 -4
  179. package/dist/utils/change-utils.js +20 -13
  180. package/dist/utils/ci.d.ts +12 -0
  181. package/dist/utils/ci.js +16 -0
  182. package/dist/utils/command-references.d.ts +92 -6
  183. package/dist/utils/command-references.js +180 -7
  184. package/dist/utils/date.d.ts +8 -0
  185. package/dist/utils/date.js +13 -0
  186. package/dist/utils/file-system.d.ts +10 -0
  187. package/dist/utils/file-system.js +70 -0
  188. package/dist/utils/index.d.ts +1 -1
  189. package/dist/utils/index.js +1 -1
  190. package/dist/utils/interactive.d.ts +23 -0
  191. package/dist/utils/interactive.js +32 -0
  192. package/dist/utils/item-discovery.d.ts +19 -0
  193. package/dist/utils/item-discovery.js +30 -48
  194. package/dist/utils/shell-detection.d.ts +1 -1
  195. package/dist/utils/shell-detection.js +64 -11
  196. package/dist/utils/spec-discovery.d.ts +40 -0
  197. package/dist/utils/spec-discovery.js +115 -0
  198. package/dist/utils/task-progress.d.ts +17 -0
  199. package/dist/utils/task-progress.js +41 -13
  200. package/package.json +14 -12
  201. package/schemas/spec-driven/schema.yaml +67 -10
  202. package/schemas/spec-driven/templates/design.md +2 -2
  203. package/schemas/spec-driven/templates/proposal.md +10 -4
  204. package/schemas/spec-driven/templates/spec.md +3 -0
  205. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  206. package/dist/core/command-generation/adapters/codex.js +0 -39
  207. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  208. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -10,20 +10,23 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
- import { transformToHyphenCommands } from '../utils/command-references.js';
13
+ import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
14
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
15
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
16
- import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
17
- import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, getToolsFromLegacyArtifacts, } from './legacy-cleanup.js';
16
+ import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
17
+ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { getGlobalConfig } from './global-config.js';
20
- import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
20
+ import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
21
+ import { getOnboardingCommands } from './onboarding-commands.js';
21
22
  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';
23
+ import { WORKFLOW_TO_SKILL_DIR, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
24
+ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, findLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
25
+ import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
26
+ import { writeSharedSkillTarget } from './shared-skill-target.js';
27
+ import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js';
24
28
  const require = createRequire(import.meta.url);
25
29
  const { version: OPENSPEC_VERSION } = require('../../package.json');
26
- const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'];
27
30
  /**
28
31
  * Scans installed workflow artifacts (skills and managed commands) across all configured tools.
29
32
  * Returns the union of detected workflow IDs that match ALL_WORKFLOWS.
@@ -41,6 +44,12 @@ export class UpdateCommand {
41
44
  constructor(options = {}) {
42
45
  this.force = options.force ?? false;
43
46
  }
47
+ /**
48
+ * Refreshes OpenSpec skills and commands for all configured tools,
49
+ * regenerating artifacts according to the effective profile and delivery mode.
50
+ *
51
+ * @param projectPath - Path to the project root containing the openspec directory
52
+ */
44
53
  async execute(projectPath) {
45
54
  const resolvedProjectPath = path.resolve(projectPath);
46
55
  const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);
@@ -48,7 +57,17 @@ export class UpdateCommand {
48
57
  if (!await FileSystemUtils.directoryExists(openspecPath)) {
49
58
  throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
50
59
  }
51
- // 2. Perform one-time migration if needed before any legacy upgrade generation.
60
+ // 2. Migrate OpenSpec-managed skills left in renamed tool directories
61
+ // (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed,
62
+ // then perform the one-time profile migration if needed before any
63
+ // legacy upgrade generation.
64
+ for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) {
65
+ if (hasMovableContent(migration)) {
66
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
67
+ }
68
+ this.reportKeptInPlace(migration);
69
+ }
70
+ const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath);
52
71
  // Use detected tool directories to preserve existing opsx skills/commands.
53
72
  const detectedTools = getAvailableTools(resolvedProjectPath);
54
73
  migrateIfNeededShared(resolvedProjectPath, detectedTools);
@@ -58,31 +77,47 @@ export class UpdateCommand {
58
77
  const delivery = globalConfig.delivery ?? 'both';
59
78
  const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows);
60
79
  const desiredWorkflows = profileWorkflows.filter((workflow) => ALL_WORKFLOWS.includes(workflow));
61
- const shouldGenerateSkills = delivery !== 'commands';
62
- const shouldGenerateCommands = delivery !== 'skills';
63
80
  // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config
64
- const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath, desiredWorkflows, delivery);
81
+ const legacyUpgrade = await this.handleLegacyCleanup(resolvedProjectPath, desiredWorkflows, delivery);
82
+ const { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides, deferredGlobalCleanup, } = legacyUpgrade;
65
83
  // 5. Find configured tools
66
84
  const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);
85
+ const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
67
86
  if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
87
+ if (deferredGlobalCleanup) {
88
+ await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
89
+ }
90
+ if (declinedMigrations.length > 0) {
91
+ // Not an unconfigured project — a configured one the user chose to
92
+ // leave in its former directory. Saying "run init" would be wrong.
93
+ for (const migration of declinedMigrations) {
94
+ console.log(chalk.yellow(`Nothing to update: this project's OpenSpec files are still in ${migration.from}/, ` +
95
+ `which OpenSpec no longer writes.`));
96
+ console.log(chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`));
97
+ }
98
+ return;
99
+ }
100
+ await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
68
101
  console.log(chalk.yellow('No configured tools found.'));
69
102
  console.log(chalk.dim('Run "openspec init" to set up tools.'));
70
103
  return;
71
104
  }
72
- // 6. Check version status for all configured tools
73
- const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath);
74
- const commandConfiguredSet = new Set(commandConfiguredTools);
75
- const toolStatuses = configuredTools.map((toolId) => {
76
- const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION);
77
- if (!status.configured && commandConfiguredSet.has(toolId)) {
78
- return { ...status, configured: true };
79
- }
80
- return status;
81
- });
105
+ // 6. Check version status for all configured tools, against the same workflow set
106
+ // the generation loop below writes — otherwise a legacy-upgraded tool would be
107
+ // fingerprinted against commands it was never given.
108
+ const toolStatuses = configuredTools.map((toolId) => getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, {
109
+ workflows: legacyWorkflowOverrides[toolId] ?? desiredWorkflows,
110
+ }));
82
111
  const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status]));
83
112
  // 7. Smart update detection
84
113
  const toolsNeedingVersionUpdate = toolStatuses
85
- .filter((s) => s.needsUpdate)
114
+ .filter((s) => {
115
+ if (!s.needsUpdate || delivery !== 'commands') {
116
+ return s.needsUpdate;
117
+ }
118
+ const tool = AI_TOOLS.find((candidate) => candidate.value === s.toolId);
119
+ return !tool || !hasGlobalSkillTarget(tool);
120
+ })
86
121
  .map((s) => s.toolId);
87
122
  const toolsNeedingConfigSync = getToolsNeedingProfileSync(resolvedProjectPath, desiredWorkflows, delivery, configuredTools);
88
123
  const toolsToUpdateSet = new Set([
@@ -90,56 +125,82 @@ export class UpdateCommand {
90
125
  ...toolsNeedingConfigSync,
91
126
  ]);
92
127
  const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId));
93
- if (!this.force && toolsToUpdateSet.size === 0) {
128
+ if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) {
129
+ if (deferredGlobalCleanup) {
130
+ await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
131
+ }
94
132
  // All tools are up to date
95
133
  this.displayUpToDateMessage(toolStatuses);
134
+ await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
96
135
  // Still check for new tool directories and extra workflows
97
136
  this.detectNewTools(resolvedProjectPath, configuredTools);
98
137
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
99
- this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows);
138
+ this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
139
+ this.displaySetupNotes(configuredTools);
100
140
  return;
101
141
  }
102
142
  // 8. Display update plan
103
143
  if (this.force) {
104
144
  console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
105
145
  }
146
+ else if (toolsToUpdateSet.size === 0) {
147
+ console.log('No additional refresh needed after legacy migration.');
148
+ }
106
149
  else {
107
150
  this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
108
151
  }
109
152
  console.log();
110
153
  // 9. Determine what to generate based on delivery
111
- const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];
112
- const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];
154
+ const deliveryIncludesCommands = delivery !== 'skills';
113
155
  // 10. Update tools (all if force, otherwise only those needing update)
114
156
  const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
115
157
  const updatedTools = [];
116
158
  const failedTools = [];
159
+ const skillsInvocableCommandSkips = [];
160
+ const zeroArtifactTools = [];
117
161
  let removedCommandCount = 0;
118
162
  let removedSkillCount = 0;
119
163
  let removedDeselectedCommandCount = 0;
120
164
  let removedDeselectedSkillCount = 0;
121
165
  for (const toolId of toolsToUpdate) {
122
166
  const tool = AI_TOOLS.find((t) => t.value === toolId);
123
- if (!tool?.skillsDir)
167
+ if (!tool || !toolSupportsSkills(tool))
124
168
  continue;
125
169
  const spinner = ora(`Updating ${tool.name}...`).start();
126
170
  try {
127
- const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
171
+ const skillsDir = resolveToolSkillsDir(resolvedProjectPath, tool);
172
+ const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath;
173
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
174
+ const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
175
+ const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows;
176
+ const skillTemplates = getSkillTemplates(toolWorkflows);
177
+ const commandContents = getCommandContents(toolWorkflows);
128
178
  // Generate skill files if delivery includes skills
129
179
  if (shouldGenerateSkills) {
130
180
  for (const { template, dirName } of skillTemplates) {
131
181
  const skillDir = path.join(skillsDir, dirName);
132
182
  const skillFile = path.join(skillDir, 'SKILL.md');
133
- // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
134
- const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
183
+ const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
135
184
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
185
+ FileSystemUtils.assertPathWithin(skillsRoot, skillFile);
136
186
  await FileSystemUtils.writeFile(skillFile, skillContent);
137
187
  }
138
- removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
188
+ writeSharedSkillTarget(resolvedProjectPath, tool.value);
189
+ removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsRoot, skillsDir, toolWorkflows);
139
190
  }
140
191
  // Delete skill directories if delivery is commands-only
141
- if (!shouldGenerateSkills) {
142
- removedSkillCount += await this.removeSkillDirs(skillsDir);
192
+ if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) {
193
+ removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir);
194
+ // Persist the selected owner even when commands-only delivery leaves
195
+ // this target with no generated skills.
196
+ writeSharedSkillTarget(resolvedProjectPath, tool.value);
197
+ // A tool with no command adapter now has zero OpenSpec artifacts;
198
+ // say so like init does, rather than deleting its skills silently
199
+ // and letting tool detection re-suggest an init that would also
200
+ // generate nothing under this delivery setting.
201
+ if (!shouldGenerateCommandsForTool(tool.value, delivery)) {
202
+ zeroArtifactTools.push(tool.name);
203
+ }
143
204
  }
144
205
  // Generate commands if delivery includes commands
145
206
  if (shouldGenerateCommands) {
@@ -147,18 +208,27 @@ export class UpdateCommand {
147
208
  if (adapter) {
148
209
  const generatedCommands = generateCommands(commandContents, adapter);
149
210
  for (const cmd of generatedCommands) {
150
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
211
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(resolvedProjectPath, cmd.path);
151
212
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
152
213
  }
153
- removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, desiredWorkflows);
214
+ removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, toolWorkflows);
154
215
  }
155
216
  }
217
+ else if (deliveryIncludesCommands && resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') {
218
+ skillsInvocableCommandSkips.push(tool.value);
219
+ }
156
220
  // Delete command files if delivery is skills-only
157
- if (!shouldGenerateCommands) {
221
+ if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
158
222
  removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
159
223
  }
160
224
  spinner.succeed(`Updated ${tool.name}`);
161
225
  updatedTools.push(tool.name);
226
+ for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
227
+ if (hasMovableContent(migration)) {
228
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
229
+ }
230
+ this.reportKeptInPlace(migration);
231
+ }
162
232
  }
163
233
  catch (error) {
164
234
  spinner.fail(`Failed to update ${tool.name}`);
@@ -168,6 +238,9 @@ export class UpdateCommand {
168
238
  });
169
239
  }
170
240
  }
241
+ if (deferredGlobalCleanup) {
242
+ await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
243
+ }
171
244
  // 11. Summary
172
245
  console.log();
173
246
  if (updatedTools.length > 0) {
@@ -176,34 +249,69 @@ export class UpdateCommand {
176
249
  if (failedTools.length > 0) {
177
250
  console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
178
251
  }
252
+ if (skillsInvocableCommandSkips.length > 0) {
253
+ console.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`));
254
+ }
179
255
  if (removedCommandCount > 0) {
180
256
  console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
181
257
  }
182
258
  if (removedSkillCount > 0) {
183
259
  console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
184
260
  }
261
+ if (zeroArtifactTools.length > 0) {
262
+ const names = zeroArtifactTools.join(', ');
263
+ console.log(chalk.yellow(`No skills or commands remain for ${names}: delivery is set to 'commands' but ` +
264
+ `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` +
265
+ `Run 'openspec config set delivery both' to generate skills.`));
266
+ }
185
267
  if (removedDeselectedCommandCount > 0) {
186
268
  console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
187
269
  }
188
270
  if (removedDeselectedSkillCount > 0) {
189
271
  console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
190
272
  }
191
- // 12. Show onboarding message for newly configured tools from legacy upgrade
273
+ // 12. Show onboarding message for newly configured tools from legacy upgrade.
274
+ // Command tools get the command name their files answer to, skill-only
275
+ // tools their documented skill invocation, and disagreements fall back to
276
+ // naming the skill.
192
277
  if (newlyConfiguredTools.length > 0) {
278
+ const referenceFor = (command) => {
279
+ const neutralForm = `the ${transformToSkillReferences(command).slice(1)} skill`;
280
+ const forms = new Set(newlyConfiguredTools.map((toolId) => {
281
+ if (shouldGenerateCommandsForTool(toolId, delivery)) {
282
+ // Name the command the tool's files actually answer to:
283
+ // /opsx-<id> where the filename is the command name.
284
+ const transformer = getTransformerForTool(toolId, delivery, resolveCommandSurfaceCapability(toolId), resolveCommandInvocation(toolId));
285
+ return transformer ? transformer(command) : command;
286
+ }
287
+ return getSkillReferenceTransformer(toolId)(command);
288
+ }));
289
+ return forms.size === 1 ? [...forms][0] : neutralForm;
290
+ };
291
+ // Only hint at workflows these tools actually received. A legacy upgrade
292
+ // can install a narrower set than the profile (inferred Codex prompts).
293
+ const installedWorkflows = [
294
+ ...new Set(newlyConfiguredTools.flatMap((toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows)),
295
+ ];
296
+ const entries = getOnboardingCommands(installedWorkflows).map(({ command, description }) => [referenceFor(command), description]);
193
297
  console.log();
194
- console.log(chalk.bold('Getting started:'));
195
- console.log(' /opsx:new Start a new change');
196
- console.log(' /opsx:continue Create the next artifact');
197
- console.log(' /opsx:apply Implement tasks');
198
- console.log();
298
+ if (entries.length > 0) {
299
+ const width = Math.max(...entries.map(([reference]) => reference.length));
300
+ console.log(chalk.bold('Getting started:'));
301
+ for (const [reference, description] of entries) {
302
+ console.log(` ${reference.padEnd(width)} ${description}`);
303
+ }
304
+ console.log();
305
+ }
199
306
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
200
307
  }
201
- const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
308
+ await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
202
309
  // 13. Detect new tool directories not currently configured
203
310
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
204
311
  // 14. Display note about extra workflows not in profile
205
312
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
206
- this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows);
313
+ this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
314
+ this.displaySetupNotes(configuredAndNewTools);
207
315
  // 15. List affected tools
208
316
  if (updatedTools.length > 0) {
209
317
  const toolDisplayNames = updatedTools;
@@ -211,6 +319,51 @@ export class UpdateCommand {
211
319
  }
212
320
  console.log();
213
321
  console.log(chalk.dim('Restart your IDE for changes to take effect.'));
322
+ if (failedTools.length > 0) {
323
+ throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
324
+ }
325
+ }
326
+ async syncCopilotCloudFiles(projectPath, configuredTools) {
327
+ try {
328
+ if (includesGitHubCopilot(configuredTools)) {
329
+ // Cloud files are opt-in (see cloud-agent.ts). `update` never prompts,
330
+ // so it only refreshes files the user has already opted into (via
331
+ // `openspec init` or a `githubCopilot.cloudAgent: true` config), or that
332
+ // a pre-opt-in project already has. Opting in is a deliberate init/config
333
+ // step, never a silent side effect of running update.
334
+ if (await isCopilotCloudEnabled(projectPath)) {
335
+ await writeCopilotCloudFiles(projectPath);
336
+ const collisions = await findUnmanagedCloudFiles(projectPath);
337
+ if (collisions.length > 0) {
338
+ console.log(chalk.dim(`Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` +
339
+ `install step by hand so the Copilot cloud agent can run openspec.`));
340
+ }
341
+ return;
342
+ }
343
+ // Explicit opt-out (githubCopilot.cloudAgent: false) means "not here":
344
+ // remove any managed files a prior opt-in left behind (customized files
345
+ // are preserved). If the user simply never decided, stay quiet unless
346
+ // we're at an interactive terminal, where a one-line hint aids discovery.
347
+ if (readCopilotCloudOptIn(projectPath) === false) {
348
+ const removed = await removeCopilotCloudFiles(projectPath);
349
+ if (removed > 0) {
350
+ console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`));
351
+ }
352
+ }
353
+ else if (isInteractive()) {
354
+ console.log(chalk.dim("GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'."));
355
+ }
356
+ return;
357
+ }
358
+ const removed = await removeCopilotCloudFiles(projectPath);
359
+ if (removed > 0) {
360
+ console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`));
361
+ }
362
+ }
363
+ catch (error) {
364
+ const message = error instanceof Error ? error.message : String(error);
365
+ console.warn(`Warning: failed to sync Copilot cloud agent files: ${message}`);
366
+ }
214
367
  }
215
368
  /**
216
369
  * Display message when all tools are up to date.
@@ -240,6 +393,18 @@ export class UpdateCommand {
240
393
  console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
241
394
  }
242
395
  }
396
+ /**
397
+ * Shows manual setup notes for configured tools that need extra
398
+ * configuration before they pick up generated files.
399
+ */
400
+ displaySetupNotes(toolIds) {
401
+ for (const toolId of toolIds) {
402
+ const tool = AI_TOOLS.find((t) => t.value === toolId);
403
+ if (tool?.setupNote) {
404
+ console.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`));
405
+ }
406
+ }
407
+ }
243
408
  /**
244
409
  * Detects new tool directories that aren't currently configured and displays a hint.
245
410
  */
@@ -268,38 +433,41 @@ export class UpdateCommand {
268
433
  }
269
434
  }
270
435
  /**
271
- * Suggest opting back into core when a custom profile still matches the old
272
- * pre-sync core set. Keep custom profiles user-owned; do not mutate them.
436
+ * Point out core workflows a custom profile is missing, so releases that
437
+ * grow CORE_WORKFLOWS stay discoverable. Keep custom profiles user-owned;
438
+ * do not mutate them.
273
439
  */
274
- displayOldCoreCustomProfileNote(profile, workflows) {
440
+ displayMissingCoreWorkflowsNote(profile, workflows) {
275
441
  if (profile !== 'custom' || !workflows) {
276
442
  return;
277
443
  }
278
444
  const workflowSet = new Set(workflows);
279
- const matchesOldCore = workflowSet.size === OLD_CORE_WORKFLOWS.length &&
280
- OLD_CORE_WORKFLOWS.every((workflow) => workflowSet.has(workflow));
281
- if (!matchesOldCore) {
445
+ const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow));
446
+ if (missing.length === 0) {
282
447
  return;
283
448
  }
284
- console.log(chalk.dim('Note: The core profile now includes sync. Your custom profile is preserving the old core workflow set.'));
285
- console.log(chalk.dim('Run `openspec config profile core` and then `openspec update` to add sync.'));
449
+ const label = missing.length === 1 ? 'workflow' : 'workflows';
450
+ const pronoun = missing.length === 1 ? 'it' : 'them';
451
+ console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
452
+ console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
286
453
  }
287
454
  /**
288
455
  * Removes skill directories for workflows when delivery changed to commands-only.
289
456
  * Returns the number of directories removed.
290
457
  */
291
- async removeSkillDirs(skillsDir) {
458
+ async removeSkillDirs(skillsRoot, skillsDir) {
292
459
  let removed = 0;
293
460
  for (const workflow of ALL_WORKFLOWS) {
294
461
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
295
462
  if (!dirName)
296
463
  continue;
297
464
  const skillDir = path.join(skillsDir, dirName);
465
+ if (!fs.existsSync(skillDir))
466
+ continue;
467
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
298
468
  try {
299
- if (fs.existsSync(skillDir)) {
300
- await fs.promises.rm(skillDir, { recursive: true, force: true });
301
- removed++;
302
- }
469
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
470
+ removed++;
303
471
  }
304
472
  catch {
305
473
  // Ignore errors
@@ -311,7 +479,7 @@ export class UpdateCommand {
311
479
  * Removes skill directories for workflows that are no longer selected in the active profile.
312
480
  * Returns the number of directories removed.
313
481
  */
314
- async removeUnselectedSkillDirs(skillsDir, desiredWorkflows) {
482
+ async removeUnselectedSkillDirs(skillsRoot, skillsDir, desiredWorkflows) {
315
483
  const desiredSet = new Set(desiredWorkflows);
316
484
  let removed = 0;
317
485
  for (const workflow of ALL_WORKFLOWS) {
@@ -321,11 +489,12 @@ export class UpdateCommand {
321
489
  if (!dirName)
322
490
  continue;
323
491
  const skillDir = path.join(skillsDir, dirName);
492
+ if (!fs.existsSync(skillDir))
493
+ continue;
494
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
324
495
  try {
325
- if (fs.existsSync(skillDir)) {
326
- await fs.promises.rm(skillDir, { recursive: true, force: true });
327
- removed++;
328
- }
496
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
497
+ removed++;
329
498
  }
330
499
  catch {
331
500
  // Ignore errors
@@ -344,7 +513,7 @@ export class UpdateCommand {
344
513
  return 0;
345
514
  for (const workflow of ALL_WORKFLOWS) {
346
515
  const cmdPath = adapter.getFilePath(workflow);
347
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
516
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
348
517
  try {
349
518
  if (fs.existsSync(fullPath)) {
350
519
  await fs.promises.unlink(fullPath);
@@ -371,7 +540,7 @@ export class UpdateCommand {
371
540
  if (desiredSet.has(workflow))
372
541
  continue;
373
542
  const cmdPath = adapter.getFilePath(workflow);
374
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
543
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
375
544
  try {
376
545
  if (fs.existsSync(fullPath)) {
377
546
  await fs.promises.unlink(fullPath);
@@ -384,6 +553,72 @@ export class UpdateCommand {
384
553
  }
385
554
  return removed;
386
555
  }
556
+ /**
557
+ * Offers to move OpenSpec content out of a renamed tool's former directory
558
+ * when the old location might still be the live one — today, Windsurf's
559
+ * `.windsurf/` after the Devin Desktop rebrand.
560
+ *
561
+ * Interactive runs are asked, because nothing on disk distinguishes a user
562
+ * who took the rebrand from one still on a pre-rebrand Windsurf build that
563
+ * reads only `.windsurf/`. `--force` and non-interactive runs migrate, which
564
+ * is what an unattended upgrade wants.
565
+ */
566
+ /** Surfaces files the move left behind rather than overwriting. */
567
+ reportKeptInPlace(migration) {
568
+ const notice = keptInPlaceNotice(migration);
569
+ if (notice)
570
+ console.log(chalk.dim(notice));
571
+ }
572
+ async offerConsentedLegacyMigrations(projectPath) {
573
+ const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent);
574
+ const declined = [];
575
+ if (pending.length === 0)
576
+ return declined;
577
+ for (const migration of pending) {
578
+ // Nothing movable: every legacy file differs from its counterpart, so
579
+ // there is no move to offer. Still say so — silence would leave two
580
+ // divergent copies the user never hears about.
581
+ if (!hasMovableContent(migration)) {
582
+ this.reportKeptInPlace(migration);
583
+ console.log();
584
+ continue;
585
+ }
586
+ console.log(chalk.yellow(legacyMigrationNotice(migration)));
587
+ if (!this.force && isInteractive()) {
588
+ const { confirm } = await import('@inquirer/prompts');
589
+ let shouldMigrate;
590
+ try {
591
+ shouldMigrate = await confirm({
592
+ message: `Move ${describeLegacyMigration(migration)} from ${migration.from}/ to ${migration.to}/?`,
593
+ default: true,
594
+ });
595
+ }
596
+ catch {
597
+ // Closed stdin is not consent, and it must not abort the update.
598
+ shouldMigrate = false;
599
+ }
600
+ if (!shouldMigrate) {
601
+ // Say what declining costs. OpenSpec writes the current root now, so
602
+ // the files keep working where they are, but OpenSpec stops managing
603
+ // them — it no longer looks in the former directory.
604
+ console.log(chalk.dim(`Left in place. OpenSpec writes ${migration.to}/ now and will not manage ` +
605
+ `${migration.from}/, so those files stay as they are until you move them. ` +
606
+ `You will be asked again next run.`));
607
+ console.log();
608
+ declined.push(migration);
609
+ continue;
610
+ }
611
+ }
612
+ for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) {
613
+ if (hasMovableContent(applied)) {
614
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`));
615
+ }
616
+ this.reportKeptInPlace(applied);
617
+ }
618
+ console.log();
619
+ }
620
+ return declined;
621
+ }
387
622
  /**
388
623
  * Detect and handle legacy OpenSpec artifacts.
389
624
  * Unlike init, update warns but continues if legacy files found in non-interactive mode.
@@ -393,25 +628,37 @@ export class UpdateCommand {
393
628
  // Detect legacy artifacts
394
629
  const detection = await detectLegacyArtifacts(projectPath);
395
630
  if (!detection.hasLegacyArtifacts) {
396
- return []; // No legacy artifacts found
631
+ return { newlyConfiguredTools: [], workflowOverrides: {} }; // No legacy artifacts found
397
632
  }
398
633
  // Show what was detected
399
- console.log();
400
- console.log(formatDetectionSummary(detection));
401
- console.log();
634
+ const immediateSummary = formatDetectionSummary(omitGlobalLegacyPromptFiles(detection));
635
+ const deferredSummary = formatDeferredGlobalPromptSummary(detection);
636
+ if (immediateSummary || deferredSummary) {
637
+ console.log();
638
+ if (immediateSummary) {
639
+ console.log(immediateSummary);
640
+ console.log();
641
+ }
642
+ if (deferredSummary) {
643
+ console.log(deferredSummary);
644
+ console.log();
645
+ }
646
+ }
402
647
  const canPrompt = isInteractive();
403
648
  if (this.force) {
404
- // --force flag: proceed with cleanup automatically
405
- await this.performLegacyCleanup(projectPath, detection);
406
- // Then upgrade legacy tools to new skills
407
- return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
649
+ const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
650
+ await this.performImmediateLegacyCleanup(projectPath, detection);
651
+ return {
652
+ ...legacyUpgrade,
653
+ deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
654
+ };
408
655
  }
409
656
  if (!canPrompt) {
410
657
  // Non-interactive mode without --force: warn and continue
411
658
  // (Unlike init, update doesn't abort - user may just want to update skills)
412
659
  console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
413
660
  console.log();
414
- return [];
661
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
415
662
  }
416
663
  // Interactive mode: prompt for confirmation
417
664
  const { confirm } = await import('@inquirer/prompts');
@@ -420,14 +667,47 @@ export class UpdateCommand {
420
667
  default: true,
421
668
  });
422
669
  if (shouldCleanup) {
423
- await this.performLegacyCleanup(projectPath, detection);
424
- // Then upgrade legacy tools to new skills
425
- return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
670
+ const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
671
+ await this.performImmediateLegacyCleanup(projectPath, detection);
672
+ return {
673
+ ...legacyUpgrade,
674
+ deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
675
+ };
426
676
  }
427
677
  else {
428
678
  console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
429
679
  console.log();
430
- return [];
680
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
681
+ }
682
+ }
683
+ /**
684
+ * Cleans approved repo-local legacy artifacts before configured tools refresh.
685
+ */
686
+ async performImmediateLegacyCleanup(projectPath, detection) {
687
+ const immediateDetection = omitGlobalLegacyPromptFiles(detection);
688
+ if (immediateDetection.hasLegacyArtifacts) {
689
+ await this.performLegacyCleanup(projectPath, immediateDetection);
690
+ }
691
+ }
692
+ /**
693
+ * Cleans approved global Codex prompts after configured tools refresh so newly
694
+ * installed replacement skills can retire their prompts in the same run.
695
+ */
696
+ async performDeferredGlobalPromptCleanup(projectPath, detection) {
697
+ const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex']));
698
+ const removableMatches = getLegacyGlobalPromptMatches(detection)
699
+ .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId)));
700
+ if (removableMatches.length > 0) {
701
+ await this.performLegacyCleanup(projectPath, pickGlobalLegacyPromptFiles(detection, removableMatches.map((prompt) => prompt.path)));
702
+ }
703
+ const blockedMatches = getLegacyGlobalPromptMatches(detection)
704
+ .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path));
705
+ if (blockedMatches.length > 0) {
706
+ console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
707
+ for (const prompt of blockedMatches) {
708
+ console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`));
709
+ }
710
+ console.log();
431
711
  }
432
712
  }
433
713
  /**
@@ -445,14 +725,14 @@ export class UpdateCommand {
445
725
  console.log();
446
726
  }
447
727
  /**
448
- * Upgrade legacy tools to new skills system.
449
- * Returns array of tool IDs that were newly configured.
728
+ * Upgrades unconfigured legacy tools into the skills-based setup and carries
729
+ * workflow overrides for migrations that should mirror legacy Codex prompts.
450
730
  */
451
731
  async upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery) {
452
732
  // Get tools that had legacy artifacts
453
733
  const legacyTools = getToolsFromLegacyArtifacts(detection);
454
734
  if (legacyTools.length === 0) {
455
- return [];
735
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
456
736
  }
457
737
  // Get currently configured tools
458
738
  const configuredTools = getConfiguredToolsForProfileSync(projectPath);
@@ -460,13 +740,13 @@ export class UpdateCommand {
460
740
  // Filter to tools that aren't already configured
461
741
  const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
462
742
  if (unconfiguredLegacyTools.length === 0) {
463
- return [];
743
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
464
744
  }
465
745
  // Get valid tools (those with skillsDir)
466
746
  const validToolIds = new Set(getToolsWithSkillsDir());
467
747
  const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t));
468
748
  if (validUnconfiguredTools.length === 0) {
469
- return [];
749
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
470
750
  }
471
751
  // Show what tools were detected from legacy artifacts
472
752
  console.log(chalk.bold('Tools detected from legacy artifacts:'));
@@ -502,32 +782,42 @@ export class UpdateCommand {
502
782
  if (selectedTools.length === 0) {
503
783
  console.log(chalk.dim('Skipping tool setup.'));
504
784
  console.log();
505
- return [];
785
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
506
786
  }
507
787
  }
788
+ const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
508
789
  // Create skills/commands for selected tools using effective profile+delivery.
509
790
  const newlyConfigured = [];
510
- const shouldGenerateSkills = delivery !== 'commands';
511
- const shouldGenerateCommands = delivery !== 'skills';
512
- const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];
513
- const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];
791
+ const workflowOverrides = {};
514
792
  for (const toolId of selectedTools) {
515
793
  const tool = AI_TOOLS.find((t) => t.value === toolId);
516
- if (!tool?.skillsDir)
794
+ if (!tool || !toolSupportsSkills(tool))
517
795
  continue;
518
796
  const spinner = ora(`Setting up ${tool.name}...`).start();
519
797
  try {
520
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
798
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
799
+ const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath;
800
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
801
+ const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
802
+ const toolWorkflows = (tool.value === 'codex' && inferredCodexWorkflows.length > 0
803
+ ? inferredCodexWorkflows
804
+ : desiredWorkflows);
805
+ if (tool.value === 'codex' && inferredCodexWorkflows.length > 0) {
806
+ workflowOverrides[tool.value] = inferredCodexWorkflows;
807
+ }
808
+ const skillTemplates = getSkillTemplates(toolWorkflows);
809
+ const commandContents = getCommandContents(toolWorkflows);
521
810
  // Create skill files when delivery includes skills
522
811
  if (shouldGenerateSkills) {
523
812
  for (const { template, dirName } of skillTemplates) {
524
813
  const skillDir = path.join(skillsDir, dirName);
525
814
  const skillFile = path.join(skillDir, 'SKILL.md');
526
- // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
527
- const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
815
+ const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
528
816
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
817
+ FileSystemUtils.assertPathWithin(skillsRoot, skillFile);
529
818
  await FileSystemUtils.writeFile(skillFile, skillContent);
530
819
  }
820
+ writeSharedSkillTarget(projectPath, tool.value);
531
821
  }
532
822
  // Create commands when delivery includes commands
533
823
  if (shouldGenerateCommands) {
@@ -535,13 +825,19 @@ export class UpdateCommand {
535
825
  if (adapter) {
536
826
  const generatedCommands = generateCommands(commandContents, adapter);
537
827
  for (const cmd of generatedCommands) {
538
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
828
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
539
829
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
540
830
  }
541
831
  }
542
832
  }
543
833
  spinner.succeed(`Setup complete for ${tool.name}`);
544
834
  newlyConfigured.push(toolId);
835
+ for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
836
+ if (hasMovableContent(migration)) {
837
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
838
+ }
839
+ this.reportKeptInPlace(migration);
840
+ }
545
841
  }
546
842
  catch (error) {
547
843
  spinner.fail(`Failed to set up ${tool.name}`);
@@ -551,7 +847,7 @@ export class UpdateCommand {
551
847
  if (newlyConfigured.length > 0) {
552
848
  console.log();
553
849
  }
554
- return newlyConfigured;
850
+ return { newlyConfiguredTools: newlyConfigured, workflowOverrides };
555
851
  }
556
852
  }
557
853
  //# sourceMappingURL=update.js.map