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 +21 -0
- package/README.md +188 -0
- package/agents.json +87 -0
- package/package.json +39 -0
- package/skill-land.mjs +385 -0
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`);
|