vskill 1.2.4 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -3
- package/agents.json +6 -1
- package/dist/agents/agents-registry.d.ts +43 -16
- package/dist/agents/agents-registry.js +119 -33
- package/dist/agents/agents-registry.js.map +1 -1
- package/dist/agents/global-dirs.d.ts +19 -0
- package/dist/agents/global-dirs.js +53 -0
- package/dist/agents/global-dirs.js.map +1 -0
- package/dist/audit/fix-suggestions.js +6 -0
- package/dist/audit/fix-suggestions.js.map +1 -1
- package/dist/commands/add-github-bundle.d.ts +29 -0
- package/dist/commands/add-github-bundle.js +135 -0
- package/dist/commands/add-github-bundle.js.map +1 -0
- package/dist/commands/add-lockfile.d.ts +2 -0
- package/dist/commands/add-lockfile.js +1 -1
- package/dist/commands/add-lockfile.js.map +1 -1
- package/dist/commands/add.d.ts +5 -7
- package/dist/commands/add.js +207 -111
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/audit-webmcp.d.ts +25 -0
- package/dist/commands/audit-webmcp.js +167 -0
- package/dist/commands/audit-webmcp.js.map +1 -0
- package/dist/commands/doctor.d.ts +69 -0
- package/dist/commands/doctor.js +760 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/remove.js +43 -8
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/scan.js +1 -1
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/update.js +12 -4
- package/dist/commands/update.js.map +1 -1
- package/dist/discovery/github-skill-bundle.d.ts +74 -0
- package/dist/discovery/github-skill-bundle.js +323 -0
- package/dist/discovery/github-skill-bundle.js.map +1 -0
- package/dist/eval/skill-scanner.js +7 -1
- package/dist/eval/skill-scanner.js.map +1 -1
- package/dist/eval-server/install-skill-routes.js +10 -3
- package/dist/eval-server/install-skill-routes.js.map +1 -1
- package/dist/eval-server/remove-skill-routes.js +262 -29
- package/dist/eval-server/remove-skill-routes.js.map +1 -1
- package/dist/eval-server/studio-privacy.js +2 -3
- package/dist/eval-server/studio-privacy.js.map +1 -1
- package/dist/eval-server/utils/scan-install-locations.js +22 -3
- package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
- package/dist/eval-ui/assets/{CreateSkillPage-BUrgworx.js → CreateSkillPage-CXDd6gDN.js} +1 -1
- package/dist/eval-ui/assets/{FindSkillsPalette-BstJ22TI.js → FindSkillsPalette-DQ6uGhFP.js} +2 -2
- package/dist/eval-ui/assets/{SearchPaletteCore-Dagnc-E7.js → SearchPaletteCore-DpHCf0mf.js} +1 -1
- package/dist/eval-ui/assets/{SkillDetailPanel-DnujVk6g.js → SkillDetailPanel-BjHFjGxg.js} +1 -1
- package/dist/eval-ui/assets/{UpdateDropdown-B7D2vXrm.js → UpdateDropdown-Cn4Vsq5d.js} +1 -1
- package/dist/eval-ui/assets/main-BmB7Zj9I.js +86 -0
- package/dist/eval-ui/index.html +1 -1
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/bundle-files.d.ts +39 -2
- package/dist/installer/bundle-files.js +80 -10
- package/dist/installer/bundle-files.js.map +1 -1
- package/dist/installer/canonical.d.ts +39 -4
- package/dist/installer/canonical.js +178 -24
- package/dist/installer/canonical.js.map +1 -1
- package/dist/installer/multi-install.d.ts +11 -2
- package/dist/installer/multi-install.js +41 -9
- package/dist/installer/multi-install.js.map +1 -1
- package/dist/installer/transformers/index.d.ts +8 -0
- package/dist/installer/yaml-safe-mutate.js +48 -2
- package/dist/installer/yaml-safe-mutate.js.map +1 -1
- package/dist/lib/github-fetch.js +2 -0
- package/dist/lib/github-fetch.js.map +1 -1
- package/dist/lib/private-source.d.ts +8 -0
- package/dist/lib/private-source.js +16 -2
- package/dist/lib/private-source.js.map +1 -1
- package/dist/scanner/hidden-content.d.ts +47 -0
- package/dist/scanner/hidden-content.js +904 -0
- package/dist/scanner/hidden-content.js.map +1 -0
- package/dist/scanner/installer-hosts.d.ts +26 -0
- package/dist/scanner/installer-hosts.js +92 -0
- package/dist/scanner/installer-hosts.js.map +1 -0
- package/dist/scanner/judge-guard.d.ts +119 -0
- package/dist/scanner/judge-guard.js +481 -0
- package/dist/scanner/judge-guard.js.map +1 -0
- package/dist/scanner/patterns.d.ts +12 -0
- package/dist/scanner/patterns.js +353 -113
- package/dist/scanner/patterns.js.map +1 -1
- package/dist/scanner/repo-scanner.d.ts +4 -0
- package/dist/scanner/repo-scanner.js +4 -1
- package/dist/scanner/repo-scanner.js.map +1 -1
- package/dist/scanner/tier1.js +37 -7
- package/dist/scanner/tier1.js.map +1 -1
- package/dist/scanner/tier2-llm.d.ts +22 -4
- package/dist/scanner/tier2-llm.js +163 -23
- package/dist/scanner/tier2-llm.js.map +1 -1
- package/dist/scanner/types.d.ts +8 -0
- package/dist/webmcp/args.d.ts +34 -0
- package/dist/webmcp/args.js +87 -0
- package/dist/webmcp/args.js.map +1 -0
- package/dist/webmcp/attribution.d.ts +38 -0
- package/dist/webmcp/attribution.js +141 -0
- package/dist/webmcp/attribution.js.map +1 -0
- package/dist/webmcp/enumerate.d.ts +81 -0
- package/dist/webmcp/enumerate.js +436 -0
- package/dist/webmcp/enumerate.js.map +1 -0
- package/dist/webmcp/format.d.ts +9 -0
- package/dist/webmcp/format.js +105 -0
- package/dist/webmcp/format.js.map +1 -0
- package/dist/webmcp/merge.d.ts +49 -0
- package/dist/webmcp/merge.js +167 -0
- package/dist/webmcp/merge.js.map +1 -0
- package/dist/webmcp/score.d.ts +70 -0
- package/dist/webmcp/score.js +586 -0
- package/dist/webmcp/score.js.map +1 -0
- package/dist/webmcp/types.d.ts +92 -0
- package/dist/webmcp/types.js +8 -0
- package/dist/webmcp/types.js.map +1 -0
- package/package.json +1 -1
- package/dist/eval-ui/assets/main-DjA5gpjV.js +0 -86
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// vskill doctor -- check (and optionally fix) skill folders across agents
|
|
3
|
+
//
|
|
4
|
+
// 0894 T-04 (AC-05). Scans every skills folder an agent reads, per the agent
|
|
5
|
+
// registry (own dir, extra dirs, and the shared `.agents/skills`), in the
|
|
6
|
+
// global and/or project scope, and reports:
|
|
7
|
+
//
|
|
8
|
+
// divergent same folder name, 2+ real folders, different content
|
|
9
|
+
// identical-copy same folder name, 2+ real folders, byte-identical (fix)
|
|
10
|
+
// broken-link symlink whose target does not exist (fix)
|
|
11
|
+
// redundant-link link in a dir whose every reader already sees the
|
|
12
|
+
// same skill through another dir it scans (fix)
|
|
13
|
+
// migration-damage SKILL.md carries migrate-to-codex residue
|
|
14
|
+
// name-mismatch frontmatter name != folder name
|
|
15
|
+
// duplicate-name two different folders visible to one agent declare
|
|
16
|
+
// the same frontmatter name
|
|
17
|
+
// bad-name / bad-description agentskills.io frontmatter limits
|
|
18
|
+
//
|
|
19
|
+
// `--fix` only removes links and replaces byte-identical copies with a
|
|
20
|
+
// relative link to the copy in the shared folder. It never deletes or
|
|
21
|
+
// rewrites a real folder whose content is unique.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, } from "node:fs";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
27
|
+
import { AGENTS_REGISTRY } from "../agents/agents-registry.js";
|
|
28
|
+
import { stripClaudeFields } from "../installer/frontmatter.js";
|
|
29
|
+
import { bold, cyan, dim, green, red, yellow } from "../utils/output.js";
|
|
30
|
+
export const DOCTOR_ISSUE_KINDS = [
|
|
31
|
+
"divergent",
|
|
32
|
+
"identical-copy",
|
|
33
|
+
"broken-link",
|
|
34
|
+
"redundant-link",
|
|
35
|
+
"migration-damage",
|
|
36
|
+
"name-mismatch",
|
|
37
|
+
"duplicate-name",
|
|
38
|
+
"bad-name",
|
|
39
|
+
"bad-description",
|
|
40
|
+
];
|
|
41
|
+
const SEVERITY = {
|
|
42
|
+
divergent: "error",
|
|
43
|
+
"identical-copy": "warning",
|
|
44
|
+
"broken-link": "warning",
|
|
45
|
+
"redundant-link": "warning",
|
|
46
|
+
"migration-damage": "error",
|
|
47
|
+
"name-mismatch": "warning",
|
|
48
|
+
"duplicate-name": "error",
|
|
49
|
+
"bad-name": "warning",
|
|
50
|
+
"bad-description": "warning",
|
|
51
|
+
};
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Dir discovery
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** Agents that keep skills on the local filesystem. */
|
|
56
|
+
function filesystemAgents() {
|
|
57
|
+
return AGENTS_REGISTRY.filter((a) => a.isRemoteOnly !== true && a.installMode !== "clipboard");
|
|
58
|
+
}
|
|
59
|
+
function expandHome(p, home) {
|
|
60
|
+
if (p === "~")
|
|
61
|
+
return home;
|
|
62
|
+
if (p.startsWith("~/"))
|
|
63
|
+
return join(home, p.slice(2));
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
/** Every dir an agent reads in a scope (absolute paths). */
|
|
67
|
+
export function agentSkillDirs(agent, scope, home, cwd) {
|
|
68
|
+
const dirs = [];
|
|
69
|
+
if (scope === "global") {
|
|
70
|
+
if (agent.id === "antigravity" && process.env.VSKILL_ANTIGRAVITY_SKILLS_DIR?.trim()) {
|
|
71
|
+
dirs.push(resolve(process.env.VSKILL_ANTIGRAVITY_SKILLS_DIR.trim()));
|
|
72
|
+
}
|
|
73
|
+
dirs.push(expandHome(agent.globalSkillsDir, home));
|
|
74
|
+
for (const d of agent.extraGlobalSkillsDirs ?? [])
|
|
75
|
+
dirs.push(expandHome(d, home));
|
|
76
|
+
if (agent.readsSharedSkills?.global)
|
|
77
|
+
dirs.push(join(home, ".agents", "skills"));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
dirs.push(join(cwd, agent.localSkillsDir));
|
|
81
|
+
for (const d of agent.extraLocalSkillsDirs ?? [])
|
|
82
|
+
dirs.push(join(cwd, d));
|
|
83
|
+
if (agent.readsSharedSkills?.project)
|
|
84
|
+
dirs.push(join(cwd, ".agents", "skills"));
|
|
85
|
+
}
|
|
86
|
+
return [...new Set(dirs)];
|
|
87
|
+
}
|
|
88
|
+
function safeRealpath(p) {
|
|
89
|
+
try {
|
|
90
|
+
return realpathSync(p);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Errors that mean a link target really is gone (vs. unreadable right now:
|
|
97
|
+
* EACCES, EIO, ETIMEDOUT, ... on a locked folder or an unreachable share). */
|
|
98
|
+
const GONE_CODES = new Set(["ENOENT", "ENOTDIR", "ELOOP"]);
|
|
99
|
+
/** Resolve a link: its real path, or null with the error code. */
|
|
100
|
+
function resolveLink(p) {
|
|
101
|
+
try {
|
|
102
|
+
return { real: realpathSync(p) };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return { real: null, code: err.code ?? "EUNKNOWN" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function collectDirs(scope, home, cwd) {
|
|
109
|
+
const sharedDir = scope === "global" ? join(home, ".agents", "skills") : join(cwd, ".agents", "skills");
|
|
110
|
+
const byReal = new Map();
|
|
111
|
+
const add = (dir, reader) => {
|
|
112
|
+
const real = safeRealpath(dir);
|
|
113
|
+
if (!real)
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
if (!statSync(real).isDirectory())
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let info = byReal.get(real);
|
|
123
|
+
if (!info) {
|
|
124
|
+
info = { scope, dir, real, readers: new Set(), shared: false };
|
|
125
|
+
byReal.set(real, info);
|
|
126
|
+
}
|
|
127
|
+
if (dir === sharedDir) {
|
|
128
|
+
info.shared = true;
|
|
129
|
+
info.dir = sharedDir;
|
|
130
|
+
}
|
|
131
|
+
if (reader)
|
|
132
|
+
info.readers.add(reader);
|
|
133
|
+
};
|
|
134
|
+
add(sharedDir, null);
|
|
135
|
+
for (const agent of filesystemAgents()) {
|
|
136
|
+
for (const dir of agentSkillDirs(agent, scope, home, cwd))
|
|
137
|
+
add(dir, agent.id);
|
|
138
|
+
}
|
|
139
|
+
return [...byReal.values()];
|
|
140
|
+
}
|
|
141
|
+
function listEntries(info, home) {
|
|
142
|
+
let names;
|
|
143
|
+
try {
|
|
144
|
+
names = readdirSync(info.dir);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const claudeSynced = join(home, ".claude", "skills", "synced");
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const name of names.sort()) {
|
|
152
|
+
if (name.startsWith("_") || name.startsWith("."))
|
|
153
|
+
continue;
|
|
154
|
+
const path = join(info.dir, name);
|
|
155
|
+
// Claude-managed folder of synced skills — not ours to judge.
|
|
156
|
+
if (info.scope === "global" && (path === claudeSynced || join(info.real, name) === safeRealpath(claudeSynced))) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let st;
|
|
160
|
+
try {
|
|
161
|
+
st = lstatSync(path);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (st.isSymbolicLink()) {
|
|
167
|
+
const { real, code } = resolveLink(path);
|
|
168
|
+
if (real) {
|
|
169
|
+
try {
|
|
170
|
+
if (!statSync(real).isDirectory())
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const entry = { scope: info.scope, name, path, dirInfo: info, isLink: true, real };
|
|
178
|
+
if (!real && code && !GONE_CODES.has(code))
|
|
179
|
+
entry.unreadable = code;
|
|
180
|
+
out.push(entry);
|
|
181
|
+
}
|
|
182
|
+
else if (st.isDirectory()) {
|
|
183
|
+
if (!existsSync(join(path, "SKILL.md")))
|
|
184
|
+
continue;
|
|
185
|
+
out.push({ scope: info.scope, name, path, dirInfo: info, isLink: false, real: safeRealpath(path) ?? path });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Content hashing + frontmatter
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
/** Hash every file in a folder (sorted by relative path), ignoring .DS_Store.
|
|
194
|
+
* 0894: a file's executable bit is part of its record (a copy whose script
|
|
195
|
+
* lost +x is not identical), and a symlink inside the skill is hashed by
|
|
196
|
+
* where it resolves — inside the skill (relative) or outside (absolute) —
|
|
197
|
+
* not by its link text, so `assets -> ../foo-assets` in two copies that
|
|
198
|
+
* point at different folders does not count as identical. */
|
|
199
|
+
export function hashSkillFolder(dir, normalizeSkillMd) {
|
|
200
|
+
const h = createHash("sha256");
|
|
201
|
+
const rootReal = safeRealpath(dir) ?? resolve(dir);
|
|
202
|
+
const walk = (abs, rel) => {
|
|
203
|
+
let names;
|
|
204
|
+
try {
|
|
205
|
+
names = readdirSync(abs).sort();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for (const name of names) {
|
|
211
|
+
if (name === ".DS_Store")
|
|
212
|
+
continue;
|
|
213
|
+
const childAbs = join(abs, name);
|
|
214
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
215
|
+
let st;
|
|
216
|
+
try {
|
|
217
|
+
st = lstatSync(childAbs);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (st.isSymbolicLink()) {
|
|
223
|
+
h.update(`L\0${childRel}\0${linkDestination(childAbs, rootReal)}\0`);
|
|
224
|
+
}
|
|
225
|
+
else if (st.isDirectory()) {
|
|
226
|
+
h.update(`D\0${childRel}\0`);
|
|
227
|
+
walk(childAbs, childRel);
|
|
228
|
+
}
|
|
229
|
+
else if (st.isFile()) {
|
|
230
|
+
const raw = readFileSync(childAbs);
|
|
231
|
+
const data = normalizeSkillMd && childRel === "SKILL.md" ? normalizeSkillMd(raw.toString("utf8")) : raw;
|
|
232
|
+
const fh = createHash("sha256").update(data).digest("hex");
|
|
233
|
+
const exec = (st.mode & 0o111) !== 0 ? "x" : "-";
|
|
234
|
+
h.update(`F\0${childRel}\0${exec}\0${fh}\0`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(dir, "");
|
|
239
|
+
return h.digest("hex");
|
|
240
|
+
}
|
|
241
|
+
/** Where a symlink inside a skill folder leads, for hashing. */
|
|
242
|
+
function linkDestination(linkAbs, rootReal) {
|
|
243
|
+
const target = safeRealpath(linkAbs);
|
|
244
|
+
if (target) {
|
|
245
|
+
const rel = relative(rootReal, target);
|
|
246
|
+
const outside = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
247
|
+
return outside ? `out:${target}` : `in:${rel}`;
|
|
248
|
+
}
|
|
249
|
+
let text = "";
|
|
250
|
+
try {
|
|
251
|
+
text = readlinkSync(linkAbs);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// unreadable link
|
|
255
|
+
}
|
|
256
|
+
return `broken:${resolve(dirname(linkAbs), text)}`;
|
|
257
|
+
}
|
|
258
|
+
function unquote(v) {
|
|
259
|
+
const t = v.trim();
|
|
260
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {
|
|
261
|
+
return t.slice(1, -1);
|
|
262
|
+
}
|
|
263
|
+
return t;
|
|
264
|
+
}
|
|
265
|
+
/** Minimal YAML frontmatter reader for top-level `name` / `description`,
|
|
266
|
+
* including folded (`>`) and literal (`|`) block scalars. */
|
|
267
|
+
export function parseSkillFrontmatter(content) {
|
|
268
|
+
const text = content.replace(/^/, "").replace(/\r\n/g, "\n");
|
|
269
|
+
const m = text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
270
|
+
if (!m)
|
|
271
|
+
return null;
|
|
272
|
+
const lines = m[1].split("\n");
|
|
273
|
+
const out = {};
|
|
274
|
+
for (let i = 0; i < lines.length; i++) {
|
|
275
|
+
const km = lines[i].match(/^(name|description):\s*(.*)$/);
|
|
276
|
+
if (!km)
|
|
277
|
+
continue;
|
|
278
|
+
const key = km[1];
|
|
279
|
+
const raw = km[2];
|
|
280
|
+
let value;
|
|
281
|
+
if (/^[>|][+-]?\s*$/.test(raw) || raw.trim() === "") {
|
|
282
|
+
const block = [];
|
|
283
|
+
while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === "")) {
|
|
284
|
+
block.push(lines[++i].trim());
|
|
285
|
+
}
|
|
286
|
+
value = raw.trim().startsWith("|") ? block.join("\n").trim() : block.filter(Boolean).join(" ").trim();
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// A plain or quoted scalar may continue on more-indented lines
|
|
290
|
+
// (YAML folds them with a space); a blank line inside is a line break.
|
|
291
|
+
const parts = [raw.trim()];
|
|
292
|
+
while (i + 1 < lines.length &&
|
|
293
|
+
(/^\s+\S/.test(lines[i + 1]) ||
|
|
294
|
+
(lines[i + 1].trim() === "" && i + 2 < lines.length && /^\s+\S/.test(lines[i + 2])))) {
|
|
295
|
+
parts.push(lines[++i].trim());
|
|
296
|
+
}
|
|
297
|
+
let folded = "";
|
|
298
|
+
for (const part of parts) {
|
|
299
|
+
if (part === "")
|
|
300
|
+
folded += "\n";
|
|
301
|
+
else
|
|
302
|
+
folded += folded === "" || folded.endsWith("\n") ? part : ` ${part}`;
|
|
303
|
+
}
|
|
304
|
+
value = unquote(folded);
|
|
305
|
+
}
|
|
306
|
+
out[key] = value;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
function readSkillMd(dir) {
|
|
311
|
+
try {
|
|
312
|
+
return readFileSync(join(dir, "SKILL.md"), "utf8");
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
319
|
+
// Residue migrate-to-codex leaves: the appended heading on its own line, and
|
|
320
|
+
// `.claude/` rewritten to `.Codex/`. A skill that merely mentions the heading
|
|
321
|
+
// in prose (migrate-to-codex itself does) is not damaged.
|
|
322
|
+
const MIGRATION_MARKERS = [
|
|
323
|
+
["## MANUAL MIGRATION REQUIRED", /^## MANUAL MIGRATION REQUIRED\s*$/m],
|
|
324
|
+
[".Codex/", /(^|[\s`"'(~/])\.Codex\//m],
|
|
325
|
+
];
|
|
326
|
+
function issue(kind, scope, skill, paths, message, fixable, agents) {
|
|
327
|
+
const out = { kind, severity: SEVERITY[kind], scope, skill, paths, message, fixable };
|
|
328
|
+
if (agents && agents.length > 0)
|
|
329
|
+
out.agents = agents;
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
/** A path as `<real parent dir>/<name>`, so two spellings of one entry match. */
|
|
333
|
+
function physicalPath(p) {
|
|
334
|
+
return join(safeRealpath(dirname(p)) ?? dirname(p), basename(p));
|
|
335
|
+
}
|
|
336
|
+
/** Every path a link passes through on its way to the real folder (one
|
|
337
|
+
* readlink hop at a time), excluding the link itself. */
|
|
338
|
+
function linkChain(p) {
|
|
339
|
+
const hops = [];
|
|
340
|
+
let cur = p;
|
|
341
|
+
for (let i = 0; i < 40; i++) {
|
|
342
|
+
let target;
|
|
343
|
+
try {
|
|
344
|
+
if (!lstatSync(cur).isSymbolicLink())
|
|
345
|
+
break;
|
|
346
|
+
target = readlinkSync(cur);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
cur = resolve(dirname(cur), target);
|
|
352
|
+
hops.push(physicalPath(cur));
|
|
353
|
+
}
|
|
354
|
+
return hops;
|
|
355
|
+
}
|
|
356
|
+
function scanScope(scope, home, cwd, platform, globalReals = new Set()) {
|
|
357
|
+
const dirs = collectDirs(scope, home, cwd);
|
|
358
|
+
const entries = dirs.flatMap((d) => listEntries(d, home));
|
|
359
|
+
const issues = [];
|
|
360
|
+
// 1. Broken links.
|
|
361
|
+
for (const e of entries) {
|
|
362
|
+
if (e.isLink && !e.real) {
|
|
363
|
+
let target = "";
|
|
364
|
+
try {
|
|
365
|
+
target = readlinkSync(e.path);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// unreadable link — still broken
|
|
369
|
+
}
|
|
370
|
+
if (e.unreadable) {
|
|
371
|
+
// Locked folder, unmounted share, I/O error: it may work again, so
|
|
372
|
+
// never delete it.
|
|
373
|
+
issues.push(issue("broken-link", scope, e.name, [e.path], `link target ${target || "?"} is unreadable (${e.unreadable}) — left alone`, false));
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
issues.push(issue("broken-link", scope, e.name, [e.path], `link target ${target || "?"} does not exist`, true));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// 2. Same folder name as 2+ distinct real folders.
|
|
381
|
+
const realByName = new Map();
|
|
382
|
+
// Folder name → real folders check 2 compared (so check 5 can skip them).
|
|
383
|
+
const checkedPairs = new Map();
|
|
384
|
+
for (const e of entries) {
|
|
385
|
+
if (e.isLink || !e.real)
|
|
386
|
+
continue;
|
|
387
|
+
const list = realByName.get(e.name) ?? [];
|
|
388
|
+
if (!list.some((x) => x.real === e.real))
|
|
389
|
+
list.push(e);
|
|
390
|
+
realByName.set(e.name, list);
|
|
391
|
+
}
|
|
392
|
+
for (const [name, list] of realByName) {
|
|
393
|
+
if (list.length < 2)
|
|
394
|
+
continue;
|
|
395
|
+
checkedPairs.set(name, new Set(list.map((e) => e.real)));
|
|
396
|
+
const hashes = list.map((e) => hashSkillFolder(e.real));
|
|
397
|
+
if (new Set(hashes).size > 1) {
|
|
398
|
+
// A Claude Code copy that only adds Claude-only frontmatter is how
|
|
399
|
+
// vskill installs such skills on purpose; it is not drift.
|
|
400
|
+
const strip = (text) => stripClaudeFields(text, name);
|
|
401
|
+
if (new Set(list.map((e) => hashSkillFolder(e.real, strip))).size === 1)
|
|
402
|
+
continue;
|
|
403
|
+
issues.push(issue("divergent", scope, name, list.map((e) => e.path), `${list.length} real copies with different content — merge by hand`, false));
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const canonical = list.find((e) => e.dirInfo.shared);
|
|
407
|
+
const ordered = canonical ? [canonical, ...list.filter((e) => e !== canonical)] : list;
|
|
408
|
+
const fixable = !!canonical && platform !== "win32";
|
|
409
|
+
const message = canonical
|
|
410
|
+
? `${list.length} byte-identical copies — keep ${canonical.path}, link the rest`
|
|
411
|
+
: `${list.length} byte-identical copies, none in the shared .agents/skills folder`;
|
|
412
|
+
issues.push(issue("identical-copy", scope, name, ordered.map((e) => e.path), message, fixable));
|
|
413
|
+
}
|
|
414
|
+
// 3. Redundant links: every reader of the link's dir already sees the same
|
|
415
|
+
// real folder through another dir it reads. A link already judged
|
|
416
|
+
// redundant no longer counts as coverage, so two links that only cover
|
|
417
|
+
// each other are never both removed.
|
|
418
|
+
const redundant = new Set();
|
|
419
|
+
const visibleElsewhere = (d, real) => entries.some((f) => f.dirInfo === d && f.real === real && !redundant.has(f));
|
|
420
|
+
const dirsByReader = new Map();
|
|
421
|
+
for (const d of dirs) {
|
|
422
|
+
for (const r of d.readers) {
|
|
423
|
+
const list = dirsByReader.get(r) ?? [];
|
|
424
|
+
list.push(d);
|
|
425
|
+
dirsByReader.set(r, list);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// A link that another scanned link passes through is load-bearing: removing
|
|
429
|
+
// it would leave that other link dangling (e.g. ~/.agents/skills/foo ->
|
|
430
|
+
// ../../.codex/skills/foo -> ~/dev/foo), so it is never redundant.
|
|
431
|
+
const throughPaths = new Set();
|
|
432
|
+
for (const f of entries) {
|
|
433
|
+
if (f.isLink)
|
|
434
|
+
for (const hop of linkChain(f.path))
|
|
435
|
+
throughPaths.add(hop);
|
|
436
|
+
}
|
|
437
|
+
for (const e of entries) {
|
|
438
|
+
if (!e.isLink || !e.real || e.dirInfo.shared)
|
|
439
|
+
continue;
|
|
440
|
+
if (throughPaths.has(join(e.dirInfo.real, e.name)))
|
|
441
|
+
continue;
|
|
442
|
+
const readers = [...e.dirInfo.readers].sort();
|
|
443
|
+
if (readers.length === 0)
|
|
444
|
+
continue;
|
|
445
|
+
const covered = readers.every((r) => (dirsByReader.get(r) ?? []).some((d) => d !== e.dirInfo && visibleElsewhere(d, e.real)));
|
|
446
|
+
if (!covered)
|
|
447
|
+
continue;
|
|
448
|
+
redundant.add(e);
|
|
449
|
+
issues.push(issue("redundant-link", scope, e.name, [e.path], `${readers.join(", ")} already load${readers.length === 1 ? "s" : ""} it from another folder`, true, readers));
|
|
450
|
+
}
|
|
451
|
+
// 4. Per real folder: migration damage, name and description checks.
|
|
452
|
+
const seenReal = new Set();
|
|
453
|
+
const declared = new Map(); // real → declared name
|
|
454
|
+
const frontmatterName = new Map(); // real → name, when declared
|
|
455
|
+
for (const e of entries) {
|
|
456
|
+
if (!e.real || seenReal.has(e.real))
|
|
457
|
+
continue;
|
|
458
|
+
seenReal.add(e.real);
|
|
459
|
+
const md = readSkillMd(e.real);
|
|
460
|
+
if (md === null)
|
|
461
|
+
continue;
|
|
462
|
+
const folder = e.isLink ? e.name : basename(e.real);
|
|
463
|
+
const shown = e.isLink ? e.path : e.real;
|
|
464
|
+
const markers = MIGRATION_MARKERS.filter(([, re]) => re.test(md)).map(([label]) => label);
|
|
465
|
+
if (markers.length > 0) {
|
|
466
|
+
issues.push(issue("migration-damage", scope, folder, [join(shown, "SKILL.md")], `SKILL.md contains ${markers.map((m) => JSON.stringify(m)).join(" and ")} (migrate-to-codex residue)`, false));
|
|
467
|
+
}
|
|
468
|
+
const fm = parseSkillFrontmatter(md);
|
|
469
|
+
const name = fm?.name?.trim();
|
|
470
|
+
declared.set(e.real, name || folder);
|
|
471
|
+
if (!name) {
|
|
472
|
+
issues.push(issue("bad-name", scope, folder, [join(shown, "SKILL.md")], "frontmatter has no name", false));
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
frontmatterName.set(e.real, name);
|
|
476
|
+
if (name.length > 64 || !NAME_RE.test(name)) {
|
|
477
|
+
issues.push(issue("bad-name", scope, folder, [join(shown, "SKILL.md")], `name "${name}" must be lowercase letters, digits and single hyphens, at most 64 chars`, false));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const desc = fm?.description?.trim() ?? "";
|
|
481
|
+
if (!desc) {
|
|
482
|
+
issues.push(issue("bad-description", scope, folder, [join(shown, "SKILL.md")], "description is missing", false));
|
|
483
|
+
}
|
|
484
|
+
else if (desc.length > 1024) {
|
|
485
|
+
issues.push(issue("bad-description", scope, folder, [join(shown, "SKILL.md")], `description is ${desc.length} chars (max 1024)`, false));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// 4b. Name vs folder, per folder name an agent actually sees: a link
|
|
489
|
+
// named `bar` to a folder declaring `foo` is a mismatch even when the
|
|
490
|
+
// real folder `foo` itself is fine (0894).
|
|
491
|
+
const seenFolderName = new Set();
|
|
492
|
+
for (const e of entries) {
|
|
493
|
+
if (!e.real)
|
|
494
|
+
continue;
|
|
495
|
+
const name = frontmatterName.get(e.real);
|
|
496
|
+
const folder = e.isLink ? e.name : basename(e.real);
|
|
497
|
+
const key = `${e.real}\0${folder}`;
|
|
498
|
+
if (!name || seenFolderName.has(key))
|
|
499
|
+
continue;
|
|
500
|
+
seenFolderName.add(key);
|
|
501
|
+
if (name !== folder) {
|
|
502
|
+
const shown = e.isLink ? e.path : e.real;
|
|
503
|
+
issues.push(issue("name-mismatch", scope, folder, [shown], `frontmatter name "${name}" differs from folder "${folder}"`, false));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// 5. Two different folders visible to one agent that declare the same
|
|
507
|
+
// name. Same-folder-name pairs of real folders are covered by check 2;
|
|
508
|
+
// a pair where one side is a link to a folder outside the scanned dirs
|
|
509
|
+
// is not, so it is reported here (0894).
|
|
510
|
+
const dupes = new Map();
|
|
511
|
+
for (const [reader, readerDirs] of dirsByReader) {
|
|
512
|
+
const byName = new Map(); // declared → real → shown path
|
|
513
|
+
for (const d of readerDirs) {
|
|
514
|
+
for (const e of entries) {
|
|
515
|
+
if (e.dirInfo !== d || !e.real)
|
|
516
|
+
continue;
|
|
517
|
+
const n = declared.get(e.real);
|
|
518
|
+
if (!n)
|
|
519
|
+
continue;
|
|
520
|
+
const m = byName.get(n) ?? new Map();
|
|
521
|
+
if (!m.has(e.real))
|
|
522
|
+
m.set(e.real, e.path);
|
|
523
|
+
byName.set(n, m);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const [n, reals] of byName) {
|
|
527
|
+
if (reals.size < 2)
|
|
528
|
+
continue;
|
|
529
|
+
const folders = new Set([...reals.values()].map((p) => basename(p)));
|
|
530
|
+
if (folders.size < 2) {
|
|
531
|
+
const compared = checkedPairs.get([...folders][0]);
|
|
532
|
+
if (compared && [...reals.keys()].every((r) => compared.has(r)))
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const key = `${n}\0${[...reals.keys()].sort().join("\0")}`;
|
|
536
|
+
const rec = dupes.get(key) ?? { name: n, reals, agents: new Set() };
|
|
537
|
+
rec.agents.add(reader);
|
|
538
|
+
dupes.set(key, rec);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const rec of dupes.values()) {
|
|
542
|
+
const paths = [...rec.reals.values()].sort();
|
|
543
|
+
issues.push(issue("duplicate-name", scope, rec.name, paths, `${paths.length} folders declare name "${rec.name}"`, false, [...rec.agents].sort()));
|
|
544
|
+
}
|
|
545
|
+
// A project folder that is physically a global one (a symlinked dotfiles
|
|
546
|
+
// setup): fixing it from the project would change what every other
|
|
547
|
+
// project sees, so leave the fix to the global scope.
|
|
548
|
+
if (scope === "project" && globalReals.size > 0) {
|
|
549
|
+
const inGlobal = new Set(entries.filter((e) => globalReals.has(e.dirInfo.real)).map((e) => e.path));
|
|
550
|
+
for (const i of issues) {
|
|
551
|
+
if (!i.fixable)
|
|
552
|
+
continue;
|
|
553
|
+
const touched = i.kind === "identical-copy" ? i.paths.slice(1) : i.paths;
|
|
554
|
+
if (touched.some((p) => inGlobal.has(p))) {
|
|
555
|
+
i.fixable = false;
|
|
556
|
+
i.message += " (folder is also a global skills folder — fix it with --global)";
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return { dirs, entries, issues };
|
|
561
|
+
}
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Fix
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
function removeLink(path, kind) {
|
|
566
|
+
try {
|
|
567
|
+
if (!lstatSync(path).isSymbolicLink()) {
|
|
568
|
+
return { kind, action: "remove-link", path, ok: false, error: "no longer a link — left alone" };
|
|
569
|
+
}
|
|
570
|
+
if (kind === "broken-link") {
|
|
571
|
+
const { real, code } = resolveLink(path);
|
|
572
|
+
if (real || !code || !GONE_CODES.has(code)) {
|
|
573
|
+
return { kind, action: "remove-link", path, ok: false, error: real ? "link works again — left alone" : `target unreadable (${code}) — left alone` };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
unlinkSync(path);
|
|
577
|
+
return { kind, action: "remove-link", path, ok: true };
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
return { kind, action: "remove-link", path, ok: false, error: err.message };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Replace a byte-identical real copy with a relative link to the kept copy.
|
|
584
|
+
* The copy is moved aside first and only deleted once the link resolves to
|
|
585
|
+
* identical content; any failure restores it. */
|
|
586
|
+
function replaceWithLink(copy, keep, platform) {
|
|
587
|
+
const base = { kind: "identical-copy", action: "replace-with-link", path: copy, target: keep, ok: false };
|
|
588
|
+
if (platform === "win32")
|
|
589
|
+
return { ...base, error: "links need Developer Mode on Windows — left as a copy" };
|
|
590
|
+
try {
|
|
591
|
+
const st = lstatSync(copy);
|
|
592
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
593
|
+
return { ...base, error: "no longer a real folder — left alone" };
|
|
594
|
+
const keepHash = hashSkillFolder(keep);
|
|
595
|
+
if (hashSkillFolder(copy) !== keepHash)
|
|
596
|
+
return { ...base, error: "content changed since the scan — left alone" };
|
|
597
|
+
// Dot-prefixed: agents and this scan ignore it if anything below stops
|
|
598
|
+
// halfway, so it never shows up as a second copy of the skill.
|
|
599
|
+
const aside = join(dirname(copy), `.${basename(copy)}.vskill-doctor-${process.pid}`);
|
|
600
|
+
renameSync(copy, aside);
|
|
601
|
+
const undo = () => {
|
|
602
|
+
try {
|
|
603
|
+
if (lstatSync(copy).isSymbolicLink())
|
|
604
|
+
unlinkSync(copy);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// nothing at copy
|
|
608
|
+
}
|
|
609
|
+
renameSync(aside, copy);
|
|
610
|
+
};
|
|
611
|
+
try {
|
|
612
|
+
symlinkSync(relative(dirname(copy), keep), copy, "dir");
|
|
613
|
+
if (hashSkillFolder(copy) !== keepHash)
|
|
614
|
+
throw new Error("new link does not resolve to the kept copy");
|
|
615
|
+
// Anything written into the copy between the scan and the move is in
|
|
616
|
+
// the aside now; deleting it would lose that edit.
|
|
617
|
+
if (hashSkillFolder(aside) !== keepHash)
|
|
618
|
+
throw new Error("content changed since the scan — left alone");
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
undo();
|
|
622
|
+
const code = err.code;
|
|
623
|
+
return { ...base, error: code === "EPERM" || code === "EACCES" ? "symlinks not permitted — left as a copy" : err.message };
|
|
624
|
+
}
|
|
625
|
+
const target = relative(dirname(copy), keep);
|
|
626
|
+
try {
|
|
627
|
+
rmSync(aside, { recursive: true, force: true });
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
// The link is in place; only the hidden leftover could not go.
|
|
631
|
+
return { ...base, target, ok: true, error: `linked, but could not remove ${aside}: ${err.message}` };
|
|
632
|
+
}
|
|
633
|
+
return { ...base, target, ok: true };
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
return { ...base, error: err.message };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function applyFixes(issues, platform, failed) {
|
|
640
|
+
const actions = [];
|
|
641
|
+
const record = (a) => {
|
|
642
|
+
actions.push(a);
|
|
643
|
+
if (!a.ok)
|
|
644
|
+
failed.add(a.path);
|
|
645
|
+
};
|
|
646
|
+
for (const i of issues) {
|
|
647
|
+
if (!i.fixable || i.kind !== "identical-copy")
|
|
648
|
+
continue;
|
|
649
|
+
const [keep, ...copies] = i.paths;
|
|
650
|
+
for (const copy of copies) {
|
|
651
|
+
if (!failed.has(copy))
|
|
652
|
+
record(replaceWithLink(copy, keep, platform));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const i of issues) {
|
|
656
|
+
if (!i.fixable || (i.kind !== "broken-link" && i.kind !== "redundant-link"))
|
|
657
|
+
continue;
|
|
658
|
+
if (!failed.has(i.paths[0]))
|
|
659
|
+
record(removeLink(i.paths[0], i.kind));
|
|
660
|
+
}
|
|
661
|
+
return actions;
|
|
662
|
+
}
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Entry points
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
function resolveScopes(opts, home, cwd) {
|
|
667
|
+
const scopes = [];
|
|
668
|
+
const both = !opts.global && !opts.project;
|
|
669
|
+
if (opts.global || both)
|
|
670
|
+
scopes.push("global");
|
|
671
|
+
// A project scan from $HOME would re-read the global folders.
|
|
672
|
+
if ((opts.project || both) && resolve(cwd) !== resolve(home))
|
|
673
|
+
scopes.push("project");
|
|
674
|
+
return scopes;
|
|
675
|
+
}
|
|
676
|
+
function emptySummary() {
|
|
677
|
+
return Object.fromEntries(DOCTOR_ISSUE_KINDS.map((k) => [k, 0]));
|
|
678
|
+
}
|
|
679
|
+
function scanAll(scopes, home, cwd, platform) {
|
|
680
|
+
const globalReals = scopes.includes("project")
|
|
681
|
+
? new Set(collectDirs("global", home, cwd).map((d) => d.real))
|
|
682
|
+
: new Set();
|
|
683
|
+
const scans = scopes.map((s) => scanScope(s, home, cwd, platform, globalReals));
|
|
684
|
+
return {
|
|
685
|
+
scanned: scans.flatMap((s) => s.dirs.map((d) => ({ scope: d.scope, dir: d.dir, readers: [...d.readers].sort() }))),
|
|
686
|
+
issues: scans.flatMap((s) => s.issues),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/** Run the checks (and fixes when `opts.fix`). Pure apart from `--fix`. */
|
|
690
|
+
export function runDoctor(opts = {}) {
|
|
691
|
+
const home = opts.home ?? os.homedir();
|
|
692
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
693
|
+
const platform = opts.platform ?? process.platform;
|
|
694
|
+
const scopes = resolveScopes(opts, home, cwd);
|
|
695
|
+
let { scanned, issues } = scanAll(scopes, home, cwd, platform);
|
|
696
|
+
const fixes = [];
|
|
697
|
+
if (opts.fix) {
|
|
698
|
+
// A copy turned into a link can itself become a redundant link, so fix
|
|
699
|
+
// again until a pass changes nothing (bounded).
|
|
700
|
+
const failed = new Set();
|
|
701
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
702
|
+
const actions = applyFixes(issues, platform, failed);
|
|
703
|
+
fixes.push(...actions);
|
|
704
|
+
if (!actions.some((a) => a.ok))
|
|
705
|
+
break;
|
|
706
|
+
({ scanned, issues } = scanAll(scopes, home, cwd, platform));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const summary = emptySummary();
|
|
710
|
+
for (const i of issues)
|
|
711
|
+
summary[i.kind]++;
|
|
712
|
+
return { scopes, scanned, issues, fixes, summary, remaining: issues.length };
|
|
713
|
+
}
|
|
714
|
+
function printReport(report, fix) {
|
|
715
|
+
if (fix && report.fixes.length > 0) {
|
|
716
|
+
console.log(bold("Fixes"));
|
|
717
|
+
for (const f of report.fixes) {
|
|
718
|
+
const what = f.action === "remove-link" ? `removed link ${f.path}` : `linked ${f.path} -> ${f.target}`;
|
|
719
|
+
const note = f.ok && f.error ? dim(` (${f.error})`) : "";
|
|
720
|
+
console.log(f.ok ? ` ${green("done")} ${what}${note}` : ` ${yellow("skip")} ${f.path}: ${f.error}`);
|
|
721
|
+
}
|
|
722
|
+
console.log("");
|
|
723
|
+
}
|
|
724
|
+
const dirCount = report.scanned.length;
|
|
725
|
+
console.log(dim(`Scanned ${dirCount} skills folder${dirCount === 1 ? "" : "s"} (${report.scopes.join(" + ") || "no scope"})`));
|
|
726
|
+
if (report.issues.length === 0) {
|
|
727
|
+
console.log(green("No problems found."));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
for (const kind of DOCTOR_ISSUE_KINDS) {
|
|
731
|
+
const list = report.issues.filter((i) => i.kind === kind);
|
|
732
|
+
if (list.length === 0)
|
|
733
|
+
continue;
|
|
734
|
+
const color = SEVERITY[kind] === "error" ? red : yellow;
|
|
735
|
+
console.log("");
|
|
736
|
+
console.log(color(bold(`${kind} (${list.length})`)) + (list[0].fixable ? dim(" fixable with --fix") : ""));
|
|
737
|
+
for (const i of list) {
|
|
738
|
+
console.log(` ${color(SEVERITY[kind])} ${bold(i.skill)} ${dim(`[${i.scope}]`)} ${i.message}`);
|
|
739
|
+
for (const p of i.paths)
|
|
740
|
+
console.log(` ${dim(p)}`);
|
|
741
|
+
if (i.agents)
|
|
742
|
+
console.log(` ${dim(`agents: ${i.agents.join(", ")}`)}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const fixable = report.issues.filter((i) => i.fixable).length;
|
|
746
|
+
console.log("");
|
|
747
|
+
console.log(`${bold(String(report.issues.length))} problem${report.issues.length === 1 ? "" : "s"}` +
|
|
748
|
+
(fixable > 0 && !fix ? `, ${cyan(String(fixable))} fixable with ${cyan("vskill doctor --fix")}` : ""));
|
|
749
|
+
}
|
|
750
|
+
export async function doctorCommand(opts) {
|
|
751
|
+
const report = runDoctor(opts);
|
|
752
|
+
if (opts.json) {
|
|
753
|
+
console.log(JSON.stringify(report, null, 2));
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
printReport(report, !!opts.fix);
|
|
757
|
+
}
|
|
758
|
+
process.exitCode = report.remaining === 0 ? 0 : 1;
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=doctor.js.map
|