@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
|
@@ -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
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type AIToolOption } from './config.js';
|
|
2
|
+
import { type FileView } from './file-view.js';
|
|
2
3
|
/** Reads a valid-looking marker value without letting linked roots escape. */
|
|
3
|
-
export declare function readSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined;
|
|
4
|
+
export declare function readSharedSkillTarget(projectPath: string, skillsDir: string, view?: FileView): string | undefined;
|
|
4
5
|
/** Whether a tool still has an allowlisted managed skill under an old root. */
|
|
5
|
-
export declare function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean;
|
|
6
|
+
export declare function hasLegacySkills(projectPath: string, tool: AIToolOption, view?: FileView): boolean;
|
|
6
7
|
/**
|
|
7
8
|
* Chooses the one selected tool that may render each physical skills root.
|
|
8
9
|
* Command files are not part of this arbitration: tools that share a skills
|
|
@@ -13,18 +14,18 @@ export declare function hasLegacySkills(projectPath: string, tool: AIToolOption)
|
|
|
13
14
|
* usable by every consumer of the shared tree. Codex is the best fresh owner
|
|
14
15
|
* because its renderer includes both Codex and generic skill invocation forms.
|
|
15
16
|
*/
|
|
16
|
-
export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[]): Set<string>;
|
|
17
|
+
export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[], view?: FileView): Set<string>;
|
|
17
18
|
/**
|
|
18
19
|
* A shared skill root can only hold one rendered variant of each skill.
|
|
19
20
|
* Keep the writer recorded so later updates do not infer every tool that
|
|
20
21
|
* happens to use the same directory.
|
|
21
22
|
*/
|
|
22
|
-
export declare function reconcileSharedSkillTargets(projectPath: string, tools: AIToolOption[]): AIToolOption[];
|
|
23
|
+
export declare function reconcileSharedSkillTargets(projectPath: string, tools: AIToolOption[], view?: FileView): AIToolOption[];
|
|
23
24
|
/**
|
|
24
25
|
* Returns whether a tool is the active writer for its physical skills root.
|
|
25
26
|
* Non-shared roots are always active.
|
|
26
27
|
*/
|
|
27
|
-
export declare function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean;
|
|
28
|
+
export declare function isSharedSkillTargetActive(projectPath: string, toolId: string, view?: FileView): boolean;
|
|
28
29
|
/**
|
|
29
30
|
* The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
|
|
30
31
|
* one does. Returns the owner's tool id only when the root already carries an
|
|
@@ -40,5 +41,11 @@ export declare function sharedSkillRootOwner(projectPath: string, toolId: string
|
|
|
40
41
|
* writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
|
|
41
42
|
*/
|
|
42
43
|
export declare function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Path of the owner marker for a tool's shared skills root, or null when the
|
|
46
|
+
* tool has no shared root to mark. Lets a caller write the marker itself —
|
|
47
|
+
* `update` routes it through its write seam so a dry run can list it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function sharedSkillTargetMarkerPath(projectPath: string, toolId: string): string | null;
|
|
43
50
|
export declare function writeSharedSkillTarget(projectPath: string, toolId: string): void;
|
|
44
51
|
//# sourceMappingURL=shared-skill-target.d.ts.map
|
|
@@ -3,31 +3,32 @@ import * as fs from 'fs';
|
|
|
3
3
|
import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from './config.js';
|
|
4
4
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
5
5
|
import { resolveCommandSurfaceCapability } from './command-surface.js';
|
|
6
|
+
import { DISK_VIEW } from './file-view.js';
|
|
6
7
|
const TARGET_MARKER = '.openspec-target';
|
|
7
8
|
/** Returns the ownership-marker path for one shared skills root. */
|
|
8
9
|
function markerPath(projectPath, skillsDir) {
|
|
9
10
|
return path.join(projectPath, skillsDir, 'skills', TARGET_MARKER);
|
|
10
11
|
}
|
|
11
12
|
/** Reads a valid-looking marker value without letting linked roots escape. */
|
|
12
|
-
export function readSharedSkillTarget(projectPath, skillsDir) {
|
|
13
|
+
export function readSharedSkillTarget(projectPath, skillsDir, view = DISK_VIEW) {
|
|
13
14
|
try {
|
|
14
15
|
const target = markerPath(projectPath, skillsDir);
|
|
15
16
|
FileSystemUtils.assertProjectArtifactPath(projectPath, target);
|
|
16
|
-
return
|
|
17
|
+
return view.read(target).trim() || undefined;
|
|
17
18
|
}
|
|
18
19
|
catch {
|
|
19
20
|
return undefined;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
/** Whether a tool still has an allowlisted managed skill under an old root. */
|
|
23
|
-
export function hasLegacySkills(projectPath, tool) {
|
|
24
|
+
export function hasLegacySkills(projectPath, tool, view = DISK_VIEW) {
|
|
24
25
|
return (tool.legacySkillsDirs ?? []).some((root) => {
|
|
25
26
|
const skillsDir = path.join(projectPath, root, 'skills');
|
|
26
27
|
return OPENSPEC_SKILL_NAMES.some((skillName) => {
|
|
27
28
|
try {
|
|
28
29
|
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
|
29
30
|
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
|
|
30
|
-
return
|
|
31
|
+
return view.exists(skillFile);
|
|
31
32
|
}
|
|
32
33
|
catch {
|
|
33
34
|
return false;
|
|
@@ -39,13 +40,13 @@ export function hasLegacySkills(projectPath, tool) {
|
|
|
39
40
|
* Infers pre-marker ownership from generated invocation syntax. This preserves
|
|
40
41
|
* both existing generic `.agents` trees and Codex trees users moved manually.
|
|
41
42
|
*/
|
|
42
|
-
function inferSharedSkillTarget(projectPath, skillsDir) {
|
|
43
|
+
function inferSharedSkillTarget(projectPath, skillsDir, view = DISK_VIEW) {
|
|
43
44
|
let foundGenericReference = false;
|
|
44
45
|
for (const skillName of OPENSPEC_SKILL_NAMES) {
|
|
45
46
|
const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md');
|
|
46
47
|
try {
|
|
47
48
|
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
|
|
48
|
-
const content =
|
|
49
|
+
const content = view.read(skillFile);
|
|
49
50
|
if (content.includes('$openspec-'))
|
|
50
51
|
return 'codex';
|
|
51
52
|
if (content.includes('/openspec-'))
|
|
@@ -58,12 +59,12 @@ function inferSharedSkillTarget(projectPath, skillsDir) {
|
|
|
58
59
|
return foundGenericReference ? 'agents' : undefined;
|
|
59
60
|
}
|
|
60
61
|
/** Whether the canonical shared root already contains an OpenSpec skill. */
|
|
61
|
-
function hasCurrentSkills(projectPath, skillsDir) {
|
|
62
|
+
function hasCurrentSkills(projectPath, skillsDir, view = DISK_VIEW) {
|
|
62
63
|
return OPENSPEC_SKILL_NAMES.some((skillName) => {
|
|
63
64
|
const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md');
|
|
64
65
|
try {
|
|
65
66
|
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
|
|
66
|
-
return
|
|
67
|
+
return view.exists(skillFile);
|
|
67
68
|
}
|
|
68
69
|
catch {
|
|
69
70
|
return false;
|
|
@@ -80,7 +81,7 @@ function hasCurrentSkills(projectPath, skillsDir) {
|
|
|
80
81
|
* usable by every consumer of the shared tree. Codex is the best fresh owner
|
|
81
82
|
* because its renderer includes both Codex and generic skill invocation forms.
|
|
82
83
|
*/
|
|
83
|
-
export function resolveSharedSkillWriters(projectPath, tools) {
|
|
84
|
+
export function resolveSharedSkillWriters(projectPath, tools, view = DISK_VIEW) {
|
|
84
85
|
const byRoot = new Map();
|
|
85
86
|
const writers = new Set();
|
|
86
87
|
for (const tool of tools) {
|
|
@@ -98,11 +99,11 @@ export function resolveSharedSkillWriters(projectPath, tools) {
|
|
|
98
99
|
}
|
|
99
100
|
const skillsNative = group.filter((tool) => resolveCommandSurfaceCapability(tool.value) !== 'adapter-backed');
|
|
100
101
|
const preferredPool = skillsNative.length > 0 ? skillsNative : group;
|
|
101
|
-
const marked = readSharedSkillTarget(projectPath, root);
|
|
102
|
-
const inferred = inferSharedSkillTarget(projectPath, root);
|
|
102
|
+
const marked = readSharedSkillTarget(projectPath, root, view);
|
|
103
|
+
const inferred = inferSharedSkillTarget(projectPath, root, view);
|
|
103
104
|
const owner = preferredPool.find((tool) => tool.value === marked) ??
|
|
104
105
|
preferredPool.find((tool) => tool.value === inferred) ??
|
|
105
|
-
(hasCurrentSkills(projectPath, root)
|
|
106
|
+
(hasCurrentSkills(projectPath, root, view)
|
|
106
107
|
? preferredPool.find((tool) => tool.value === 'agents')
|
|
107
108
|
: undefined) ??
|
|
108
109
|
preferredPool.find((tool) => tool.value === 'codex') ??
|
|
@@ -116,7 +117,7 @@ export function resolveSharedSkillWriters(projectPath, tools) {
|
|
|
116
117
|
* Keep the writer recorded so later updates do not infer every tool that
|
|
117
118
|
* happens to use the same directory.
|
|
118
119
|
*/
|
|
119
|
-
export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
120
|
+
export function reconcileSharedSkillTargets(projectPath, tools, view = DISK_VIEW) {
|
|
120
121
|
const byRoot = new Map();
|
|
121
122
|
for (const tool of tools) {
|
|
122
123
|
if (!tool.skillsDir)
|
|
@@ -132,14 +133,14 @@ export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
|
132
133
|
continue;
|
|
133
134
|
}
|
|
134
135
|
const root = group[0].skillsDir;
|
|
135
|
-
const marked = readSharedSkillTarget(projectPath, root);
|
|
136
|
+
const marked = readSharedSkillTarget(projectPath, root, view);
|
|
136
137
|
const markedTool = group.find((tool) => tool.value === marked);
|
|
137
138
|
if (markedTool) {
|
|
138
139
|
reconciled.push(markedTool);
|
|
139
140
|
continue;
|
|
140
141
|
}
|
|
141
|
-
const inferred = inferSharedSkillTarget(projectPath, root);
|
|
142
|
-
const legacyCodex = group.find((tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool));
|
|
142
|
+
const inferred = inferSharedSkillTarget(projectPath, root, view);
|
|
143
|
+
const legacyCodex = group.find((tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool, view));
|
|
143
144
|
if (inferred === 'agents' && legacyCodex) {
|
|
144
145
|
// Before ownership markers existed, selecting both targets produced a
|
|
145
146
|
// generic canonical tree plus a Codex-only legacy tree. Codex now emits
|
|
@@ -154,11 +155,11 @@ export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
|
154
155
|
}
|
|
155
156
|
// An unmarked canonical tree predates Codex's move into `.agents`; keep
|
|
156
157
|
// that established agents target instead of overwriting it from `.codex`.
|
|
157
|
-
if (hasCurrentSkills(projectPath, root)) {
|
|
158
|
+
if (hasCurrentSkills(projectPath, root, view)) {
|
|
158
159
|
reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]);
|
|
159
160
|
continue;
|
|
160
161
|
}
|
|
161
|
-
const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool));
|
|
162
|
+
const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool, view));
|
|
162
163
|
if (legacyTool) {
|
|
163
164
|
reconciled.push(legacyTool);
|
|
164
165
|
continue;
|
|
@@ -173,14 +174,14 @@ export function reconcileSharedSkillTargets(projectPath, tools) {
|
|
|
173
174
|
* Returns whether a tool is the active writer for its physical skills root.
|
|
174
175
|
* Non-shared roots are always active.
|
|
175
176
|
*/
|
|
176
|
-
export function isSharedSkillTargetActive(projectPath, toolId) {
|
|
177
|
+
export function isSharedSkillTargetActive(projectPath, toolId, view = DISK_VIEW) {
|
|
177
178
|
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
|
|
178
179
|
if (!tool?.skillsDir)
|
|
179
180
|
return false;
|
|
180
181
|
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
|
|
181
182
|
if (sharingRoot.length < 2)
|
|
182
183
|
return true;
|
|
183
|
-
return reconcileSharedSkillTargets(projectPath, sharingRoot)
|
|
184
|
+
return reconcileSharedSkillTargets(projectPath, sharingRoot, view)
|
|
184
185
|
.some((candidate) => candidate.value === toolId);
|
|
185
186
|
}
|
|
186
187
|
/**
|
|
@@ -213,14 +214,24 @@ export function sharedSkillRootOwner(projectPath, toolId) {
|
|
|
213
214
|
export function sharedSkillRootOwnedByOther(projectPath, toolId) {
|
|
214
215
|
return sharedSkillRootOwner(projectPath, toolId) !== undefined;
|
|
215
216
|
}
|
|
216
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Path of the owner marker for a tool's shared skills root, or null when the
|
|
219
|
+
* tool has no shared root to mark. Lets a caller write the marker itself —
|
|
220
|
+
* `update` routes it through its write seam so a dry run can list it.
|
|
221
|
+
*/
|
|
222
|
+
export function sharedSkillTargetMarkerPath(projectPath, toolId) {
|
|
217
223
|
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
|
|
218
224
|
if (!tool?.skillsDir)
|
|
219
|
-
return;
|
|
225
|
+
return null;
|
|
220
226
|
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
|
|
221
227
|
if (sharingRoot.length < 2)
|
|
228
|
+
return null;
|
|
229
|
+
return markerPath(projectPath, tool.skillsDir);
|
|
230
|
+
}
|
|
231
|
+
export function writeSharedSkillTarget(projectPath, toolId) {
|
|
232
|
+
const target = sharedSkillTargetMarkerPath(projectPath, toolId);
|
|
233
|
+
if (!target)
|
|
222
234
|
return;
|
|
223
|
-
const target = markerPath(projectPath, tool.skillsDir);
|
|
224
235
|
FileSystemUtils.assertProjectArtifactPath(projectPath, target);
|
|
225
236
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
226
237
|
fs.writeFileSync(target, `${toolId}\n`, 'utf-8');
|