@fission-ai/openspec 1.14.3 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -10
- package/dist/cli/index.js +47 -0
- package/dist/commands/completion.js +4 -2
- package/dist/commands/feedback.js +33 -20
- package/dist/commands/install-standalone.d.ts +27 -0
- package/dist/commands/install-standalone.js +150 -0
- package/dist/commands/schema.js +10 -3
- package/dist/commands/validate.js +33 -3
- package/dist/commands/workflow/instructions.js +15 -4
- package/dist/commands/workflow/new-change.js +12 -4
- package/dist/commands/workflow/status.js +5 -2
- package/dist/commands/workflow/templates.js +5 -2
- package/dist/core/artifact-graph/resolver.d.ts +6 -0
- package/dist/core/artifact-graph/resolver.js +11 -0
- package/dist/core/available-tools.d.ts +2 -1
- package/dist/core/available-tools.js +8 -28
- package/dist/core/completion-tip.d.ts +23 -0
- package/dist/core/completion-tip.js +4 -4
- package/dist/core/completions/command-registry.js +15 -0
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
- package/dist/core/completions/installers/zsh-installer.js +64 -19
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/file-view.d.ts +13 -0
- package/dist/core/file-view.js +14 -0
- package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
- package/dist/core/github-copilot/cloud-agent.js +13 -9
- package/dist/core/global-config.d.ts +4 -0
- package/dist/core/global-config.js +5 -1
- package/dist/core/id.d.ts +8 -0
- package/dist/core/id.js +15 -0
- package/dist/core/legacy-cleanup.d.ts +11 -1
- package/dist/core/legacy-cleanup.js +15 -18
- package/dist/core/migration.d.ts +27 -1
- package/dist/core/migration.js +77 -29
- package/dist/core/parsers/code-fence.js +9 -1
- package/dist/core/parsers/requirement-blocks.d.ts +30 -0
- package/dist/core/parsers/requirement-blocks.js +22 -9
- package/dist/core/parsers/requirement-text.d.ts +14 -0
- package/dist/core/parsers/requirement-text.js +39 -0
- package/dist/core/parsers/spec-structure.js +1 -1
- package/dist/core/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- package/dist/core/project-config.d.ts +10 -0
- package/dist/core/project-config.js +26 -0
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +37 -0
- package/dist/core/shared/tool-detection.d.ts +12 -4
- package/dist/core/shared/tool-detection.js +29 -27
- package/dist/core/shared-skill-target.d.ts +12 -5
- package/dist/core/shared-skill-target.js +34 -23
- package/dist/core/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- package/dist/core/templates/workflows/verify-change.js +4 -4
- package/dist/core/update-plan.d.ts +127 -0
- package/dist/core/update-plan.js +390 -0
- package/dist/core/update.d.ts +45 -1
- package/dist/core/update.js +279 -137
- package/dist/core/validation/validator.d.ts +27 -2
- package/dist/core/validation/validator.js +58 -4
- package/dist/core/version-check.d.ts +8 -1
- package/dist/core/version-check.js +48 -5
- package/dist/utils/executable-path.d.ts +5 -0
- package/dist/utils/executable-path.js +44 -0
- package/package.json +5 -4
- package/schemas/spec-driven/schema.yaml +1 -0
package/dist/core/update.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
215
|
+
this.stopIfUpdateFailed(legacyUpgradeFailures);
|
|
156
216
|
return;
|
|
157
217
|
}
|
|
158
218
|
// 8. Display update plan
|
|
159
219
|
if (this.force) {
|
|
160
|
-
|
|
220
|
+
this.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
|
|
161
221
|
}
|
|
162
222
|
else if (toolsToUpdateSet.size === 0) {
|
|
163
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
337
|
+
this.log();
|
|
270
338
|
if (updatedTools.length > 0) {
|
|
271
|
-
|
|
339
|
+
this.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
|
|
272
340
|
}
|
|
273
341
|
if (failedTools.length > 0) {
|
|
274
|
-
|
|
342
|
+
this.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
|
|
275
343
|
}
|
|
276
344
|
if (skillsInvocableCommandSkips.length > 0) {
|
|
277
|
-
|
|
345
|
+
this.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`));
|
|
278
346
|
}
|
|
279
347
|
if (removedCommandCount > 0) {
|
|
280
|
-
|
|
348
|
+
this.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
|
|
281
349
|
}
|
|
282
350
|
if (removedSkillCount > 0) {
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
+
this.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
|
|
293
361
|
}
|
|
294
362
|
if (removedDeselectedSkillCount > 0) {
|
|
295
|
-
|
|
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
|
-
|
|
389
|
+
this.log();
|
|
322
390
|
if (entries.length > 0) {
|
|
323
391
|
const width = Math.max(...entries.map(([reference]) => reference.length));
|
|
324
|
-
|
|
392
|
+
this.log(chalk.bold('Getting started:'));
|
|
325
393
|
for (const [reference, description] of entries) {
|
|
326
|
-
|
|
394
|
+
this.log(` ${reference.padEnd(width)} ${description}`);
|
|
327
395
|
}
|
|
328
|
-
|
|
396
|
+
this.log();
|
|
329
397
|
}
|
|
330
|
-
|
|
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
|
-
|
|
409
|
+
this.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
|
|
342
410
|
}
|
|
343
|
-
|
|
411
|
+
this.log();
|
|
344
412
|
const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
|
|
345
413
|
const restartHint = formatIdeRestart(affectedToolIds, delivery);
|
|
346
414
|
if (restartHint) {
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
551
|
+
if (!this.view.exists(skillFile))
|
|
420
552
|
return false;
|
|
421
553
|
try {
|
|
422
|
-
const existing =
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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 (!
|
|
711
|
+
if (!this.view.exists(skillDir))
|
|
580
712
|
continue;
|
|
581
713
|
FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
|
|
582
714
|
try {
|
|
583
|
-
await
|
|
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 (!
|
|
738
|
+
if (!this.view.exists(skillDir))
|
|
607
739
|
continue;
|
|
608
740
|
FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
|
|
609
741
|
try {
|
|
610
|
-
await
|
|
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 (
|
|
633
|
-
await
|
|
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 (
|
|
660
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
829
|
+
this.log();
|
|
698
830
|
continue;
|
|
699
831
|
}
|
|
700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
860
|
+
this.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`));
|
|
729
861
|
}
|
|
730
862
|
this.reportKeptInPlace(applied);
|
|
731
863
|
}
|
|
732
|
-
|
|
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
|
-
|
|
883
|
+
this.log();
|
|
752
884
|
if (immediateSummary) {
|
|
753
|
-
|
|
754
|
-
|
|
885
|
+
this.log(immediateSummary);
|
|
886
|
+
this.log();
|
|
755
887
|
}
|
|
756
888
|
if (deferredSummary) {
|
|
757
|
-
|
|
758
|
-
|
|
889
|
+
this.log(deferredSummary);
|
|
890
|
+
this.log();
|
|
759
891
|
}
|
|
760
892
|
}
|
|
761
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
958
|
+
this.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
|
|
823
959
|
for (const prompt of blockedMatches) {
|
|
824
|
-
|
|
960
|
+
this.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`));
|
|
825
961
|
}
|
|
826
|
-
|
|
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 =
|
|
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
|
-
|
|
839
|
-
|
|
974
|
+
this.log();
|
|
975
|
+
this.log(summary);
|
|
840
976
|
}
|
|
841
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1007
|
+
this.log(` • ${tool?.name || toolId}`);
|
|
872
1008
|
}
|
|
873
|
-
|
|
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
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
+
this.log();
|
|
990
1132
|
}
|
|
991
1133
|
return {
|
|
992
1134
|
newlyConfiguredTools: newlyConfigured,
|