@tacuchi/agent-workflow-cli 4.3.0 → 4.5.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/dist/application/plugin-doctor-service.d.ts.map +1 -1
- package/dist/application/plugin-doctor-service.js +534 -438
- package/dist/application/plugin-doctor-service.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +5 -3
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/main.js +3 -4
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/render.d.ts +9 -0
- package/dist/cli/render.d.ts.map +1 -1
- package/dist/cli/render.js +24 -0
- package/dist/cli/render.js.map +1 -1
- package/package.json +1 -1
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
//
|
|
13
13
|
// `exported_skills` se lee de `.claude-plugin/plugin.json:exportedSkills` o
|
|
14
14
|
// de un --exports-file JSON.
|
|
15
|
+
//
|
|
16
|
+
// Estructura interna (post-session011 G2 refactor):
|
|
17
|
+
// `runPluginDoctor` orquesta 8 helpers self-contained, cada uno con
|
|
18
|
+
// complexity <= 15. Cada helper retorna `{...result, findings}` y el orchestrator
|
|
19
|
+
// agrega los findings al final.
|
|
15
20
|
import { spawnSync } from "node:child_process";
|
|
16
21
|
import { extname, join, relative, resolve, sep } from "node:path";
|
|
17
22
|
const SESSION_SPECIFIC_MARKERS = [
|
|
@@ -27,459 +32,538 @@ export async function runPluginDoctor(fs, env, paths, runtime, input) {
|
|
|
27
32
|
? resolve(input.pluginRoot.startsWith("/") ? input.pluginRoot : join(cwd, input.pluginRoot))
|
|
28
33
|
: cwd;
|
|
29
34
|
const flow = input.flow ?? "core";
|
|
30
|
-
const inputPluginVersion = input.pluginVersion ?? null;
|
|
31
35
|
const compatRange = input.compatRange ?? null;
|
|
32
36
|
const skillsDir = join(pluginRoot, "skills");
|
|
33
37
|
const readmePath = join(pluginRoot, "README.md");
|
|
38
|
+
const skillsResult = await checkSkillsFrontmatter(skillsDir, fs);
|
|
39
|
+
const readmeResult = await checkReadmeSync(readmePath, skillsResult.skillsCount, fs);
|
|
40
|
+
const fdFindings = await checkFrontendDesignGeneralization(skillsDir, pluginRoot, fs);
|
|
41
|
+
const manifestsResult = await parseManifests(pluginRoot, fs, input.pluginVersion ?? null);
|
|
42
|
+
const isSinglePathContract = isContractVersionAtLeast(manifestsResult.manifestQtcContractVersion, 6, 3);
|
|
43
|
+
const pluginVersion = manifestsResult.canonicalVersion ?? "unknown";
|
|
44
|
+
const pluginName = input.pluginName ?? manifestsResult.manifestPluginName ?? `${paths.namespace}-${flow}`;
|
|
45
|
+
const legacyResult = await checkLegacyMarkers(paths, flow, pluginVersion, compatRange, isSinglePathContract, fs);
|
|
46
|
+
const hooksResult = await parseHooks(pluginRoot, fs);
|
|
47
|
+
const mcpResult = await validateMcp(pluginRoot, runtime, env, fs);
|
|
48
|
+
const exportedResult = await validateExportedSkills(skillsDir, pluginRoot, input.exportsFile, pluginName, skillsResult.skillsInfo, fs);
|
|
49
|
+
const findings = [
|
|
50
|
+
...skillsResult.findings,
|
|
51
|
+
...readmeResult.findings,
|
|
52
|
+
...fdFindings,
|
|
53
|
+
...manifestsResult.findings,
|
|
54
|
+
...legacyResult.findings,
|
|
55
|
+
...hooksResult.findings,
|
|
56
|
+
...mcpResult.findings,
|
|
57
|
+
...exportedResult.findings,
|
|
58
|
+
];
|
|
59
|
+
const hasError = findings.some((f) => f.level === "error");
|
|
60
|
+
const hasWarn = findings.some((f) => f.level === "warn");
|
|
61
|
+
const status = hasError ? "error" : hasWarn ? "warn" : "ok";
|
|
62
|
+
return {
|
|
63
|
+
data: {
|
|
64
|
+
status,
|
|
65
|
+
plugin: pluginName,
|
|
66
|
+
plugin_root: pluginRoot,
|
|
67
|
+
plugin_version: pluginVersion,
|
|
68
|
+
qtc_core_installed: legacyResult.qtcCoreInstalled,
|
|
69
|
+
compat_range: compatRange,
|
|
70
|
+
compat_ok: legacyResult.compatOk,
|
|
71
|
+
python_version: legacyResult.pythonVersion,
|
|
72
|
+
skills_count: skillsResult.skillsCount,
|
|
73
|
+
readme_count_expected: readmeResult.readmeCountExpected,
|
|
74
|
+
readme_count_match: readmeResult.readmeCountMatch,
|
|
75
|
+
manifests: manifestsResult.manifestsInfo,
|
|
76
|
+
installed_marker: legacyResult.installedMarker,
|
|
77
|
+
hooks: hooksResult.hooksInfo,
|
|
78
|
+
mcp: mcpResult.mcpInfo,
|
|
79
|
+
skills: skillsResult.skillsInfo,
|
|
80
|
+
exported_skills: exportedResult.exportedInfo,
|
|
81
|
+
findings,
|
|
82
|
+
},
|
|
83
|
+
hasError,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function checkSkillsFrontmatter(skillsDir, fs) {
|
|
34
87
|
const findings = [];
|
|
35
88
|
const skillsInfo = [];
|
|
36
|
-
|
|
37
|
-
const skillDirs = [];
|
|
38
|
-
if (await fs.exists(skillsDir)) {
|
|
39
|
-
const entries = await fs.list(skillsDir);
|
|
40
|
-
for (const entry of entries) {
|
|
41
|
-
if (entry.type !== "dir")
|
|
42
|
-
continue;
|
|
43
|
-
const skillMd = join(entry.path, "SKILL.md");
|
|
44
|
-
if (await fs.exists(skillMd))
|
|
45
|
-
skillDirs.push(entry.path);
|
|
46
|
-
}
|
|
47
|
-
skillDirs.sort((a, b) => a.localeCompare(b));
|
|
48
|
-
}
|
|
49
|
-
const skillsCount = skillDirs.length;
|
|
89
|
+
const skillDirs = await collectSkillDirs(skillsDir, fs);
|
|
50
90
|
for (const sd of skillDirs) {
|
|
51
91
|
const skillMd = join(sd, "SKILL.md");
|
|
52
92
|
const dirName = sd.split(sep).pop() ?? "";
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
catch (e) {
|
|
58
|
-
findings.push({ level: "error", file: skillMd, msg: `cannot read: ${e.message}` });
|
|
59
|
-
skillsInfo.push({ dir: dirName, name: null, version: null });
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
const lines = content.split(/\r?\n/);
|
|
63
|
-
if (lines.length === 0 || (lines[0] ?? "").trim() !== "---") {
|
|
64
|
-
findings.push({ level: "error", file: skillMd, msg: "missing frontmatter opening ---" });
|
|
93
|
+
const parsed = await parseSkillFile(skillMd, fs);
|
|
94
|
+
if (parsed.error) {
|
|
95
|
+
findings.push({ level: "error", file: skillMd, msg: parsed.error });
|
|
65
96
|
skillsInfo.push({ dir: dirName, name: null, version: null });
|
|
66
97
|
continue;
|
|
67
98
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
validateSkillFrontmatter(skillMd, dirName, parsed.frontmatter, findings);
|
|
100
|
+
skillsInfo.push({
|
|
101
|
+
dir: dirName,
|
|
102
|
+
name: parsed.frontmatter.name ?? null,
|
|
103
|
+
version: parsed.frontmatter.version ?? null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return { skillsCount: skillDirs.length, skillsInfo, findings };
|
|
107
|
+
}
|
|
108
|
+
async function collectSkillDirs(skillsDir, fs) {
|
|
109
|
+
const out = [];
|
|
110
|
+
if (!(await fs.exists(skillsDir)))
|
|
111
|
+
return out;
|
|
112
|
+
const entries = await fs.list(skillsDir);
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (entry.type !== "dir")
|
|
78
115
|
continue;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const m = (lines[i] ?? "").match(/^(\w+):\s*(.+)$/);
|
|
83
|
-
if (m?.[1] && m[2] !== undefined)
|
|
84
|
-
fm[m[1]] = m[2].trim();
|
|
85
|
-
}
|
|
86
|
-
const name = fm.name ?? null;
|
|
87
|
-
const version = fm.version ?? null;
|
|
88
|
-
const description = fm.description ?? null;
|
|
89
|
-
if (!name) {
|
|
90
|
-
findings.push({ level: "error", file: skillMd, msg: "missing 'name' in frontmatter" });
|
|
91
|
-
}
|
|
92
|
-
else if (name !== dirName) {
|
|
93
|
-
findings.push({
|
|
94
|
-
level: "warn",
|
|
95
|
-
file: skillMd,
|
|
96
|
-
msg: `frontmatter name '${name}' differs from directory '${dirName}'`,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
if (!description) {
|
|
100
|
-
findings.push({ level: "error", file: skillMd, msg: "missing 'description' in frontmatter" });
|
|
101
|
-
}
|
|
102
|
-
if (!version) {
|
|
103
|
-
findings.push({ level: "warn", file: skillMd, msg: "missing 'version' in frontmatter" });
|
|
104
|
-
}
|
|
105
|
-
else if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
106
|
-
findings.push({
|
|
107
|
-
level: "warn",
|
|
108
|
-
file: skillMd,
|
|
109
|
-
msg: `version '${version}' not semver-compatible`,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
skillsInfo.push({ dir: dirName, name, version });
|
|
116
|
+
const skillMd = join(entry.path, "SKILL.md");
|
|
117
|
+
if (await fs.exists(skillMd))
|
|
118
|
+
out.push(entry.path);
|
|
113
119
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
findings.push({
|
|
135
|
-
level: "warn",
|
|
136
|
-
file: "README.md",
|
|
137
|
-
msg: `cannot read: ${e.message}`,
|
|
138
|
-
});
|
|
120
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
async function parseSkillFile(skillMd, fs) {
|
|
124
|
+
let content;
|
|
125
|
+
try {
|
|
126
|
+
content = await fs.readText(skillMd);
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return { frontmatter: {}, error: `cannot read: ${e.message}` };
|
|
130
|
+
}
|
|
131
|
+
const lines = content.split(/\r?\n/);
|
|
132
|
+
if (lines.length === 0 || (lines[0] ?? "").trim() !== "---") {
|
|
133
|
+
return { frontmatter: {}, error: "missing frontmatter opening ---" };
|
|
134
|
+
}
|
|
135
|
+
let endIdx = -1;
|
|
136
|
+
for (let i = 1; i < lines.length; i++) {
|
|
137
|
+
if ((lines[i] ?? "").trim() === "---") {
|
|
138
|
+
endIdx = i;
|
|
139
|
+
break;
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
|
-
|
|
142
|
+
if (endIdx === -1) {
|
|
143
|
+
return { frontmatter: {}, error: "missing frontmatter closing ---" };
|
|
144
|
+
}
|
|
145
|
+
const fm = {};
|
|
146
|
+
for (let i = 1; i < endIdx; i++) {
|
|
147
|
+
const m = (lines[i] ?? "").match(/^(\w+):\s*(.+)$/);
|
|
148
|
+
if (m?.[1] && m[2] !== undefined)
|
|
149
|
+
fm[m[1]] = m[2].trim();
|
|
150
|
+
}
|
|
151
|
+
return { frontmatter: fm, error: null };
|
|
152
|
+
}
|
|
153
|
+
function validateSkillFrontmatter(skillMd, dirName, fm, findings) {
|
|
154
|
+
const { name, version, description } = fm;
|
|
155
|
+
if (!name) {
|
|
156
|
+
findings.push({ level: "error", file: skillMd, msg: "missing 'name' in frontmatter" });
|
|
157
|
+
}
|
|
158
|
+
else if (name !== dirName) {
|
|
159
|
+
findings.push({
|
|
160
|
+
level: "warn",
|
|
161
|
+
file: skillMd,
|
|
162
|
+
msg: `frontmatter name '${name}' differs from directory '${dirName}'`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!description) {
|
|
166
|
+
findings.push({ level: "error", file: skillMd, msg: "missing 'description' in frontmatter" });
|
|
167
|
+
}
|
|
168
|
+
if (!version) {
|
|
169
|
+
findings.push({ level: "warn", file: skillMd, msg: "missing 'version' in frontmatter" });
|
|
170
|
+
}
|
|
171
|
+
else if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
172
|
+
findings.push({
|
|
173
|
+
level: "warn",
|
|
174
|
+
file: skillMd,
|
|
175
|
+
msg: `version '${version}' not semver-compatible`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function checkReadmeSync(readmePath, skillsCount, fs) {
|
|
180
|
+
const findings = [];
|
|
181
|
+
if (!(await fs.exists(readmePath))) {
|
|
142
182
|
findings.push({ level: "warn", file: "README.md", msg: "README.md not found at plugin root" });
|
|
183
|
+
return { readmeCountExpected: null, readmeCountMatch: null, findings };
|
|
143
184
|
}
|
|
144
|
-
|
|
185
|
+
let readmeText;
|
|
186
|
+
try {
|
|
187
|
+
readmeText = await fs.readText(readmePath);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
findings.push({
|
|
191
|
+
level: "warn",
|
|
192
|
+
file: "README.md",
|
|
193
|
+
msg: `cannot read: ${e.message}`,
|
|
194
|
+
});
|
|
195
|
+
return { readmeCountExpected: null, readmeCountMatch: null, findings };
|
|
196
|
+
}
|
|
197
|
+
const m = readmeText.match(/\*\*Skills\*\*\s*\((\d+)/);
|
|
198
|
+
if (!m?.[1]) {
|
|
199
|
+
return { readmeCountExpected: null, readmeCountMatch: null, findings };
|
|
200
|
+
}
|
|
201
|
+
const readmeCountExpected = Number.parseInt(m[1], 10);
|
|
202
|
+
const readmeCountMatch = readmeCountExpected === skillsCount;
|
|
203
|
+
if (!readmeCountMatch) {
|
|
204
|
+
findings.push({
|
|
205
|
+
level: "warn",
|
|
206
|
+
file: "README.md",
|
|
207
|
+
msg: `Skills count mismatch: README claims ${readmeCountExpected}, actual ${skillsCount}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return { readmeCountExpected, readmeCountMatch, findings };
|
|
211
|
+
}
|
|
212
|
+
// ---------- 3. Frontend-design generalization ----------
|
|
213
|
+
async function checkFrontendDesignGeneralization(skillsDir, pluginRoot, fs) {
|
|
214
|
+
const findings = [];
|
|
145
215
|
const fdDir = join(skillsDir, "frontend-design");
|
|
146
|
-
if ((await fs.exists(skillsDir))
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
catch {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
for (const marker of SESSION_SPECIFIC_MARKERS) {
|
|
158
|
-
if (text.includes(marker)) {
|
|
159
|
-
const rel = relative(pluginRoot, mdFile).split(sep).join("/");
|
|
160
|
-
findings.push({
|
|
161
|
-
level: "error",
|
|
162
|
-
file: rel,
|
|
163
|
-
msg: `contains session-specific name '${marker}' (frontend-design must stay generalized)`,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
216
|
+
if (!(await fs.exists(skillsDir)) || !(await fs.exists(fdDir)))
|
|
217
|
+
return findings;
|
|
218
|
+
const mdFiles = await collectMarkdownFiles(fs, fdDir);
|
|
219
|
+
mdFiles.sort((a, b) => a.localeCompare(b));
|
|
220
|
+
for (const mdFile of mdFiles) {
|
|
221
|
+
let text;
|
|
222
|
+
try {
|
|
223
|
+
text = await fs.readText(mdFile);
|
|
167
224
|
}
|
|
225
|
+
catch {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
scanForSessionMarkers(text, mdFile, pluginRoot, findings);
|
|
229
|
+
}
|
|
230
|
+
return findings;
|
|
231
|
+
}
|
|
232
|
+
function scanForSessionMarkers(text, mdFile, pluginRoot, findings) {
|
|
233
|
+
for (const marker of SESSION_SPECIFIC_MARKERS) {
|
|
234
|
+
if (!text.includes(marker))
|
|
235
|
+
continue;
|
|
236
|
+
const rel = relative(pluginRoot, mdFile).split(sep).join("/");
|
|
237
|
+
findings.push({
|
|
238
|
+
level: "error",
|
|
239
|
+
file: rel,
|
|
240
|
+
msg: `contains session-specific name '${marker}' (frontend-design must stay generalized)`,
|
|
241
|
+
});
|
|
168
242
|
}
|
|
169
|
-
|
|
243
|
+
}
|
|
244
|
+
async function parseManifests(pluginRoot, fs, inputPluginVersion) {
|
|
245
|
+
const findings = [];
|
|
170
246
|
const manifestsInfo = {};
|
|
171
|
-
let manifestQtcContractVersion = null;
|
|
172
247
|
let canonicalVersion = inputPluginVersion;
|
|
173
248
|
let manifestPluginName = null;
|
|
249
|
+
let manifestQtcContractVersion = null;
|
|
174
250
|
for (const relPath of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"]) {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
let raw;
|
|
182
|
-
try {
|
|
183
|
-
raw = await fs.readText(manifestPath);
|
|
184
|
-
}
|
|
185
|
-
catch (e) {
|
|
186
|
-
findings.push({
|
|
187
|
-
level: "error",
|
|
188
|
-
file: relPath,
|
|
189
|
-
msg: `invalid JSON: ${e.message}`,
|
|
190
|
-
});
|
|
191
|
-
manifestsInfo[relPath] = null;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
let data;
|
|
195
|
-
try {
|
|
196
|
-
data = JSON.parse(raw);
|
|
197
|
-
}
|
|
198
|
-
catch (e) {
|
|
199
|
-
findings.push({
|
|
200
|
-
level: "error",
|
|
201
|
-
file: relPath,
|
|
202
|
-
msg: `invalid JSON: ${e.message}`,
|
|
203
|
-
});
|
|
204
|
-
manifestsInfo[relPath] = null;
|
|
251
|
+
const parsed = await parseManifestFile(join(pluginRoot, relPath), relPath, fs);
|
|
252
|
+
manifestsInfo[relPath] = parsed.version;
|
|
253
|
+
findings.push(...parsed.findings);
|
|
254
|
+
if (parsed.parseError)
|
|
205
255
|
continue;
|
|
256
|
+
if (manifestPluginName === null && parsed.name !== null) {
|
|
257
|
+
manifestPluginName = parsed.name;
|
|
206
258
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (manifestPluginName === null &&
|
|
210
|
-
isRecord(data) &&
|
|
211
|
-
typeof data.name === "string" &&
|
|
212
|
-
data.name.length > 0) {
|
|
213
|
-
manifestPluginName = data.name;
|
|
214
|
-
}
|
|
215
|
-
if (canonicalVersion === null && manifestVersion !== null) {
|
|
216
|
-
canonicalVersion = manifestVersion;
|
|
259
|
+
if (canonicalVersion === null && parsed.version !== null) {
|
|
260
|
+
canonicalVersion = parsed.version;
|
|
217
261
|
}
|
|
218
|
-
else if (canonicalVersion !== null &&
|
|
262
|
+
else if (canonicalVersion !== null &&
|
|
263
|
+
parsed.version !== null &&
|
|
264
|
+
parsed.version !== canonicalVersion) {
|
|
219
265
|
findings.push({
|
|
220
266
|
level: "error",
|
|
221
267
|
file: relPath,
|
|
222
|
-
msg: `version drift: manifest=${
|
|
268
|
+
msg: `version drift: manifest=${parsed.version} vs declared=${canonicalVersion}`,
|
|
223
269
|
});
|
|
224
270
|
}
|
|
225
|
-
if (manifestQtcContractVersion === null &&
|
|
226
|
-
|
|
227
|
-
typeof data.qtcContractVersion === "string") {
|
|
228
|
-
manifestQtcContractVersion = data.qtcContractVersion;
|
|
271
|
+
if (manifestQtcContractVersion === null && parsed.qtcContractVersion !== null) {
|
|
272
|
+
manifestQtcContractVersion = parsed.qtcContractVersion;
|
|
229
273
|
}
|
|
230
274
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
275
|
+
return {
|
|
276
|
+
manifestsInfo,
|
|
277
|
+
canonicalVersion,
|
|
278
|
+
manifestPluginName,
|
|
279
|
+
manifestQtcContractVersion,
|
|
280
|
+
findings,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
async function parseManifestFile(manifestPath, relPath, fs) {
|
|
284
|
+
const findings = [];
|
|
285
|
+
if (!(await fs.exists(manifestPath))) {
|
|
286
|
+
findings.push({ level: "warn", file: relPath, msg: "manifest missing" });
|
|
287
|
+
return { version: null, name: null, qtcContractVersion: null, parseError: true, findings };
|
|
288
|
+
}
|
|
289
|
+
let raw;
|
|
290
|
+
try {
|
|
291
|
+
raw = await fs.readText(manifestPath);
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
findings.push({
|
|
295
|
+
level: "error",
|
|
296
|
+
file: relPath,
|
|
297
|
+
msg: `invalid JSON: ${e.message}`,
|
|
298
|
+
});
|
|
299
|
+
return { version: null, name: null, qtcContractVersion: null, parseError: true, findings };
|
|
300
|
+
}
|
|
301
|
+
let data;
|
|
302
|
+
try {
|
|
303
|
+
data = JSON.parse(raw);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
findings.push({
|
|
307
|
+
level: "error",
|
|
308
|
+
file: relPath,
|
|
309
|
+
msg: `invalid JSON: ${e.message}`,
|
|
310
|
+
});
|
|
311
|
+
return { version: null, name: null, qtcContractVersion: null, parseError: true, findings };
|
|
312
|
+
}
|
|
313
|
+
if (!isRecord(data)) {
|
|
314
|
+
return { version: null, name: null, qtcContractVersion: null, parseError: false, findings };
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
version: typeof data.version === "string" ? data.version : null,
|
|
318
|
+
name: typeof data.name === "string" && data.name.length > 0 ? data.name : null,
|
|
319
|
+
qtcContractVersion: typeof data.qtcContractVersion === "string" ? data.qtcContractVersion : null,
|
|
320
|
+
parseError: false,
|
|
321
|
+
findings,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function checkLegacyMarkers(paths, flow, pluginVersion, compatRange, isSinglePathContract, fs) {
|
|
325
|
+
const findings = [];
|
|
326
|
+
if (isSinglePathContract) {
|
|
327
|
+
return {
|
|
328
|
+
installedMarker: null,
|
|
329
|
+
qtcCoreInstalled: null,
|
|
330
|
+
compatOk: null,
|
|
331
|
+
pythonVersion: null,
|
|
332
|
+
findings,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const installedMarker = await readPluginVersionMarker(paths, flow, pluginVersion, fs, findings);
|
|
336
|
+
const coreLibMarker = paths.userCoreLibMarker();
|
|
337
|
+
const qtcCoreInstalled = await readMarkerText(coreLibMarker, fs);
|
|
338
|
+
const compatOk = compatRange
|
|
339
|
+
? evaluateCompat(qtcCoreInstalled, compatRange, coreLibMarker, findings)
|
|
340
|
+
: null;
|
|
341
|
+
const pythonVersion = checkPythonVersion(findings);
|
|
342
|
+
return { installedMarker, qtcCoreInstalled, compatOk, pythonVersion, findings };
|
|
343
|
+
}
|
|
344
|
+
async function readPluginVersionMarker(paths, flow, pluginVersion, fs, findings) {
|
|
345
|
+
const markerFile = paths.userPluginVersionFile(flow);
|
|
346
|
+
if (!(await fs.exists(markerFile)))
|
|
347
|
+
return null;
|
|
239
348
|
let installedMarker = null;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// 5. Marker file (~/.<ns>/<flow>/.plugin-version) — solo legacy.
|
|
244
|
-
const markerFile = paths.userPluginVersionFile(flow);
|
|
245
|
-
if (await fs.exists(markerFile)) {
|
|
246
|
-
try {
|
|
247
|
-
const raw = (await fs.readText(markerFile)).trim();
|
|
248
|
-
installedMarker = raw.length > 0 ? raw : null;
|
|
249
|
-
}
|
|
250
|
-
catch (e) {
|
|
251
|
-
findings.push({
|
|
252
|
-
level: "warn",
|
|
253
|
-
file: markerFile,
|
|
254
|
-
msg: `cannot read marker: ${e.message}`,
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
if (installedMarker && installedMarker !== pluginVersion) {
|
|
258
|
-
findings.push({
|
|
259
|
-
level: "warn",
|
|
260
|
-
file: markerFile,
|
|
261
|
-
msg: `installed plugin v${installedMarker} differs from declared v${pluginVersion} — reinstall/re-sync`,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
// 5b. core lib install state + compat range — solo legacy.
|
|
266
|
-
const coreLibMarker = paths.userCoreLibMarker();
|
|
267
|
-
if (await fs.exists(coreLibMarker)) {
|
|
268
|
-
try {
|
|
269
|
-
const raw = (await fs.readText(coreLibMarker)).trim();
|
|
270
|
-
qtcCoreInstalled = raw.length > 0 ? raw : null;
|
|
271
|
-
}
|
|
272
|
-
catch {
|
|
273
|
-
// ignore
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (compatRange) {
|
|
277
|
-
compatOk = evaluateCompat(qtcCoreInstalled, compatRange, coreLibMarker, findings);
|
|
278
|
-
}
|
|
349
|
+
try {
|
|
350
|
+
const raw = (await fs.readText(markerFile)).trim();
|
|
351
|
+
installedMarker = raw.length > 0 ? raw : null;
|
|
279
352
|
}
|
|
280
|
-
|
|
353
|
+
catch (e) {
|
|
354
|
+
findings.push({
|
|
355
|
+
level: "warn",
|
|
356
|
+
file: markerFile,
|
|
357
|
+
msg: `cannot read marker: ${e.message}`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (installedMarker && installedMarker !== pluginVersion) {
|
|
361
|
+
findings.push({
|
|
362
|
+
level: "warn",
|
|
363
|
+
file: markerFile,
|
|
364
|
+
msg: `installed plugin v${installedMarker} differs from declared v${pluginVersion} — reinstall/re-sync`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return installedMarker;
|
|
368
|
+
}
|
|
369
|
+
async function readMarkerText(markerFile, fs) {
|
|
370
|
+
if (!(await fs.exists(markerFile)))
|
|
371
|
+
return null;
|
|
372
|
+
try {
|
|
373
|
+
const raw = (await fs.readText(markerFile)).trim();
|
|
374
|
+
return raw.length > 0 ? raw : null;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function checkPythonVersion(findings) {
|
|
381
|
+
const pythonVersion = detectPythonVersion();
|
|
382
|
+
if (!pythonVersion)
|
|
383
|
+
return null;
|
|
384
|
+
const m = pythonVersion.match(/^(\d+)\.(\d+)/);
|
|
385
|
+
if (!m?.[1] || !m[2])
|
|
386
|
+
return pythonVersion;
|
|
387
|
+
const major = Number.parseInt(m[1], 10);
|
|
388
|
+
const minor = Number.parseInt(m[2], 10);
|
|
389
|
+
if (major < 3 || (major === 3 && minor < 8)) {
|
|
390
|
+
findings.push({
|
|
391
|
+
level: "warn",
|
|
392
|
+
file: "python",
|
|
393
|
+
msg: `python ${pythonVersion} is too old; recommend 3.8+`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return pythonVersion;
|
|
397
|
+
}
|
|
398
|
+
async function parseHooks(pluginRoot, fs) {
|
|
399
|
+
const findings = [];
|
|
281
400
|
const hooksInfo = {};
|
|
282
401
|
for (const relPath of ["hooks/hooks.json", "codex-hooks/hooks.json"]) {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
catch (e) {
|
|
307
|
-
findings.push({
|
|
308
|
-
level: "error",
|
|
309
|
-
file: relPath,
|
|
310
|
-
msg: `invalid JSON: ${e.message}`,
|
|
311
|
-
});
|
|
312
|
-
hooksInfo[relPath] = null;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
if (!isRecord(data) || !("hooks" in data) || !isRecord(data.hooks)) {
|
|
316
|
-
findings.push({
|
|
317
|
-
level: "warn",
|
|
318
|
-
file: relPath,
|
|
319
|
-
msg: "hooks JSON missing top-level 'hooks' key",
|
|
320
|
-
});
|
|
321
|
-
hooksInfo[relPath] = "invalid-structure";
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
hooksInfo[relPath] = Object.keys(data.hooks).sort();
|
|
325
|
-
}
|
|
402
|
+
const result = await parseHookFile(join(pluginRoot, relPath), relPath, fs);
|
|
403
|
+
hooksInfo[relPath] = result.value;
|
|
404
|
+
findings.push(...result.findings);
|
|
405
|
+
}
|
|
406
|
+
return { hooksInfo, findings };
|
|
407
|
+
}
|
|
408
|
+
async function parseHookFile(hookPath, relPath, fs) {
|
|
409
|
+
const findings = [];
|
|
410
|
+
if (!(await fs.exists(hookPath))) {
|
|
411
|
+
findings.push({ level: "warn", file: relPath, msg: "hooks file missing" });
|
|
412
|
+
return { value: null, findings };
|
|
413
|
+
}
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = await fs.readText(hookPath);
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
findings.push({
|
|
420
|
+
level: "error",
|
|
421
|
+
file: relPath,
|
|
422
|
+
msg: `invalid JSON: ${e.message}`,
|
|
423
|
+
});
|
|
424
|
+
return { value: null, findings };
|
|
326
425
|
}
|
|
327
|
-
|
|
426
|
+
let data;
|
|
427
|
+
try {
|
|
428
|
+
data = JSON.parse(raw);
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
findings.push({
|
|
432
|
+
level: "error",
|
|
433
|
+
file: relPath,
|
|
434
|
+
msg: `invalid JSON: ${e.message}`,
|
|
435
|
+
});
|
|
436
|
+
return { value: null, findings };
|
|
437
|
+
}
|
|
438
|
+
if (!isRecord(data) || !("hooks" in data) || !isRecord(data.hooks)) {
|
|
439
|
+
findings.push({
|
|
440
|
+
level: "warn",
|
|
441
|
+
file: relPath,
|
|
442
|
+
msg: "hooks JSON missing top-level 'hooks' key",
|
|
443
|
+
});
|
|
444
|
+
return { value: "invalid-structure", findings };
|
|
445
|
+
}
|
|
446
|
+
return { value: Object.keys(data.hooks).sort(), findings };
|
|
447
|
+
}
|
|
448
|
+
async function validateMcp(pluginRoot, runtime, env, fs) {
|
|
449
|
+
const findings = [];
|
|
328
450
|
const mcpInfo = {};
|
|
329
451
|
const mcpPath = join(pluginRoot, ".mcp.json");
|
|
330
452
|
const expectedMcpServers = runtime.expectedMcpServers ?? [];
|
|
331
|
-
if (expectedMcpServers.length
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
mcpData = JSON.parse(await fs.readText(mcpPath));
|
|
335
|
-
}
|
|
336
|
-
catch (e) {
|
|
337
|
-
findings.push({
|
|
338
|
-
level: "error",
|
|
339
|
-
file: ".mcp.json",
|
|
340
|
-
msg: `invalid JSON: ${e.message}`,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
if (mcpData !== null && isRecord(mcpData)) {
|
|
344
|
-
const servers = isRecord(mcpData.mcpServers) ? mcpData.mcpServers : {};
|
|
345
|
-
for (const exp of expectedMcpServers) {
|
|
346
|
-
const server = servers[exp];
|
|
347
|
-
if (server === undefined) {
|
|
348
|
-
findings.push({
|
|
349
|
-
level: "warn",
|
|
350
|
-
file: ".mcp.json",
|
|
351
|
-
msg: `expected server '${exp}' not configured`,
|
|
352
|
-
});
|
|
353
|
-
mcpInfo[exp] = "missing";
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
const dsnRaw = isRecord(server) && isRecord(server.env) && typeof server.env.DSN === "string"
|
|
357
|
-
? server.env.DSN
|
|
358
|
-
: "";
|
|
359
|
-
const m = dsnRaw.match(/^\$\{(\w+)\}$/);
|
|
360
|
-
if (m?.[1]) {
|
|
361
|
-
const envVar = m[1];
|
|
362
|
-
const envSet = Boolean(env.get(envVar));
|
|
363
|
-
mcpInfo[exp] = { dsn_env: envVar, env_set: envSet };
|
|
364
|
-
if (!envSet) {
|
|
365
|
-
findings.push({
|
|
366
|
-
level: "warn",
|
|
367
|
-
file: ".mcp.json",
|
|
368
|
-
msg: `env var ${envVar} not set (required by mcp server '${exp}')`,
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
else {
|
|
373
|
-
mcpInfo[exp] = { dsn_env: null, env_set: null };
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
453
|
+
if (expectedMcpServers.length === 0 || !(await fs.exists(mcpPath))) {
|
|
454
|
+
return { mcpInfo, findings };
|
|
377
455
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
456
|
+
let mcpData = null;
|
|
457
|
+
try {
|
|
458
|
+
mcpData = JSON.parse(await fs.readText(mcpPath));
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
findings.push({
|
|
462
|
+
level: "error",
|
|
463
|
+
file: ".mcp.json",
|
|
464
|
+
msg: `invalid JSON: ${e.message}`,
|
|
465
|
+
});
|
|
466
|
+
return { mcpInfo, findings };
|
|
467
|
+
}
|
|
468
|
+
if (!isRecord(mcpData))
|
|
469
|
+
return { mcpInfo, findings };
|
|
470
|
+
const servers = isRecord(mcpData.mcpServers) ? mcpData.mcpServers : {};
|
|
471
|
+
for (const exp of expectedMcpServers) {
|
|
472
|
+
const server = servers[exp];
|
|
473
|
+
mcpInfo[exp] = validateMcpServer(server, exp, env, findings);
|
|
474
|
+
}
|
|
475
|
+
return { mcpInfo, findings };
|
|
476
|
+
}
|
|
477
|
+
function validateMcpServer(server, exp, env, findings) {
|
|
478
|
+
if (server === undefined) {
|
|
479
|
+
findings.push({
|
|
480
|
+
level: "warn",
|
|
481
|
+
file: ".mcp.json",
|
|
482
|
+
msg: `expected server '${exp}' not configured`,
|
|
483
|
+
});
|
|
484
|
+
return "missing";
|
|
396
485
|
}
|
|
397
|
-
|
|
486
|
+
const dsnRaw = isRecord(server) && isRecord(server.env) && typeof server.env.DSN === "string"
|
|
487
|
+
? server.env.DSN
|
|
488
|
+
: "";
|
|
489
|
+
const m = dsnRaw.match(/^\$\{(\w+)\}$/);
|
|
490
|
+
if (!m?.[1])
|
|
491
|
+
return { dsn_env: null, env_set: null };
|
|
492
|
+
const envVar = m[1];
|
|
493
|
+
const envSet = Boolean(env.get(envVar));
|
|
494
|
+
if (!envSet) {
|
|
495
|
+
findings.push({
|
|
496
|
+
level: "warn",
|
|
497
|
+
file: ".mcp.json",
|
|
498
|
+
msg: `env var ${envVar} not set (required by mcp server '${exp}')`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return { dsn_env: envVar, env_set: envSet };
|
|
502
|
+
}
|
|
503
|
+
async function validateExportedSkills(skillsDir, pluginRoot, exportsFile, pluginName, skillsInfo, fs) {
|
|
504
|
+
const findings = [];
|
|
398
505
|
const exportedInfo = [];
|
|
399
|
-
const exportedSkills = await loadExportedSkills(fs, pluginRoot,
|
|
400
|
-
const
|
|
506
|
+
const exportedSkills = await loadExportedSkills(fs, pluginRoot, exportsFile, pluginName);
|
|
507
|
+
const skillsByDirName = new Map();
|
|
401
508
|
for (const s of skillsInfo) {
|
|
402
509
|
if (s.name)
|
|
403
|
-
|
|
510
|
+
skillsByDirName.set(s.dir, s);
|
|
404
511
|
}
|
|
512
|
+
const skillsDirExists = await fs.exists(skillsDir);
|
|
405
513
|
for (const exp of exportedSkills) {
|
|
406
|
-
const
|
|
407
|
-
const record = {
|
|
408
|
-
namespace: exp.namespace,
|
|
409
|
-
version_declared: exp.version ?? null,
|
|
410
|
-
since: exp.since ?? null,
|
|
411
|
-
exists_in_disk: false,
|
|
412
|
-
frontmatter_ok: false,
|
|
413
|
-
};
|
|
414
|
-
if (await fs.exists(skillsDir)) {
|
|
415
|
-
const target = join(skillsDir, expSkillName);
|
|
416
|
-
const targetSkill = join(target, "SKILL.md");
|
|
417
|
-
if (await fs.exists(targetSkill)) {
|
|
418
|
-
record.exists_in_disk = true;
|
|
419
|
-
const diskSkill = skillsByName.get(expSkillName);
|
|
420
|
-
if (diskSkill?.name && diskSkill.version) {
|
|
421
|
-
record.frontmatter_ok = true;
|
|
422
|
-
record.version_in_skill = diskSkill.version;
|
|
423
|
-
if (diskSkill.version && exp.version && diskSkill.version !== exp.version) {
|
|
424
|
-
findings.push({
|
|
425
|
-
level: "warn",
|
|
426
|
-
file: `skills/${expSkillName}/SKILL.md`,
|
|
427
|
-
msg: `exported skill version drift: registry declares ${exp.version}, SKILL.md frontmatter declares ${diskSkill.version}`,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
else {
|
|
432
|
-
findings.push({
|
|
433
|
-
level: "error",
|
|
434
|
-
file: `skills/${expSkillName}/SKILL.md`,
|
|
435
|
-
msg: "exported skill SKILL.md missing required frontmatter (name + version)",
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
else {
|
|
440
|
-
findings.push({
|
|
441
|
-
level: "error",
|
|
442
|
-
file: `skills/${expSkillName}/SKILL.md`,
|
|
443
|
-
msg: `exported skill '${exp.namespace}' registered but not found in plugin's skills/ directory — fix the register_exported_skill call or add the SKILL.md`,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
else {
|
|
448
|
-
findings.push({
|
|
449
|
-
level: "error",
|
|
450
|
-
file: "skills/",
|
|
451
|
-
msg: `exported skill '${exp.namespace}' registered but plugin has no skills/ directory`,
|
|
452
|
-
});
|
|
453
|
-
}
|
|
514
|
+
const record = await validateSingleExportedSkill(exp, skillsDir, skillsDirExists, skillsByDirName, fs, findings);
|
|
454
515
|
exportedInfo.push(record);
|
|
455
516
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
compat_range: compatRange,
|
|
467
|
-
compat_ok: compatOk,
|
|
468
|
-
python_version: pythonVersion,
|
|
469
|
-
skills_count: skillsCount,
|
|
470
|
-
readme_count_expected: readmeCountExpected,
|
|
471
|
-
readme_count_match: readmeCountMatch,
|
|
472
|
-
manifests: manifestsInfo,
|
|
473
|
-
installed_marker: installedMarker,
|
|
474
|
-
hooks: hooksInfo,
|
|
475
|
-
mcp: mcpInfo,
|
|
476
|
-
skills: skillsInfo,
|
|
477
|
-
exported_skills: exportedInfo,
|
|
478
|
-
findings,
|
|
479
|
-
},
|
|
480
|
-
hasError,
|
|
517
|
+
return { exportedInfo, findings };
|
|
518
|
+
}
|
|
519
|
+
async function validateSingleExportedSkill(exp, skillsDir, skillsDirExists, skillsByDirName, fs, findings) {
|
|
520
|
+
const expSkillName = exp.skill;
|
|
521
|
+
const record = {
|
|
522
|
+
namespace: exp.namespace,
|
|
523
|
+
version_declared: exp.version ?? null,
|
|
524
|
+
since: exp.since ?? null,
|
|
525
|
+
exists_in_disk: false,
|
|
526
|
+
frontmatter_ok: false,
|
|
481
527
|
};
|
|
528
|
+
if (!skillsDirExists) {
|
|
529
|
+
findings.push({
|
|
530
|
+
level: "error",
|
|
531
|
+
file: "skills/",
|
|
532
|
+
msg: `exported skill '${exp.namespace}' registered but plugin has no skills/ directory`,
|
|
533
|
+
});
|
|
534
|
+
return record;
|
|
535
|
+
}
|
|
536
|
+
const targetSkill = join(skillsDir, expSkillName, "SKILL.md");
|
|
537
|
+
if (!(await fs.exists(targetSkill))) {
|
|
538
|
+
findings.push({
|
|
539
|
+
level: "error",
|
|
540
|
+
file: `skills/${expSkillName}/SKILL.md`,
|
|
541
|
+
msg: `exported skill '${exp.namespace}' registered but not found in plugin's skills/ directory — fix the register_exported_skill call or add the SKILL.md`,
|
|
542
|
+
});
|
|
543
|
+
return record;
|
|
544
|
+
}
|
|
545
|
+
record.exists_in_disk = true;
|
|
546
|
+
const diskSkill = skillsByDirName.get(expSkillName);
|
|
547
|
+
if (!diskSkill?.name || !diskSkill.version) {
|
|
548
|
+
findings.push({
|
|
549
|
+
level: "error",
|
|
550
|
+
file: `skills/${expSkillName}/SKILL.md`,
|
|
551
|
+
msg: "exported skill SKILL.md missing required frontmatter (name + version)",
|
|
552
|
+
});
|
|
553
|
+
return record;
|
|
554
|
+
}
|
|
555
|
+
record.frontmatter_ok = true;
|
|
556
|
+
record.version_in_skill = diskSkill.version;
|
|
557
|
+
if (exp.version && diskSkill.version !== exp.version) {
|
|
558
|
+
findings.push({
|
|
559
|
+
level: "warn",
|
|
560
|
+
file: `skills/${expSkillName}/SKILL.md`,
|
|
561
|
+
msg: `exported skill version drift: registry declares ${exp.version}, SKILL.md frontmatter declares ${diskSkill.version}`,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return record;
|
|
482
565
|
}
|
|
566
|
+
// ---------- compat range evaluation (unchanged) ----------
|
|
483
567
|
function evaluateCompat(qtcCoreInstalled, compatRange, coreLibMarker, findings) {
|
|
484
568
|
if (!qtcCoreInstalled) {
|
|
485
569
|
findings.push({
|
|
@@ -507,56 +591,68 @@ function evaluateCompat(qtcCoreInstalled, compatRange, coreLibMarker, findings)
|
|
|
507
591
|
return compatOk;
|
|
508
592
|
}
|
|
509
593
|
async function loadExportedSkills(fs, pluginRoot, exportsFile, pluginName) {
|
|
510
|
-
const sources =
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
594
|
+
const sources = exportsFile
|
|
595
|
+
? await readExportsFromCustomFile(fs, exportsFile)
|
|
596
|
+
: await readExportsFromClaudeManifest(fs, pluginRoot);
|
|
597
|
+
return parseExportedSkillEntries(sources, pluginName);
|
|
598
|
+
}
|
|
599
|
+
async function readExportsFromCustomFile(fs, exportsFile) {
|
|
600
|
+
if (!(await fs.exists(exportsFile)))
|
|
601
|
+
return [];
|
|
602
|
+
try {
|
|
603
|
+
return [JSON.parse(await fs.readText(exportsFile))];
|
|
520
604
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
605
|
+
catch {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async function readExportsFromClaudeManifest(fs, pluginRoot) {
|
|
610
|
+
const claudeManifest = join(pluginRoot, ".claude-plugin", "plugin.json");
|
|
611
|
+
if (!(await fs.exists(claudeManifest)))
|
|
612
|
+
return [];
|
|
613
|
+
try {
|
|
614
|
+
const data = JSON.parse(await fs.readText(claudeManifest));
|
|
615
|
+
if (isRecord(data) && Array.isArray(data.exportedSkills)) {
|
|
616
|
+
return [data.exportedSkills];
|
|
533
617
|
}
|
|
534
618
|
}
|
|
619
|
+
catch {
|
|
620
|
+
// ignore
|
|
621
|
+
}
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
function parseExportedSkillEntries(sources, pluginName) {
|
|
535
625
|
const out = [];
|
|
536
626
|
for (const src of sources) {
|
|
537
627
|
if (!Array.isArray(src))
|
|
538
628
|
continue;
|
|
539
629
|
for (const item of src) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (!skill)
|
|
544
|
-
continue;
|
|
545
|
-
const plugin = typeof item.plugin === "string" && item.plugin.length > 0 ? item.plugin : pluginName;
|
|
546
|
-
const namespace = typeof item.namespace === "string" && item.namespace.length > 0
|
|
547
|
-
? item.namespace
|
|
548
|
-
: `${plugin}:${skill}`;
|
|
549
|
-
out.push({
|
|
550
|
-
plugin,
|
|
551
|
-
skill,
|
|
552
|
-
namespace,
|
|
553
|
-
version: typeof item.version === "string" ? item.version : null,
|
|
554
|
-
since: typeof item.since === "string" ? item.since : null,
|
|
555
|
-
});
|
|
630
|
+
const entry = parseExportedSkillItem(item, pluginName);
|
|
631
|
+
if (entry)
|
|
632
|
+
out.push(entry);
|
|
556
633
|
}
|
|
557
634
|
}
|
|
558
635
|
return out;
|
|
559
636
|
}
|
|
637
|
+
function parseExportedSkillItem(item, pluginName) {
|
|
638
|
+
if (!isRecord(item))
|
|
639
|
+
return null;
|
|
640
|
+
const skill = typeof item.skill === "string" ? item.skill : null;
|
|
641
|
+
if (!skill)
|
|
642
|
+
return null;
|
|
643
|
+
const plugin = typeof item.plugin === "string" && item.plugin.length > 0 ? item.plugin : pluginName;
|
|
644
|
+
const namespace = typeof item.namespace === "string" && item.namespace.length > 0
|
|
645
|
+
? item.namespace
|
|
646
|
+
: `${plugin}:${skill}`;
|
|
647
|
+
return {
|
|
648
|
+
plugin,
|
|
649
|
+
skill,
|
|
650
|
+
namespace,
|
|
651
|
+
version: typeof item.version === "string" ? item.version : null,
|
|
652
|
+
since: typeof item.since === "string" ? item.since : null,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// ---------- low-level utilities (unchanged) ----------
|
|
560
656
|
async function collectMarkdownFiles(fs, dir) {
|
|
561
657
|
const out = [];
|
|
562
658
|
const stack = [dir];
|