@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.
- package/README.md +13 -9
- package/dist/cli/index.js +47 -0
- package/dist/commands/change.js +2 -1
- 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/spec.js +2 -10
- package/dist/commands/validate.js +11 -4
- 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/outputs.js +204 -29
- 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/completion-provider.js +1 -1
- 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/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/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- 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/specs-apply.js +23 -5
- package/dist/core/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/templates/workflows/continue-change.js +18 -12
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +8 -6
- package/dist/core/templates/workflows/propose.js +8 -6
- 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.js +3 -3
- 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/dist/utils/file-system.d.ts +1 -1
- package/dist/utils/file-system.js +1 -1
- package/dist/utils/interactive.d.ts +15 -0
- package/dist/utils/interactive.js +18 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +7 -17
- package/dist/utils/spec-path-containment.d.ts +17 -0
- package/dist/utils/spec-path-containment.js +122 -0
- package/package.json +8 -4
|
@@ -242,10 +242,14 @@ function isLegacyCopilotCloudFile(relPath, content) {
|
|
|
242
242
|
function isManagedCopilotCloudFile(relPath, content) {
|
|
243
243
|
return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
|
|
244
244
|
}
|
|
245
|
-
|
|
245
|
+
const DIRECT_CLOUD_FILE_OPS = {
|
|
246
|
+
writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
|
|
247
|
+
removeFile: (filePath) => fs.unlink(filePath),
|
|
248
|
+
};
|
|
249
|
+
async function reconcileCopilotCloudFile(fullPath, relPath, ops) {
|
|
246
250
|
const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
|
|
247
251
|
if (!(await FileSystemUtils.fileExists(fullPath))) {
|
|
248
|
-
await
|
|
252
|
+
await ops.writeFile(fullPath, currentContent);
|
|
249
253
|
return true;
|
|
250
254
|
}
|
|
251
255
|
const existingContent = await FileSystemUtils.readFile(fullPath);
|
|
@@ -255,7 +259,7 @@ async function reconcileCopilotCloudFile(fullPath, relPath) {
|
|
|
255
259
|
if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
|
|
256
260
|
return false;
|
|
257
261
|
}
|
|
258
|
-
await
|
|
262
|
+
await ops.writeFile(fullPath, currentContent);
|
|
259
263
|
return true;
|
|
260
264
|
}
|
|
261
265
|
async function assertCreatableFilePath(filePath) {
|
|
@@ -313,7 +317,7 @@ async function classifyCopilotAgentReconciliation(agentPath, alternateAgentPath)
|
|
|
313
317
|
*
|
|
314
318
|
* @returns Object indicating which files were written.
|
|
315
319
|
*/
|
|
316
|
-
export async function writeCopilotCloudFiles(projectPath) {
|
|
320
|
+
export async function writeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
|
|
317
321
|
const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.setupSteps);
|
|
318
322
|
const agentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.agent);
|
|
319
323
|
const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_AGENT_ALTERNATE_FILE);
|
|
@@ -323,13 +327,13 @@ export async function writeCopilotCloudFiles(projectPath) {
|
|
|
323
327
|
await assertMissingOrRegularFile(agentPath);
|
|
324
328
|
await assertMissingOrRegularFile(alternateAgentPath);
|
|
325
329
|
const agentReconciliation = await classifyCopilotAgentReconciliation(agentPath, alternateAgentPath);
|
|
326
|
-
const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps);
|
|
330
|
+
const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps, ops);
|
|
327
331
|
let agentWritten = false;
|
|
328
332
|
if (agentReconciliation === 'reconcile') {
|
|
329
|
-
agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
|
|
333
|
+
agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent, ops);
|
|
330
334
|
}
|
|
331
335
|
else if (agentReconciliation === 'remove-managed') {
|
|
332
|
-
await
|
|
336
|
+
await ops.removeFile(agentPath);
|
|
333
337
|
}
|
|
334
338
|
return { setupStepsWritten, agentWritten };
|
|
335
339
|
}
|
|
@@ -339,7 +343,7 @@ export async function writeCopilotCloudFiles(projectPath) {
|
|
|
339
343
|
*
|
|
340
344
|
* @returns Number of files removed.
|
|
341
345
|
*/
|
|
342
|
-
export async function removeCopilotCloudFiles(projectPath) {
|
|
346
|
+
export async function removeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
|
|
343
347
|
let removed = 0;
|
|
344
348
|
const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
|
|
345
349
|
relPath,
|
|
@@ -354,7 +358,7 @@ export async function removeCopilotCloudFiles(projectPath) {
|
|
|
354
358
|
if (!isManagedCopilotCloudFile(relPath, content)) {
|
|
355
359
|
continue;
|
|
356
360
|
}
|
|
357
|
-
await
|
|
361
|
+
await ops.removeFile(fullPath);
|
|
358
362
|
removed++;
|
|
359
363
|
}
|
|
360
364
|
}
|
|
@@ -28,6 +28,8 @@ export interface GlobalConfig {
|
|
|
28
28
|
telemetry?: TelemetryConfig;
|
|
29
29
|
/** Whether the first-run shell-completions tip has been shown. */
|
|
30
30
|
completionTipSeen?: boolean;
|
|
31
|
+
/** Whether the one-time note about the standalone install has been shown. */
|
|
32
|
+
standaloneTipSeen?: boolean;
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
33
35
|
* Gets the global configuration directory path following XDG Base Directory Specification.
|
|
@@ -94,4 +96,6 @@ export interface SaveGlobalConfigOptions {
|
|
|
94
96
|
* existing file it cannot parse unless `replaceUnreadable` is set.
|
|
95
97
|
*/
|
|
96
98
|
export declare function saveGlobalConfig(config: GlobalConfig, options?: SaveGlobalConfigOptions): void;
|
|
99
|
+
/** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
|
|
100
|
+
export declare function serializeGlobalConfig(config: GlobalConfig): string;
|
|
97
101
|
//# sourceMappingURL=global-config.d.ts.map
|
|
@@ -173,6 +173,10 @@ export function saveGlobalConfig(config, options = {}) {
|
|
|
173
173
|
if (!fs.existsSync(configDir)) {
|
|
174
174
|
fs.mkdirSync(configDir, { recursive: true });
|
|
175
175
|
}
|
|
176
|
-
fs.writeFileSync(configPath,
|
|
176
|
+
fs.writeFileSync(configPath, serializeGlobalConfig(config), 'utf-8');
|
|
177
|
+
}
|
|
178
|
+
/** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
|
|
179
|
+
export function serializeGlobalConfig(config) {
|
|
180
|
+
return JSON.stringify(config, null, 2) + '\n';
|
|
177
181
|
}
|
|
178
182
|
//# sourceMappingURL=global-config.js.map
|
|
@@ -166,7 +166,17 @@ export interface CleanupResult {
|
|
|
166
166
|
* @param detection - Detection result from detectLegacyArtifacts
|
|
167
167
|
* @returns Cleanup result with summary of actions taken
|
|
168
168
|
*/
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Every change cleanup makes on disk. `update --dry-run` passes a recorder, so
|
|
171
|
+
* the plan comes from this same code rather than a description of it.
|
|
172
|
+
*/
|
|
173
|
+
export interface LegacyCleanupOps {
|
|
174
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
175
|
+
removeFile(filePath: string): Promise<void>;
|
|
176
|
+
/** Removes a directory already known to be empty. */
|
|
177
|
+
removeEmptyDirectory(dirPath: string): Promise<void>;
|
|
178
|
+
}
|
|
179
|
+
export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult, ops?: LegacyCleanupOps): Promise<CleanupResult>;
|
|
170
180
|
/**
|
|
171
181
|
* Generates a cleanup summary message for display.
|
|
172
182
|
*
|
|
@@ -323,14 +323,14 @@ function legacyCommandDirForFile(file) {
|
|
|
323
323
|
* or records what is left in it as kept. Never recursive: whatever remains was
|
|
324
324
|
* not written by OpenSpec. Returns true when the directory was removed.
|
|
325
325
|
*/
|
|
326
|
-
async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
326
|
+
async function settleLegacyCommandDir(projectPath, dirPath, result, ops = DIRECT_CLEANUP_OPS) {
|
|
327
327
|
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
328
328
|
const remaining = await readLegacyCommandDir(fullPath, []);
|
|
329
329
|
if (!remaining) {
|
|
330
330
|
return false;
|
|
331
331
|
}
|
|
332
332
|
if (remaining.others.length === 0) {
|
|
333
|
-
await
|
|
333
|
+
await ops.removeEmptyDirectory(fullPath);
|
|
334
334
|
result.deletedDirs.push(dirPath);
|
|
335
335
|
return true;
|
|
336
336
|
}
|
|
@@ -493,15 +493,12 @@ export function isOnlyOpenSpecContent(content) {
|
|
|
493
493
|
export function removeMarkerBlock(content) {
|
|
494
494
|
return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
|
|
495
495
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
* @returns Cleanup result with summary of actions taken
|
|
503
|
-
*/
|
|
504
|
-
export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
496
|
+
const DIRECT_CLEANUP_OPS = {
|
|
497
|
+
writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
|
|
498
|
+
removeFile: (filePath) => fs.unlink(filePath),
|
|
499
|
+
removeEmptyDirectory: (dirPath) => fs.rmdir(dirPath),
|
|
500
|
+
};
|
|
501
|
+
export async function cleanupLegacyArtifacts(projectPath, detection, ops = DIRECT_CLEANUP_OPS) {
|
|
505
502
|
const result = {
|
|
506
503
|
deletedFiles: [],
|
|
507
504
|
deletedFileReplacementLabels: {},
|
|
@@ -519,7 +516,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
519
516
|
const content = await FileSystemUtils.readFile(filePath);
|
|
520
517
|
const newContent = removeMarkerBlock(content);
|
|
521
518
|
// Always write the file, even if empty - never delete user config files
|
|
522
|
-
await
|
|
519
|
+
await ops.writeFile(filePath, newContent);
|
|
523
520
|
result.modifiedFiles.push(fileName);
|
|
524
521
|
}
|
|
525
522
|
catch (error) {
|
|
@@ -545,10 +542,10 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
545
542
|
if (!(await isGeneratedLegacyCommand(filePath))) {
|
|
546
543
|
continue;
|
|
547
544
|
}
|
|
548
|
-
await
|
|
545
|
+
await ops.removeFile(filePath);
|
|
549
546
|
deleted.push(name);
|
|
550
547
|
}
|
|
551
|
-
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
|
548
|
+
if (!(await settleLegacyCommandDir(projectPath, dirPath, result, ops))) {
|
|
552
549
|
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
|
553
550
|
}
|
|
554
551
|
}
|
|
@@ -570,7 +567,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
570
567
|
continue;
|
|
571
568
|
}
|
|
572
569
|
}
|
|
573
|
-
await
|
|
570
|
+
await ops.removeFile(fullPath);
|
|
574
571
|
result.deletedFiles.push(filePath);
|
|
575
572
|
}
|
|
576
573
|
catch (error) {
|
|
@@ -581,7 +578,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
581
578
|
// file by file above; record what was left in it.
|
|
582
579
|
for (const dirPath of partlyCleanedDirs) {
|
|
583
580
|
try {
|
|
584
|
-
await settleLegacyCommandDir(projectPath, dirPath, result);
|
|
581
|
+
await settleLegacyCommandDir(projectPath, dirPath, result, ops);
|
|
585
582
|
}
|
|
586
583
|
catch (error) {
|
|
587
584
|
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
@@ -595,7 +592,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
595
592
|
continue;
|
|
596
593
|
}
|
|
597
594
|
try {
|
|
598
|
-
await
|
|
595
|
+
await ops.removeFile(filePath);
|
|
599
596
|
result.deletedFiles.push(filePath);
|
|
600
597
|
const promptMatch = globalPromptMatchesByPath.get(filePath);
|
|
601
598
|
if (promptMatch?.replacementLabel) {
|
|
@@ -611,7 +608,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
611
608
|
const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
|
612
609
|
if (await FileSystemUtils.fileExists(agentsPath)) {
|
|
613
610
|
try {
|
|
614
|
-
await
|
|
611
|
+
await ops.removeFile(agentsPath);
|
|
615
612
|
result.deletedFiles.push('openspec/AGENTS.md');
|
|
616
613
|
}
|
|
617
614
|
catch (error) {
|
package/dist/core/migration.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Called by both init and update commands before profile resolution.
|
|
6
6
|
*/
|
|
7
7
|
import { type AIToolOption } from './config.js';
|
|
8
|
+
import { type Delivery, type GlobalConfig, type Profile } from './global-config.js';
|
|
9
|
+
import { type FileView } from './file-view.js';
|
|
8
10
|
export interface LegacyToolRoot {
|
|
9
11
|
/** Former tool root, e.g. '.kimi' */
|
|
10
12
|
root: string;
|
|
@@ -41,11 +43,27 @@ export interface LegacyToolMigration {
|
|
|
41
43
|
/** Whether this move needs the user's consent first */
|
|
42
44
|
needsConsent: boolean;
|
|
43
45
|
}
|
|
46
|
+
/** One file a legacy-root migration would move or drop. */
|
|
47
|
+
export interface LegacyMigrationFileOp {
|
|
48
|
+
toolId: string;
|
|
49
|
+
source: string;
|
|
50
|
+
destination: string;
|
|
51
|
+
disposition: 'move' | 'drop';
|
|
52
|
+
/** Whether an interactive run asks before this move */
|
|
53
|
+
needsConsent: boolean;
|
|
54
|
+
}
|
|
44
55
|
/**
|
|
45
56
|
* Reports the OpenSpec content sitting under each tool's legacy root, without
|
|
46
57
|
* moving anything. Callers use this to ask before a move that needs consent.
|
|
47
58
|
*/
|
|
48
59
|
export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
|
|
60
|
+
/**
|
|
61
|
+
* Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
|
|
62
|
+
* touching anything. Consent-gated roots are included: a non-interactive run
|
|
63
|
+
* moves them without asking. `view` decides what already sits at each
|
|
64
|
+
* destination, so a dry run can plan against files it has not written.
|
|
65
|
+
*/
|
|
66
|
+
export declare function planLegacyToolMigrations(projectPath: string, timing: 'before-generation' | 'after-generation', toolIds: string[] | undefined, view: FileView): LegacyMigrationFileOp[];
|
|
49
67
|
/**
|
|
50
68
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
51
69
|
* (opsx-*) from a tool's legacy root to its current one. When the destination
|
|
@@ -81,7 +99,7 @@ export declare function legacyMigrationNotice(migration: LegacyToolMigration): s
|
|
|
81
99
|
* Scans installed workflow files across all detected tools and returns
|
|
82
100
|
* the union of installed workflow IDs.
|
|
83
101
|
*/
|
|
84
|
-
export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[];
|
|
102
|
+
export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[], view?: FileView): string[];
|
|
85
103
|
/**
|
|
86
104
|
* Performs one-time migration if the global config does not yet have a profile field.
|
|
87
105
|
* Called by both init and update before profile resolution.
|
|
@@ -91,5 +109,13 @@ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToo
|
|
|
91
109
|
* - If no profile field exists and no workflows are installed: no-op (defaults apply).
|
|
92
110
|
* - If profile field already exists: no-op.
|
|
93
111
|
*/
|
|
112
|
+
export declare function computeProfileMigration(projectPath: string, tools: AIToolOption[], view?: FileView): ProfileMigration | null;
|
|
113
|
+
export type ProfileMigration = {
|
|
114
|
+
profile: Profile;
|
|
115
|
+
workflows: string[];
|
|
116
|
+
delivery?: Delivery;
|
|
117
|
+
};
|
|
118
|
+
/** The global config the one-time profile migration saves. */
|
|
119
|
+
export declare function applyProfileMigration(migration: ProfileMigration): GlobalConfig;
|
|
94
120
|
export declare function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void;
|
|
95
121
|
//# sourceMappingURL=migration.d.ts.map
|
package/dist/core/migration.js
CHANGED
|
@@ -14,6 +14,7 @@ import { ALL_WORKFLOWS } from './profiles.js';
|
|
|
14
14
|
import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
|
|
15
15
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
16
16
|
import { isSharedSkillTargetActive } from './shared-skill-target.js';
|
|
17
|
+
import { DISK_VIEW } from './file-view.js';
|
|
17
18
|
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
|
|
18
19
|
import path from 'path';
|
|
19
20
|
import * as fs from 'fs';
|
|
@@ -41,14 +42,14 @@ export const LEGACY_TOOL_ROOTS = {
|
|
|
41
42
|
// reported and kept rather than overwritten.
|
|
42
43
|
antigravity: [{ root: '.agent', needsConsent: false, timing: 'after-generation' }],
|
|
43
44
|
};
|
|
44
|
-
function classifyManagedFile(source, destination) {
|
|
45
|
+
function classifyManagedFile(source, destination, view = DISK_VIEW) {
|
|
45
46
|
if (isSamePath(source, destination))
|
|
46
47
|
return 'skip';
|
|
47
|
-
if (!
|
|
48
|
+
if (!view.exists(destination))
|
|
48
49
|
return 'move';
|
|
49
50
|
try {
|
|
50
51
|
const sourceContent = fs.readFileSync(source, 'utf-8');
|
|
51
|
-
const destinationContent =
|
|
52
|
+
const destinationContent = view.read(destination);
|
|
52
53
|
const equivalentGeneratedSkills = path.basename(source) === 'SKILL.md' &&
|
|
53
54
|
path.basename(destination) === 'SKILL.md' &&
|
|
54
55
|
isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent);
|
|
@@ -85,6 +86,17 @@ function legacyCommandPath(commandPath, currentRoot, legacyRoot) {
|
|
|
85
86
|
export function findLegacyToolMigrations(projectPath, timing = 'before-generation') {
|
|
86
87
|
return collectLegacyToolMigrations(projectPath, false, undefined, timing);
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
|
|
91
|
+
* touching anything. Consent-gated roots are included: a non-interactive run
|
|
92
|
+
* moves them without asking. `view` decides what already sits at each
|
|
93
|
+
* destination, so a dry run can plan against files it has not written.
|
|
94
|
+
*/
|
|
95
|
+
export function planLegacyToolMigrations(projectPath, timing, toolIds, view) {
|
|
96
|
+
const ops = [];
|
|
97
|
+
collectLegacyToolMigrations(projectPath, false, toolIds, timing, { view, ops });
|
|
98
|
+
return ops;
|
|
99
|
+
}
|
|
88
100
|
/**
|
|
89
101
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
90
102
|
* (opsx-*) from a tool's legacy root to its current one. When the destination
|
|
@@ -99,7 +111,7 @@ export function findLegacyToolMigrations(projectPath, timing = 'before-generatio
|
|
|
99
111
|
export function migrateLegacyToolDirs(projectPath, toolIds, timing = 'before-generation') {
|
|
100
112
|
return collectLegacyToolMigrations(projectPath, true, toolIds, timing);
|
|
101
113
|
}
|
|
102
|
-
function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation') {
|
|
114
|
+
function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation', plan) {
|
|
103
115
|
const migrations = [];
|
|
104
116
|
for (const tool of AI_TOOLS) {
|
|
105
117
|
if (!tool.skillsDir)
|
|
@@ -126,8 +138,11 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
|
|
|
126
138
|
console.warn(`Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.`);
|
|
127
139
|
continue;
|
|
128
140
|
}
|
|
129
|
-
const
|
|
130
|
-
|
|
141
|
+
const sink = plan
|
|
142
|
+
? { ...plan, toolId: tool.value, needsConsent: legacy.needsConsent }
|
|
143
|
+
: undefined;
|
|
144
|
+
const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation', sink);
|
|
145
|
+
const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply, legacyTiming === 'after-generation', sink);
|
|
131
146
|
if (apply) {
|
|
132
147
|
removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
|
|
133
148
|
removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
|
|
@@ -152,7 +167,8 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'befo
|
|
|
152
167
|
}
|
|
153
168
|
return migrations;
|
|
154
169
|
}
|
|
155
|
-
function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false) {
|
|
170
|
+
function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false, sink) {
|
|
171
|
+
const view = sink?.view ?? DISK_VIEW;
|
|
156
172
|
const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
|
|
157
173
|
if (!fs.existsSync(legacySkillsDir))
|
|
158
174
|
return { moved: 0, kept: 0 };
|
|
@@ -167,13 +183,13 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
|
|
|
167
183
|
continue;
|
|
168
184
|
const destination = path.join(currentSkillsDir, dirName);
|
|
169
185
|
const destinationSkill = path.join(destination, 'SKILL.md');
|
|
170
|
-
if (requireDestination && !
|
|
186
|
+
if (requireDestination && !view.exists(destinationSkill))
|
|
171
187
|
continue;
|
|
172
188
|
if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) {
|
|
173
189
|
console.warn(`Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.`);
|
|
174
190
|
continue;
|
|
175
191
|
}
|
|
176
|
-
const disposition = classifyManagedFile(sourceSkill, destinationSkill);
|
|
192
|
+
const disposition = classifyManagedFile(sourceSkill, destinationSkill, view);
|
|
177
193
|
if (disposition === 'skip')
|
|
178
194
|
continue;
|
|
179
195
|
if (disposition === 'keep') {
|
|
@@ -181,6 +197,13 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
|
|
|
181
197
|
continue;
|
|
182
198
|
}
|
|
183
199
|
if (!apply) {
|
|
200
|
+
sink?.ops.push({
|
|
201
|
+
toolId: sink.toolId,
|
|
202
|
+
source: sourceSkill,
|
|
203
|
+
destination: destinationSkill,
|
|
204
|
+
disposition,
|
|
205
|
+
needsConsent: sink.needsConsent,
|
|
206
|
+
});
|
|
184
207
|
moved++;
|
|
185
208
|
continue;
|
|
186
209
|
}
|
|
@@ -207,7 +230,8 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDe
|
|
|
207
230
|
}
|
|
208
231
|
return { moved, kept };
|
|
209
232
|
}
|
|
210
|
-
function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false) {
|
|
233
|
+
function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestination = false, sink) {
|
|
234
|
+
const view = sink?.view ?? DISK_VIEW;
|
|
211
235
|
const adapter = CommandAdapterRegistry.get(tool.value);
|
|
212
236
|
if (!adapter || !tool.skillsDir)
|
|
213
237
|
return { moved: 0, kept: 0 };
|
|
@@ -226,13 +250,13 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestin
|
|
|
226
250
|
// No replacement means this command is not one OpenSpec installs now — a
|
|
227
251
|
// skills-only delivery or a deselected workflow — so relocating the legacy
|
|
228
252
|
// file would resurrect it under the current root.
|
|
229
|
-
if (requireDestination && !
|
|
253
|
+
if (requireDestination && !view.exists(destination))
|
|
230
254
|
continue;
|
|
231
255
|
if (!areProjectArtifacts(projectPath, source, destination)) {
|
|
232
256
|
console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
|
|
233
257
|
continue;
|
|
234
258
|
}
|
|
235
|
-
const disposition = classifyManagedFile(source, destination);
|
|
259
|
+
const disposition = classifyManagedFile(source, destination, view);
|
|
236
260
|
if (disposition === 'skip')
|
|
237
261
|
continue;
|
|
238
262
|
if (disposition === 'keep') {
|
|
@@ -240,6 +264,13 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply, requireDestin
|
|
|
240
264
|
continue;
|
|
241
265
|
}
|
|
242
266
|
if (!apply) {
|
|
267
|
+
sink?.ops.push({
|
|
268
|
+
toolId: sink.toolId,
|
|
269
|
+
source,
|
|
270
|
+
destination,
|
|
271
|
+
disposition,
|
|
272
|
+
needsConsent: sink.needsConsent,
|
|
273
|
+
});
|
|
243
274
|
moved++;
|
|
244
275
|
continue;
|
|
245
276
|
}
|
|
@@ -344,7 +375,7 @@ function removeDirIfEmpty(dirPath) {
|
|
|
344
375
|
// Missing or non-empty directory — nothing to do
|
|
345
376
|
}
|
|
346
377
|
}
|
|
347
|
-
function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false) {
|
|
378
|
+
function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots = false, view = DISK_VIEW) {
|
|
348
379
|
const installed = new Set();
|
|
349
380
|
let hasSkills = false;
|
|
350
381
|
let hasCommands = false;
|
|
@@ -355,7 +386,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
355
386
|
if (tool.globalSkillsDir) {
|
|
356
387
|
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
|
|
357
388
|
}
|
|
358
|
-
else if (isSharedSkillTargetActive(projectPath, tool.value)) {
|
|
389
|
+
else if (isSharedSkillTargetActive(projectPath, tool.value, view)) {
|
|
359
390
|
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
|
|
360
391
|
if (includeLegacyRoots) {
|
|
361
392
|
skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
|
|
@@ -365,7 +396,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
365
396
|
for (const workflowId of ALL_WORKFLOWS) {
|
|
366
397
|
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
|
|
367
398
|
const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
|
|
368
|
-
if (
|
|
399
|
+
if (view.exists(skillFile)) {
|
|
369
400
|
installed.add(workflowId);
|
|
370
401
|
hasSkills = true;
|
|
371
402
|
}
|
|
@@ -395,7 +426,7 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
395
426
|
const fullPath = path.isAbsolute(candidate)
|
|
396
427
|
? candidate
|
|
397
428
|
: path.join(projectPath, candidate);
|
|
398
|
-
if (
|
|
429
|
+
if (view.exists(fullPath)) {
|
|
399
430
|
installed.add(workflowId);
|
|
400
431
|
hasCommands = true;
|
|
401
432
|
break;
|
|
@@ -413,8 +444,8 @@ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacyRoots =
|
|
|
413
444
|
* Scans installed workflow files across all detected tools and returns
|
|
414
445
|
* the union of installed workflow IDs.
|
|
415
446
|
*/
|
|
416
|
-
export function scanInstalledWorkflows(projectPath, tools) {
|
|
417
|
-
return scanInstalledWorkflowArtifacts(projectPath, tools).workflows;
|
|
447
|
+
export function scanInstalledWorkflows(projectPath, tools, view = DISK_VIEW) {
|
|
448
|
+
return scanInstalledWorkflowArtifacts(projectPath, tools, false, view).workflows;
|
|
418
449
|
}
|
|
419
450
|
function inferDelivery(artifacts) {
|
|
420
451
|
if (artifacts.hasSkills && artifacts.hasCommands) {
|
|
@@ -434,13 +465,12 @@ function inferDelivery(artifacts) {
|
|
|
434
465
|
* - If no profile field exists and no workflows are installed: no-op (defaults apply).
|
|
435
466
|
* - If profile field already exists: no-op.
|
|
436
467
|
*/
|
|
437
|
-
export function
|
|
468
|
+
export function computeProfileMigration(projectPath, tools, view = DISK_VIEW) {
|
|
438
469
|
// A config that cannot be parsed, or is not a JSON object, is never saved
|
|
439
470
|
// over; skip migration rather than fail init or update on it.
|
|
440
471
|
if (isGlobalConfigUnreadable()) {
|
|
441
|
-
return;
|
|
472
|
+
return null;
|
|
442
473
|
}
|
|
443
|
-
const config = getGlobalConfig();
|
|
444
474
|
// Check raw config file for profile field presence
|
|
445
475
|
const configPath = getGlobalConfigPath();
|
|
446
476
|
let rawConfig = {};
|
|
@@ -450,25 +480,43 @@ export function migrateIfNeeded(projectPath, tools) {
|
|
|
450
480
|
}
|
|
451
481
|
}
|
|
452
482
|
catch {
|
|
453
|
-
return; // Can't read config, skip migration
|
|
483
|
+
return null; // Can't read config, skip migration
|
|
454
484
|
}
|
|
455
485
|
// If profile is already explicitly set, no migration needed
|
|
456
486
|
if (rawConfig.profile !== undefined) {
|
|
457
|
-
return;
|
|
487
|
+
return null;
|
|
458
488
|
}
|
|
459
489
|
// Scan for installed workflows
|
|
460
|
-
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true);
|
|
490
|
+
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true, view);
|
|
461
491
|
const installedWorkflows = artifacts.workflows;
|
|
462
492
|
if (installedWorkflows.length === 0) {
|
|
463
493
|
// No workflows installed, new user — defaults will apply
|
|
464
|
-
return;
|
|
494
|
+
return null;
|
|
465
495
|
}
|
|
496
|
+
return {
|
|
497
|
+
profile: 'custom',
|
|
498
|
+
workflows: installedWorkflows,
|
|
499
|
+
...(rawConfig.delivery === undefined ? { delivery: inferDelivery(artifacts) } : {}),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** The global config the one-time profile migration saves. */
|
|
503
|
+
export function applyProfileMigration(migration) {
|
|
504
|
+
const config = getGlobalConfig();
|
|
466
505
|
// Migrate: set profile to custom with detected workflows
|
|
467
|
-
config.profile =
|
|
468
|
-
config.workflows =
|
|
469
|
-
if (
|
|
470
|
-
config.delivery =
|
|
506
|
+
config.profile = migration.profile;
|
|
507
|
+
config.workflows = migration.workflows;
|
|
508
|
+
if (migration.delivery !== undefined) {
|
|
509
|
+
config.delivery = migration.delivery;
|
|
510
|
+
}
|
|
511
|
+
return config;
|
|
512
|
+
}
|
|
513
|
+
export function migrateIfNeeded(projectPath, tools) {
|
|
514
|
+
const migration = computeProfileMigration(projectPath, tools);
|
|
515
|
+
if (!migration) {
|
|
516
|
+
return;
|
|
471
517
|
}
|
|
518
|
+
const config = applyProfileMigration(migration);
|
|
519
|
+
const installedWorkflows = migration.workflows;
|
|
472
520
|
saveGlobalConfig(config);
|
|
473
521
|
console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);
|
|
474
522
|
// Each detected tool resolves to a propose reference for its surface: the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Delivery } from './global-config.js';
|
|
2
2
|
import { ALL_WORKFLOWS } from './profiles.js';
|
|
3
|
+
import { type FileView } from './file-view.js';
|
|
3
4
|
type WorkflowId = (typeof ALL_WORKFLOWS)[number];
|
|
4
5
|
/**
|
|
5
6
|
* Maps workflow IDs to their skill directory names.
|
|
@@ -8,7 +9,7 @@ export declare const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string>;
|
|
|
8
9
|
/**
|
|
9
10
|
* Returns tools that are configured via either skills or commands.
|
|
10
11
|
*/
|
|
11
|
-
export declare function getConfiguredToolsForProfileSync(projectPath: string): string[];
|
|
12
|
+
export declare function getConfiguredToolsForProfileSync(projectPath: string, view?: FileView): string[];
|
|
12
13
|
/**
|
|
13
14
|
* Detects if a single tool has profile/delivery drift against the desired state.
|
|
14
15
|
*
|
|
@@ -17,11 +18,11 @@ export declare function getConfiguredToolsForProfileSync(projectPath: string): s
|
|
|
17
18
|
* - artifacts that should not exist for the selected delivery mode
|
|
18
19
|
* - artifacts for workflows that were deselected from the current profile
|
|
19
20
|
*/
|
|
20
|
-
export declare function hasToolProfileOrDeliveryDrift(projectPath: string, toolId: string, desiredWorkflows: readonly string[], delivery: Delivery): boolean;
|
|
21
|
+
export declare function hasToolProfileOrDeliveryDrift(projectPath: string, toolId: string, desiredWorkflows: readonly string[], delivery: Delivery, view?: FileView): boolean;
|
|
21
22
|
/**
|
|
22
23
|
* Returns configured tools that currently need a profile/delivery sync.
|
|
23
24
|
*/
|
|
24
|
-
export declare function getToolsNeedingProfileSync(projectPath: string, desiredWorkflows: readonly string[], delivery: Delivery, configuredTools?: readonly string[]): string[];
|
|
25
|
+
export declare function getToolsNeedingProfileSync(projectPath: string, desiredWorkflows: readonly string[], delivery: Delivery, configuredTools?: readonly string[], view?: FileView): string[];
|
|
25
26
|
/**
|
|
26
27
|
* Detects whether the current project has any profile/delivery drift.
|
|
27
28
|
*/
|