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