skill-land 1.2.0 → 1.3.1
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 +19 -2
- package/package.json +1 -1
- package/skill-land.mjs +115 -30
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
|
-
|
|
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
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
|
-
|
|
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)"} [${
|
|
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) {
|
|
@@ -367,6 +407,7 @@ function runSkill(skillPath, root, quiet) {
|
|
|
367
407
|
}
|
|
368
408
|
|
|
369
409
|
const bad = [];
|
|
410
|
+
const baseSummaries = [];
|
|
370
411
|
let targetCount = 0;
|
|
371
412
|
|
|
372
413
|
for (const agent of targets) {
|
|
@@ -377,39 +418,81 @@ function runSkill(skillPath, root, quiet) {
|
|
|
377
418
|
|
|
378
419
|
if (!quiet) console.log(` ${a.display}`);
|
|
379
420
|
|
|
421
|
+
const baseResults = [];
|
|
422
|
+
|
|
380
423
|
for (const base of bases) {
|
|
381
424
|
const destDir = join(base, skillName);
|
|
382
|
-
const target = join(destDir, a.file);
|
|
383
425
|
|
|
384
426
|
targetCount++;
|
|
385
427
|
if (dryRun) {
|
|
386
428
|
if (quiet) continue;
|
|
387
|
-
console.log(` would write ${
|
|
429
|
+
console.log(` would write ${destDir} (${files.length} file${files.length === 1 ? "" : "s"}, ${fmtBytes(totalBytes)})`);
|
|
388
430
|
continue;
|
|
389
431
|
}
|
|
390
432
|
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
433
|
+
// Every file in the skill, each read back. A skill whose SKILL.md landed
|
|
434
|
+
// but whose scripts/ did not is broken, and must not report verified.
|
|
435
|
+
const badFiles = [];
|
|
436
|
+
for (const f of files) {
|
|
437
|
+
const target = join(destDir, f.rel);
|
|
438
|
+
|
|
439
|
+
// A write that throws is a failed install, not a crash. Report it the
|
|
440
|
+
// same way as a failed read-back so one exit path covers both.
|
|
441
|
+
let writeErr = null;
|
|
442
|
+
if (!verifyOnly) {
|
|
443
|
+
try {
|
|
444
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
445
|
+
cpSync(join(skillDir, f.rel), target);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
writeErr = `${e.code || "error"}: ${e.message.split("\n")[0]}`;
|
|
448
|
+
}
|
|
400
449
|
}
|
|
450
|
+
|
|
451
|
+
const checks = writeErr
|
|
452
|
+
? [{ name: "write", pass: false, detail: writeErr }]
|
|
453
|
+
: verify(target, f);
|
|
454
|
+
if (!checks.every((c) => c.pass)) badFiles.push({ target, rel: f.rel, checks });
|
|
401
455
|
}
|
|
402
456
|
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
457
|
+
const absent = badFiles.length === files.length
|
|
458
|
+
&& badFiles.every((b) => b.checks.some((c) => c.name === "file exists" && !c.pass));
|
|
459
|
+
baseResults.push({ destDir, badFiles, passed: badFiles.length === 0, absent });
|
|
460
|
+
baseSummaries.push({ destDir, passed: badFiles.length === 0, absent,
|
|
461
|
+
n: files.length - badFiles.length });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!dryRun) {
|
|
465
|
+
// An install lives in ONE directory the agent reads. Requiring every
|
|
466
|
+
// fallback to hold a copy manufactures failures: skill-land writes three
|
|
467
|
+
// paths for Antigravity, but a skill complete in any one of them IS
|
|
468
|
+
// installed. Installing still demands all three, since it just wrote them.
|
|
469
|
+
const agentPassed = verifyOnly
|
|
470
|
+
? baseResults.some((r) => r.passed)
|
|
471
|
+
: baseResults.every((r) => r.passed);
|
|
472
|
+
if (!agentPassed) {
|
|
473
|
+
skillFailed++;
|
|
474
|
+
for (const r of baseResults) for (const b of r.badFiles) bad.push(b);
|
|
475
|
+
}
|
|
408
476
|
|
|
409
|
-
if (quiet)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
477
|
+
if (!quiet) {
|
|
478
|
+
for (const r of baseResults) {
|
|
479
|
+
const n = files.length - r.badFiles.length;
|
|
480
|
+
// Nothing present at all is one fact, not N failures. Listing every
|
|
481
|
+
// absent file buries the directories that are partially installed,
|
|
482
|
+
// which are the ones worth looking at.
|
|
483
|
+
if (r.absent) {
|
|
484
|
+
console.log(` FAIL ${r.destDir} not installed${verifyOnly ? " (verify only)" : ""}`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
console.log(` ${r.passed ? "OK " : "FAIL"} ${r.destDir} ${n}/${files.length} file${files.length === 1 ? "" : "s"}${verifyOnly ? " (verify only)" : ""}`);
|
|
488
|
+
for (const b of r.badFiles.slice(0, 10)) {
|
|
489
|
+
console.log(` x ${b.rel}`);
|
|
490
|
+
for (const c of b.checks) if (!c.pass) console.log(` ${c.name}: ${c.detail}`);
|
|
491
|
+
}
|
|
492
|
+
if (r.badFiles.length > 10) console.log(` ... and ${r.badFiles.length - 10} more file(s)`);
|
|
493
|
+
}
|
|
494
|
+
if (verifyOnly && agentPassed && baseResults.some((r) => !r.passed))
|
|
495
|
+
console.log(` note complete in at least one directory ${a.display} reads; that is an install`);
|
|
413
496
|
}
|
|
414
497
|
}
|
|
415
498
|
|
|
@@ -422,12 +505,13 @@ function runSkill(skillPath, root, quiet) {
|
|
|
422
505
|
// One line per skill: the counts, then every failing path underneath it. A
|
|
423
506
|
// summary that hid which path failed would need a second run to be actionable.
|
|
424
507
|
if (quiet) {
|
|
425
|
-
const
|
|
426
|
-
if (dryRun) console.log(`-- ${targetCount} target${targetCount === 1 ? "" : "s"} (dry run)`);
|
|
427
|
-
else console.log(`${skillFailed ? "FAIL" : "OK "} ${
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
508
|
+
const nf = `${files.length} file${files.length === 1 ? "" : "s"}`;
|
|
509
|
+
if (dryRun) console.log(`-- ${targetCount} target${targetCount === 1 ? "" : "s"}, ${nf} (dry run)`);
|
|
510
|
+
else console.log(`${skillFailed ? "FAIL" : "OK "} ${nf}`);
|
|
511
|
+
// Per directory, not per file. Flattening 3 paths x 148 files into one list
|
|
512
|
+
// buries the only thing that is actionable: which directory is wrong, and how.
|
|
513
|
+
for (const b of baseSummaries.filter((x) => !x.passed)) {
|
|
514
|
+
console.log(` x ${b.destDir} ${b.absent ? "not installed" : `${b.n}/${files.length} files`}`);
|
|
431
515
|
}
|
|
432
516
|
}
|
|
433
517
|
return { name: skillName, failed: skillFailed, skipped: skillSkipped };
|
|
@@ -454,7 +538,8 @@ if (!argv.length || argv.includes("-h") || argv.includes("--help")) {
|
|
|
454
538
|
--strict refuse to install anything SkillSpector flags
|
|
455
539
|
--list show known agents and their directories
|
|
456
540
|
|
|
457
|
-
|
|
541
|
+
Copies every file in the skill, then reads each back. Exits non-zero if any
|
|
542
|
+
file did not land where the agent actually reads.
|
|
458
543
|
`);
|
|
459
544
|
process.exit(0);
|
|
460
545
|
}
|