skill-land 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -2
  2. package/package.json +1 -1
  3. package/skill-land.mjs +110 -28
package/README.md CHANGED
@@ -114,16 +114,33 @@ let one flagged skill block every clean one.
114
114
 
115
115
  ## What "verified" means
116
116
 
117
- An install is not done until read-back confirms it:
117
+ A skill is a **directory**, not a file. `skill-land` copies every file under it and reads
118
+ each one back. For each file:
118
119
 
119
120
  1. file exists at the target path
120
121
  2. non-empty
121
122
  3. byte-identical to the source
122
- 4. frontmatter `name` matches the source
123
+ 4. frontmatter `name` matches the source (`SKILL.md` only)
123
124
 
124
125
  Any failure prints the specific check that failed and **exits non-zero**. A write that
125
126
  throws (permissions, read-only directory) is reported as a failed install, not a crash.
126
127
 
128
+ Self-contained skills are unaffected — the directory holds one file, so one file is copied.
129
+ Multi-file skills are the reason this matters: `impeccable` ships 148 files, and an install
130
+ of only its `SKILL.md` leaves every `node .../scripts/context.mjs` reference dangling.
131
+
132
+ Where an agent has several directories it reads, `--verify` counts the skill as installed if
133
+ it is **complete in any one of them**. Requiring every fallback to hold a copy reports
134
+ working installs as broken.
135
+
136
+ ### What it cannot tell you
137
+
138
+ Verification assumes the installer **copied**. Some installers **rewrite** on install — the
139
+ `skills` CLI adapts `impeccable` for Antigravity, changing `.agents/skills/...` paths to
140
+ `.gemini/skills/...` and `$impeccable` to `/impeccable`. Those files are correct and will
141
+ still report `content matches source` failures, because they genuinely differ from the source.
142
+ `skill-land` reports the difference and does not guess which side is right.
143
+
127
144
  Tested against: truncated file, wrong skill in the right place, missing file,
128
145
  unwritable destination, and a real `npx skills add` install (correctly reported FAIL).
129
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-land",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Install an agent skill to the directory the agent actually reads, then verify it landed. Exits non-zero if it didn't.",
5
5
  "keywords": [
6
6
  "agent-skills",
package/skill-land.mjs CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  readFileSync, realpathSync, rmSync, statSync,
17
17
  } from "fs";
18
18
  import { homedir, tmpdir } from "os";
19
- import { basename, dirname, join, resolve } from "path";
19
+ import { basename, dirname, join, relative, resolve } from "path";
20
20
  import { fileURLToPath } from "url";
21
21
 
