skill-land 1.1.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.
- package/README.md +113 -11
- package/agents.json +5 -2
- package/package.json +1 -1
- package/skill-land.mjs +254 -89
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ npx skill-land <source> --for <agent> [options]
|
|
|
56
56
|
|---|---|
|
|
57
57
|
| `--for <agent>` | target agent; repeatable or comma-separated |
|
|
58
58
|
| `--skill <name>` | which skill, when the source contains several |
|
|
59
|
+
| `--all` | every skill in the source, not just one |
|
|
60
|
+
| `--verbose` | per-skill detail when `--all` (default: one line each) |
|
|
59
61
|
| `--project` | install into `./` instead of the user-global directory |
|
|
60
62
|
| `--dry-run` | resolve and report, write nothing |
|
|
61
63
|
| `--verify` | check an existing install, write nothing |
|
|
@@ -69,38 +71,138 @@ skill-land Magithar/SKILLmama --for codex,antigravity
|
|
|
69
71
|
|
|
70
72
|
# audit an install someone else did
|
|
71
73
|
skill-land Magithar/SKILLmama --for codex --verify
|
|
74
|
+
|
|
75
|
+
# audit every skill you have, however it was installed
|
|
76
|
+
skill-land ~/.agents/skills --for antigravity --verify --all
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Auditing a whole directory
|
|
80
|
+
|
|
81
|
+
`--all` takes every skill in the source instead of one. It is never the default:
|
|
82
|
+
[`selectSkill()`](skill-land.mjs) refuses to guess when a source holds several,
|
|
83
|
+
and `--all` is you saying you meant all of them.
|
|
84
|
+
|
|
85
|
+
The use it was built for is `--verify --all` — checking whether the skills you
|
|
86
|
+
already installed are where the agent actually reads, whatever put them there.
|
|
87
|
+
That question is unanswerable today even if your setup works, because nothing in
|
|
88
|
+
the ecosystem reads an install back:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
$ skill-land ~/.agents/skills --for antigravity --verify --all
|
|
92
|
+
|
|
93
|
+
source /Users/you/.agents/skills
|
|
94
|
+
skills 1 found
|
|
95
|
+
impeccable FAIL 0/3
|
|
96
|
+
x ~/.gemini/config/skills/impeccable/SKILL.md
|
|
97
|
+
file exists: ~/.gemini/config/skills/impeccable/SKILL.md
|
|
98
|
+
x ~/.gemini/skills/impeccable/SKILL.md
|
|
99
|
+
content matches source: 10401 bytes sha abd76e36cf1d vs 10401 bytes sha a1ea82ce80f4
|
|
100
|
+
1 skill, 3 failed
|
|
72
101
|
```
|
|
73
102
|
|
|
103
|
+
That middle line is why the check compares hashes and not sizes: both files are
|
|
104
|
+
10401 bytes and they are not the same file. A copy that drifted after install
|
|
105
|
+
looks identical to `ls`.
|
|
106
|
+
|
|
107
|
+
One line per skill, because a report nobody reads verifies nothing; `--verbose`
|
|
108
|
+
restores the full per-target output. Failing paths are always named — a summary
|
|
109
|
+
you have to re-run to act on is not a summary.
|
|
110
|
+
|
|
111
|
+
With `--strict`, a flagged skill is skipped and the rest still install; the run
|
|
112
|
+
exits non-zero and lists what it skipped. All-or-nothing across a directory would
|
|
113
|
+
let one flagged skill block every clean one.
|
|
114
|
+
|
|
74
115
|
## What "verified" means
|
|
75
116
|
|
|
76
|
-
|
|
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:
|
|
77
119
|
|
|
78
120
|
1. file exists at the target path
|
|
79
121
|
2. non-empty
|
|
80
122
|
3. byte-identical to the source
|
|
81
|
-
4. frontmatter `name` matches the source
|
|
123
|
+
4. frontmatter `name` matches the source (`SKILL.md` only)
|
|
82
124
|
|
|
83
125
|
Any failure prints the specific check that failed and **exits non-zero**. A write that
|
|
84
126
|
throws (permissions, read-only directory) is reported as a failed install, not a crash.
|
|
85
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
|
+
|
|
86
144
|
Tested against: truncated file, wrong skill in the right place, missing file,
|
|
87
145
|
unwritable destination, and a real `npx skills add` install (correctly reported FAIL).
|
|
88
146
|
|
|
89
147
|
## Antigravity
|
|
90
148
|
|
|
91
|
-
Antigravity loads skills at startup, so an install cannot be confirmed live.
|
|
92
|
-
|
|
93
|
-
|
|
149
|
+
Antigravity loads skills at startup, so an install cannot be confirmed live. The
|
|
150
|
+
`agy` search path was stated by an Antigravity maintainer on 2026-09-05 in
|
|
151
|
+
[antigravity-cli#103](https://github.com/google-antigravity/antigravity-cli/issues/103#issuecomment-5547952239):
|
|
94
152
|
|
|
95
153
|
```
|
|
96
|
-
~/.gemini/
|
|
97
|
-
~/.gemini/
|
|
154
|
+
~/.gemini/config/skills primary; independently confirmed by live testing
|
|
155
|
+
~/.gemini/skills fallback
|
|
156
|
+
~/.gemini/antigravity-cli/skills fallback
|
|
98
157
|
```
|
|
99
158
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
159
|
+
`--for antigravity` writes to all three and says so. The `language_server` binary
|
|
160
|
+
contains `skillsPaths` and `skills_paths`, so this is a configurable list rather than a
|
|
161
|
+
single directory, and the maintainer statement covers the CLI — whether the IDE surface
|
|
162
|
+
reads the same list is **unverified**.
|
|
163
|
+
|
|
164
|
+
Checked against the shipped binaries (Antigravity 2026-07-30 build, inspected
|
|
165
|
+
2026-09-05). `language_server` contains **no** `.gemini/*/skills` literal at all:
|
|
166
|
+
`skills_paths` is a repeated protobuf field, so the client passes the list in, and
|
|
167
|
+
skills resolve to `skills/<name>/` under a *customization root*. Both roots we write
|
|
168
|
+
to are named in the shipped strings and config:
|
|
169
|
+
|
|
170
|
+
- `~/.gemini/config/` holds `config.json` and `skills/` — the global root
|
|
171
|
+
- `~/.gemini/antigravity-cli/` — "The CLI is configured via
|
|
172
|
+
`~/.gemini/antigravity-cli/settings.json`" (`language_server`)
|
|
173
|
+
|
|
174
|
+
1.1.0 and earlier also wrote `~/.gemini/antigravity/skills`, on the reasoning that
|
|
175
|
+
Antigravity created that directory itself. That reasoning was wrong: `app.asar` defines
|
|
176
|
+
`~/.gemini/antigravity` as `IDE_OLD_DATA_DIR`, the **legacy IDE data directory** being
|
|
177
|
+
migrated to `~/.gemini/antigravity-ide`. It was never a skills root. No longer written.
|
|
178
|
+
|
|
179
|
+
### Live verification
|
|
180
|
+
|
|
181
|
+
Confirmed against **`agy` 1.1.27 on 2026-09-05**, not inferred. A uniquely-named
|
|
182
|
+
canary skill was planted in each candidate directory and `agy -p "/skills"` run from
|
|
183
|
+
a neutral workspace:
|
|
184
|
+
|
|
185
|
+
| directory | listed? |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `~/.gemini/config/skills` | **yes** |
|
|
188
|
+
| `~/.gemini/skills` | **yes** |
|
|
189
|
+
| `~/.gemini/antigravity-cli/skills` | **yes** |
|
|
190
|
+
| `~/.agents/skills` | no |
|
|
191
|
+
| `~/.gemini/antigravity/skills` | no |
|
|
192
|
+
|
|
193
|
+
All three directories `skill-land` writes to are read. The two it does not write to
|
|
194
|
+
are not.
|
|
195
|
+
|
|
196
|
+
`~/.agents/skills` appears **only** when its parent is the active workspace
|
|
197
|
+
(`agy --add-dir $HOME`) — that is project-local behaviour, not a global search path,
|
|
198
|
+
which is exactly what [#103](https://github.com/google-antigravity/antigravity-cli/issues/103)
|
|
199
|
+
is asking to change. Confirmed still open at 1.1.27.
|
|
200
|
+
|
|
201
|
+
Two side findings: project-local `.agents/skills` is read only when the directory is
|
|
202
|
+
a **registered workspace** — being the current directory was not enough — and
|
|
203
|
+
`.antigravity/skills` and `.agy/skills` are not read at all.
|
|
204
|
+
|
|
205
|
+
Restart Antigravity fully after installing.
|
|
104
206
|
|
|
105
207
|
## Security scan
|
|
106
208
|
|
package/agents.json
CHANGED
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
"broken": true
|
|
19
19
|
},
|
|
20
20
|
"antigravity": {
|
|
21
|
-
"_paths": "
|
|
21
|
+
"_paths": "LIVE VERIFIED against agy 1.1.27 on 2026-09-05: a uniquely-named canary skill was planted in each candidate directory and `agy -p /skills` run from a neutral workspace. Read as global: ~/.gemini/config/skills, ~/.gemini/skills, ~/.gemini/antigravity-cli/skills — matching the maintainer statement in google-antigravity/antigravity-cli#103 comment 5547952239. NOT read: ~/.agents/skills (appears only when its parent is the active workspace, i.e. `--add-dir $HOME`, which is project-local behavior, not global) and ~/.gemini/antigravity/skills (app.asar defines ~/.gemini/antigravity as IDE_OLD_DATA_DIR, the legacy IDE data dir migrating to ~/.gemini/antigravity-ide; never a skills root). Project dir .agents/skills is read only when the directory is a registered workspace — cwd alone was not enough; .antigravity/skills and .agy/skills are not read at all. language_server hardcodes no .gemini/*/skills literal: skills_paths is a repeated protobuf field the client fills in.",
|
|
22
22
|
"display": "Antigravity",
|
|
23
23
|
"global": "~/.gemini/config/skills",
|
|
24
|
-
"alsoWrite": [
|
|
24
|
+
"alsoWrite": [
|
|
25
|
+
"~/.gemini/skills",
|
|
26
|
+
"~/.gemini/antigravity-cli/skills"
|
|
27
|
+
],
|
|
25
28
|
"project": ".agents/skills",
|
|
26
29
|
"file": "SKILL.md",
|
|
27
30
|
"restart": true,
|
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
|
|
@@ -120,7 +122,7 @@ function selectSkill(root, wanted) {
|
|
|
120
122
|
const shown = named.slice(0, 15).map((n) => ` ${n.name.padEnd(28)} ${n.path.replace(root, ".")}`);
|
|
121
123
|
die(
|
|
122
124
|
`${named.length} skills found in ${root}; refusing to guess.\n` +
|
|
123
|
-
` Pick one with --skill <name
|
|
125
|
+
` Pick one with --skill <name>, or --all for every one of them:\n` +
|
|
124
126
|
shown.join("\n") +
|
|
125
127
|
(named.length > 15 ? `\n ... and ${named.length - 15} more` : "")
|
|
126
128
|
);
|
|
@@ -128,6 +130,44 @@ function selectSkill(root, wanted) {
|
|
|
128
130
|
return named[0].path;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
// --all is the third arm of the same decision: refuse, pick one, or take them
|
|
134
|
+
// all. Deliberately never the default — silently installing 78 skills because
|
|
135
|
+
// a source happened to contain them is the failure selectSkill() exists to
|
|
136
|
+
// prevent, and --all is the user saying they meant it.
|
|
137
|
+
function selectAll(root) {
|
|
138
|
+
const hits = findSkills(root);
|
|
139
|
+
if (!hits.length)
|
|
140
|
+
die(`no SKILL.md found under ${root}\n searched ${MAX_DEPTH} levels deep`);
|
|
141
|
+
return hits;
|
|
142
|
+
}
|
|
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
|
+
|
|
131
171
|
function parseFrontmatter(text) {
|
|
132
172
|
const m = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
133
173
|
if (!m) return {};
|
|
@@ -261,6 +301,7 @@ function securityScan(skillPath) {
|
|
|
261
301
|
// ---------------------------------------------------------------- verify
|
|
262
302
|
|
|
263
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.
|
|
264
305
|
function verify(target, expected) {
|
|
265
306
|
const checks = [];
|
|
266
307
|
const ok = (name, pass, detail) => { checks.push({ name, pass, detail }); return pass; };
|
|
@@ -287,74 +328,35 @@ function verify(target, expected) {
|
|
|
287
328
|
return checks;
|
|
288
329
|
}
|
|
289
330
|
|
|
290
|
-
// ----------------------------------------------------------------
|
|
291
|
-
|
|
292
|
-
const argv = process.argv.slice(2);
|
|
293
|
-
if (!argv.length || argv.includes("-h") || argv.includes("--help")) {
|
|
294
|
-
console.log(`
|
|
295
|
-
skill-land <source> --for <agent> [options]
|
|
296
|
-
|
|
297
|
-
<source> owner/repo | github URL | raw .md URL | local path
|
|
298
|
-
|
|
299
|
-
Options
|
|
300
|
-
--for <agent> target agent (repeatable, or comma-separated)
|
|
301
|
-
--skill <name> which skill, when the source contains several
|
|
302
|
-
--project install into ./ instead of the user-global dir
|
|
303
|
-
--dry-run resolve and report, write nothing
|
|
304
|
-
--verify check an existing install, write nothing
|
|
305
|
-
--skip-security skip the SkillSpector scan (acknowledged, not silent)
|
|
306
|
-
--strict refuse to install anything SkillSpector flags
|
|
307
|
-
--list show known agents and their directories
|
|
308
|
-
|
|
309
|
-
Verifies the file landed where the agent actually reads. Exits non-zero if not.
|
|
310
|
-
`);
|
|
311
|
-
process.exit(0);
|
|
312
|
-
}
|
|
331
|
+
// ---------------------------------------------------------------- per skill
|
|
313
332
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const flag = (n) => argv.includes(n);
|
|
325
|
-
const values = (n) => argv.reduce((acc, a, i) =>
|
|
326
|
-
a === n && argv[i + 1] ? acc.concat(argv[i + 1].split(",")) : acc, []);
|
|
327
|
-
|
|
328
|
-
// Flags that consume the next argument; their values are not the source.
|
|
329
|
-
const VALUE_FLAGS = new Set(["--for", "--skill"]);
|
|
330
|
-
const source = argv.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(argv[i - 1]));
|
|
331
|
-
if (!source) die("no source given");
|
|
332
|
-
|
|
333
|
-
const targets = values("--for").map((s) => s.trim()).filter(Boolean);
|
|
334
|
-
if (!targets.length) die(`no --for <agent> given\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
335
|
-
for (const t of targets) if (!AGENTS[t]) die(`unknown agent "${t}"\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
336
|
-
|
|
337
|
-
const isProject = flag("--project");
|
|
338
|
-
const dryRun = flag("--dry-run");
|
|
339
|
-
const verifyOnly = flag("--verify");
|
|
340
|
-
const skipSecurity = flag("--skip-security");
|
|
341
|
-
const strict = flag("--strict");
|
|
342
|
-
|
|
343
|
-
const src = resolveSource(source);
|
|
344
|
-
const tmp = mkdtempSync(join(tmpdir(), "skill-install-"));
|
|
345
|
-
let failed = 0;
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
const root = fetchSource(src, tmp);
|
|
349
|
-
const skillPath = selectSkill(root, values("--skill")[0]);
|
|
333
|
+
// One skill, every target path: scan, disclose, write, read back. Factored out
|
|
334
|
+
// of main so --all is a loop over it rather than a second code path — the
|
|
335
|
+
// single-skill output is unchanged, byte for byte.
|
|
336
|
+
//
|
|
337
|
+
// Returns what the caller needs to summarise: how many targets failed
|
|
338
|
+
// verification, and whether --strict refused this one.
|
|
339
|
+
function runSkill(skillPath, root, quiet) {
|
|
340
|
+
let skillFailed = 0;
|
|
341
|
+
let skillSkipped = false;
|
|
350
342
|
const body = readFileSync(skillPath, "utf-8");
|
|
351
343
|
const fm = parseFrontmatter(body);
|
|
352
344
|
const skillName = (fm.name || "skill").toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
353
345
|
const rawBody = readFileSync(skillPath);
|
|
354
|
-
|
|
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);
|
|
355
357
|
|
|
356
358
|
// Scan before any write. A blocked skill must never reach disk.
|
|
357
|
-
const scan = skipSecurity ? { status: "skipped" } : securityScan(skillPath);
|
|
359
|
+
const scan = (skipSecurity || verifyOnly) ? { status: "skipped" } : securityScan(skillPath);
|
|
358
360
|
const contents = disclose(rawBody.toString("utf-8"));
|
|
359
361
|
const findings = (scan.top || []).length
|
|
360
362
|
? "\n" + scan.top.map((t) => ` ${t}`).join("\n") : "";
|
|
@@ -372,10 +374,14 @@ try {
|
|
|
372
374
|
skipped: () => ` security NOT SCANNED --skip-security given`,
|
|
373
375
|
};
|
|
374
376
|
|
|
377
|
+
if (quiet) {
|
|
378
|
+
process.stdout.write(` ${(fm.name || basename(dirname(skillPath))).slice(0, 28).padEnd(30)}`);
|
|
379
|
+
} else {
|
|
375
380
|
console.log(`\n source ${src.label}`);
|
|
376
|
-
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)}]`);
|
|
377
382
|
console.log(` from ${skillPath.replace(root, ".")}`);
|
|
378
|
-
|
|
383
|
+
}
|
|
384
|
+
if (!verifyOnly && !quiet) {
|
|
379
385
|
// Always shown, with or without a scanner. Counts and quotes only.
|
|
380
386
|
if (contents.length) {
|
|
381
387
|
console.log(" contains");
|
|
@@ -388,56 +394,215 @@ try {
|
|
|
388
394
|
}
|
|
389
395
|
console.log(SCAN_LINE[scan.status]());
|
|
390
396
|
}
|
|
391
|
-
console.log("");
|
|
397
|
+
if (!quiet) console.log("");
|
|
392
398
|
|
|
393
399
|
// Refuse to write a skill the scanner flagged. --force is an explicit,
|
|
394
400
|
// logged override, never a silent one.
|
|
395
401
|
if (!verifyOnly && strict && scan.status === "flagged") {
|
|
396
|
-
|
|
402
|
+
if (!quiet)
|
|
403
|
+
console.error(`\n error --strict: SkillSpector reports ${scan.detail}\n Review the findings above. Drop --strict to install anyway.\n`);
|
|
404
|
+
else console.log(`SKIP --strict: ${scan.detail}`);
|
|
405
|
+
// One flagged skill must not block the clean ones, but the run still fails.
|
|
406
|
+
return { name: skillName, failed: 0, skipped: true };
|
|
397
407
|
}
|
|
398
408
|
|
|
409
|
+
const bad = [];
|
|
410
|
+
let targetCount = 0;
|
|
411
|
+
|
|
399
412
|
for (const agent of targets) {
|
|
400
413
|
const a = AGENTS[agent];
|
|
401
414
|
const bases = isProject
|
|
402
415
|
? [resolve(a.project)]
|
|
403
416
|
: [a.global, ...(a.alsoWrite || [])].map(expand);
|
|
404
417
|
|
|
405
|
-
console.log(` ${a.display}`);
|
|
418
|
+
if (!quiet) console.log(` ${a.display}`);
|
|
419
|
+
|
|
420
|
+
const baseResults = [];
|
|
406
421
|
|
|
407
422
|
for (const base of bases) {
|
|
408
423
|
const destDir = join(base, skillName);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (dryRun) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
424
|
+
|
|
425
|
+
targetCount++;
|
|
426
|
+
if (dryRun) {
|
|
427
|
+
if (quiet) continue;
|
|
428
|
+
console.log(` would write ${destDir} (${files.length} file${files.length === 1 ? "" : "s"}, ${fmtBytes(totalBytes)})`);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
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
|
+
}
|
|
422
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 });
|
|
423
454
|
}
|
|
424
455
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
+
}
|
|
430
471
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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`);
|
|
434
493
|
}
|
|
435
494
|
}
|
|
436
495
|
|
|
496
|
+
if (quiet) continue;
|
|
437
497
|
if (!dryRun && a.restart) console.log(` note restart ${a.display} fully; skills load at startup`);
|
|
438
498
|
if (a.broken) console.log(` note \`npx skills add -a ${agent} -g\` writes to ~/.agents/skills instead`);
|
|
439
499
|
console.log();
|
|
440
500
|
}
|
|
501
|
+
|
|
502
|
+
// One line per skill: the counts, then every failing path underneath it. A
|
|
503
|
+
// summary that hid which path failed would need a second run to be actionable.
|
|
504
|
+
if (quiet) {
|
|
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)) {
|
|
509
|
+
console.log(` x ${b.target}`);
|
|
510
|
+
for (const c of b.checks) if (!c.pass) console.log(` ${c.name}: ${c.detail}`);
|
|
511
|
+
}
|
|
512
|
+
if (bad.length > 10) console.log(` ... and ${bad.length - 10} more file(s)`);
|
|
513
|
+
}
|
|
514
|
+
return { name: skillName, failed: skillFailed, skipped: skillSkipped };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------- main
|
|
518
|
+
|
|
519
|
+
const argv = process.argv.slice(2);
|
|
520
|
+
if (!argv.length || argv.includes("-h") || argv.includes("--help")) {
|
|
521
|
+
console.log(`
|
|
522
|
+
skill-land <source> --for <agent> [options]
|
|
523
|
+
|
|
524
|
+
<source> owner/repo | github URL | raw .md URL | local path
|
|
525
|
+
|
|
526
|
+
Options
|
|
527
|
+
--for <agent> target agent (repeatable, or comma-separated)
|
|
528
|
+
--skill <name> which skill, when the source contains several
|
|
529
|
+
--all every skill in the source, not just one
|
|
530
|
+
--verbose per-skill detail when --all (default: one line each)
|
|
531
|
+
--project install into ./ instead of the user-global dir
|
|
532
|
+
--dry-run resolve and report, write nothing
|
|
533
|
+
--verify check an existing install, write nothing
|
|
534
|
+
--skip-security skip the SkillSpector scan (acknowledged, not silent)
|
|
535
|
+
--strict refuse to install anything SkillSpector flags
|
|
536
|
+
--list show known agents and their directories
|
|
537
|
+
|
|
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.
|
|
540
|
+
`);
|
|
541
|
+
process.exit(0);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (argv.includes("--list")) {
|
|
545
|
+
console.log("\n agent directory upstream");
|
|
546
|
+
console.log(" " + "-".repeat(70));
|
|
547
|
+
for (const [k, a] of Object.entries(AGENTS)) {
|
|
548
|
+
console.log(` ${k.padEnd(17)} ${a.global.padEnd(38)} ${a.broken ? "BROKEN" : "ok"}`);
|
|
549
|
+
}
|
|
550
|
+
console.log();
|
|
551
|
+
process.exit(0);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const flag = (n) => argv.includes(n);
|
|
555
|
+
const values = (n) => argv.reduce((acc, a, i) =>
|
|
556
|
+
a === n && argv[i + 1] ? acc.concat(argv[i + 1].split(",")) : acc, []);
|
|
557
|
+
|
|
558
|
+
// Flags that consume the next argument; their values are not the source.
|
|
559
|
+
const VALUE_FLAGS = new Set(["--for", "--skill"]);
|
|
560
|
+
const source = argv.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(argv[i - 1]));
|
|
561
|
+
if (!source) die("no source given");
|
|
562
|
+
|
|
563
|
+
const targets = values("--for").map((s) => s.trim()).filter(Boolean);
|
|
564
|
+
if (!targets.length) die(`no --for <agent> given\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
565
|
+
for (const t of targets) if (!AGENTS[t]) die(`unknown agent "${t}"\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
566
|
+
|
|
567
|
+
const isProject = flag("--project");
|
|
568
|
+
const dryRun = flag("--dry-run");
|
|
569
|
+
const verifyOnly = flag("--verify");
|
|
570
|
+
const skipSecurity = flag("--skip-security");
|
|
571
|
+
const strict = flag("--strict");
|
|
572
|
+
const allSkills = flag("--all");
|
|
573
|
+
const verbose = flag("--verbose");
|
|
574
|
+
|
|
575
|
+
// --skill names one, --all takes every one. Asking for both is a contradiction
|
|
576
|
+
// about intent, not something to resolve by precedence.
|
|
577
|
+
if (allSkills && values("--skill").length)
|
|
578
|
+
die("--all and --skill are mutually exclusive: --skill names one, --all takes every one");
|
|
579
|
+
|
|
580
|
+
const src = resolveSource(source);
|
|
581
|
+
const tmp = mkdtempSync(join(tmpdir(), "skill-install-"));
|
|
582
|
+
let failed = 0;
|
|
583
|
+
const skipped = [];
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const root = fetchSource(src, tmp);
|
|
587
|
+
const skillPaths = allSkills ? selectAll(root) : [selectSkill(root, values("--skill")[0])];
|
|
588
|
+
|
|
589
|
+
// With --all the per-skill detail becomes a wall nobody reads, and an unread
|
|
590
|
+
// report verifies nothing. Collapse to one line each unless asked otherwise.
|
|
591
|
+
const quiet = allSkills && !verbose;
|
|
592
|
+
if (quiet) console.log(`\n source ${src.label}\n skills ${skillPaths.length} found`);
|
|
593
|
+
|
|
594
|
+
for (const skillPath of skillPaths) {
|
|
595
|
+
const r = runSkill(skillPath, root, quiet);
|
|
596
|
+
failed += r.failed;
|
|
597
|
+
if (r.skipped) skipped.push(r.name);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (allSkills) {
|
|
601
|
+
const n = skillPaths.length;
|
|
602
|
+
console.log(` ${n} skill${n === 1 ? "" : "s"}, ${failed} failed${skipped.length ? `, ${skipped.length} skipped by --strict` : ""}`);
|
|
603
|
+
if (skipped.length) console.log(` skipped ${skipped.join(", ")}`);
|
|
604
|
+
console.log();
|
|
605
|
+
}
|
|
441
606
|
} finally {
|
|
442
607
|
rmSync(tmp, { recursive: true, force: true });
|
|
443
608
|
}
|