@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.
- package/dist/application/code-scan-service.js +65 -56
- package/dist/application/code-scan-service.js.map +1 -1
- package/dist/application/plugin-doctor-service.d.ts.map +1 -1
- package/dist/application/plugin-doctor-service.js +455 -536
- package/dist/application/plugin-doctor-service.js.map +1 -1
- package/dist/application/release-data-service.d.ts.map +1 -1
- package/dist/application/release-data-service.js +96 -97
- package/dist/application/release-data-service.js.map +1 -1
- package/dist/application/self/update-self.d.ts +3 -1
- package/dist/application/self/update-self.d.ts.map +1 -1
- package/dist/application/self/update-self.js +12 -4
- package/dist/application/self/update-self.js.map +1 -1
- package/dist/application/upgrade-hub-mode-service.d.ts.map +1 -1
- package/dist/application/upgrade-hub-mode-service.js +30 -27
- package/dist/application/upgrade-hub-mode-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/commands/project-md-upsert.d.ts.map +1 -1
- package/dist/cli/commands/project-md-upsert.js +37 -34
- package/dist/cli/commands/project-md-upsert.js.map +1 -1
- package/dist/cli/commands/self.d.ts.map +1 -1
- package/dist/cli/commands/self.js +8 -7
- package/dist/cli/commands/self.js.map +1 -1
- package/dist/cli/help-groups.d.ts +7 -0
- package/dist/cli/help-groups.d.ts.map +1 -0
- package/dist/cli/help-groups.js +102 -0
- package/dist/cli/help-groups.js.map +1 -0
- package/dist/cli/main.js +6 -5
- 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
|
@@ -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).
|
|
11
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 });
|
|
112
|
+
const skillMd = join(entry.path, "SKILL.md");
|
|
113
|
+
if (await fs.exists(skillMd))
|
|
114
|
+
out.push(entry.path);
|
|
113
115
|
}
|
|
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
|
-
});
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
raw = await fs.readText(manifestPath);
|
|
255
|
+
if (canonicalVersion === null && parsed.version !== null) {
|
|
256
|
+
canonicalVersion = parsed.version;
|
|
184
257
|
}
|
|
185
|
-
|
|
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: `
|
|
264
|
+
msg: `version drift: manifest=${parsed.version} vs declared=${canonicalVersion}`,
|
|
190
265
|
});
|
|
191
|
-
manifestsInfo[relPath] = null;
|
|
192
|
-
continue;
|
|
193
266
|
}
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
375
|
+
if (expectedMcpServers.length === 0 || !(await fs.exists(mcpPath))) {
|
|
376
|
+
return { mcpInfo, findings };
|
|
377
377
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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,
|
|
400
|
-
const
|
|
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
|
-
|
|
432
|
+
skillsByDirName.set(s.dir, s);
|
|
404
433
|
}
|
|
434
|
+
const skillsDirExists = await fs.exists(skillsDir);
|
|
405
435
|
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
|
-
}
|
|
436
|
+
const record = await validateSingleExportedSkill(exp, skillsDir, skillsDirExists, skillsByDirName, fs, findings);
|
|
454
437
|
exportedInfo.push(record);
|
|
455
438
|
}
|
|
456
|
-
|
|
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
|
|
484
|
-
|
|
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:
|
|
488
|
-
msg: `
|
|
453
|
+
file: "skills/",
|
|
454
|
+
msg: `exported skill '${exp.namespace}' registered but plugin has no skills/ directory`,
|
|
489
455
|
});
|
|
490
|
-
return
|
|
456
|
+
return record;
|
|
491
457
|
}
|
|
492
|
-
const
|
|
493
|
-
if (
|
|
458
|
+
const targetSkill = join(skillsDir, expSkillName, "SKILL.md");
|
|
459
|
+
if (!(await fs.exists(targetSkill))) {
|
|
494
460
|
findings.push({
|
|
495
|
-
level: "
|
|
496
|
-
file:
|
|
497
|
-
msg: `
|
|
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
|
-
|
|
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:
|
|
504
|
-
msg:
|
|
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
|
|
486
|
+
return record;
|
|
508
487
|
}
|
|
509
488
|
async function loadExportedSkills(fs, pluginRoot, exportsFile, pluginName) {
|
|
510
|
-
const sources =
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
}
|