22
22
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -24,6 +24,8 @@ const { agents: AGENTS } = JSON.parse(readFileSync(join(HERE, "agents.json"), "u
24
24
 
25
25
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
26
26
  const expand = (p) => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
27
+ const fmtBytes = (n) => n < 1024 ? `${n} B`
28
+ : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} kB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
27
29
  const die = (msg) => { console.error(`\n error ${msg}\n`); process.exit(1); };
28
30
 
29
31
  // ---------------------------------------------------------------- source
@@ -139,6 +141,33 @@ function selectAll(root) {
139
141
  return hits;
140
142
  }
141
143
 
144
+ // A skill is a directory, not a file. impeccable ships 148 files; copying only
145
+ // SKILL.md yields a skill whose every `node .../scripts/context.mjs` reference
146
+ // dangles — and 1.2.0 called that "verified", which is precisely the failure
147
+ // this tool exists to catch. Walk the whole skill directory instead.
148
+ //
149
+ // Returns paths relative to the skill root, SKILL.md first so it is written and
150
+ // reported before anything that references it.
151
+ function collectSkillFiles(skillDir, skillFile) {
152
+ const out = [];
153
+ (function walk(dir, depth) {
154
+ if (depth > MAX_DEPTH) return;
155
+ let entries;
156
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
157
+ for (const e of entries) {
158
+ const abs = join(dir, e.name);
159
+ if (e.isDirectory()) {
160
+ if (!NEVER_SKILLS.has(e.name)) walk(abs, depth + 1);
161
+ continue;
162
+ }
163
+ if (!e.isFile() && !e.isSymbolicLink()) continue;
164
+ out.push(relative(skillDir, abs));
165
+ }
166
+ })(skillDir, 0);
167
+ return out.sort((a, b) =>
168
+ (a === skillFile ? -1 : b === skillFile ? 1 : 0) || a.localeCompare(b));
169
+ }
170
+
142
171
  function parseFrontmatter(text) {
143
172
  const m = text.match(/^---\n([\s\S]*?)\n---\n/);
144
173
  if (!m) return {};
@@ -272,6 +301,7 @@ function securityScan(skillPath) {
272
301
  // ---------------------------------------------------------------- verify
273
302
 
274
303
  // An install is not done until read-back confirms it. This is the whole point.
304
+ // Called once per file in the skill; `expected.name` is set only for SKILL.md.
275
305
  function verify(target, expected) {
276
306
  const checks = [];
277
307
  const ok = (name, pass, detail) => { checks.push({ name, pass, detail }); return pass; };
@@ -313,7 +343,17 @@ function runSkill(skillPath, root, quiet) {
313
343
  const fm = parseFrontmatter(body);
314
344
  const skillName = (fm.name || "skill").toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
315
345
  const rawBody = readFileSync(skillPath);
316
- const expected = { bytes: rawBody.length, hash: sha256(rawBody), name: fm.name };
346
+
347
+ // The skill is the directory SKILL.md sits in. `name` is set only on SKILL.md
348
+ // so the frontmatter check does not run against scripts and reference docs.
349
+ const skillDir = dirname(skillPath);
350
+ const skillFile = basename(skillPath);
351
+ const files = collectSkillFiles(skillDir, skillFile).map((rel) => {
352
+ const raw = readFileSync(join(skillDir, rel));
353
+ return { rel, bytes: raw.length, hash: sha256(raw),
354
+ name: rel === skillFile ? fm.name : undefined };
355
+ });
356
+ const totalBytes = files.reduce((n, f) => n + f.bytes, 0);
317
357
 
318
358
  // Scan before any write. A blocked skill must never reach disk.
319
359
  const scan = (skipSecurity || verifyOnly) ? { status: "skipped" } : securityScan(skillPath);
@@ -338,7 +378,7 @@ function runSkill(skillPath, root, quiet) {
338
378
  process.stdout.write(` ${(fm.name || basename(dirname(skillPath))).slice(0, 28).padEnd(30)}`);
339
379
  } else {
340
380
  console.log(`\n source ${src.label}`);
341
- console.log(` skill ${fm.name || "(no frontmatter name)"} [${rawBody.length} bytes, sha ${expected.hash.slice(0,12)}]`);
381
+ console.log(` skill ${fm.name || "(no frontmatter name)"} [${files.length} file${files.length === 1 ? "" : "s"}, ${fmtBytes(totalBytes)}, SKILL.md sha ${files[0].hash.slice(0,12)}]`);
342
382
  console.log(` from ${skillPath.replace(root, ".")}`);
343
383
  }
344
384
  if (!verifyOnly && !quiet) {
@@ -377,39 +417,79 @@ function runSkill(skillPath, root, quiet) {
377
417
 
378
418
  if (!quiet) console.log(` ${a.display}`);
379
419
 
420
+ const baseResults = [];
421
+
380
422
  for (const base of bases) {
381
423
  const destDir = join(base, skillName);
382
- const target = join(destDir, a.file);
383
424
 
384
425
  targetCount++;
385
426
  if (dryRun) {
386
427
  if (quiet) continue;
387
- console.log(` would write ${target}`);
428
+ console.log(` would write ${destDir} (${files.length} file${files.length === 1 ? "" : "s"}, ${fmtBytes(totalBytes)})`);
388
429
  continue;
389
430
  }
390
431
 
391
- // A write that throws is a failed install, not a crash. Report it the
392
- // same way as a failed read-back so one exit path covers both.
393
- let writeErr = null;
394
- if (!verifyOnly) {
395
- try {
396
- mkdirSync(destDir, { recursive: true });
397
- cpSync(skillPath, target);
398
- } catch (e) {
399
- writeErr = `${e.code || "error"}: ${e.message.split("\n")[0]}`;
432
+ // Every file in the skill, each read back. A skill whose SKILL.md landed
433
+ // but whose scripts/ did not is broken, and must not report verified.
434
+ const badFiles = [];
435
+ for (const f of files) {
436
+ const target = join(destDir, f.rel);
437
+
438
+ // A write that throws is a failed install, not a crash. Report it the
439
+ // same way as a failed read-back so one exit path covers both.
440
+ let writeErr = null;
441
+ if (!verifyOnly) {
442
+ try {
443
+ mkdirSync(dirname(target), { recursive: true });
444
+ cpSync(join(skillDir, f.rel), target);
445
+ } catch (e) {
446
+ writeErr = `${e.code || "error"}: ${e.message.split("\n")[0]}`;
447
+ }
400
448
  }
449
+
450
+ const checks = writeErr
451
+ ? [{ name: "write", pass: false, detail: writeErr }]
452
+ : verify(target, f);
453
+ if (!checks.every((c) => c.pass)) badFiles.push({ target, rel: f.rel, checks });
401
454
  }
402
455
 
403
- const checks = writeErr
404
- ? [{ name: "write", pass: false, detail: writeErr }]
405
- : verify(target, expected);
406
- const passed = checks.every((c) => c.pass);
407
- if (!passed) { skillFailed++; bad.push({ target, checks }); }
456
+ baseResults.push({ destDir, badFiles, passed: badFiles.length === 0 });
457
+ }
458
+
459
+ if (!dryRun) {
460
+ // An install lives in ONE directory the agent reads. Requiring every
461
+ // fallback to hold a copy manufactures failures: skill-land writes three
462
+ // paths for Antigravity, but a skill complete in any one of them IS
463
+ // installed. Installing still demands all three, since it just wrote them.
464
+ const agentPassed = verifyOnly
465
+ ? baseResults.some((r) => r.passed)
466
+ : baseResults.every((r) => r.passed);
467
+ if (!agentPassed) {
468
+ skillFailed++;
469
+ for (const r of baseResults) for (const b of r.badFiles) bad.push(b);
470
+ }
408
471
 
409
- if (quiet) continue;
410
- console.log(` ${passed ? "OK " : "FAIL"} ${target}${verifyOnly ? " (verify only)" : ""}`);
411
- for (const c of checks) {
412
- if (!c.pass) console.log(` x ${c.name}: ${c.detail}`);
472
+ if (!quiet) {
473
+ for (const r of baseResults) {
474
+ const n = files.length - r.badFiles.length;
475
+ // Nothing present at all is one fact, not N failures. Listing every
476
+ // absent file buries the directories that are partially installed,
477
+ // which are the ones worth looking at.
478
+ const absent = r.badFiles.length === files.length
479
+ && r.badFiles.every((b) => b.checks.some((c) => c.name === "file exists" && !c.pass));
480
+ if (absent) {
481
+ console.log(` FAIL ${r.destDir} not installed${verifyOnly ? " (verify only)" : ""}`);
482
+ continue;
483
+ }
484
+ console.log(` ${r.passed ? "OK " : "FAIL"} ${r.destDir} ${n}/${files.length} file${files.length === 1 ? "" : "s"}${verifyOnly ? " (verify only)" : ""}`);
485
+ for (const b of r.badFiles.slice(0, 10)) {
486
+ console.log(` x ${b.rel}`);
487
+ for (const c of b.checks) if (!c.pass) console.log(` ${c.name}: ${c.detail}`);
488
+ }
489
+ if (r.badFiles.length > 10) console.log(` ... and ${r.badFiles.length - 10} more file(s)`);
490
+ }
491
+ if (verifyOnly && agentPassed && baseResults.some((r) => !r.passed))
492
+ console.log(` note complete in at least one directory ${a.display} reads; that is an install`);
413
493
  }
414
494
  }
415
495
 
@@ -422,13 +502,14 @@ function runSkill(skillPath, root, quiet) {
422
502
  // One line per skill: the counts, then every failing path underneath it. A
423
503
  // summary that hid which path failed would need a second run to be actionable.
424
504
  if (quiet) {
425
- const okCount = targetCount - skillFailed;
426
- if (dryRun) console.log(`-- ${targetCount} target${targetCount === 1 ? "" : "s"} (dry run)`);
427
- else console.log(`${skillFailed ? "FAIL" : "OK "} ${okCount}/${targetCount}`);
428
- for (const b of bad) {
505
+ const nf = `${files.length} file${files.length === 1 ? "" : "s"}`;
506
+ if (dryRun) console.log(`-- ${targetCount} target${targetCount === 1 ? "" : "s"}, ${nf} (dry run)`);
507
+ else console.log(`${skillFailed ? "FAIL" : "OK "} ${nf}`);
508
+ for (const b of bad.slice(0, 10)) {
429
509
  console.log(` x ${b.target}`);
430
510
  for (const c of b.checks) if (!c.pass) console.log(` ${c.name}: ${c.detail}`);
431
511
  }
512
+ if (bad.length > 10) console.log(` ... and ${bad.length - 10} more file(s)`);
432
513
  }
433
514
  return { name: skillName, failed: skillFailed, skipped: skillSkipped };
434
515
  }
@@ -454,7 +535,8 @@ if (!argv.length || argv.includes("-h") || argv.includes("--help")) {
454
535
  --strict refuse to install anything SkillSpector flags
455
536
  --list show known agents and their directories
456
537
 
457
- Verifies the file landed where the agent actually reads. Exits non-zero if not.
538
+ Copies every file in the skill, then reads each back. Exits non-zero if any
539
+ file did not land where the agent actually reads.
458
540
  `);
459
541
  process.exit(0);
460
542
  }