@fission-ai/openspec 1.14.3 → 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/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 +10 -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/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/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- 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/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 +4 -3
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
|
*/
|
|
@@ -7,6 +7,7 @@ import { getConfiguredTools } from './shared/index.js';
|
|
|
7
7
|
import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
|
|
8
8
|
import { readSharedSkillTarget } from './shared-skill-target.js';
|
|
9
9
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
10
|
+
import { DISK_VIEW } from './file-view.js';
|
|
10
11
|
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
|
|
11
12
|
import { hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/skill-paths.js';
|
|
12
13
|
/**
|
|
@@ -32,8 +33,8 @@ function toKnownWorkflows(workflows) {
|
|
|
32
33
|
/**
|
|
33
34
|
* Returns tools that are configured via either skills or commands.
|
|
34
35
|
*/
|
|
35
|
-
export function getConfiguredToolsForProfileSync(projectPath) {
|
|
36
|
-
return getConfiguredTools(projectPath);
|
|
36
|
+
export function getConfiguredToolsForProfileSync(projectPath, view = DISK_VIEW) {
|
|
37
|
+
return getConfiguredTools(projectPath, view);
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* Detects if a single tool has profile/delivery drift against the desired state.
|
|
@@ -43,7 +44,7 @@ export function getConfiguredToolsForProfileSync(projectPath) {
|
|
|
43
44
|
* - artifacts that should not exist for the selected delivery mode
|
|
44
45
|
* - artifacts for workflows that were deselected from the current profile
|
|
45
46
|
*/
|
|
46
|
-
export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) {
|
|
47
|
+
export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery, view = DISK_VIEW) {
|
|
47
48
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
48
49
|
if (!tool || !toolSupportsSkills(tool))
|
|
49
50
|
return false;
|
|
@@ -54,16 +55,16 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
54
55
|
const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
|
|
55
56
|
const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);
|
|
56
57
|
const sharedTarget = tool.skillsDir
|
|
57
|
-
? readSharedSkillTarget(projectPath, tool.skillsDir)
|
|
58
|
+
? readSharedSkillTarget(projectPath, tool.skillsDir, view)
|
|
58
59
|
: undefined;
|
|
59
60
|
for (const root of tool.legacySkillsDirs ?? []) {
|
|
60
61
|
for (const workflow of knownDesiredWorkflows) {
|
|
61
62
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
62
63
|
const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md');
|
|
63
|
-
if (!
|
|
64
|
+
if (!view.exists(legacySkill))
|
|
64
65
|
continue;
|
|
65
66
|
const currentSkill = path.join(skillsDir, dirName, 'SKILL.md');
|
|
66
|
-
if (!
|
|
67
|
+
if (!view.exists(currentSkill) || sharedTarget !== toolId) {
|
|
67
68
|
return true;
|
|
68
69
|
}
|
|
69
70
|
try {
|
|
@@ -75,7 +76,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
75
76
|
// remove the redundant legacy file even when version, line endings,
|
|
76
77
|
// or supported invocation syntax changed. Materially divergent copies
|
|
77
78
|
// stay in place without forcing an update on every run.
|
|
78
|
-
if (isLegacyCodexSkillEquivalentToCurrent(
|
|
79
|
+
if (isLegacyCodexSkillEquivalentToCurrent(view.read(legacySkill), view.read(currentSkill))) {
|
|
79
80
|
return true;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
@@ -88,7 +89,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
88
89
|
for (const workflow of knownDesiredWorkflows) {
|
|
89
90
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
90
91
|
const skillFile = path.join(skillsDir, dirName, 'SKILL.md');
|
|
91
|
-
if (!
|
|
92
|
+
if (!view.exists(skillFile)) {
|
|
92
93
|
return true;
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -98,7 +99,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
98
99
|
continue;
|
|
99
100
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
100
101
|
const skillDir = path.join(skillsDir, dirName);
|
|
101
|
-
if (
|
|
102
|
+
if (view.exists(skillDir)) {
|
|
102
103
|
return true;
|
|
103
104
|
}
|
|
104
105
|
}
|
|
@@ -107,7 +108,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
107
108
|
for (const workflow of ALL_WORKFLOWS) {
|
|
108
109
|
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
|
|
109
110
|
const skillDir = path.join(skillsDir, dirName);
|
|
110
|
-
if (
|
|
111
|
+
if (view.exists(skillDir)) {
|
|
111
112
|
return true;
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -116,7 +117,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
116
117
|
for (const workflow of knownDesiredWorkflows) {
|
|
117
118
|
const cmdPath = adapter.getFilePath(workflow);
|
|
118
119
|
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
|
119
|
-
if (!
|
|
120
|
+
if (!view.exists(fullPath)) {
|
|
120
121
|
return true;
|
|
121
122
|
}
|
|
122
123
|
}
|
|
@@ -126,7 +127,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
126
127
|
continue;
|
|
127
128
|
const cmdPath = adapter.getFilePath(workflow);
|
|
128
129
|
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
|
129
|
-
if (
|
|
130
|
+
if (view.exists(fullPath)) {
|
|
130
131
|
return true;
|
|
131
132
|
}
|
|
132
133
|
}
|
|
@@ -135,7 +136,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
135
136
|
for (const workflow of ALL_WORKFLOWS) {
|
|
136
137
|
const cmdPath = adapter.getFilePath(workflow);
|
|
137
138
|
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
|
138
|
-
if (
|
|
139
|
+
if (view.exists(fullPath)) {
|
|
139
140
|
return true;
|
|
140
141
|
}
|
|
141
142
|
}
|
|
@@ -145,9 +146,11 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
|
|
|
145
146
|
/**
|
|
146
147
|
* Returns configured tools that currently need a profile/delivery sync.
|
|
147
148
|
*/
|
|
148
|
-
export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools) {
|
|
149
|
-
const tools = configuredTools
|
|
150
|
-
|
|
149
|
+
export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools, view = DISK_VIEW) {
|
|
150
|
+
const tools = configuredTools
|
|
151
|
+
? [...new Set(configuredTools)]
|
|
152
|
+
: getConfiguredToolsForProfileSync(projectPath, view);
|
|
153
|
+
return tools.filter((toolId) => hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery, view));
|
|
151
154
|
}
|
|
152
155
|
function getInstalledWorkflowsForTool(projectPath, toolId, options) {
|
|
153
156
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shared utilities for detecting tool configurations and version status.
|
|
5
5
|
*/
|
|
6
|
+
import { type Delivery } from '../global-config.js';
|
|
7
|
+
import { type FileView } from '../file-view.js';
|
|
6
8
|
/**
|
|
7
9
|
* Names of skill directories created by openspec init.
|
|
8
10
|
*/
|
|
@@ -50,11 +52,11 @@ export declare function getToolsWithSkillsDir(): string[];
|
|
|
50
52
|
/**
|
|
51
53
|
* Checks which skill files exist for a tool.
|
|
52
54
|
*/
|
|
53
|
-
export declare function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus;
|
|
55
|
+
export declare function getToolSkillStatus(projectRoot: string, toolId: string, view?: FileView): ToolSkillStatus;
|
|
54
56
|
/**
|
|
55
57
|
* Checks whether a tool has at least one generated OpenSpec command file.
|
|
56
58
|
*/
|
|
57
|
-
export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean;
|
|
59
|
+
export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId: string, view?: FileView): boolean;
|
|
58
60
|
/**
|
|
59
61
|
* Checks whether command files for a tool on disk match current generated command contents.
|
|
60
62
|
*
|
|
@@ -63,6 +65,8 @@ export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId:
|
|
|
63
65
|
*/
|
|
64
66
|
export declare function areCommandFilesUpToDate(projectRoot: string, toolId: string, options?: {
|
|
65
67
|
workflows?: readonly string[];
|
|
68
|
+
/** The tree to read; the disk unless an update is planning. */
|
|
69
|
+
view?: FileView;
|
|
66
70
|
}): boolean;
|
|
67
71
|
/**
|
|
68
72
|
* Gets the skill status for all tools with skillsDir configured.
|
|
@@ -72,18 +76,22 @@ export declare function getToolStates(projectRoot: string): Map<string, ToolSkil
|
|
|
72
76
|
* Extracts the generatedBy version from a skill file's YAML frontmatter.
|
|
73
77
|
* Returns null if the field is not found or the file doesn't exist.
|
|
74
78
|
*/
|
|
75
|
-
export declare function extractGeneratedByVersion(skillFilePath: string): string | null;
|
|
79
|
+
export declare function extractGeneratedByVersion(skillFilePath: string, view?: FileView): string | null;
|
|
76
80
|
/**
|
|
77
81
|
* Gets version status for a tool by reading its skill files, falling back to a
|
|
78
82
|
* command-content fingerprint for installs that have commands but no skills.
|
|
79
83
|
*/
|
|
80
84
|
export declare function getToolVersionStatus(projectRoot: string, toolId: string, currentVersion: string, options?: {
|
|
81
85
|
workflows?: readonly string[];
|
|
86
|
+
/** The tree to read; the disk unless an update is planning. */
|
|
87
|
+
view?: FileView;
|
|
88
|
+
/** Effective delivery; read from the global config when omitted. */
|
|
89
|
+
delivery?: Delivery;
|
|
82
90
|
}): ToolVersionStatus;
|
|
83
91
|
/**
|
|
84
92
|
* Gets all configured tools in the project (configured via skills or commands).
|
|
85
93
|
*/
|
|
86
|
-
export declare function getConfiguredTools(projectRoot: string): string[];
|
|
94
|
+
export declare function getConfiguredTools(projectRoot: string, view?: FileView): string[];
|
|
87
95
|
/**
|
|
88
96
|
* Gets version status for all configured tools.
|
|
89
97
|
*/
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Shared utilities for detecting tool configurations and version status.
|
|
5
5
|
*/
|
|
6
6
|
import path from 'path';
|
|
7
|
-
import * as fs from 'fs';
|
|
8
7
|
import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from '../config.js';
|
|
9
8
|
import { CommandAdapterRegistry, generateCommands } from '../command-generation/index.js';
|
|
10
9
|
import { getCommandContents } from './skill-generation.js';
|
|
@@ -13,6 +12,7 @@ import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js';
|
|
|
13
12
|
import { isSharedSkillTargetActive, hasLegacySkills, readSharedSkillTarget, reconcileSharedSkillTargets, } from '../shared-skill-target.js';
|
|
14
13
|
import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, resolveCommandSurfaceCapability, } from '../command-surface.js';
|
|
15
14
|
import { getSkillCapableTools, resolveToolSkillsDir, toolSupportsSkills, } from './skill-paths.js';
|
|
15
|
+
import { DISK_VIEW } from '../file-view.js';
|
|
16
16
|
/**
|
|
17
17
|
* Names of skill directories created by openspec init.
|
|
18
18
|
*/
|
|
@@ -43,12 +43,12 @@ export function getToolsWithSkillsDir() {
|
|
|
43
43
|
/**
|
|
44
44
|
* Checks which skill files exist for a tool.
|
|
45
45
|
*/
|
|
46
|
-
export function getToolSkillStatus(projectRoot, toolId) {
|
|
46
|
+
export function getToolSkillStatus(projectRoot, toolId, view = DISK_VIEW) {
|
|
47
47
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
48
48
|
if (!tool || !toolSupportsSkills(tool)) {
|
|
49
49
|
return { configured: false, fullyConfigured: false, skillCount: 0 };
|
|
50
50
|
}
|
|
51
|
-
if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId)) {
|
|
51
|
+
if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId, view)) {
|
|
52
52
|
return { configured: false, fullyConfigured: false, skillCount: 0 };
|
|
53
53
|
}
|
|
54
54
|
const skillsDirs = [
|
|
@@ -57,7 +57,7 @@ export function getToolSkillStatus(projectRoot, toolId) {
|
|
|
57
57
|
];
|
|
58
58
|
let skillCount = 0;
|
|
59
59
|
for (const skillName of SKILL_NAMES) {
|
|
60
|
-
if (skillsDirs.some((skillsDir) =>
|
|
60
|
+
if (skillsDirs.some((skillsDir) => view.exists(path.join(skillsDir, skillName, 'SKILL.md')))) {
|
|
61
61
|
skillCount++;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -70,14 +70,14 @@ export function getToolSkillStatus(projectRoot, toolId) {
|
|
|
70
70
|
/**
|
|
71
71
|
* Checks whether a tool has at least one generated OpenSpec command file.
|
|
72
72
|
*/
|
|
73
|
-
export function toolHasAnyConfiguredCommand(projectPath, toolId) {
|
|
73
|
+
export function toolHasAnyConfiguredCommand(projectPath, toolId, view = DISK_VIEW) {
|
|
74
74
|
const adapter = CommandAdapterRegistry.get(toolId);
|
|
75
75
|
if (!adapter)
|
|
76
76
|
return false;
|
|
77
77
|
for (const commandId of COMMAND_IDS) {
|
|
78
78
|
const cmdPath = adapter.getFilePath(commandId);
|
|
79
79
|
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
|
|
80
|
-
if (
|
|
80
|
+
if (view.exists(fullPath)) {
|
|
81
81
|
return true;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -101,6 +101,7 @@ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
|
|
|
101
101
|
const adapter = CommandAdapterRegistry.get(toolId);
|
|
102
102
|
if (!adapter)
|
|
103
103
|
return false;
|
|
104
|
+
const view = options?.view ?? DISK_VIEW;
|
|
104
105
|
let workflows;
|
|
105
106
|
if (options?.workflows) {
|
|
106
107
|
workflows = options.workflows;
|
|
@@ -123,11 +124,11 @@ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
|
|
|
123
124
|
}
|
|
124
125
|
for (const cmd of generatedCommands) {
|
|
125
126
|
const cmdPath = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectRoot, cmd.path);
|
|
126
|
-
if (!
|
|
127
|
+
if (!view.exists(cmdPath)) {
|
|
127
128
|
return false;
|
|
128
129
|
}
|
|
129
130
|
try {
|
|
130
|
-
const existingContent =
|
|
131
|
+
const existingContent = view.read(cmdPath);
|
|
131
132
|
if (normalizeCommandContent(existingContent) !== normalizeCommandContent(cmd.fileContent)) {
|
|
132
133
|
return false;
|
|
133
134
|
}
|
|
@@ -143,7 +144,7 @@ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
|
|
|
143
144
|
continue;
|
|
144
145
|
const cmdPath = adapter.getFilePath(workflow);
|
|
145
146
|
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath);
|
|
146
|
-
if (
|
|
147
|
+
if (view.exists(fullPath)) {
|
|
147
148
|
return false;
|
|
148
149
|
}
|
|
149
150
|
}
|
|
@@ -176,12 +177,12 @@ export function getToolStates(projectRoot) {
|
|
|
176
177
|
* Extracts the generatedBy version from a skill file's YAML frontmatter.
|
|
177
178
|
* Returns null if the field is not found or the file doesn't exist.
|
|
178
179
|
*/
|
|
179
|
-
export function extractGeneratedByVersion(skillFilePath) {
|
|
180
|
+
export function extractGeneratedByVersion(skillFilePath, view = DISK_VIEW) {
|
|
180
181
|
try {
|
|
181
|
-
if (!
|
|
182
|
+
if (!view.exists(skillFilePath)) {
|
|
182
183
|
return null;
|
|
183
184
|
}
|
|
184
|
-
const content =
|
|
185
|
+
const content = view.read(skillFilePath);
|
|
185
186
|
// Look for generatedBy in the YAML frontmatter
|
|
186
187
|
// The file format is:
|
|
187
188
|
// ---
|
|
@@ -212,6 +213,7 @@ export function extractGeneratedByVersion(skillFilePath) {
|
|
|
212
213
|
* command-content fingerprint for installs that have commands but no skills.
|
|
213
214
|
*/
|
|
214
215
|
export function getToolVersionStatus(projectRoot, toolId, currentVersion, options) {
|
|
216
|
+
const view = options?.view ?? DISK_VIEW;
|
|
215
217
|
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
|
216
218
|
if (!tool || !toolSupportsSkills(tool)) {
|
|
217
219
|
return {
|
|
@@ -232,8 +234,8 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
|
|
|
232
234
|
for (const skillName of SKILL_NAMES) {
|
|
233
235
|
for (const skillsDir of skillsDirs) {
|
|
234
236
|
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
|
235
|
-
if (
|
|
236
|
-
generatedByVersion = extractGeneratedByVersion(skillFile);
|
|
237
|
+
if (view.exists(skillFile)) {
|
|
238
|
+
generatedByVersion = extractGeneratedByVersion(skillFile, view);
|
|
237
239
|
foundSkill = true;
|
|
238
240
|
break;
|
|
239
241
|
}
|
|
@@ -241,10 +243,10 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
|
|
|
241
243
|
if (foundSkill)
|
|
242
244
|
break;
|
|
243
245
|
}
|
|
244
|
-
const skillConfigured = getToolSkillStatus(projectRoot, toolId).configured;
|
|
245
|
-
const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId);
|
|
246
|
+
const skillConfigured = getToolSkillStatus(projectRoot, toolId, view).configured;
|
|
247
|
+
const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId, view);
|
|
246
248
|
const markerConfigured = Boolean(tool.skillsDir) &&
|
|
247
|
-
readSharedSkillTarget(projectRoot, tool.skillsDir) === toolId;
|
|
249
|
+
readSharedSkillTarget(projectRoot, tool.skillsDir, view) === toolId;
|
|
248
250
|
const configured = skillConfigured || commandConfigured || markerConfigured;
|
|
249
251
|
// 2. Commands-only installs have no skill file to read a version from, so fall
|
|
250
252
|
// back to comparing the generated command content. Deliberately skipped when
|
|
@@ -253,7 +255,7 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
|
|
|
253
255
|
generatedByVersion = currentVersion;
|
|
254
256
|
}
|
|
255
257
|
if (!skillConfigured && !commandConfigured && markerConfigured) {
|
|
256
|
-
const delivery = getGlobalConfig().delivery ?? 'both';
|
|
258
|
+
const delivery = options?.delivery ?? getGlobalConfig().delivery ?? 'both';
|
|
257
259
|
if (!shouldGenerateSkillsForTool(toolId, delivery) &&
|
|
258
260
|
!shouldGenerateCommandsForTool(toolId, delivery)) {
|
|
259
261
|
generatedByVersion = currentVersion;
|
|
@@ -275,7 +277,7 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
|
|
|
275
277
|
if (skillConfigured && commandConfigured) {
|
|
276
278
|
let generatesCommands = true;
|
|
277
279
|
try {
|
|
278
|
-
generatesCommands = shouldGenerateCommandsForTool(toolId, getGlobalConfig().delivery ?? 'both');
|
|
280
|
+
generatesCommands = shouldGenerateCommandsForTool(toolId, options?.delivery ?? getGlobalConfig().delivery ?? 'both');
|
|
279
281
|
}
|
|
280
282
|
catch {
|
|
281
283
|
generatesCommands = true;
|
|
@@ -298,24 +300,24 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
|
|
|
298
300
|
/**
|
|
299
301
|
* Gets all configured tools in the project (configured via skills or commands).
|
|
300
302
|
*/
|
|
301
|
-
export function getConfiguredTools(projectRoot) {
|
|
303
|
+
export function getConfiguredTools(projectRoot, view = DISK_VIEW) {
|
|
302
304
|
const configured = AI_TOOLS
|
|
303
305
|
.filter((t) => {
|
|
304
306
|
if (!toolSupportsSkills(t))
|
|
305
307
|
return false;
|
|
306
|
-
return (getToolSkillStatus(projectRoot, t.value).configured ||
|
|
307
|
-
toolHasAnyConfiguredCommand(projectRoot, t.value) ||
|
|
308
|
+
return (getToolSkillStatus(projectRoot, t.value, view).configured ||
|
|
309
|
+
toolHasAnyConfiguredCommand(projectRoot, t.value, view) ||
|
|
308
310
|
(resolveCommandSurfaceCapability(t.value) === 'adapter-backed' &&
|
|
309
|
-
hasLegacySkills(projectRoot, t)) ||
|
|
311
|
+
hasLegacySkills(projectRoot, t, view)) ||
|
|
310
312
|
(Boolean(t.skillsDir) &&
|
|
311
|
-
readSharedSkillTarget(projectRoot, t.skillsDir) === t.value));
|
|
313
|
+
readSharedSkillTarget(projectRoot, t.skillsDir, view) === t.value));
|
|
312
314
|
});
|
|
313
|
-
const activeProjectTools = new Set(reconcileSharedSkillTargets(projectRoot, configured.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
|
|
315
|
+
const activeProjectTools = new Set(reconcileSharedSkillTargets(projectRoot, configured.filter((tool) => tool.skillsDir), view).map((tool) => tool.value));
|
|
314
316
|
return configured
|
|
315
317
|
.filter((tool) => tool.globalSkillsDir ||
|
|
316
|
-
toolHasAnyConfiguredCommand(projectRoot, tool.value) ||
|
|
318
|
+
toolHasAnyConfiguredCommand(projectRoot, tool.value, view) ||
|
|
317
319
|
(resolveCommandSurfaceCapability(tool.value) === 'adapter-backed' &&
|
|
318
|
-
hasLegacySkills(projectRoot, tool)) ||
|
|
320
|
+
hasLegacySkills(projectRoot, tool, view)) ||
|
|
319
321
|
activeProjectTools.has(tool.value))
|
|
320
322
|
.map((tool) => tool.value);
|
|
321
323
|
}
|