skill-land 1.0.1 → 1.2.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 +136 -9
- package/agents.json +5 -2
- package/package.json +4 -1
- package/skill-land.mjs +214 -67
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,8 +71,47 @@ 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
|
An install is not done until read-back confirms it:
|
|
@@ -88,19 +129,63 @@ unwritable destination, and a real `npx skills add` install (correctly reported
|
|
|
88
129
|
|
|
89
130
|
## Antigravity
|
|
90
131
|
|
|
91
|
-
Antigravity loads skills at startup, so an install cannot be confirmed live.
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
Antigravity loads skills at startup, so an install cannot be confirmed live. The
|
|
133
|
+
`agy` search path was stated by an Antigravity maintainer on 2026-09-05 in
|
|
134
|
+
[antigravity-cli#103](https://github.com/google-antigravity/antigravity-cli/issues/103#issuecomment-5547952239):
|
|
94
135
|
|
|
95
136
|
```
|
|
96
|
-
~/.gemini/
|
|
97
|
-
~/.gemini/
|
|
137
|
+
~/.gemini/config/skills primary; independently confirmed by live testing
|
|
138
|
+
~/.gemini/skills fallback
|
|
139
|
+
~/.gemini/antigravity-cli/skills fallback
|
|
98
140
|
```
|
|
99
141
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
`--for antigravity` writes to all three and says so. The `language_server` binary
|
|
143
|
+
contains `skillsPaths` and `skills_paths`, so this is a configurable list rather than a
|
|
144
|
+
single directory, and the maintainer statement covers the CLI — whether the IDE surface
|
|
145
|
+
reads the same list is **unverified**.
|
|
146
|
+
|
|
147
|
+
Checked against the shipped binaries (Antigravity 2026-07-30 build, inspected
|
|
148
|
+
2026-09-05). `language_server` contains **no** `.gemini/*/skills` literal at all:
|
|
149
|
+
`skills_paths` is a repeated protobuf field, so the client passes the list in, and
|
|
150
|
+
skills resolve to `skills/<name>/` under a *customization root*. Both roots we write
|
|
151
|
+
to are named in the shipped strings and config:
|
|
152
|
+
|
|
153
|
+
- `~/.gemini/config/` holds `config.json` and `skills/` — the global root
|
|
154
|
+
- `~/.gemini/antigravity-cli/` — "The CLI is configured via
|
|
155
|
+
`~/.gemini/antigravity-cli/settings.json`" (`language_server`)
|
|
156
|
+
|
|
157
|
+
1.1.0 and earlier also wrote `~/.gemini/antigravity/skills`, on the reasoning that
|
|
158
|
+
Antigravity created that directory itself. That reasoning was wrong: `app.asar` defines
|
|
159
|
+
`~/.gemini/antigravity` as `IDE_OLD_DATA_DIR`, the **legacy IDE data directory** being
|
|
160
|
+
migrated to `~/.gemini/antigravity-ide`. It was never a skills root. No longer written.
|
|
161
|
+
|
|
162
|
+
### Live verification
|
|
163
|
+
|
|
164
|
+
Confirmed against **`agy` 1.1.27 on 2026-09-05**, not inferred. A uniquely-named
|
|
165
|
+
canary skill was planted in each candidate directory and `agy -p "/skills"` run from
|
|
166
|
+
a neutral workspace:
|
|
167
|
+
|
|
168
|
+
| directory | listed? |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `~/.gemini/config/skills` | **yes** |
|
|
171
|
+
| `~/.gemini/skills` | **yes** |
|
|
172
|
+
| `~/.gemini/antigravity-cli/skills` | **yes** |
|
|
173
|
+
| `~/.agents/skills` | no |
|
|
174
|
+
| `~/.gemini/antigravity/skills` | no |
|
|
175
|
+
|
|
176
|
+
All three directories `skill-land` writes to are read. The two it does not write to
|
|
177
|
+
are not.
|
|
178
|
+
|
|
179
|
+
`~/.agents/skills` appears **only** when its parent is the active workspace
|
|
180
|
+
(`agy --add-dir $HOME`) — that is project-local behaviour, not a global search path,
|
|
181
|
+
which is exactly what [#103](https://github.com/google-antigravity/antigravity-cli/issues/103)
|
|
182
|
+
is asking to change. Confirmed still open at 1.1.27.
|
|
183
|
+
|
|
184
|
+
Two side findings: project-local `.agents/skills` is read only when the directory is
|
|
185
|
+
a **registered workspace** — being the current directory was not enough — and
|
|
186
|
+
`.antigravity/skills` and `.agy/skills` are not read at all.
|
|
187
|
+
|
|
188
|
+
Restart Antigravity fully after installing.
|
|
104
189
|
|
|
105
190
|
## Security scan
|
|
106
191
|
|
|
@@ -112,6 +197,32 @@ directory an agent auto-loads. So it scans before writing, delegating to
|
|
|
112
197
|
uv tool install git+https://github.com/NVIDIA/skillspector.git
|
|
113
198
|
```
|
|
114
199
|
|
|
200
|
+
### What always runs: disclosure
|
|
201
|
+
|
|
202
|
+
A skill is instructions an agent executes with full permissions, so before writing anything
|
|
203
|
+
`skill-land` shows what is actually in the file. No dependencies, so it runs for everyone:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
contains
|
|
207
|
+
3 shell commands curl, rm, git
|
|
208
|
+
1 network hosts attacker.example.com
|
|
209
|
+
5 credential refs AWS_SECRET_ACCESS_KEY, ~/.ssh, id_rsa, ~/.aws
|
|
210
|
+
2 destructive commands rm -rf, git push --force
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A skill with nothing notable says so:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
contains no shell commands, network calls or credential references
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**This is disclosure, not a verdict.** It counts and names; it never scores and never prints
|
|
220
|
+
"safe". That distinction is deliberate. A built-in matcher that declared a skill safe would be
|
|
221
|
+
worse than no check, and the numbers below show why. Counting stays honest at any false-positive
|
|
222
|
+
rate because it makes no claim.
|
|
223
|
+
|
|
224
|
+
### What runs when available: SkillSpector
|
|
225
|
+
|
|
115
226
|
**It reports, it does not block.** That is a measured decision, not a shortcut.
|
|
116
227
|
|
|
117
228
|
Scanning 18 known-good skills already installed on a dev machine (2026-08-08, SkillSpector
|
|
@@ -184,6 +295,22 @@ $ skill-land product-on-purpose/pm-skills --for codex
|
|
|
184
295
|
...
|
|
185
296
|
```
|
|
186
297
|
|
|
298
|
+
## Releasing
|
|
299
|
+
|
|
300
|
+
Published from CI by [trusted publishing](https://docs.npmjs.com/trusted-publishers), so there is
|
|
301
|
+
no `NPM_TOKEN` in this repo and the npm account keeps 2FA on write actions. npm mints a
|
|
302
|
+
short-lived token from the GitHub identity of `.github/workflows/publish-skill-land.yml`.
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
# bump version in package.json, commit, then:
|
|
306
|
+
git tag skill-land-v1.0.2
|
|
307
|
+
git push origin skill-land-v1.0.2
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
CI runs the tests, refuses to publish a version that already exists, checks the tag matches
|
|
311
|
+
`package.json`, and publishes. The tag is package-scoped so other tools in this repo can release
|
|
312
|
+
independently.
|
|
313
|
+
|
|
187
314
|
## Adding an agent
|
|
188
315
|
|
|
189
316
|
Edit `agents.json`. `broken: true` marks agents where the upstream CLI misroutes a
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-land",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.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",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"bin": {
|
|
28
28
|
"skill-land": "skill-land.mjs"
|
|
29
29
|
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node test.mjs"
|
|
32
|
+
},
|
|
30
33
|
"files": [
|
|
31
34
|
"skill-land.mjs",
|
|
32
35
|
"agents.json",
|
package/skill-land.mjs
CHANGED
|
@@ -120,7 +120,7 @@ function selectSkill(root, wanted) {
|
|
|
120
120
|
const shown = named.slice(0, 15).map((n) => ` ${n.name.padEnd(28)} ${n.path.replace(root, ".")}`);
|
|
121
121
|
die(
|
|
122
122
|
`${named.length} skills found in ${root}; refusing to guess.\n` +
|
|
123
|
-
` Pick one with --skill <name
|
|
123
|
+
` Pick one with --skill <name>, or --all for every one of them:\n` +
|
|
124
124
|
shown.join("\n") +
|
|
125
125
|
(named.length > 15 ? `\n ... and ${named.length - 15} more` : "")
|
|
126
126
|
);
|
|
@@ -128,6 +128,17 @@ function selectSkill(root, wanted) {
|
|
|
128
128
|
return named[0].path;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// --all is the third arm of the same decision: refuse, pick one, or take them
|
|
132
|
+
// all. Deliberately never the default — silently installing 78 skills because
|
|
133
|
+
// a source happened to contain them is the failure selectSkill() exists to
|
|
134
|
+
// prevent, and --all is the user saying they meant it.
|
|
135
|
+
function selectAll(root) {
|
|
136
|
+
const hits = findSkills(root);
|
|
137
|
+
if (!hits.length)
|
|
138
|
+
die(`no SKILL.md found under ${root}\n searched ${MAX_DEPTH} levels deep`);
|
|
139
|
+
return hits;
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
function parseFrontmatter(text) {
|
|
132
143
|
const m = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
133
144
|
if (!m) return {};
|
|
@@ -141,6 +152,57 @@ function parseFrontmatter(text) {
|
|
|
141
152
|
|
|
142
153
|
// ---------------------------------------------------------------- security
|
|
143
154
|
|
|
155
|
+
// A skill is instructions an agent executes with full permissions, so the one
|
|
156
|
+
// check worth always running is showing what is actually in the file.
|
|
157
|
+
//
|
|
158
|
+
// This is disclosure, not a verdict: it counts and quotes, it never scores.
|
|
159
|
+
// That distinction is the point. A built-in pattern-matcher that printed
|
|
160
|
+
// "SAFE" would be worse than nothing, and we measured why: SkillSpector's
|
|
161
|
+
// static mode calls 44% of known-good skills DO_NOT_INSTALL. Counting is
|
|
162
|
+
// honest at any false-positive rate because it makes no claim.
|
|
163
|
+
//
|
|
164
|
+
// SkillSpector, when installed, does the judging. This does the showing, and
|
|
165
|
+
// it has no dependencies so it runs for everyone.
|
|
166
|
+
// Each entry reduces its matches to a short, stable label (the binary, the
|
|
167
|
+
// host) rather than quoting raw text, so the output stays readable and does
|
|
168
|
+
// not leak half-sentences from prose that happens to contain a keyword.
|
|
169
|
+
const DISCLOSE = [
|
|
170
|
+
{
|
|
171
|
+
label: "shell commands",
|
|
172
|
+
re: /\b(sudo|rm|mv|cp|chmod|chown|curl|wget|git|npm|npx|pip|uv|docker|kubectl|ssh|scp|eval|bash|sh)\s+(?:-{1,2}[\w-]+|[\w./~$@-]+)/g,
|
|
173
|
+
key: (m) => m[1],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
label: "network hosts",
|
|
177
|
+
re: /https?:\/\/([^\s/"'`)>\]]+)/g,
|
|
178
|
+
key: (m) => m[1],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
label: "credential refs",
|
|
182
|
+
re: /(~\/\.ssh|~\/\.aws|\.env(?:\.[a-z]+)?|id_rsa|[A-Z][A-Z_]*(?:API_KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*)/g,
|
|
183
|
+
key: (m) => m[1],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
label: "destructive commands",
|
|
187
|
+
re: /(rm\s+-[rf]{1,2}|git\s+push\s+--force|push\s+-f\b|DROP\s+(?:TABLE|DATABASE)|TRUNCATE)/gi,
|
|
188
|
+
key: (m) => m[1].replace(/\s+/g, " ").toLowerCase(),
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function disclose(text) {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const { label, re, key } of DISCLOSE) {
|
|
195
|
+
const keys = [...text.matchAll(re)].map(key).filter(Boolean);
|
|
196
|
+
if (!keys.length) continue;
|
|
197
|
+
// Rank by frequency so the most-used thing is what you see first.
|
|
198
|
+
const freq = new Map();
|
|
199
|
+
for (const k of keys) freq.set(k, (freq.get(k) || 0) + 1);
|
|
200
|
+
const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
201
|
+
out.push({ label, count: keys.length, distinct: top.length, sample: top.slice(0, 4) });
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
144
206
|
// Skills run with full agent permissions, and this tool writes a file fetched
|
|
145
207
|
// from a URL into a directory an agent auto-loads. So scan before writing.
|
|
146
208
|
//
|
|
@@ -236,66 +298,17 @@ function verify(target, expected) {
|
|
|
236
298
|
return checks;
|
|
237
299
|
}
|
|
238
300
|
|
|
239
|
-
// ----------------------------------------------------------------
|
|
240
|
-
|
|
241
|
-
const argv = process.argv.slice(2);
|
|
242
|
-
if (!argv.length || argv.includes("-h") || argv.includes("--help")) {
|
|
243
|
-
console.log(`
|
|
244
|
-
skill-land <source> --for <agent> [options]
|
|
245
|
-
|
|
246
|
-
<source> owner/repo | github URL | raw .md URL | local path
|
|
247
|
-
|
|
248
|
-
Options
|
|
249
|
-
--for <agent> target agent (repeatable, or comma-separated)
|
|
250
|
-
--skill <name> which skill, when the source contains several
|
|
251
|
-
--project install into ./ instead of the user-global dir
|
|
252
|
-
--dry-run resolve and report, write nothing
|
|
253
|
-
--verify check an existing install, write nothing
|
|
254
|
-
--skip-security skip the SkillSpector scan (acknowledged, not silent)
|
|
255
|
-
--strict refuse to install anything SkillSpector flags
|
|
256
|
-
--list show known agents and their directories
|
|
257
|
-
|
|
258
|
-
Verifies the file landed where the agent actually reads. Exits non-zero if not.
|
|
259
|
-
`);
|
|
260
|
-
process.exit(0);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (argv.includes("--list")) {
|
|
264
|
-
console.log("\n agent directory upstream");
|
|
265
|
-
console.log(" " + "-".repeat(70));
|
|
266
|
-
for (const [k, a] of Object.entries(AGENTS)) {
|
|
267
|
-
console.log(` ${k.padEnd(17)} ${a.global.padEnd(38)} ${a.broken ? "BROKEN" : "ok"}`);
|
|
268
|
-
}
|
|
269
|
-
console.log();
|
|
270
|
-
process.exit(0);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const flag = (n) => argv.includes(n);
|
|
274
|
-
const values = (n) => argv.reduce((acc, a, i) =>
|
|
275
|
-
a === n && argv[i + 1] ? acc.concat(argv[i + 1].split(",")) : acc, []);
|
|
276
|
-
|
|
277
|
-
// Flags that consume the next argument; their values are not the source.
|
|
278
|
-
const VALUE_FLAGS = new Set(["--for", "--skill"]);
|
|
279
|
-
const source = argv.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(argv[i - 1]));
|
|
280
|
-
if (!source) die("no source given");
|
|
281
|
-
|
|
282
|
-
const targets = values("--for").map((s) => s.trim()).filter(Boolean);
|
|
283
|
-
if (!targets.length) die(`no --for <agent> given\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
284
|
-
for (const t of targets) if (!AGENTS[t]) die(`unknown agent "${t}"\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
301
|
+
// ---------------------------------------------------------------- per skill
|
|
285
302
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
let
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
const root = fetchSource(src, tmp);
|
|
298
|
-
const skillPath = selectSkill(root, values("--skill")[0]);
|
|
303
|
+
// One skill, every target path: scan, disclose, write, read back. Factored out
|
|
304
|
+
// of main so --all is a loop over it rather than a second code path — the
|
|
305
|
+
// single-skill output is unchanged, byte for byte.
|
|
306
|
+
//
|
|
307
|
+
// Returns what the caller needs to summarise: how many targets failed
|
|
308
|
+
// verification, and whether --strict refused this one.
|
|
309
|
+
function runSkill(skillPath, root, quiet) {
|
|
310
|
+
let skillFailed = 0;
|
|
311
|
+
let skillSkipped = false;
|
|
299
312
|
const body = readFileSync(skillPath, "utf-8");
|
|
300
313
|
const fm = parseFrontmatter(body);
|
|
301
314
|
const skillName = (fm.name || "skill").toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
@@ -303,7 +316,8 @@ try {
|
|
|
303
316
|
const expected = { bytes: rawBody.length, hash: sha256(rawBody), name: fm.name };
|
|
304
317
|
|
|
305
318
|
// Scan before any write. A blocked skill must never reach disk.
|
|
306
|
-
const scan = skipSecurity ? { status: "skipped" } : securityScan(skillPath);
|
|
319
|
+
const scan = (skipSecurity || verifyOnly) ? { status: "skipped" } : securityScan(skillPath);
|
|
320
|
+
const contents = disclose(rawBody.toString("utf-8"));
|
|
307
321
|
const findings = (scan.top || []).length
|
|
308
322
|
? "\n" + scan.top.map((t) => ` ${t}`).join("\n") : "";
|
|
309
323
|
// Reported, never applied. See securityScan().
|
|
@@ -320,31 +334,59 @@ try {
|
|
|
320
334
|
skipped: () => ` security NOT SCANNED --skip-security given`,
|
|
321
335
|
};
|
|
322
336
|
|
|
337
|
+
if (quiet) {
|
|
338
|
+
process.stdout.write(` ${(fm.name || basename(dirname(skillPath))).slice(0, 28).padEnd(30)}`);
|
|
339
|
+
} else {
|
|
323
340
|
console.log(`\n source ${src.label}`);
|
|
324
341
|
console.log(` skill ${fm.name || "(no frontmatter name)"} [${rawBody.length} bytes, sha ${expected.hash.slice(0,12)}]`);
|
|
325
342
|
console.log(` from ${skillPath.replace(root, ".")}`);
|
|
326
|
-
|
|
327
|
-
|
|
343
|
+
}
|
|
344
|
+
if (!verifyOnly && !quiet) {
|
|
345
|
+
// Always shown, with or without a scanner. Counts and quotes only.
|
|
346
|
+
if (contents.length) {
|
|
347
|
+
console.log(" contains");
|
|
348
|
+
for (const c of contents) {
|
|
349
|
+
const more = c.distinct > c.sample.length ? `, +${c.distinct - c.sample.length} more` : "";
|
|
350
|
+
console.log(` ${String(c.count).padStart(3)} ${c.label.padEnd(21)} ${c.sample.join(", ")}${more}`.slice(0, 100));
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
console.log(" contains no shell commands, network calls or credential references");
|
|
354
|
+
}
|
|
355
|
+
console.log(SCAN_LINE[scan.status]());
|
|
356
|
+
}
|
|
357
|
+
if (!quiet) console.log("");
|
|
328
358
|
|
|
329
359
|
// Refuse to write a skill the scanner flagged. --force is an explicit,
|
|
330
360
|
// logged override, never a silent one.
|
|
331
361
|
if (!verifyOnly && strict && scan.status === "flagged") {
|
|
332
|
-
|
|
362
|
+
if (!quiet)
|
|
363
|
+
console.error(`\n error --strict: SkillSpector reports ${scan.detail}\n Review the findings above. Drop --strict to install anyway.\n`);
|
|
364
|
+
else console.log(`SKIP --strict: ${scan.detail}`);
|
|
365
|
+
// One flagged skill must not block the clean ones, but the run still fails.
|
|
366
|
+
return { name: skillName, failed: 0, skipped: true };
|
|
333
367
|
}
|
|
334
368
|
|
|
369
|
+
const bad = [];
|
|
370
|
+
let targetCount = 0;
|
|
371
|
+
|
|
335
372
|
for (const agent of targets) {
|
|
336
373
|
const a = AGENTS[agent];
|
|
337
374
|
const bases = isProject
|
|
338
375
|
? [resolve(a.project)]
|
|
339
376
|
: [a.global, ...(a.alsoWrite || [])].map(expand);
|
|
340
377
|
|
|
341
|
-
console.log(` ${a.display}`);
|
|
378
|
+
if (!quiet) console.log(` ${a.display}`);
|
|
342
379
|
|
|
343
380
|
for (const base of bases) {
|
|
344
381
|
const destDir = join(base, skillName);
|
|
345
382
|
const target = join(destDir, a.file);
|
|
346
383
|
|
|
347
|
-
|
|
384
|
+
targetCount++;
|
|
385
|
+
if (dryRun) {
|
|
386
|
+
if (quiet) continue;
|
|
387
|
+
console.log(` would write ${target}`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
348
390
|
|
|
349
391
|
// A write that throws is a failed install, not a crash. Report it the
|
|
350
392
|
// same way as a failed read-back so one exit path covers both.
|
|
@@ -362,18 +404,123 @@ try {
|
|
|
362
404
|
? [{ name: "write", pass: false, detail: writeErr }]
|
|
363
405
|
: verify(target, expected);
|
|
364
406
|
const passed = checks.every((c) => c.pass);
|
|
365
|
-
if (!passed)
|
|
407
|
+
if (!passed) { skillFailed++; bad.push({ target, checks }); }
|
|
366
408
|
|
|
409
|
+
if (quiet) continue;
|
|
367
410
|
console.log(` ${passed ? "OK " : "FAIL"} ${target}${verifyOnly ? " (verify only)" : ""}`);
|
|
368
411
|
for (const c of checks) {
|
|
369
412
|
if (!c.pass) console.log(` x ${c.name}: ${c.detail}`);
|
|
370
413
|
}
|
|
371
414
|
}
|
|
372
415
|
|
|
416
|
+
if (quiet) continue;
|
|
373
417
|
if (!dryRun && a.restart) console.log(` note restart ${a.display} fully; skills load at startup`);
|
|
374
418
|
if (a.broken) console.log(` note \`npx skills add -a ${agent} -g\` writes to ~/.agents/skills instead`);
|
|
375
419
|
console.log();
|
|
376
420
|
}
|
|
421
|
+
|
|
422
|
+
// One line per skill: the counts, then every failing path underneath it. A
|
|
423
|
+
// summary that hid which path failed would need a second run to be actionable.
|
|
424
|
+
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) {
|
|
429
|
+
console.log(` x ${b.target}`);
|
|
430
|
+
for (const c of b.checks) if (!c.pass) console.log(` ${c.name}: ${c.detail}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { name: skillName, failed: skillFailed, skipped: skillSkipped };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------- main
|
|
437
|
+
|
|
438
|
+
const argv = process.argv.slice(2);
|
|
439
|
+
if (!argv.length || argv.includes("-h") || argv.includes("--help")) {
|
|
440
|
+
console.log(`
|
|
441
|
+
skill-land <source> --for <agent> [options]
|
|
442
|
+
|
|
443
|
+
<source> owner/repo | github URL | raw .md URL | local path
|
|
444
|
+
|
|
445
|
+
Options
|
|
446
|
+
--for <agent> target agent (repeatable, or comma-separated)
|
|
447
|
+
--skill <name> which skill, when the source contains several
|
|
448
|
+
--all every skill in the source, not just one
|
|
449
|
+
--verbose per-skill detail when --all (default: one line each)
|
|
450
|
+
--project install into ./ instead of the user-global dir
|
|
451
|
+
--dry-run resolve and report, write nothing
|
|
452
|
+
--verify check an existing install, write nothing
|
|
453
|
+
--skip-security skip the SkillSpector scan (acknowledged, not silent)
|
|
454
|
+
--strict refuse to install anything SkillSpector flags
|
|
455
|
+
--list show known agents and their directories
|
|
456
|
+
|
|
457
|
+
Verifies the file landed where the agent actually reads. Exits non-zero if not.
|
|
458
|
+
`);
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (argv.includes("--list")) {
|
|
463
|
+
console.log("\n agent directory upstream");
|
|
464
|
+
console.log(" " + "-".repeat(70));
|
|
465
|
+
for (const [k, a] of Object.entries(AGENTS)) {
|
|
466
|
+
console.log(` ${k.padEnd(17)} ${a.global.padEnd(38)} ${a.broken ? "BROKEN" : "ok"}`);
|
|
467
|
+
}
|
|
468
|
+
console.log();
|
|
469
|
+
process.exit(0);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const flag = (n) => argv.includes(n);
|
|
473
|
+
const values = (n) => argv.reduce((acc, a, i) =>
|
|
474
|
+
a === n && argv[i + 1] ? acc.concat(argv[i + 1].split(",")) : acc, []);
|
|
475
|
+
|
|
476
|
+
// Flags that consume the next argument; their values are not the source.
|
|
477
|
+
const VALUE_FLAGS = new Set(["--for", "--skill"]);
|
|
478
|
+
const source = argv.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(argv[i - 1]));
|
|
479
|
+
if (!source) die("no source given");
|
|
480
|
+
|
|
481
|
+
const targets = values("--for").map((s) => s.trim()).filter(Boolean);
|
|
482
|
+
if (!targets.length) die(`no --for <agent> given\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
483
|
+
for (const t of targets) if (!AGENTS[t]) die(`unknown agent "${t}"\n known: ${Object.keys(AGENTS).join(", ")}`);
|
|
484
|
+
|
|
485
|
+
const isProject = flag("--project");
|
|
486
|
+
const dryRun = flag("--dry-run");
|
|
487
|
+
const verifyOnly = flag("--verify");
|
|
488
|
+
const skipSecurity = flag("--skip-security");
|
|
489
|
+
const strict = flag("--strict");
|
|
490
|
+
const allSkills = flag("--all");
|
|
491
|
+
const verbose = flag("--verbose");
|
|
492
|
+
|
|
493
|
+
// --skill names one, --all takes every one. Asking for both is a contradiction
|
|
494
|
+
// about intent, not something to resolve by precedence.
|
|
495
|
+
if (allSkills && values("--skill").length)
|
|
496
|
+
die("--all and --skill are mutually exclusive: --skill names one, --all takes every one");
|
|
497
|
+
|
|
498
|
+
const src = resolveSource(source);
|
|
499
|
+
const tmp = mkdtempSync(join(tmpdir(), "skill-install-"));
|
|
500
|
+
let failed = 0;
|
|
501
|
+
const skipped = [];
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const root = fetchSource(src, tmp);
|
|
505
|
+
const skillPaths = allSkills ? selectAll(root) : [selectSkill(root, values("--skill")[0])];
|
|
506
|
+
|
|
507
|
+
// With --all the per-skill detail becomes a wall nobody reads, and an unread
|
|
508
|
+
// report verifies nothing. Collapse to one line each unless asked otherwise.
|
|
509
|
+
const quiet = allSkills && !verbose;
|
|
510
|
+
if (quiet) console.log(`\n source ${src.label}\n skills ${skillPaths.length} found`);
|
|
511
|
+
|
|
512
|
+
for (const skillPath of skillPaths) {
|
|
513
|
+
const r = runSkill(skillPath, root, quiet);
|
|
514
|
+
failed += r.failed;
|
|
515
|
+
if (r.skipped) skipped.push(r.name);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (allSkills) {
|
|
519
|
+
const n = skillPaths.length;
|
|
520
|
+
console.log(` ${n} skill${n === 1 ? "" : "s"}, ${failed} failed${skipped.length ? `, ${skipped.length} skipped by --strict` : ""}`);
|
|
521
|
+
if (skipped.length) console.log(` skipped ${skipped.join(", ")}`);
|
|
522
|
+
console.log();
|
|
523
|
+
}
|
|
377
524
|
} finally {
|
|
378
525
|
rmSync(tmp, { recursive: true, force: true });
|
|
379
526
|
}
|