skill-land 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Magithar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # skill-land
2
+
3
+ Install an agent skill to the directory the agent **actually reads**, then verify it landed.
4
+ Exits non-zero if it didn't.
5
+
6
+ ```bash
7
+ npx skill-land Magithar/SKILLmama --for codex,antigravity
8
+ npx skill-land Magithar/SKILLmama --for codex --verify # audit an existing install
9
+ ```
10
+
11
+ ## Why this exists
12
+
13
+ `npx skills add -a <agent> -g` reports success while writing to the wrong directory
14
+ for 13 agents, including Codex, Antigravity, Cursor, GitHub Copilot, Gemini CLI and
15
+ opencode. Reproduced against `skills@1.5.22` (latest) on 2026-08-08:
16
+
17
+ ```
18
+ $ npx skills add Magithar/SKILLmama -a codex -g -y
19
+ copy → Codex
20
+ ✓ SKILLmama (copied)
21
+ → ~/.agents/skills/skillmama
22
+ Done!
23
+ $ echo $?
24
+ 0
25
+ $ ls ~/.codex/skills/
26
+ # empty. Codex never sees it.
27
+ ```
28
+
29
+ The correct path is in the CLI's own registry and never read. `isUniversalAgent()`
30
+ (`dist/cli.mjs:1678`) classifies any agent whose *project* directory is
31
+ `.agents/skills` as "universal", and `getAgentBaseDir()` then routes it to the
32
+ canonical store, discarding `globalSkillsDir`:
33
+
34
+ ```js
35
+ function isUniversalAgent(type) {
36
+ return agents[type].skillsDir === ".agents/skills";
37
+ }
38
+ ```
39
+
40
+ Every failure of this kind exits 0. Nothing in the ecosystem checks the install took.
41
+
42
+ A second, separate bug: `-a claude-code,antigravity` is rejected as "Invalid agents"
43
+ while both names appear in the valid list it prints.
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ npx skill-land <source> --for <agent> [options]
49
+ ```
50
+
51
+ `<source>` is `owner/repo`, a GitHub URL, a raw `.md` URL, or a local path.
52
+
53
+ | Option | Effect |
54
+ |---|---|
55
+ | `--for <agent>` | target agent; repeatable or comma-separated |
56
+ | `--skill <name>` | which skill, when the source contains several |
57
+ | `--project` | install into `./` instead of the user-global directory |
58
+ | `--dry-run` | resolve and report, write nothing |
59
+ | `--verify` | check an existing install, write nothing |
60
+ | `--skip-security` | skip the SkillSpector scan (acknowledged, not silent) |
61
+ | `--strict` | refuse to install anything SkillSpector flags |
62
+ | `--list` | show known agents and their directories |
63
+
64
+ ```bash
65
+ # install and verify
66
+ skill-land Magithar/SKILLmama --for codex,antigravity
67
+
68
+ # audit an install someone else did
69
+ skill-land Magithar/SKILLmama --for codex --verify
70
+ ```
71
+
72
+ ## What "verified" means
73
+
74
+ An install is not done until read-back confirms it:
75
+
76
+ 1. file exists at the target path
77
+ 2. non-empty
78
+ 3. byte-identical to the source
79
+ 4. frontmatter `name` matches the source
80
+
81
+ Any failure prints the specific check that failed and **exits non-zero**. A write that
82
+ throws (permissions, read-only directory) is reported as a failed install, not a crash.
83
+
84
+ Tested against: truncated file, wrong skill in the right place, missing file,
85
+ unwritable destination, and a real `npx skills add` install (correctly reported FAIL).
86
+
87
+ ## Antigravity
88
+
89
+ Antigravity loads skills at startup, so an install cannot be confirmed live. Two
90
+ candidate directories exist and both were created by Antigravity itself in the same
91
+ second:
92
+
93
+ ```
94
+ ~/.gemini/antigravity/skills what the skills CLI registry declares
95
+ ~/.gemini/config/skills confirmed working by live testing
96
+ ```
97
+
98
+ The `language_server` binary contains `skillsPaths` and `skills_paths`, so the search
99
+ path is a configurable list rather than a single directory. Until that list is pinned
100
+ down, `--for antigravity` writes to both and says so. Restart Antigravity fully after
101
+ installing.
102
+
103
+ ## Security scan
104
+
105
+ Skills run with full agent permissions, and this tool writes a file fetched from a URL into a
106
+ directory an agent auto-loads. So it scans before writing, delegating to
107
+ [NVIDIA/SkillSpector](https://github.com/NVIDIA/SkillSpector) rather than reimplementing it:
108
+
109
+ ```bash
110
+ uv tool install git+https://github.com/NVIDIA/skillspector.git
111
+ ```
112
+
113
+ **It reports, it does not block.** That is a measured decision, not a shortcut.
114
+
115
+ Scanning 18 known-good skills already installed on a dev machine (2026-08-08, SkillSpector
116
+ v2.8.1, `--no-llm`):
117
+
118
+ | Verdict | Count |
119
+ |---|---|
120
+ | SAFE | 1 |
121
+ | CAUTION | 9 |
122
+ | **DO_NOT_INSTALL** | **8 (44%)** |
123
+
124
+ SKILLmama's own `SKILL.md` scores **100 / CRITICAL / DO_NOT_INSTALL**, because its Phase 3.7
125
+ DISCARD rules contain the literal phrase *"instructions to bypass safety checks"* and the pattern
126
+ matcher reads that as an anti-refusal attack. A security checklist gets flagged for describing
127
+ the attacks it checks for.
128
+
129
+ A gate that blocks 44% of legitimate skills does not make anyone safer; it teaches people to pass
130
+ the override flag reflexively. So findings are surfaced with file and line, and the decision stays
131
+ with the user. `--strict` blocks on a flag for anyone who wants that.
132
+
133
+ | SkillSpector verdict | Default | `--strict` |
134
+ |---|---|---|
135
+ | `SAFE` | installs | installs |
136
+ | `CAUTION` | installs, findings shown | installs |
137
+ | `DO_NOT_INSTALL` | installs, findings shown | **refuses, exit 1** |
138
+ | scanner error | `treated as NOT scanned` | same |
139
+ | not installed | `NOT SCANNED` + install hint | same |
140
+
141
+ ### Baselines are reported, never applied
142
+
143
+ A skill repo can ship `.skillspector-baseline.yaml` to suppress findings. This installer scans
144
+ **without** it, deliberately: a baseline that travels with untrusted code would let a malicious
145
+ skill silence its own detections. SkillSpector does not auto-discover baselines either (verified:
146
+ a baseline in the scanned directory has no effect unless `--baseline` is passed), so honouring one
147
+ found in a fetched repo would be strictly less safe than the default.
148
+
149
+ Instead its presence is surfaced as a fact about the skill, which is itself a signal:
150
+
151
+ ```
152
+ security SAFE SAFE, LOW, score 7, 2 issue(s)
153
+ NOTE this repo ships a baseline suppressing 2 finding(s).
154
+ Scanned WITHOUT it: a baseline from an untrusted repo can hide real findings.
155
+ ```
156
+
157
+ `--no-llm` is always passed. Without it SkillSpector requires an LLM provider and exits 2 with no
158
+ credentials, which would silently degrade this gate to a no-op for anyone without an API key. A
159
+ check that did not run must never read as a check that passed.
160
+
161
+ ## Skill discovery
162
+
163
+ Walks up to 10 levels, skipping only `.git`, `node_modules`, `.venv` and
164
+ `__pycache__`, with a symlink-loop guard. Project-specific folder names are
165
+ deliberately not hardcoded: a repo whose skill lives in `assets/` is legal, and
166
+ skipping it produced a false "no SKILL.md found".
167
+
168
+ When a source contains more than one skill the tool **refuses to guess**. It lists
169
+ what it found and exits non-zero until you pass `--skill <name>`:
170
+
171
+ ```
172
+ $ skill-land product-on-purpose/pm-skills --for codex
173
+ error 78 skills found; refusing to guess.
174
+ Pick one with --skill <name>:
175
+ define-hypothesis ./skills/define-hypothesis/SKILL.md
176
+ ...
177
+ ```
178
+
179
+ ## Adding an agent
180
+
181
+ Edit `agents.json`. `broken: true` marks agents where the upstream CLI misroutes a
182
+ global install, which is what drives the warning note.
183
+
184
+ ## Scope
185
+
186
+ Install and verify only. No registry, no publishing, no per-platform content
187
+ transforms. One `SKILL.md` works across all of these agents; the failure mode here is
188
+ a wrong-path bug, not a content-format problem.
package/agents.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "_comment": "Per-agent skill directories. Verified 2026-08-07 against skills@1.5.18. Agents marked broken:true are ones where `npx skills add -a <agent> -g` reports success but writes to ~/.agents/skills instead of the directory below, because isUniversalAgent() (cli.mjs:1678) classifies any agent whose PROJECT dir is .agents/skills as universal and discards its globalSkillsDir.",
3
+ "agents": {
4
+ "claude-code": {
5
+ "display": "Claude Code",
6
+ "global": "~/.claude/skills",
7
+ "project": ".claude/skills",
8
+ "file": "SKILL.md",
9
+ "restart": false,
10
+ "broken": false
11
+ },
12
+ "codex": {
13
+ "display": "OpenAI Codex",
14
+ "global": "~/.codex/skills",
15
+ "project": ".agents/skills",
16
+ "file": "SKILL.md",
17
+ "restart": false,
18
+ "broken": true
19
+ },
20
+ "antigravity": {
21
+ "_paths": "Global dir is ~/.gemini/config/skills for ALL Antigravity surfaces (2.0, IDE, CLI); they consolidated config across the three clients. Sources: vercel-labs/skills#1470, where the agy binary's own docs give global root ~/.gemini/config/ with skills at skills/<name>/, and no ~/.gemini/antigravity-cli/skills string exists in the binary. Project dir <workspace>/.agents/skills confirmed by behavior 2026-08-08. The other two are written as fallbacks only, pending PR #1483 landing upstream.",
22
+ "display": "Antigravity",
23
+ "global": "~/.gemini/config/skills",
24
+ "alsoWrite": ["~/.gemini/antigravity/skills", "~/.gemini/skills"],
25
+ "project": ".agents/skills",
26
+ "file": "SKILL.md",
27
+ "restart": true,
28
+ "broken": true
29
+ },
30
+ "antigravity-cli": {
31
+ "display": "Antigravity CLI",
32
+ "global": "~/.gemini/antigravity-cli/skills",
33
+ "project": ".agents/skills",
34
+ "file": "SKILL.md",
35
+ "restart": true,
36
+ "broken": true
37
+ },
38
+ "gemini-cli": {
39
+ "display": "Gemini CLI",
40
+ "global": "~/.gemini/skills",
41
+ "project": ".agents/skills",
42
+ "file": "SKILL.md",
43
+ "restart": false,
44
+ "broken": true
45
+ },
46
+ "cursor": {
47
+ "display": "Cursor",
48
+ "global": "~/.cursor/skills",
49
+ "project": ".agents/skills",
50
+ "file": "SKILL.md",
51
+ "restart": false,
52
+ "broken": true
53
+ },
54
+ "github-copilot": {
55
+ "display": "GitHub Copilot",
56
+ "global": "~/.copilot/skills",
57
+ "project": ".agents/skills",
58
+ "file": "SKILL.md",
59
+ "restart": false,
60
+ "broken": true
61
+ },
62
+ "opencode": {
63
+ "display": "opencode",
64
+ "global": "~/.config/opencode/skills",
65
+ "project": ".agents/skills",
66
+ "file": "SKILL.md",
67
+ "restart": false,
68
+ "broken": true
69
+ },
70
+ "firebender": {
71
+ "display": "Firebender",
72
+ "global": "~/.firebender/skills",
73
+ "project": ".agents/skills",
74
+ "file": "SKILL.md",
75
+ "restart": false,
76
+ "broken": true
77
+ },
78
+ "amp": {
79
+ "display": "Amp",
80
+ "global": "~/.config/agents/skills",
81
+ "project": ".agents/skills",
82
+ "file": "SKILL.md",
83
+ "restart": false,
84
+ "broken": true
85
+ }
86
+ }
87
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "skill-land",
3
+ "version": "1.0.0",
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
+ "keywords": [
6
+ "agent-skills",
7
+ "claude-code",
8
+ "codex",
9
+ "antigravity",
10
+ "cursor",
11
+ "skills",
12
+ "install",
13
+ "verify"
14
+ ],
15
+ "homepage": "https://github.com/Magithar/skills/tree/main/tools/skill-land",
16
+ "bugs": {
17
+ "url": "https://github.com/Magithar/skills/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Magithar/skills.git",
22
+ "directory": "tools/skill-land"
23
+ },
24
+ "author": "Magithar",
25
+ "license": "MIT",
26
+ "type": "module",
27
+ "bin": {
28
+ "skill-land": "skill-land.mjs"
29
+ },
30
+ "files": [
31
+ "skill-land.mjs",
32
+ "agents.json",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ }
39
+ }
package/skill-land.mjs ADDED
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ // skill-land — install an agent skill and verify it actually landed.
3
+ //
4
+ // Exists because `npx skills add -a <agent> -g` reports success while writing to
5
+ // ~/.agents/skills for 13 agents (Codex, Antigravity, Cursor, Copilot, ...). The
6
+ // correct path is in its registry and never read: isUniversalAgent() (cli.mjs:1678)
7
+ // classifies any agent whose PROJECT dir is .agents/skills as "universal" and
8
+ // discards its globalSkillsDir. Every such failure exits 0.
9
+ //
10
+ // This tool owns the path table and refuses to exit 0 on an unverified install.
11
+
12
+ import { execFileSync } from "child_process";
13
+ import { createHash } from "crypto";
14
+ import {
15
+ cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync,
16
+ readFileSync, realpathSync, rmSync, statSync,
17
+ } from "fs";
18
+ import { homedir, tmpdir } from "os";
19
+ import { basename, dirname, join, resolve } from "path";
20
+ import { fileURLToPath } from "url";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ const { agents: AGENTS } = JSON.parse(readFileSync(join(HERE, "agents.json"), "utf-8"));
24
+
25
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
26
+ const expand = (p) => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
27
+ const die = (msg) => { console.error(`\n error ${msg}\n`); process.exit(1); };
28
+
29
+ // ---------------------------------------------------------------- source
30
+
31
+ // Accepts: owner/repo | https://github.com/owner/repo | raw .md URL | local path
32
+ function resolveSource(input) {
33
+ if (existsSync(input) && statSync(input).isDirectory())
34
+ return { kind: "dir", dir: resolve(input), label: resolve(input) };
35
+
36
+ if (/^https?:\/\/.*\.md(\?.*)?$/i.test(input))
37
+ return { kind: "raw", url: input, label: input };
38
+
39
+ const slug = input.replace(/^https?:\/\/github\.com\//i, "").replace(/\.git$/, "").replace(/\/$/, "");
40
+ if (!/^[\w.-]+\/[\w.-]+$/.test(slug))
41
+ die(`cannot parse source "${input}"\n expected owner/repo, a github URL, a raw .md URL, or a local path`);
42
+ return { kind: "repo", url: `https://github.com/${slug}.git`, label: slug };
43
+ }
44
+
45
+ function fetchSource(src, tmp) {
46
+ if (src.kind === "dir") return src.dir;
47
+ if (src.kind === "raw") {
48
+ const dest = join(tmp, "SKILL.md");
49
+ execFileSync("curl", ["-fsSL", src.url, "-o", dest], { stdio: "pipe" });
50
+ return tmp;
51
+ }
52
+ execFileSync("git", ["clone", "--depth", "1", "--quiet", src.url, tmp], { stdio: "pipe" });
53
+ return tmp;
54
+ }
55
+
56
+ // ---------------------------------------------------------------- discovery
57
+
58
+ // Only skip things that are never a skill anywhere. Project-specific folder
59
+ // names must not be hardcoded here: a repo whose skill lives in assets/ is
60
+ // perfectly legal, and skipping it produced a false "no SKILL.md found".
61
+ const NEVER_SKILLS = new Set([".git", "node_modules", ".venv", "__pycache__"]);
62
+ const MAX_DEPTH = 10;
63
+
64
+ function findSkills(root) {
65
+ const hits = [];
66
+ const seen = new Set();
67
+ (function walk(dir, depth) {
68
+ if (depth > MAX_DEPTH) return;
69
+ let real;
70
+ try { real = statSync(dir).ino + ":" + statSync(dir).dev; } catch { return; }
71
+ if (seen.has(real)) return; // symlink loop guard
72
+ seen.add(real);
73
+ let entries;
74
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
75
+ for (const e of entries) {
76
+ const p = join(dir, e.name);
77
+ if (e.isDirectory() && !NEVER_SKILLS.has(e.name)) { walk(p, depth + 1); continue; }
78
+ // isFile() is false for symlinks, and the skills CLI symlinks by default,
79
+ // so a symlinked SKILL.md must still count. Dedupe by real path below.
80
+ if (e.name !== "SKILL.md") continue;
81
+ if (e.isFile() || e.isSymbolicLink()) hits.push(p);
82
+ }
83
+ })(root, 0);
84
+
85
+ // A repo may symlink its own skill (e.g. .claude/skills → skillmama/). Those
86
+ // are the same skill, not two, so collapse by resolved target.
87
+ const byReal = new Map();
88
+ for (const p of hits) {
89
+ let key; try { key = realpathSync(p); } catch { key = p; }
90
+ const prev = byReal.get(key);
91
+ if (!prev || p.split("/").length < prev.split("/").length) byReal.set(key, p);
92
+ }
93
+ return [...byReal.values()].sort(
94
+ (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
95
+ }
96
+
97
+ // Never silently pick one skill out of many. A repo with 78 skills must not
98
+ // install an arbitrary one and report success.
99
+ function selectSkill(root, wanted) {
100
+ const hits = findSkills(root);
101
+ if (!hits.length)
102
+ die(`no SKILL.md found under ${root}\n searched ${MAX_DEPTH} levels deep`);
103
+
104
+ const named = hits.map((p) => ({
105
+ path: p,
106
+ name: parseFrontmatter(readFileSync(p, "utf-8")).name || basename(dirname(p)),
107
+ }));
108
+
109
+ if (wanted) {
110
+ const w = wanted.toLowerCase();
111
+ const match = named.filter((n) => n.name.toLowerCase() === w);
112
+ if (!match.length)
113
+ die(`no skill named "${wanted}" in ${root}\n available: ${named.map((n) => n.name).join(", ")}`);
114
+ if (match.length > 1)
115
+ die(`"${wanted}" is ambiguous, ${match.length} matches:\n ${match.map((m) => m.path.replace(root, ".")).join("\n ")}`);
116
+ return match[0].path;
117
+ }
118
+
119
+ if (named.length > 1) {
120
+ const shown = named.slice(0, 15).map((n) => ` ${n.name.padEnd(28)} ${n.path.replace(root, ".")}`);
121
+ die(
122
+ `${named.length} skills found in ${root}; refusing to guess.\n` +
123
+ ` Pick one with --skill <name>:\n` +
124
+ shown.join("\n") +
125
+ (named.length > 15 ? `\n ... and ${named.length - 15} more` : "")
126
+ );
127
+ }
128
+ return named[0].path;
129
+ }
130
+
131
+ function parseFrontmatter(text) {
132
+ const m = text.match(/^---\n([\s\S]*?)\n---\n/);
133
+ if (!m) return {};
134
+ const fm = {};
135
+ for (const line of m[1].split("\n")) {
136
+ const i = line.indexOf(":");
137
+ if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim();
138
+ }
139
+ return fm;
140
+ }
141
+
142
+ // ---------------------------------------------------------------- security
143
+
144
+ // Skills run with full agent permissions, and this tool writes a file fetched
145
+ // from a URL into a directory an agent auto-loads. So scan before writing.
146
+ //
147
+ // Delegates to NVIDIA/SkillSpector rather than reimplementing its pattern set.
148
+ // Exit codes per its docs: 0 = SAFE or CAUTION, 1 = DO_NOT_INSTALL, 2 = error.
149
+ //
150
+ // Borrowed from SKILLmama's own Phase 3.5 lesson: never let a check that did
151
+ // not run read as a check that passed. When the scanner is absent we say so
152
+ // loudly and mark the install unscanned, rather than silently continuing.
153
+ // --no-llm is not optional. Without it SkillSpector needs an LLM provider and
154
+ // exits 2 with no credentials, which would silently degrade this gate to a
155
+ // no-op for anyone without an API key.
156
+ function securityScan(skillPath) {
157
+ let out = "", code = 0;
158
+ try {
159
+ out = execFileSync("skillspector",
160
+ ["scan", skillPath, "--no-llm", "--format", "json"],
161
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024 });
162
+ } catch (e) {
163
+ if (e.code === "ENOENT") return { status: "unavailable" };
164
+ code = typeof e.status === "number" ? e.status : 2;
165
+ out = e.stdout || "";
166
+ }
167
+
168
+ let ra = null, issues = [];
169
+ try {
170
+ const j = JSON.parse(out);
171
+ ra = j.risk_assessment || null; // { score, severity, recommendation }
172
+ issues = Array.isArray(j.issues) ? j.issues : [];
173
+ } catch { /* fall through */ }
174
+
175
+ if (!ra) return { status: "error", detail: `skillspector exited ${code}, no parsable report` };
176
+
177
+ // A repo can ship .skillspector-baseline.yaml to suppress findings. We scan
178
+ // WITHOUT it deliberately: honouring a baseline that travels with untrusted
179
+ // code would let a malicious skill silence its own detections. Instead the
180
+ // baseline is surfaced as a fact about the skill, which is itself a signal.
181
+ let baseline = null;
182
+ for (const dir of [dirname(skillPath), join(dirname(skillPath), "..")]) {
183
+ const b = join(dir, ".skillspector-baseline.yaml");
184
+ if (existsSync(b)) {
185
+ const n = (readFileSync(b, "utf-8").match(/^\s*-\s*hash:/gm) || []).length;
186
+ baseline = { path: b, count: n };
187
+ break;
188
+ }
189
+ }
190
+
191
+ // Findings are reported, never used to block by default. Measured on this
192
+ // machine 2026-08-08: 8 of 18 known-good installed skills scored
193
+ // DO_NOT_INSTALL (44%), and SKILLmama's own SKILL.md scored 100/CRITICAL
194
+ // because its Phase 3.7 DISCARD rules literally contain the phrase
195
+ // "instructions to bypass safety checks". A gate that blocks half of all
196
+ // legitimate skills is worse than no gate: people learn to pass --force.
197
+ const top = [...issues]
198
+ .sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
199
+ .slice(0, 3)
200
+ .map((i) => `${i.id} ${i.severity} ${i.location?.file || "?"}:${i.location?.start_line ?? "?"}`);
201
+
202
+ return {
203
+ status: ra.recommendation === "DO_NOT_INSTALL" ? "flagged"
204
+ : ra.recommendation === "CAUTION" ? "caution" : "pass",
205
+ detail: `${ra.recommendation}, ${ra.severity}, score ${ra.score}, ${issues.length} issue(s)`,
206
+ top, baseline,
207
+ };
208
+ }
209
+
210
+ // ---------------------------------------------------------------- verify
211
+
212
+ // An install is not done until read-back confirms it. This is the whole point.
213
+ function verify(target, expected) {
214
+ const checks = [];
215
+ const ok = (name, pass, detail) => { checks.push({ name, pass, detail }); return pass; };
216
+
217
+ if (!ok("file exists", existsSync(target), target)) return checks;
218
+
219
+ // Compare the raw bytes by hash. An earlier version compared
220
+ // readFileSync(...,"utf-8").length, which is UTF-16 code units, not bytes:
221
+ // it read 25266 for a 25546-byte file and would pass two different files
222
+ // that happened to share a character count.
223
+ const raw = readFileSync(target);
224
+ ok("non-empty", raw.length > 0, `${raw.length} bytes`);
225
+ const gotHash = sha256(raw);
226
+ ok("content matches source", gotHash === expected.hash,
227
+ `${raw.length} bytes sha ${gotHash.slice(0, 12)} vs ${expected.bytes} bytes sha ${expected.hash.slice(0, 12)}`);
228
+
229
+ const fm = parseFrontmatter(raw.toString("utf-8"));
230
+ if (expected.name) {
231
+ ok("frontmatter name matches", fm.name === expected.name,
232
+ `found ${fm.name ? `"${fm.name}"` : "none"}, expected "${expected.name}"`);
233
+ } else {
234
+ ok("parses", true, "no frontmatter in source, skipped name check");
235
+ }
236
+ return checks;
237
+ }
238
+
239
+ // ---------------------------------------------------------------- main
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(", ")}`);
285
+
286
+ const isProject = flag("--project");
287
+ const dryRun = flag("--dry-run");
288
+ const verifyOnly = flag("--verify");
289
+ const skipSecurity = flag("--skip-security");
290
+ const strict = flag("--strict");
291
+
292
+ const src = resolveSource(source);
293
+ const tmp = mkdtempSync(join(tmpdir(), "skill-install-"));
294
+ let failed = 0;
295
+
296
+ try {
297
+ const root = fetchSource(src, tmp);
298
+ const skillPath = selectSkill(root, values("--skill")[0]);
299
+ const body = readFileSync(skillPath, "utf-8");
300
+ const fm = parseFrontmatter(body);
301
+ const skillName = (fm.name || "skill").toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
302
+ const rawBody = readFileSync(skillPath);
303
+ const expected = { bytes: rawBody.length, hash: sha256(rawBody), name: fm.name };
304
+
305
+ // Scan before any write. A blocked skill must never reach disk.
306
+ const scan = skipSecurity ? { status: "skipped" } : securityScan(skillPath);
307
+ const findings = (scan.top || []).length
308
+ ? "\n" + scan.top.map((t) => ` ${t}`).join("\n") : "";
309
+ // Reported, never applied. See securityScan().
310
+ const baselineNote = scan.baseline
311
+ ? `\n NOTE this repo ships a baseline suppressing ${scan.baseline.count} finding(s).`
312
+ + `\n Scanned WITHOUT it: a baseline from an untrusted repo can hide real findings.`
313
+ : "";
314
+ const SCAN_LINE = {
315
+ pass: () => ` security SAFE ${scan.detail}${baselineNote}`,
316
+ caution: () => ` security CAUTION ${scan.detail}${findings}${baselineNote}`,
317
+ flagged: () => ` security FLAGGED ${scan.detail}${findings}\n SkillSpector flags ~44% of known-good skills; review, don't just trust it.\n --strict to refuse installing flagged skills.${baselineNote}`,
318
+ error: () => ` security ERROR ${scan.detail} — treated as NOT scanned`,
319
+ unavailable: () => ` security NOT SCANNED skillspector not on PATH\n uv tool install git+https://github.com/NVIDIA/skillspector.git`,
320
+ skipped: () => ` security NOT SCANNED --skip-security given`,
321
+ };
322
+
323
+ console.log(`\n source ${src.label}`);
324
+ console.log(` skill ${fm.name || "(no frontmatter name)"} [${rawBody.length} bytes, sha ${expected.hash.slice(0,12)}]`);
325
+ console.log(` from ${skillPath.replace(root, ".")}`);
326
+ if (!verifyOnly) console.log(SCAN_LINE[scan.status]());
327
+ console.log("");
328
+
329
+ // Refuse to write a skill the scanner flagged. --force is an explicit,
330
+ // logged override, never a silent one.
331
+ if (!verifyOnly && strict && scan.status === "flagged") {
332
+ die(`--strict: SkillSpector reports ${scan.detail}\n Review the findings above. Drop --strict to install anyway.`);
333
+ }
334
+
335
+ for (const agent of targets) {
336
+ const a = AGENTS[agent];
337
+ const bases = isProject
338
+ ? [resolve(a.project)]
339
+ : [a.global, ...(a.alsoWrite || [])].map(expand);
340
+
341
+ console.log(` ${a.display}`);
342
+
343
+ for (const base of bases) {
344
+ const destDir = join(base, skillName);
345
+ const target = join(destDir, a.file);
346
+
347
+ if (dryRun) { console.log(` would write ${target}`); continue; }
348
+
349
+ // A write that throws is a failed install, not a crash. Report it the
350
+ // same way as a failed read-back so one exit path covers both.
351
+ let writeErr = null;
352
+ if (!verifyOnly) {
353
+ try {
354
+ mkdirSync(destDir, { recursive: true });
355
+ cpSync(skillPath, target);
356
+ } catch (e) {
357
+ writeErr = `${e.code || "error"}: ${e.message.split("\n")[0]}`;
358
+ }
359
+ }
360
+
361
+ const checks = writeErr
362
+ ? [{ name: "write", pass: false, detail: writeErr }]
363
+ : verify(target, expected);
364
+ const passed = checks.every((c) => c.pass);
365
+ if (!passed) failed++;
366
+
367
+ console.log(` ${passed ? "OK " : "FAIL"} ${target}${verifyOnly ? " (verify only)" : ""}`);
368
+ for (const c of checks) {
369
+ if (!c.pass) console.log(` x ${c.name}: ${c.detail}`);
370
+ }
371
+ }
372
+
373
+ if (!dryRun && a.restart) console.log(` note restart ${a.display} fully; skills load at startup`);
374
+ if (a.broken) console.log(` note \`npx skills add -a ${agent} -g\` writes to ~/.agents/skills instead`);
375
+ console.log();
376
+ }
377
+ } finally {
378
+ rmSync(tmp, { recursive: true, force: true });
379
+ }
380
+
381
+ if (failed) {
382
+ console.error(` ${failed} install(s) failed verification\n`);
383
+ process.exit(1);
384
+ }
385
+ if (!dryRun) console.log(` ${verifyOnly ? "verified existing install" : "verified"}\n`);