@fission-ai/openspec 1.14.2 → 1.15.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 (66) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/change.js +2 -1
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/spec.js +2 -10
  9. package/dist/commands/validate.js +11 -4
  10. package/dist/commands/workflow/instructions.js +15 -4
  11. package/dist/commands/workflow/new-change.js +12 -4
  12. package/dist/commands/workflow/status.js +5 -2
  13. package/dist/commands/workflow/templates.js +5 -2
  14. package/dist/core/artifact-graph/outputs.js +204 -29
  15. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  16. package/dist/core/artifact-graph/resolver.js +11 -0
  17. package/dist/core/available-tools.d.ts +2 -1
  18. package/dist/core/available-tools.js +8 -28
  19. package/dist/core/completion-tip.d.ts +23 -0
  20. package/dist/core/completion-tip.js +4 -4
  21. package/dist/core/completions/command-registry.js +15 -0
  22. package/dist/core/completions/completion-provider.js +1 -1
  23. package/dist/core/config-schema.d.ts +1 -0
  24. package/dist/core/config-schema.js +1 -0
  25. package/dist/core/file-view.d.ts +13 -0
  26. package/dist/core/file-view.js +14 -0
  27. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  28. package/dist/core/github-copilot/cloud-agent.js +13 -9
  29. package/dist/core/global-config.d.ts +4 -0
  30. package/dist/core/global-config.js +5 -1
  31. package/dist/core/legacy-cleanup.d.ts +11 -1
  32. package/dist/core/legacy-cleanup.js +15 -18
  33. package/dist/core/migration.d.ts +27 -1
  34. package/dist/core/migration.js +77 -29
  35. package/dist/core/profile-sync-drift.d.ts +4 -3
  36. package/dist/core/profile-sync-drift.js +19 -16
  37. package/dist/core/shared/tool-detection.d.ts +12 -4
  38. package/dist/core/shared/tool-detection.js +29 -27
  39. package/dist/core/shared-skill-target.d.ts +12 -5
  40. package/dist/core/shared-skill-target.js +34 -23
  41. package/dist/core/specs-apply.js +23 -5
  42. package/dist/core/standalone-tip.d.ts +6 -0
  43. package/dist/core/standalone-tip.js +38 -0
  44. package/dist/core/templates/workflows/continue-change.js +18 -12
  45. package/dist/core/templates/workflows/explore.js +2 -2
  46. package/dist/core/templates/workflows/ff-change.js +8 -6
  47. package/dist/core/templates/workflows/propose.js +8 -6
  48. package/dist/core/templates/workflows/verify-change.js +4 -4
  49. package/dist/core/update-plan.d.ts +127 -0
  50. package/dist/core/update-plan.js +390 -0
  51. package/dist/core/update.d.ts +45 -1
  52. package/dist/core/update.js +279 -137
  53. package/dist/core/validation/validator.js +3 -3
  54. package/dist/core/version-check.d.ts +8 -1
  55. package/dist/core/version-check.js +48 -5
  56. package/dist/utils/executable-path.d.ts +5 -0
  57. package/dist/utils/executable-path.js +44 -0
  58. package/dist/utils/file-system.d.ts +1 -1
  59. package/dist/utils/file-system.js +1 -1
  60. package/dist/utils/interactive.d.ts +15 -0
  61. package/dist/utils/interactive.js +18 -0
  62. package/dist/utils/spec-discovery.d.ts +4 -4
  63. package/dist/utils/spec-discovery.js +7 -17
  64. package/dist/utils/spec-path-containment.d.ts +17 -0
  65. package/dist/utils/spec-path-containment.js +122 -0
  66. package/package.json +8 -4
@@ -7,7 +7,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';
11
10
  import { createRequire } from 'module';
12
11
  import { FileSystemUtils } from '../utils/file-system.js';
13
12
  import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
@@ -16,14 +15,16 @@ import { generateCommands, CommandAdapterRegistry, } from './command-generation/
16
15
  import { getToolSkillStatus, getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, formatIdeRestart, } from './shared/index.js';
17
16
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, omitToolLegacyArtifacts, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
17
  import { isInteractive } from '../utils/interactive.js';
19
- import { getGlobalConfig } from './global-config.js';
18
+ import { getGlobalConfig, getGlobalConfigPath, serializeGlobalConfig } from './global-config.js';
20
19
  import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
21
20
  import { formatOptionalWorkflowsNote, getOnboardingCommands } from './onboarding-commands.js';
22
21
  import { getAvailableTools } from './available-tools.js';
23
22
  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';
23
+ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, computeProfileMigration, applyProfileMigration, findLegacyToolMigrations, planLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
25
24
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
26
- import { resolveSharedSkillWriters, writeSharedSkillTarget, } from './shared-skill-target.js';
25
+ import { resolveSharedSkillWriters, sharedSkillTargetMarkerPath, } from './shared-skill-target.js';
26
+ import { DISK_VIEW } from './file-view.js';
27
+ import { realFileOps, UpdatePlanRecorder, } from './update-plan.js';
27
28
  import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js';
28
29
  const require = createRequire(import.meta.url);
29
30
  const { version: OPENSPEC_VERSION } = require('../../package.json');
