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.
Files changed (114) hide show
  1. package/README.md +51 -3
  2. package/agents.json +6 -1
  3. package/dist/agents/agents-registry.d.ts +43 -16
  4. package/dist/agents/agents-registry.js +119 -33
  5. package/dist/agents/agents-registry.js.map +1 -1
  6. package/dist/agents/global-dirs.d.ts +19 -0
  7. package/dist/agents/global-dirs.js +53 -0
  8. package/dist/agents/global-dirs.js.map +1 -0
  9. package/dist/audit/fix-suggestions.js +6 -0
  10. package/dist/audit/fix-suggestions.js.map +1 -1
  11. package/dist/commands/add-github-bundle.d.ts +29 -0
  12. package/dist/commands/add-github-bundle.js +135 -0
  13. package/dist/commands/add-github-bundle.js.map +1 -0
  14. package/dist/commands/add-lockfile.d.ts +2 -0
  15. package/dist/commands/add-lockfile.js +1 -1
  16. package/dist/commands/add-lockfile.js.map +1 -1
  17. package/dist/commands/add.d.ts +5 -7
  18. package/dist/commands/add.js +207 -111
  19. package/dist/commands/add.js.map +1 -1
  20. package/dist/commands/audit-webmcp.d.ts +25 -0
  21. package/dist/commands/audit-webmcp.js +167 -0
  22. package/dist/commands/audit-webmcp.js.map +1 -0
  23. package/dist/commands/doctor.d.ts +69 -0
  24. package/dist/commands/doctor.js +760 -0
  25. package/dist/commands/doctor.js.map +1 -0
  26. package/dist/commands/remove.js +43 -8
  27. package/dist/commands/remove.js.map +1 -1
  28. package/dist/commands/scan.js +1 -1
  29. package/dist/commands/scan.js.map +1 -1
  30. package/dist/commands/update.js +12 -4
  31. package/dist/commands/update.js.map +1 -1
  32. package/dist/discovery/github-skill-bundle.d.ts +74 -0
  33. package/dist/discovery/github-skill-bundle.js +323 -0
  34. package/dist/discovery/github-skill-bundle.js.map +1 -0
  35. package/dist/eval/skill-scanner.js +7 -1
  36. package/dist/eval/skill-scanner.js.map +1 -1
  37. package/dist/eval-server/install-skill-routes.js +10 -3
  38. package/dist/eval-server/install-skill-routes.js.map +1 -1
  39. package/dist/eval-server/remove-skill-routes.js +262 -29
  40. package/dist/eval-server/remove-skill-routes.js.map +1 -1
  41. package/dist/eval-server/studio-privacy.js +2 -3
  42. package/dist/eval-server/studio-privacy.js.map +1 -1
  43. package/dist/eval-server/utils/scan-install-locations.js +22 -3
  44. package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
  45. package/dist/eval-ui/assets/{CreateSkillPage-BUrgworx.js → CreateSkillPage-CXDd6gDN.js} +1 -1
  46. package/dist/eval-ui/assets/{FindSkillsPalette-BstJ22TI.js → FindSkillsPalette-DQ6uGhFP.js} +2 -2
  47. package/dist/eval-ui/assets/{SearchPaletteCore-Dagnc-E7.js → SearchPaletteCore-DpHCf0mf.js} +1 -1
  48. package/dist/eval-ui/assets/{SkillDetailPanel-DnujVk6g.js → SkillDetailPanel-BjHFjGxg.js} +1 -1
  49. package/dist/eval-ui/assets/{UpdateDropdown-B7D2vXrm.js → UpdateDropdown-Cn4Vsq5d.js} +1 -1
  50. package/dist/eval-ui/assets/main-BmB7Zj9I.js +86 -0
  51. package/dist/eval-ui/index.html +1 -1
  52. package/dist/index.js +34 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/installer/bundle-files.d.ts +39 -2
  55. package/dist/installer/bundle-files.js +80 -10
  56. package/dist/installer/bundle-files.js.map +1 -1
  57. package/dist/installer/canonical.d.ts +39 -4
  58. package/dist/installer/canonical.js +178 -24
  59. package/dist/installer/canonical.js.map +1 -1
  60. package/dist/installer/multi-install.d.ts +11 -2
  61. package/dist/installer/multi-install.js +41 -9
  62. package/dist/installer/multi-install.js.map +1 -1
  63. package/dist/installer/transformers/index.d.ts +8 -0
  64. package/dist/installer/yaml-safe-mutate.js +48 -2
  65. package/dist/installer/yaml-safe-mutate.js.map +1 -1
  66. package/dist/lib/github-fetch.js +2 -0
  67. package/dist/lib/github-fetch.js.map +1 -1
  68. package/dist/lib/private-source.d.ts +8 -0
  69. package/dist/lib/private-source.js +16 -2
  70. package/dist/lib/private-source.js.map +1 -1
  71. package/dist/scanner/hidden-content.d.ts +47 -0
  72. package/dist/scanner/hidden-content.js +904 -0
  73. package/dist/scanner/hidden-content.js.map +1 -0
  74. package/dist/scanner/installer-hosts.d.ts +26 -0
  75. package/dist/scanner/installer-hosts.js +92 -0
  76. package/dist/scanner/installer-hosts.js.map +1 -0
  77. package/dist/scanner/judge-guard.d.ts +119 -0
  78. package/dist/scanner/judge-guard.js +481 -0
  79. package/dist/scanner/judge-guard.js.map +1 -0
  80. package/dist/scanner/patterns.d.ts +12 -0
  81. package/dist/scanner/patterns.js +353 -113
  82. package/dist/scanner/patterns.js.map +1 -1
  83. package/dist/scanner/repo-scanner.d.ts +4 -0
  84. package/dist/scanner/repo-scanner.js +4 -1
  85. package/dist/scanner/repo-scanner.js.map +1 -1
  86. package/dist/scanner/tier1.js +37 -7
  87. package/dist/scanner/tier1.js.map +1 -1
  88. package/dist/scanner/tier2-llm.d.ts +22 -4
  89. package/dist/scanner/tier2-llm.js +163 -23
  90. package/dist/scanner/tier2-llm.js.map +1 -1
  91. package/dist/scanner/types.d.ts +8 -0
  92. package/dist/webmcp/args.d.ts +34 -0
  93. package/dist/webmcp/args.js +87 -0
  94. package/dist/webmcp/args.js.map +1 -0
  95. package/dist/webmcp/attribution.d.ts +38 -0
  96. package/dist/webmcp/attribution.js +141 -0
  97. package/dist/webmcp/attribution.js.map +1 -0
  98. package/dist/webmcp/enumerate.d.ts +81 -0
  99. package/dist/webmcp/enumerate.js +436 -0
  100. package/dist/webmcp/enumerate.js.map +1 -0
  101. package/dist/webmcp/format.d.ts +9 -0
  102. package/dist/webmcp/format.js +105 -0
  103. package/dist/webmcp/format.js.map +1 -0
  104. package/dist/webmcp/merge.d.ts +49 -0
  105. package/dist/webmcp/merge.js +167 -0
  106. package/dist/webmcp/merge.js.map +1 -0
  107. package/dist/webmcp/score.d.ts +70 -0
  108. package/dist/webmcp/score.js +586 -0
  109. package/dist/webmcp/score.js.map +1 -0
  110. package/dist/webmcp/types.d.ts +92 -0
  111. package/dist/webmcp/types.js +8 -0
  112. package/dist/webmcp/types.js.map +1 -0
  113. package/package.json +1 -1
  114. 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