@tacuchi/agent-workflow-cli 4.2.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.
@@ -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
- // 1. Skills frontmatter.
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
- let content;
54
- try {
55
- content = await fs.readText(skillMd);
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
- let endIdx = -1;
69
- for (let i = 1; i < lines.length; i++) {
70
- if ((lines[i] ?? "").trim() === "---") {
71
- endIdx = i;
72
- break;
73
- }
74
- }
75
- if (endIdx === -1) {
76
- findings.push({ level: "error", file: skillMd, msg: "missing frontmatter closing ---" });
77
- skillsInfo.push({ dir: dirName, name: null, version: null });
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
- const fm = {};
81
- for (let i = 1; i < endIdx; i++) {
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
- // 2. README sync check.
115
- let readmeCountExpected = null;
116
- let readmeCountMatch = null;
117
- if (await fs.exists(readmePath)) {
118
- try {
119
- const readmeText = await fs.readText(readmePath);
120
- const m = readmeText.match(/\*\*Skills\*\*\s*\((\d+)/);
121
- if (m?.[1]) {
122
- readmeCountExpected = Number.parseInt(m[1], 10);
123
- readmeCountMatch = readmeCountExpected === skillsCount;
124
- if (!readmeCountMatch) {
125
- findings.push({
126
- level: "warn",
127
- file: "README.md",
128
- msg: `Skills count mismatch: README claims ${readmeCountExpected}, actual ${skillsCount}`,
129
- });
130
- }
131
- }
132
- }
133
- catch (e) {
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
- else {
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
- // 3. frontend-design generalization.
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)) && (await fs.exists(fdDir))) {
147
- const mdFiles = await collectMarkdownFiles(fs, fdDir);
148
- mdFiles.sort((a, b) => a.localeCompare(b));
149
- for (const mdFile of mdFiles) {
150
- let text;
151
- try {
152
- text = await fs.readText(mdFile);
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
- // 4. Manifest version drift + qtcContractVersion gate.
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 manifestPath = join(pluginRoot, relPath);
176
- if (!(await fs.exists(manifestPath))) {
177
- findings.push({ level: "warn", file: relPath, msg: "manifest missing" });
178
- manifestsInfo[relPath] = null;
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
- const manifestVersion = isRecord(data) && typeof data.version === "string" ? data.version : null;
208
- manifestsInfo[relPath] = manifestVersion;
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 && manifestVersion !== canonicalVersion) {
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=${manifestVersion} vs declared=${canonicalVersion}`,
268
+ msg: `version drift: manifest=${parsed.version} vs declared=${canonicalVersion}`,
223
269
  });
224
270
  }
225
- if (manifestQtcContractVersion === null &&
226
- isRecord(data) &&
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
- const pluginVersion = canonicalVersion ?? "unknown";
232
- const pluginName = input.pluginName ?? manifestPluginName ?? `${paths.namespace}-${flow}`;
233
- // Si el manifest declara qtcContractVersion >= 6.3, el plugin opera en
234
- // single-path y no debe chequearse la presencia de artefactos legacy.
235
- const isSinglePathContract = isContractVersionAtLeast(manifestQtcContractVersion, 6, 3);
236
- // 5/5b — checks legacy de era dual-path. Solo el marker de plugin version y
237
- // el marker de core-lib se siguen revisando para detectar instalaciones rotas
238
- // en plugins viejos. Los scripts Python se eliminaron por completo.
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
- let qtcCoreInstalled = null;
241
- let compatOk = null;
242
- if (!isSinglePathContract) {
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
- // 7. Hooks JSON.
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 hookPath = join(pluginRoot, relPath);
284
- if (!(await fs.exists(hookPath))) {
285
- findings.push({ level: "warn", file: relPath, msg: "hooks file missing" });
286
- hooksInfo[relPath] = null;
287
- continue;
288
- }
289
- let raw;
290
- try {
291
- raw = await fs.readText(hookPath);
292
- }
293
- catch (e) {
294
- findings.push({
295
- level: "error",
296
- file: relPath,
297
- msg: `invalid JSON: ${e.message}`,
298
- });
299
- hooksInfo[relPath] = null;
300
- continue;
301
- }
302
- let data;
303
- try {
304
- data = JSON.parse(raw);
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
- // 8. MCP — servidores esperados leídos del runtime config (Phase 3).
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 > 0 && (await fs.exists(mcpPath))) {
332
- let mcpData = null;
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
- // 9. Python version — solo legacy. En single-path no existe runtime Python.
379
- let pythonVersion = null;
380
- if (!isSinglePathContract) {
381
- pythonVersion = detectPythonVersion();
382
- if (pythonVersion) {
383
- const m = pythonVersion.match(/^(\d+)\.(\d+)/);
384
- if (m?.[1] && m[2]) {
385
- const major = Number.parseInt(m[1], 10);
386
- const minor = Number.parseInt(m[2], 10);
387
- if (major < 3 || (major === 3 && minor < 8)) {
388
- findings.push({
389
- level: "warn",
390
- file: "python",
391
- msg: `python ${pythonVersion} is too old; recommend 3.8+`,
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
- // 10. Exported skills.
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, input.exportsFile, pluginName);
400
- const skillsByName = new Map();
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
- skillsByName.set(s.dir, s);
510
+ skillsByDirName.set(s.dir, s);
404
511
  }
512
+ const skillsDirExists = await fs.exists(skillsDir);
405
513
  for (const exp of exportedSkills) {
406
- const expSkillName = exp.skill;
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
- const hasError = findings.some((f) => f.level === "error");
457
- const hasWarn = findings.some((f) => f.level === "warn");
458
- const status = hasError ? "error" : hasWarn ? "warn" : "ok";
459
- return {
460
- data: {
461
- status,
462
- plugin: pluginName,
463
- plugin_root: pluginRoot,
464
- plugin_version: pluginVersion,
465
- qtc_core_installed: qtcCoreInstalled,
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
- if (exportsFile) {
512
- if (await fs.exists(exportsFile)) {
513
- try {
514
- sources.push(JSON.parse(await fs.readText(exportsFile)));
515
- }
516
- catch {
517
- // ignore
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
- else {
522
- const claudeManifest = join(pluginRoot, ".claude-plugin", "plugin.json");
523
- if (await fs.exists(claudeManifest)) {
524
- try {
525
- const data = JSON.parse(await fs.readText(claudeManifest));
526
- if (isRecord(data) && Array.isArray(data.exportedSkills)) {
527
- sources.push(data.exportedSkills);
528
- }
529
- }
530
- catch {
531
- // ignore
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
- if (!isRecord(item))
541
- continue;
542
- const skill = typeof item.skill === "string" ? item.skill : null;
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];