@@ -46,16 +47,46 @@ function normalizeGeneratedFile(content) {
46
47
  *
47
48
  * Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs.
48
49
  */
49
- export function scanInstalledWorkflows(projectPath, toolIds) {
50
+ export function scanInstalledWorkflows(projectPath, toolIds, view) {
50
51
  const tools = toolIds
51
52
  .map((id) => AI_TOOLS.find((t) => t.value === id))
52
53
  .filter((t) => t != null);
53
- return scanInstalledWorkflowsShared(projectPath, tools);
54
+ return scanInstalledWorkflowsShared(projectPath, tools, view);
54
55
  }
55
56
  export class UpdateCommand {
56
57
  force;
58
+ dryRun;
59
+ recorder = null;
60
+ ops = realFileOps;
57
61
  constructor(options = {}) {
58
62
  this.force = options.force ?? false;
63
+ this.dryRun = options.dryRun ?? false;
64
+ }
65
+ /**
66
+ * Reports what `execute` would add, change or delete, writing nothing.
67
+ *
68
+ * Same code path as the real update: the plan is recorded by the write seam
69
+ * the generation loop already goes through, so it cannot drift from it.
70
+ */
71
+ async plan(projectPath) {
72
+ if (!this.dryRun) {
73
+ throw new Error('plan() requires the update command to be constructed with dryRun: true');
74
+ }
75
+ await this.execute(projectPath);
76
+ return this.recorder?.getPlan() ?? { changes: [], notes: [], failures: [] };
77
+ }
78
+ /** Prints progress on a real run, stays quiet while planning. */
79
+ log(...args) {
80
+ if (this.dryRun)
81
+ return;
82
+ console.log(...args);
83
+ }
84
+ /** A spinner on a real run, a no-op while planning. */
85
+ startSpinner(text) {
86
+ if (this.dryRun) {
87
+ return { succeed: () => { }, fail: () => { }, info: () => { } };
88
+ }
89
+ return ora(text).start();
59
90
  }
60
91
  /**
61
92
  * Refreshes OpenSpec skills and commands for all configured tools,
@@ -66,6 +97,10 @@ export class UpdateCommand {
66
97
  async execute(projectPath) {
67
98
  const resolvedProjectPath = path.resolve(projectPath);
68
99
  const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);
100
+ if (this.dryRun) {
101
+ this.recorder = new UpdatePlanRecorder(resolvedProjectPath);
102
+ this.ops = this.recorder;
103
+ }
69
104
  // 1. Check openspec directory exists
70
105
  if (!await FileSystemUtils.directoryExists(openspecPath)) {
71
106
  throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
@@ -74,18 +109,40 @@ export class UpdateCommand {
74
109
  // (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed,
75
110
  // then perform the one-time profile migration if needed before any
76
111
  // legacy upgrade generation.
77
- for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) {
78
- if (hasMovableContent(migration)) {
79
- console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
112
+ if (!this.dryRun) {
113
+ for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) {
114
+ if (hasMovableContent(migration)) {
115
+ this.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
116
+ }
117
+ this.reportKeptInPlace(migration);
80
118
  }
81
- this.reportKeptInPlace(migration);
82
119
  }
83
- const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath);
120
+ // A dry run plans the same moves. Every later step reads `this.view` -
121
+ // the disk in a real run, the tree as planned in a dry run - so it sees
122
+ // the moved files exactly as the real update would.
123
+ if (this.dryRun) {
124
+ await this.planLegacyToolMoves(resolvedProjectPath, 'before-generation');
125
+ }
126
+ const declinedMigrations = this.dryRun
127
+ ? []
128
+ : await this.offerConsentedLegacyMigrations(resolvedProjectPath);
84
129
  // Use detected tool directories to preserve existing opsx skills/commands.
85
- const detectedTools = getAvailableTools(resolvedProjectPath);
86
- migrateIfNeededShared(resolvedProjectPath, detectedTools);
87
- // 3. Read global config for profile/delivery
88
- const globalConfig = getGlobalConfig();
130
+ const detectedTools = getAvailableTools(resolvedProjectPath, this.view);
131
+ if (!this.dryRun) {
132
+ migrateIfNeededShared(resolvedProjectPath, detectedTools);
133
+ }
134
+ // 3. Read global config for profile/delivery. A dry run plans the one-time
135
+ // profile migration's save instead of making it, and resolves against
136
+ // the config that save would leave — otherwise the preview would plan
137
+ // for a different profile than the real update acts on.
138
+ let globalConfig = getGlobalConfig();
139
+ if (this.recorder) {
140
+ const migration = computeProfileMigration(resolvedProjectPath, detectedTools, this.view);
141
+ if (migration) {
142
+ globalConfig = applyProfileMigration(migration);
143
+ await this.recorder.writeFile(getGlobalConfigPath(), serializeGlobalConfig(globalConfig), 'openspec');
144
+ }
145
+ }
89
146
  const profile = globalConfig.profile ?? 'core';
90
147
  const delivery = globalConfig.delivery ?? 'both';
91
148
  const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows);
@@ -94,26 +151,27 @@ export class UpdateCommand {
94
151
  const legacyUpgrade = await this.handleLegacyCleanup(resolvedProjectPath, desiredWorkflows, delivery);
95
152
  const { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides, deferredGlobalCleanup, failedTools: legacyUpgradeFailures = [], } = legacyUpgrade;
96
153
  // 5. Find configured tools
97
- const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);
154
+ const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath, this.view);
98
155
  const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
99
156
  if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
100
157
  if (deferredGlobalCleanup) {
101
158
  await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
102
159
  }
103
- throwIfUpdateFailed(legacyUpgradeFailures);
160
+ if (this.stopIfUpdateFailed(legacyUpgradeFailures))
161
+ return;
104
162
  if (declinedMigrations.length > 0) {
105
163
  // Not an unconfigured project — a configured one the user chose to
106
164
  // leave in its former directory. Saying "run init" would be wrong.
107
165
  for (const migration of declinedMigrations) {
108
- console.log(chalk.yellow(`Nothing to update: this project's OpenSpec files are still in ${migration.from}/, ` +
166
+ this.log(chalk.yellow(`Nothing to update: this project's OpenSpec files are still in ${migration.from}/, ` +
109
167
  `which OpenSpec no longer writes.`));
110
- console.log(chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`));
168
+ this.log(chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`));
111
169
  }
112
170
  return;
113
171
  }
114
172
  await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
115
- console.log(chalk.yellow('No configured tools found.'));
116
- console.log(chalk.dim('Run "openspec init" to set up tools.'));
173
+ this.log(chalk.yellow('No configured tools found.'));
174
+ this.log(chalk.dim('Run "openspec init" to set up tools.'));
117
175
  return;
118
176
  }
119
177
  // 6. Check version status for all configured tools, against the same workflow set
@@ -121,6 +179,8 @@ export class UpdateCommand {
121
179
  // fingerprinted against commands it was never given.
122
180
  const toolStatuses = configuredTools.map((toolId) => getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, {
123
181
  workflows: legacyWorkflowOverrides[toolId] ?? desiredWorkflows,
182
+ delivery,
183
+ view: this.view,
124
184
  }));
125
185
  const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status]));
126
186
  // 7. Smart update detection
@@ -133,7 +193,7 @@ export class UpdateCommand {
133
193
  return !tool || !hasGlobalSkillTarget(tool);
134
194
  })
135
195
  .map((s) => s.toolId);
136
- const toolsNeedingConfigSync = getToolsNeedingProfileSync(resolvedProjectPath, desiredWorkflows, delivery, configuredTools);
196
+ const toolsNeedingConfigSync = getToolsNeedingProfileSync(resolvedProjectPath, desiredWorkflows, delivery, configuredTools, this.view);
137
197
  const toolsWithDriftedSkills = this.findToolsWithDriftedSkills(resolvedProjectPath, configuredTools, delivery, (toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows);
138
198
  const toolsToUpdateSet = new Set([
139
199
  ...toolsNeedingVersionUpdate,
@@ -152,27 +212,29 @@ export class UpdateCommand {
152
212
  this.detectNewTools(resolvedProjectPath, configuredTools);
153
213
  this.displayProfileNotes(resolvedProjectPath, configuredTools, desiredWorkflows, profile, delivery);
154
214
  this.displaySetupNotes(configuredTools);
155
- throwIfUpdateFailed(legacyUpgradeFailures);
215
+ this.stopIfUpdateFailed(legacyUpgradeFailures);
156
216
  return;
157
217
  }
158
218
  // 8. Display update plan
159
219
  if (this.force) {
160
- console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
220
+ this.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
161
221
  }
162
222
  else if (toolsToUpdateSet.size === 0) {
163
- console.log('No additional refresh needed after legacy migration.');
223
+ this.log('No additional refresh needed after legacy migration.');
164
224
  }
165
225
  else {
166
226
  this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate, new Set(toolsWithDriftedSkills));
167
227
  }
168
- console.log();
228
+ this.log();
169
229
  // 9. Determine what to generate based on delivery
170
230
  const deliveryIncludesCommands = delivery !== 'skills';
171
231
  // 10. Update tools (all if force, otherwise only those needing update)
172
232
  const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
173
233
  const sharedSkillWriters = resolveSharedSkillWriters(resolvedProjectPath, configuredAndNewTools
174
234
  .map((toolId) => AI_TOOLS.find((tool) => tool.value === toolId))
175
- .filter((tool) => tool !== undefined));
235
+ .filter((tool) => tool !== undefined),
236
+ // A legacy upgrade earlier in this run may have claimed the root.
237
+ this.view);
176
238
  const updatedTools = [];
177
239
  const updatedToolIds = [];
178
240
  const failedTools = [...legacyUpgradeFailures];
@@ -186,7 +248,7 @@ export class UpdateCommand {
186
248
  const tool = AI_TOOLS.find((t) => t.value === toolId);
187
249
  if (!tool || !toolSupportsSkills(tool))
188
250
  continue;
189
- const spinner = ora(`Updating ${tool.name}...`).start();
251
+ const spinner = this.startSpinner(`Updating ${tool.name}...`);
190
252
  try {
191
253
  const skillsDir = resolveToolSkillsDir(resolvedProjectPath, tool);
192
254
  const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath;
@@ -204,19 +266,19 @@ export class UpdateCommand {
204
266
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
205
267
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
206
268
  FileSystemUtils.assertPathWithin(skillsRoot, skillFile);
207
- await FileSystemUtils.writeFile(skillFile, skillContent);
269
+ await this.ops.writeFile(skillFile, skillContent, tool.value);
208
270
  }
209
- writeSharedSkillTarget(resolvedProjectPath, tool.value);
210
- removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsRoot, skillsDir, toolWorkflows);
271
+ await this.writeSharedSkillTarget(resolvedProjectPath, tool.value);
272
+ removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsRoot, skillsDir, toolWorkflows, tool.value);
211
273
  }
212
274
  // Delete skill directories if delivery is commands-only
213
275
  if (shouldRemoveSkillsForTool(tool.value, delivery) &&
214
276
  writesSkills &&
215
277
  !hasGlobalSkillTarget(tool)) {
216
- removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir);
278
+ removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir, tool.value);
217
279
  // Persist the selected owner even when commands-only delivery leaves
218
280
  // this target with no generated skills.
219
- writeSharedSkillTarget(resolvedProjectPath, tool.value);
281
+ await this.writeSharedSkillTarget(resolvedProjectPath, tool.value);
220
282
  // A tool with no command adapter now has zero OpenSpec artifacts;
221
283
  // say so like init does, rather than deleting its skills silently
222
284
  // and letting tool detection re-suggest an init that would also
@@ -232,7 +294,7 @@ export class UpdateCommand {
232
294
  const generatedCommands = generateCommands(commandContents, adapter);
233
295
  for (const cmd of generatedCommands) {
234
296
  const commandFile = FileSystemUtils.resolveProjectArtifactPath(resolvedProjectPath, cmd.path);
235
- await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
297
+ await this.ops.writeFile(commandFile, cmd.fileContent, tool.value);
236
298
  }
237
299
  removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, toolWorkflows);
238
300
  }
@@ -247,52 +309,58 @@ export class UpdateCommand {
247
309
  spinner.succeed(`Updated ${tool.name}`);
248
310
  updatedTools.push(tool.name);
249
311
  updatedToolIds.push(tool.value);
250
- for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
251
- if (hasMovableContent(migration)) {
252
- console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
312
+ if (!this.dryRun) {
313
+ for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
314
+ if (hasMovableContent(migration)) {
315
+ this.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
316
+ }
317
+ this.reportKeptInPlace(migration);
253
318
  }
254
- this.reportKeptInPlace(migration);
319
+ }
320
+ else {
321
+ await this.planLegacyToolMoves(resolvedProjectPath, 'after-generation', [tool.value]);
255
322
  }
256
323
  }
257
324
  catch (error) {
258
325
  spinner.fail(`Failed to update ${tool.name}`);
259
- failedTools.push({
260
- name: tool.name,
261
- error: error instanceof Error ? error.message : String(error)
262
- });
326
+ const message = error instanceof Error ? error.message : String(error);
327
+ failedTools.push({ name: tool.name, error: message });
328
+ // A dry run reports the failure in the plan rather than throwing it:
329
+ // one broken tool must not cost the caller the other tools' changes.
330
+ this.recorder?.addFailure(tool.value, message);
263
331
  }
264
332
  }
265
333
  if (deferredGlobalCleanup) {
266
334
  await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
267
335
  }
268
336
  // 11. Summary
269
- console.log();
337
+ this.log();
270
338
  if (updatedTools.length > 0) {
271
- console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
339
+ this.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
272
340
  }
273
341
  if (failedTools.length > 0) {
274
- console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
342
+ this.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
275
343
  }
276
344
  if (skillsInvocableCommandSkips.length > 0) {
277
- console.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`));
345
+ this.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`));
278
346
  }
279
347
  if (removedCommandCount > 0) {
280
- console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
348
+ this.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
281
349
  }
282
350
  if (removedSkillCount > 0) {
283
- console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
351
+ this.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
284
352
  }
285
353
  if (zeroArtifactTools.length > 0) {
286
354
  const names = zeroArtifactTools.join(', ');
287
- console.log(chalk.yellow(`No skills or commands remain for ${names}: delivery is set to 'commands' but ` +
355
+ this.log(chalk.yellow(`No skills or commands remain for ${names}: delivery is set to 'commands' but ` +
288
356
  `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` +
289
357
  `Run 'openspec config set delivery both' to generate skills.`));
290
358
  }
291
359
  if (removedDeselectedCommandCount > 0) {
292
- console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
360
+ this.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
293
361
  }
294
362
  if (removedDeselectedSkillCount > 0) {
295
- console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
363
+ this.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
296
364
  }
297
365
  // 12. Show onboarding message for newly configured tools from legacy upgrade.
298
366
  // Command tools get the command name their files answer to, skill-only
@@ -318,16 +386,16 @@ export class UpdateCommand {
318
386
  ...new Set(newlyConfiguredTools.flatMap((toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows)),
319
387
  ];
320
388
  const entries = getOnboardingCommands(installedWorkflows).map(({ command, description }) => [referenceFor(command), description]);
321
- console.log();
389
+ this.log();
322
390
  if (entries.length > 0) {
323
391
  const width = Math.max(...entries.map(([reference]) => reference.length));
324
- console.log(chalk.bold('Getting started:'));
392
+ this.log(chalk.bold('Getting started:'));
325
393
  for (const [reference, description] of entries) {
326
- console.log(` ${reference.padEnd(width)} ${description}`);
394
+ this.log(` ${reference.padEnd(width)} ${description}`);
327
395
  }
328
- console.log();
396
+ this.log();
329
397
  }
330
- console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
398
+ this.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
331
399
  }
332
400
  await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
333
401
  // 13. Detect new tool directories not currently configured
@@ -338,17 +406,81 @@ export class UpdateCommand {
338
406
  // 15. List affected tools
339
407
  if (updatedTools.length > 0) {
340
408
  const toolDisplayNames = updatedTools;
341
- console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
409
+ this.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
342
410
  }
343
- console.log();
411
+ this.log();
344
412
  const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
345
413
  const restartHint = formatIdeRestart(affectedToolIds, delivery);
346
414
  if (restartHint) {
347
- console.log(chalk.dim(restartHint));
415
+ this.log(chalk.dim(restartHint));
416
+ }
417
+ this.stopIfUpdateFailed(failedTools);
418
+ }
419
+ /**
420
+ * Records the shared-skills-root owner through the write seam, so a dry run
421
+ * lists the marker file a real update would write.
422
+ */
423
+ async writeSharedSkillTarget(projectPath, toolId) {
424
+ const marker = sharedSkillTargetMarkerPath(projectPath, toolId);
425
+ if (!marker)
426
+ return;
427
+ FileSystemUtils.assertProjectArtifactPath(projectPath, marker);
428
+ await this.ops.writeFile(marker, `${toolId}\n`, toolId);
429
+ }
430
+ /**
431
+ * Where a real update throws for failed tools, it also stops. A dry run
432
+ * stops at the same point - so it plans nothing the real update would not
433
+ * reach - but returns instead of throwing: the failures are already in the
434
+ * plan, and the caller still gets every change planned before them.
435
+ *
436
+ * @returns whether the update stops here.
437
+ */
438
+ stopIfUpdateFailed(failedTools) {
439
+ if (!this.dryRun)
440
+ throwIfUpdateFailed(failedTools);
441
+ return failedTools.length > 0;
442
+ }
443
+ /** The tree as the real update would see it at this point. */
444
+ get view() {
445
+ return this.recorder?.view ?? DISK_VIEW;
446
+ }
447
+ /** The update's write seam, in the shape modules with their own writers take. */
448
+ fileOps(tool) {
449
+ return {
450
+ writeFile: (filePath, content) => this.ops.writeFile(filePath, content, tool),
451
+ removeFile: (filePath) => this.ops.removeFile(filePath, tool),
452
+ removeEmptyDirectory: (dirPath) => this.ops.removeEmptyDirectory(dirPath),
453
+ };
454
+ }
455
+ /**
456
+ * Plans the moves out of former tool roots that a non-interactive update
457
+ * makes, against the tree as planned so far.
458
+ */
459
+ async planLegacyToolMoves(projectPath, timing, toolIds) {
460
+ const recorder = this.recorder;
461
+ if (!recorder)
462
+ return;
463
+ for (const op of planLegacyToolMigrations(projectPath, timing, toolIds, recorder.view)) {
464
+ try {
465
+ if (op.disposition === 'move') {
466
+ await recorder.moveFile(op.source, op.destination, op.toolId);
467
+ }
468
+ else {
469
+ await recorder.removeFile(op.source, op.toolId);
470
+ }
471
+ }
472
+ catch {
473
+ // The real migration leaves a file it cannot move where it is, and
474
+ // says nothing; so does the plan.
475
+ continue;
476
+ }
477
+ if (op.needsConsent) {
478
+ recorder.addNote('legacy-tool-directories', 'Moves out of a renamed tool directory are listed as a non-interactive update makes them. At a terminal, update asks first, and declining leaves those files where they are.');
479
+ }
348
480
  }
349
- throwIfUpdateFailed(failedTools);
350
481
  }
351
482
  async syncCopilotCloudFiles(projectPath, configuredTools) {
483
+ const ops = this.fileOps('github-copilot');
352
484
  try {
353
485
  if (includesGitHubCopilot(configuredTools)) {
354
486
  // Cloud files are opt-in (see cloud-agent.ts). `update` never prompts,
@@ -357,10 +489,10 @@ export class UpdateCommand {
357
489
  // a pre-opt-in project already has. Opting in is a deliberate init/config
358
490
  // step, never a silent side effect of running update.
359
491
  if (await isCopilotCloudEnabled(projectPath)) {
360
- await writeCopilotCloudFiles(projectPath);
492
+ await writeCopilotCloudFiles(projectPath, ops);
361
493
  const collisions = await findUnmanagedCloudFiles(projectPath);
362
494
  if (collisions.length > 0) {
363
- console.log(chalk.dim(`Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` +
495
+ this.log(chalk.dim(`Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` +
364
496
  `install step by hand so the Copilot cloud agent can run openspec.`));
365
497
  }
366
498
  return;
@@ -370,19 +502,19 @@ export class UpdateCommand {
370
502
  // are preserved). If the user simply never decided, stay quiet unless
371
503
  // we're at an interactive terminal, where a one-line hint aids discovery.
372
504
  if (readCopilotCloudOptIn(projectPath) === false) {
373
- const removed = await removeCopilotCloudFiles(projectPath);
505
+ const removed = await removeCopilotCloudFiles(projectPath, ops);
374
506
  if (removed > 0) {
375
- console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`));
507
+ this.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`));
376
508
  }
377
509
  }
378
510
  else if (isInteractive()) {
379
- console.log(chalk.dim("GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'."));
511
+ this.log(chalk.dim("GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'."));
380
512
  }
381
513
  return;
382
514
  }
383
- const removed = await removeCopilotCloudFiles(projectPath);
515
+ const removed = await removeCopilotCloudFiles(projectPath, ops);
384
516
  if (removed > 0) {
385
- console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`));
517
+ this.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`));
386
518
  }
387
519
  }
388
520
  catch (error) {
@@ -410,16 +542,16 @@ export class UpdateCommand {
410
542
  return false;
411
543
  // A shared skills root is generated with its owner's transformer, so
412
544
  // only the owner may compare it. getToolSkillStatus settles ownership.
413
- if (!getToolSkillStatus(projectPath, tool.value).configured)
545
+ if (!getToolSkillStatus(projectPath, tool.value, this.view).configured)
414
546
  return false;
415
547
  const skillsDir = resolveToolSkillsDir(projectPath, tool);
416
548
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
417
549
  return getSkillTemplates(workflowsForTool(toolId)).some(({ template, dirName }) => {
418
550
  const skillFile = path.join(skillsDir, dirName, 'SKILL.md');
419
- if (!fs.existsSync(skillFile))
551
+ if (!this.view.exists(skillFile))
420
552
  return false;
421
553
  try {
422
- const existing = fs.readFileSync(skillFile, 'utf-8');
554
+ const existing = this.view.read(skillFile);
423
555
  const generated = generateSkillContent(template, OPENSPEC_VERSION, transformer);
424
556
  return normalizeGeneratedFile(existing) !== normalizeGeneratedFile(generated);
425
557
  }
@@ -434,10 +566,10 @@ export class UpdateCommand {
434
566
  */
435
567
  displayUpToDateMessage(toolStatuses) {
436
568
  const toolNames = toolStatuses.map((s) => s.toolId);
437
- console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
438
- console.log(chalk.dim(` Tools: ${toolNames.join(', ')}`));
439
- console.log();
440
- console.log(chalk.dim('Use --force to refresh files anyway.'));
569
+ this.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
570
+ this.log(chalk.dim(` Tools: ${toolNames.join(', ')}`));
571
+ this.log();
572
+ this.log(chalk.dim('Use --force to refresh files anyway.'));
441
573
  }
442
574
  /**
443
575
  * Display the update plan showing which tools need updating.
@@ -456,10 +588,10 @@ export class UpdateCommand {
456
588
  }
457
589
  return `${toolId} (config sync)`;
458
590
  });
459
- console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);
591
+ this.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);
460
592
  if (upToDate.length > 0) {
461
593
  const upToDateNames = upToDate.map((s) => s.toolId);
462
- console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
594
+ this.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
463
595
  }
464
596
  }
465
597
  /**
@@ -470,7 +602,7 @@ export class UpdateCommand {
470
602
  for (const toolId of toolIds) {
471
603
  const tool = AI_TOOLS.find((t) => t.value === toolId);
472
604
  if (tool?.setupNote) {
473
- console.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`));
605
+ this.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`));
474
606
  }
475
607
  }
476
608
  }
@@ -486,8 +618,8 @@ export class UpdateCommand {
486
618
  const isSingleTool = newToolNames.length === 1;
487
619
  const toolNoun = isSingleTool ? 'tool' : 'tools';
488
620
  const pronoun = isSingleTool ? 'it' : 'them';
489
- console.log();
490
- console.log(chalk.yellow(`Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`));
621
+ this.log();
622
+ this.log(chalk.yellow(`Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`));
491
623
  }
492
624
  }
493
625
  /**
@@ -510,7 +642,7 @@ export class UpdateCommand {
510
642
  const profileSet = new Set(profileWorkflows);
511
643
  const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));
512
644
  if (extraWorkflows.length > 0) {
513
- console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
645
+ this.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
514
646
  return true;
515
647
  }
516
648
  return false;
@@ -531,8 +663,8 @@ export class UpdateCommand {
531
663
  }
532
664
  const label = missing.length === 1 ? 'workflow' : 'workflows';
533
665
  const pronoun = missing.length === 1 ? 'it' : 'them';
534
- console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
535
- console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
666
+ this.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
667
+ this.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
536
668
  return true;
537
669
  }
538
670
  /**
@@ -562,25 +694,25 @@ export class UpdateCommand {
562
694
  return;
563
695
  }
564
696
  for (const line of note) {
565
- console.log(chalk.dim(line));
697
+ this.log(chalk.dim(line));
566
698
  }
567
699
  }
568
700
  /**
569
701
  * Removes skill directories for workflows when delivery changed to commands-only.
570
702
  * Returns the number of directories removed.
571
703
  */
572
- async removeSkillDirs(skillsRoot, skillsDir) {
704
+ async removeSkillDirs(skillsRoot, skillsDir, toolId) {
573
705
  let removed = 0;
574
706
  for (const workflow of ALL_WORKFLOWS) {
575
707
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
576
708
  if (!dirName)
577
709
  continue;
578
710
  const skillDir = path.join(skillsDir, dirName);
579
- if (!fs.existsSync(skillDir))
711
+ if (!this.view.exists(skillDir))
580
712
  continue;
581
713
  FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
582
714
  try {
583
- await fs.promises.rm(skillDir, { recursive: true, force: true });
715
+ await this.ops.removeDirectory(skillDir, toolId);
584
716
  removed++;
585
717
  }
586
718
  catch {
@@ -593,7 +725,7 @@ export class UpdateCommand {
593
725
  * Removes skill directories for workflows that are no longer selected in the active profile.
594
726
  * Returns the number of directories removed.
595
727
  */
596
- async removeUnselectedSkillDirs(skillsRoot, skillsDir, desiredWorkflows) {
728
+ async removeUnselectedSkillDirs(skillsRoot, skillsDir, desiredWorkflows, toolId) {
597
729
  const desiredSet = new Set(desiredWorkflows);
598
730
  let removed = 0;
599
731
  for (const workflow of ALL_WORKFLOWS) {
@@ -603,11 +735,11 @@ export class UpdateCommand {
603
735
  if (!dirName)
604
736
  continue;
605
737
  const skillDir = path.join(skillsDir, dirName);
606
- if (!fs.existsSync(skillDir))
738
+ if (!this.view.exists(skillDir))
607
739
  continue;
608
740
  FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
609
741
  try {
610
- await fs.promises.rm(skillDir, { recursive: true, force: true });
742
+ await this.ops.removeDirectory(skillDir, toolId);
611
743
  removed++;
612
744
  }
613
745
  catch {
@@ -629,8 +761,8 @@ export class UpdateCommand {
629
761
  const cmdPath = adapter.getFilePath(workflow);
630
762
  const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
631
763
  try {
632
- if (fs.existsSync(fullPath)) {
633
- await fs.promises.unlink(fullPath);
764
+ if (this.view.exists(fullPath)) {
765
+ await this.ops.removeFile(fullPath, toolId);
634
766
  removed++;
635
767
  }
636
768
  }
@@ -656,8 +788,8 @@ export class UpdateCommand {
656
788
  const cmdPath = adapter.getFilePath(workflow);
657
789
  const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
658
790
  try {
659
- if (fs.existsSync(fullPath)) {
660
- await fs.promises.unlink(fullPath);
791
+ if (this.view.exists(fullPath)) {
792
+ await this.ops.removeFile(fullPath, toolId);
661
793
  removed++;
662
794
  }
663
795
  }
@@ -681,7 +813,7 @@ export class UpdateCommand {
681
813
  reportKeptInPlace(migration) {
682
814
  const notice = keptInPlaceNotice(migration);
683
815
  if (notice)
684
- console.log(chalk.dim(notice));
816
+ this.log(chalk.dim(notice));
685
817
  }
686
818
  async offerConsentedLegacyMigrations(projectPath) {
687
819
  const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent);
@@ -694,10 +826,10 @@ export class UpdateCommand {
694
826
  // divergent copies the user never hears about.
695
827
  if (!hasMovableContent(migration)) {
696
828
  this.reportKeptInPlace(migration);
697
- console.log();
829
+ this.log();
698
830
  continue;
699
831
  }
700
- console.log(chalk.yellow(legacyMigrationNotice(migration)));
832
+ this.log(chalk.yellow(legacyMigrationNotice(migration)));
701
833
  if (!this.force && isInteractive()) {
702
834
  const { confirm } = await import('@inquirer/prompts');
703
835
  let shouldMigrate;
@@ -715,21 +847,21 @@ export class UpdateCommand {
715
847
  // Say what declining costs. OpenSpec writes the current root now, so
716
848
  // the files keep working where they are, but OpenSpec stops managing
717
849
  // them — it no longer looks in the former directory.
718
- console.log(chalk.dim(`Left in place. OpenSpec writes ${migration.to}/ now and will not manage ` +
850
+ this.log(chalk.dim(`Left in place. OpenSpec writes ${migration.to}/ now and will not manage ` +
719
851
  `${migration.from}/, so those files stay as they are until you move them. ` +
720
852
  `You will be asked again next run.`));
721
- console.log();
853
+ this.log();
722
854
  declined.push(migration);
723
855
  continue;
724
856
  }
725
857
  }
726
858
  for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) {
727
859
  if (hasMovableContent(applied)) {
728
- console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`));
860
+ this.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`));
729
861
  }
730
862
  this.reportKeptInPlace(applied);
731
863
  }
732
- console.log();
864
+ this.log();
733
865
  }
734
866
  return declined;
735
867
  }
@@ -748,17 +880,18 @@ export class UpdateCommand {
748
880
  const immediateSummary = formatDetectionSummary(omitGlobalLegacyPromptFiles(detection));
749
881
  const deferredSummary = formatDeferredGlobalPromptSummary(detection);
750
882
  if (immediateSummary || deferredSummary) {
751
- console.log();
883
+ this.log();
752
884
  if (immediateSummary) {
753
- console.log(immediateSummary);
754
- console.log();
885
+ this.log(immediateSummary);
886
+ this.log();
755
887
  }
756
888
  if (deferredSummary) {
757
- console.log(deferredSummary);
758
- console.log();
889
+ this.log(deferredSummary);
890
+ this.log();
759
891
  }
760
892
  }
761
- const canPrompt = isInteractive();
893
+ // A dry run plans a non-interactive update, so it never prompts.
894
+ const canPrompt = !this.dryRun && isInteractive();
762
895
  if (this.force) {
763
896
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
764
897
  await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
@@ -770,8 +903,9 @@ export class UpdateCommand {
770
903
  if (!canPrompt) {
771
904
  // Non-interactive mode without --force: warn and continue
772
905
  // (Unlike init, update doesn't abort - user may just want to update skills)
773
- console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
774
- console.log();
906
+ this.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
907
+ this.log();
908
+ this.recorder?.addNote('legacy-artifacts', 'Legacy OpenSpec artifacts were found. Update cleans them up only with --force or when you confirm at a terminal, so this plan leaves them alone; --dry-run --force lists that cleanup.');
775
909
  return { newlyConfiguredTools: [], workflowOverrides: {} };
776
910
  }
777
911
  // Interactive mode: prompt for confirmation
@@ -789,8 +923,8 @@ export class UpdateCommand {
789
923
  };
790
924
  }
791
925
  else {
792
- console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
793
- console.log();
926
+ this.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
927
+ this.log();
794
928
  return { newlyConfiguredTools: [], workflowOverrides: {} };
795
929
  }
796
930
  }
@@ -810,7 +944,9 @@ export class UpdateCommand {
810
944
  * installed replacement skills can retire their prompts in the same run.
811
945
  */
812
946
  async performDeferredGlobalPromptCleanup(projectPath, detection) {
813
- const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex']));
947
+ // Read the tree as planned: a dry run has not written the replacement
948
+ // skills this check looks for.
949
+ const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex'], this.view));
814
950
  const removableMatches = getLegacyGlobalPromptMatches(detection)
815
951
  .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId)));
816
952
  if (removableMatches.length > 0) {
@@ -819,26 +955,26 @@ export class UpdateCommand {
819
955
  const blockedMatches = getLegacyGlobalPromptMatches(detection)
820
956
  .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path));
821
957
  if (blockedMatches.length > 0) {
822
- console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
958
+ this.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
823
959
  for (const prompt of blockedMatches) {
824
- console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`));
960
+ this.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`));
825
961
  }
826
- console.log();
962
+ this.log();
827
963
  }
828
964
  }
829
965
  /**
830
966
  * Perform cleanup of legacy artifacts.
831
967
  */
832
968
  async performLegacyCleanup(projectPath, detection) {
833
- const spinner = ora('Cleaning up legacy files...').start();
834
- const result = await cleanupLegacyArtifacts(projectPath, detection);
969
+ const spinner = this.startSpinner('Cleaning up legacy files...');
970
+ const result = await cleanupLegacyArtifacts(projectPath, detection, this.fileOps('openspec'));
835
971
  spinner.succeed('Legacy files cleaned up');
836
972
  const summary = formatCleanupSummary(result);
837
973
  if (summary) {
838
- console.log();
839
- console.log(summary);
974
+ this.log();
975
+ this.log(summary);
840
976
  }
841
- console.log();
977
+ this.log();
842
978
  }
843
979
  /**
844
980
  * Upgrades unconfigured legacy tools into the skills-based setup and carries
@@ -851,7 +987,7 @@ export class UpdateCommand {
851
987
  return { newlyConfiguredTools: [], workflowOverrides: {} };
852
988
  }
853
989
  // Get currently configured tools
854
- const configuredTools = getConfiguredToolsForProfileSync(projectPath);
990
+ const configuredTools = getConfiguredToolsForProfileSync(projectPath, this.view);
855
991
  const configuredSet = new Set(configuredTools);
856
992
  // Filter to tools that aren't already configured
857
993
  const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
@@ -865,17 +1001,17 @@ export class UpdateCommand {
865
1001
  return { newlyConfiguredTools: [], workflowOverrides: {} };
866
1002
  }
867
1003
  // Show what tools were detected from legacy artifacts
868
- console.log(chalk.bold('Tools detected from legacy artifacts:'));
1004
+ this.log(chalk.bold('Tools detected from legacy artifacts:'));
869
1005
  for (const toolId of validUnconfiguredTools) {
870
1006
  const tool = AI_TOOLS.find((t) => t.value === toolId);
871
- console.log(` • ${tool?.name || toolId}`);
1007
+ this.log(` • ${tool?.name || toolId}`);
872
1008
  }
873
- console.log();
1009
+ this.log();
874
1010
  let selectedTools;
875
1011
  if (this.force || !canPrompt) {
876
1012
  // Non-interactive with --force: auto-select detected tools
877
1013
  selectedTools = validUnconfiguredTools;
878
- console.log(`Setting up skills for: ${selectedTools.join(', ')}`);
1014
+ this.log(`Setting up skills for: ${selectedTools.join(', ')}`);
879
1015
  }
880
1016
  else {
881
1017
  // Interactive mode: prompt for tool selection with detected tools pre-selected
@@ -897,8 +1033,8 @@ export class UpdateCommand {
897
1033
  validate: (_selected) => true, // Allow empty selection (user can skip)
898
1034
  });
899
1035
  if (selectedTools.length === 0) {
900
- console.log(chalk.dim('Skipping tool setup.'));
901
- console.log();
1036
+ this.log(chalk.dim('Skipping tool setup.'));
1037
+ this.log();
902
1038
  return { newlyConfiguredTools: [], workflowOverrides: {} };
903
1039
  }
904
1040
  }
@@ -911,12 +1047,12 @@ export class UpdateCommand {
911
1047
  const arbitrationTools = [...new Set([...configuredTools, ...selectedTools])]
912
1048
  .map((toolId) => AI_TOOLS.find((tool) => tool.value === toolId))
913
1049
  .filter((tool) => tool !== undefined);
914
- const sharedSkillWriters = resolveSharedSkillWriters(projectPath, arbitrationTools);
1050
+ const sharedSkillWriters = resolveSharedSkillWriters(projectPath, arbitrationTools, this.view);
915
1051
  for (const toolId of selectedTools) {
916
1052
  const tool = AI_TOOLS.find((t) => t.value === toolId);
917
1053
  if (!tool || !toolSupportsSkills(tool))
918
1054
  continue;
919
- const spinner = ora(`Setting up ${tool.name}...`).start();
1055
+ const spinner = this.startSpinner(`Setting up ${tool.name}...`);
920
1056
  try {
921
1057
  const skillsDir = resolveToolSkillsDir(projectPath, tool);
922
1058
  const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath;
@@ -954,9 +1090,9 @@ export class UpdateCommand {
954
1090
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
955
1091
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
956
1092
  FileSystemUtils.assertPathWithin(skillsRoot, skillFile);
957
- await FileSystemUtils.writeFile(skillFile, skillContent);
1093
+ await this.ops.writeFile(skillFile, skillContent, tool.value);
958
1094
  }
959
- writeSharedSkillTarget(projectPath, tool.value);
1095
+ await this.writeSharedSkillTarget(projectPath, tool.value);
960
1096
  }
961
1097
  // Create commands when delivery includes commands
962
1098
  if (shouldGenerateCommands) {
@@ -965,28 +1101,34 @@ export class UpdateCommand {
965
1101
  const generatedCommands = generateCommands(commandContents, adapter);
966
1102
  for (const cmd of generatedCommands) {
967
1103
  const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
968
- await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
1104
+ await this.ops.writeFile(commandFile, cmd.fileContent, tool.value);
969
1105
  }
970
1106
  }
971
1107
  }
972
1108
  spinner.succeed(`Setup complete for ${tool.name}`);
973
1109
  newlyConfigured.push(toolId);
974
- for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
975
- if (hasMovableContent(migration)) {
976
- console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
1110
+ if (this.dryRun) {
1111
+ await this.planLegacyToolMoves(projectPath, 'after-generation', [tool.value]);
1112
+ }
1113
+ else {
1114
+ for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
1115
+ if (hasMovableContent(migration)) {
1116
+ this.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
1117
+ }
1118
+ this.reportKeptInPlace(migration);
977
1119
  }
978
- this.reportKeptInPlace(migration);
979
1120
  }
980
1121
  }
981
1122
  catch (error) {
982
1123
  spinner.fail(`Failed to set up ${tool.name}`);
983
1124
  const message = error instanceof Error ? error.message : String(error);
984
- console.log(chalk.red(` ${message}`));
1125
+ this.log(chalk.red(` ${message}`));
985
1126
  failedTools.push({ name: tool.name, error: message });
1127
+ this.recorder?.addFailure(tool.value, message);
986
1128
  }
987
1129
  }
988
1130
  if (newlyConfigured.length > 0) {
989
- console.log();
1131
+ this.log();
990
1132
  }
991
1133
  return {
992
1134
  newlyConfiguredTools: newlyConfigured,