@rckflr/llms-skills 0.1.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 +90 -0
- package/bin/llms-skills.mjs +205 -0
- package/lib/core.mjs +282 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @rckflr/llms-skills
|
|
2
|
+
|
|
3
|
+
One-command publisher CLI for the [llms.txt Skills](https://github.com/MauricioPerera/llms-txt-skills)
|
|
4
|
+
standard. Scaffold a skill, then let the CLI compute the `sha256`, render the
|
|
5
|
+
`## Skills` section into your `llms.txt`, sync `.well-known/agent-skills/index.json`,
|
|
6
|
+
and (optionally) sign — so you never touch the seven artifacts by hand.
|
|
7
|
+
|
|
8
|
+
It produces artifacts **byte-identical** to the reference Python generator
|
|
9
|
+
(`scripts/generate.py`), enforced by `cli/test.mjs`. What you publish is exactly
|
|
10
|
+
what a runtime — [mcpwasm](https://github.com/MauricioPerera/mcpwasm), the MCP
|
|
11
|
+
server, agents.txt — verifies. **Zero dependencies** (ed25519 and hashing via
|
|
12
|
+
Node's built-in `crypto`).
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @rckflr/llms-skills <command>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## The 2-minute path (L0)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @rckflr/llms-skills init my-skill # scaffolds skills/my-skill/SKILL.md + a manifest
|
|
22
|
+
# …edit the SKILL.md (frontmatter + instructions) and set the manifest summary…
|
|
23
|
+
npx @rckflr/llms-skills publish # writes ## Skills into llms.txt + index.json
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That's it — an agent reading your `llms.txt` now discovers the skill. Everything
|
|
27
|
+
below is optional hardening you climb into when your risk model asks for it (see
|
|
28
|
+
the [adoption ladder](../docs/adoption.md)).
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | What it does |
|
|
33
|
+
| :--- | :--- |
|
|
34
|
+
| `init <name> [--tool]` | Scaffold `skills/<name>/SKILL.md` (and `tool.js` with `--tool` for an executable skill), and add the entry to `llms-skills.json`. |
|
|
35
|
+
| `publish [--check]` | Read the manifest, hash every `SKILL.md`/`tool.js`, render `## Skills` into `llms.txt`, write `index.json`, and sign if the manifest has a `signing` block. `--check` writes nothing and exits non-zero on drift — the CI guard. |
|
|
36
|
+
| `validate <src> [--strict]` | Validate an `llms.txt` (local path or `https://` URL) and its skills: section shape, `sha256`/`tool_sha256` match, frontmatter, memory line. |
|
|
37
|
+
| `keygen [--out <file>]` | Generate an ed25519 signing keypair. Keep the private key **offline** (never commit it); the public key is embedded in `index.json` on publish. |
|
|
38
|
+
|
|
39
|
+
## Executable skills (L2)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx @rckflr/llms-skills init fetch-quote --tool # also scaffolds tool.js
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`tool.js` runs verbatim inside a sandbox (QuickJS-wasm in mcpwasm); its only
|
|
46
|
+
bridge out is the host capabilities the runtime injects (`host.fetchOrigin`,
|
|
47
|
+
scoped to your origin). `publish` computes `tool_sha256` and carries `tool` +
|
|
48
|
+
`tool_sha256` in both the inline metadata and `index.json`.
|
|
49
|
+
|
|
50
|
+
## Signing / attestation (L3)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx @rckflr/llms-skills keygen --out signing.key # once; keep signing.key offline
|
|
54
|
+
# add to the manifest: "signing": { "private_key_path": "signing.key" }
|
|
55
|
+
npx @rckflr/llms-skills publish # now every skill is ed25519-signed
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The signature covers the CRLF-normalized `SKILL.md` bytes; consumers pin the
|
|
59
|
+
public key per origin (TOFU). A server compromise *without* the offline key
|
|
60
|
+
cannot forge valid signatures. For identity-bound, keyless provenance, the
|
|
61
|
+
standard also defines Sigstore attestations (see the
|
|
62
|
+
[Skill Attestations](../docs/ext-skill-attestations.md) extension).
|
|
63
|
+
|
|
64
|
+
## Manifest (`llms-skills.json`)
|
|
65
|
+
|
|
66
|
+
`init` creates and maintains it; you rarely edit it by hand beyond the summaries.
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"section_intro": "Remote Agent Skills published by this domain.",
|
|
71
|
+
"signing": { "private_key_path": "signing.key" },
|
|
72
|
+
"published": [
|
|
73
|
+
{ "path": "skills/my-skill/SKILL.md", "url": "/skills/my-skill/SKILL.md", "summary": "what it does" }
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`section_intro` and `signing` are optional. Your `llms.txt` is the source of
|
|
79
|
+
truth; `index.json` is a **derived** artifact the CLI keeps in sync.
|
|
80
|
+
|
|
81
|
+
## CI
|
|
82
|
+
|
|
83
|
+
Run `publish --check` on every push to fail the build if the published artifacts
|
|
84
|
+
drift from the sources:
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
- run: npx @rckflr/llms-skills publish --check
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
MIT. Part of the [llms-txt-skills](https://github.com/MauricioPerera/llms-txt-skills) project.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// llms-skills — one-command publisher CLI for the llms.txt Skills standard.
|
|
3
|
+
//
|
|
4
|
+
// llms-skills init <name> scaffold a SKILL.md (+ optional tool.js) and manifest entry
|
|
5
|
+
// llms-skills publish regenerate ## Skills in llms.txt + .well-known/index.json (+ sign)
|
|
6
|
+
// llms-skills validate <src> validate an llms.txt (local path or URL) and its skills
|
|
7
|
+
// llms-skills keygen generate an ed25519 signing keypair (keep the key offline)
|
|
8
|
+
//
|
|
9
|
+
// Produces byte-identical artifacts to scripts/generate.py (enforced by cli/test.mjs),
|
|
10
|
+
// so what you publish is exactly what a runtime (mcpwasm, the MCP server, agents.txt) verifies.
|
|
11
|
+
// Zero external dependencies.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join, dirname, relative } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
loadSkills, loadMemory, renderSkillsSection, renderSkillsMemoryLine, renderLlmsTxt,
|
|
17
|
+
renderIndex, loadSigningKey, signSkills, publicKeyRawB64, generateKeyPair, validateLlmsTxt,
|
|
18
|
+
} from "../lib/core.mjs";
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const cmd = args[0];
|
|
22
|
+
|
|
23
|
+
function flag(name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
|
|
24
|
+
function has(name) { return args.includes(name); }
|
|
25
|
+
function die(msg) { console.error(msg); process.exit(1); }
|
|
26
|
+
|
|
27
|
+
const USAGE = `llms-skills — publisher CLI for the llms.txt Skills standard
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
llms-skills init <name> [--tool] [--root <dir>] scaffold SKILL.md (+ tool.js) + manifest entry
|
|
31
|
+
llms-skills publish [--check] [--manifest <p>] [--root <dir>] generate llms.txt ## Skills + index.json
|
|
32
|
+
llms-skills validate <src> [--strict] validate an llms.txt (path or URL)
|
|
33
|
+
llms-skills keygen [--out <keyfile>] generate an ed25519 signing keypair
|
|
34
|
+
|
|
35
|
+
The minimal case (L0) needs no signing key and no tool.js — just init + publish.
|
|
36
|
+
Add --tool for an executable skill; add a "signing" block to the manifest for L3 attestation.`;
|
|
37
|
+
|
|
38
|
+
function findManifest(root) {
|
|
39
|
+
const cands = [flag("--manifest"), "llms-skills.json", "scripts/skills-manifest.json"].filter(Boolean);
|
|
40
|
+
for (const c of cands) { const p = join(root, c); if (existsSync(p)) return p; }
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- init ----
|
|
45
|
+
function cmdInit() {
|
|
46
|
+
const name = args[1];
|
|
47
|
+
if (!name || name.startsWith("--")) die("init: needs a skill name, e.g. `llms-skills init my-skill`");
|
|
48
|
+
const root = flag("--root") || process.cwd();
|
|
49
|
+
const withTool = has("--tool");
|
|
50
|
+
|
|
51
|
+
const skillDir = join(root, "skills", name);
|
|
52
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
53
|
+
if (existsSync(skillPath)) die(`init: ${skillPath} already exists (refusing to overwrite)`);
|
|
54
|
+
mkdirSync(skillDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
writeFileSync(skillPath,
|
|
57
|
+
`---
|
|
58
|
+
name: ${name}
|
|
59
|
+
description: <one line — when an agent should reach for this skill>
|
|
60
|
+
version: 0.1.0
|
|
61
|
+
license: MIT
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
# ${name}
|
|
65
|
+
|
|
66
|
+
<Concrete instructions the agent follows to use your site/API. Name the exact
|
|
67
|
+
endpoints, parameters, and the shape of a correct call.>
|
|
68
|
+
`, "utf8");
|
|
69
|
+
console.log(`[WRITE] skills/${name}/SKILL.md`);
|
|
70
|
+
|
|
71
|
+
const entry = { path: `skills/${name}/SKILL.md`, url: `/skills/${name}/SKILL.md`, summary: `<what this skill does>` };
|
|
72
|
+
if (withTool) {
|
|
73
|
+
const toolPath = join(skillDir, "tool.js");
|
|
74
|
+
writeFileSync(toolPath,
|
|
75
|
+
`// tool.js — executed verbatim inside a QuickJS-wasm sandbox. Call registerTool once.
|
|
76
|
+
// The only bridge out of the sandbox is the host capabilities the runtime injects
|
|
77
|
+
// (e.g. host.fetchOrigin(path, init) — scoped to your origin).
|
|
78
|
+
registerTool({
|
|
79
|
+
name: "${name}",
|
|
80
|
+
description: "<what this tool does>",
|
|
81
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
82
|
+
async handler(args, host) {
|
|
83
|
+
// const res = await host.fetchOrigin("/api/...", { method: "GET" });
|
|
84
|
+
return { ok: true };
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
`, "utf8");
|
|
88
|
+
console.log(`[WRITE] skills/${name}/tool.js`);
|
|
89
|
+
entry.tool = `skills/${name}/tool.js`;
|
|
90
|
+
entry.tool_url = `/skills/${name}/tool.js`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const manifestPath = join(root, "llms-skills.json");
|
|
94
|
+
let manifest = existsSync(manifestPath)
|
|
95
|
+
? JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
96
|
+
: { section_intro: "Remote Agent Skills published by this domain.", published: [] };
|
|
97
|
+
if (!manifest.published) manifest.published = [];
|
|
98
|
+
if (manifest.published.some((e) => e.path === entry.path)) {
|
|
99
|
+
console.log(`[skip] manifest already lists ${entry.path}`);
|
|
100
|
+
} else {
|
|
101
|
+
manifest.published.push(entry);
|
|
102
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
103
|
+
console.log(`[WRITE] llms-skills.json (added ${name})`);
|
|
104
|
+
}
|
|
105
|
+
console.log(`\nNext: fill in the SKILL.md${withTool ? " and tool.js" : ""}, then run llms-skills publish`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- publish ----
|
|
109
|
+
function cmdPublish() {
|
|
110
|
+
const root = flag("--root") || process.cwd();
|
|
111
|
+
const manifestPath = findManifest(root);
|
|
112
|
+
if (!manifestPath) die("publish: no manifest found (looked for llms-skills.json / scripts/skills-manifest.json). Run `llms-skills init` first.");
|
|
113
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
114
|
+
|
|
115
|
+
const skills = loadSkills(manifest, root);
|
|
116
|
+
const memory = loadMemory(manifest, root);
|
|
117
|
+
const section = renderSkillsSection(manifest, skills);
|
|
118
|
+
const memoryLine = renderSkillsMemoryLine(memory);
|
|
119
|
+
|
|
120
|
+
const llmsTxtPath = join(root, "llms.txt");
|
|
121
|
+
const currentLlms = existsSync(llmsTxtPath) ? readFileSync(llmsTxtPath, "utf8") : "# " + (manifest.title || "My site") + "\n";
|
|
122
|
+
const newLlms = renderLlmsTxt(currentLlms, section, memoryLine);
|
|
123
|
+
|
|
124
|
+
const privateKey = loadSigningKey(manifest, root);
|
|
125
|
+
let signingKeyB64 = null;
|
|
126
|
+
if (privateKey) { signSkills(skills, privateKey); signingKeyB64 = publicKeyRawB64(privateKey); }
|
|
127
|
+
const newIndex = renderIndex(skills, signingKeyB64);
|
|
128
|
+
|
|
129
|
+
const indexPath = join(root, ".well-known", "agent-skills", "index.json");
|
|
130
|
+
const pubKeyPath = join(root, ".well-known", "agent-skills", "signing-key.pub");
|
|
131
|
+
const targets = [[llmsTxtPath, newLlms], [indexPath, newIndex]];
|
|
132
|
+
if (signingKeyB64) targets.push([pubKeyPath, signingKeyB64 + "\n"]);
|
|
133
|
+
|
|
134
|
+
const check = has("--check");
|
|
135
|
+
const rel = (p) => (relative(root, p) || p).replaceAll("\\", "/");
|
|
136
|
+
// Newline-insensitive comparison (matches generate.py, which reads via Python's
|
|
137
|
+
// universal-newline mode): the generated content is LF; a file checked out with
|
|
138
|
+
// CRLF (git autocrlf on Windows) is not real drift.
|
|
139
|
+
const norm = (s) => s.replaceAll("\r\n", "\n");
|
|
140
|
+
const drift = targets.filter(([p, c]) => (existsSync(p) ? norm(readFileSync(p, "utf8")) : null) !== norm(c));
|
|
141
|
+
if (check) {
|
|
142
|
+
if (drift.length) {
|
|
143
|
+
console.error("[DRIFT] Out-of-date files. Run: llms-skills publish");
|
|
144
|
+
for (const [p] of drift) console.error(` - ${rel(p)}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
console.log("[OK] Everything in sync.");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const [p, c] of targets) {
|
|
151
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
152
|
+
writeFileSync(p, c, "utf8");
|
|
153
|
+
console.log(`[WRITE] ${rel(p)}`);
|
|
154
|
+
}
|
|
155
|
+
console.log(`\n${skills.length} skill(s) published${signingKeyB64 ? " (signed)" : ""}.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- validate ----
|
|
159
|
+
async function cmdValidate() {
|
|
160
|
+
const src = args[1];
|
|
161
|
+
if (!src || src.startsWith("--")) die("validate: needs a path or URL to an llms.txt");
|
|
162
|
+
let text;
|
|
163
|
+
if (/^https?:\/\//.test(src)) {
|
|
164
|
+
const res = await fetch(src);
|
|
165
|
+
if (!res.ok) die(`validate: fetch failed: ${res.status} ${res.statusText}`);
|
|
166
|
+
text = await res.text();
|
|
167
|
+
} else {
|
|
168
|
+
if (!existsSync(src)) die(`validate: file not found: ${src}`);
|
|
169
|
+
text = readFileSync(src, "utf8");
|
|
170
|
+
}
|
|
171
|
+
const { errors, warnings } = validateLlmsTxt(src, text);
|
|
172
|
+
for (const e of errors) { console.log(`[ERROR] ${e.message}`); if (e.line) console.log(` line: ${e.line}`); }
|
|
173
|
+
for (const w of warnings) { console.log(`[WARNING] ${w.message}`); if (w.line) console.log(` line: ${w.line}`); }
|
|
174
|
+
if (!errors.length && !warnings.length) console.log("[OK] Valid. No errors or warnings.");
|
|
175
|
+
else if (!errors.length) console.log(`[OK] Valid with ${warnings.length} warning(s).`);
|
|
176
|
+
else console.log(`[FAIL] ${errors.length} error(s), ${warnings.length} warning(s).`);
|
|
177
|
+
if (errors.length || (has("--strict") && warnings.length)) process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- keygen ----
|
|
181
|
+
function cmdKeygen() {
|
|
182
|
+
const out = flag("--out") || "llms-skills-signing.key";
|
|
183
|
+
if (existsSync(out)) die(`keygen: ${out} already exists (refusing to overwrite)`);
|
|
184
|
+
const { privatePem, publicB64 } = generateKeyPair();
|
|
185
|
+
writeFileSync(out, privatePem, { encoding: "utf8", mode: 0o600 });
|
|
186
|
+
console.log(`[WRITE] ${out} (ed25519 private key — keep it OFFLINE, never commit it)`);
|
|
187
|
+
console.log(`public key (base64): ${publicB64}`);
|
|
188
|
+
console.log(`\nTo sign on publish, add to your manifest:\n "signing": { "private_key_path": "${out}" }\nand keep ${out} out of git (.gitignore).`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- dispatch ----
|
|
192
|
+
(async () => {
|
|
193
|
+
try {
|
|
194
|
+
switch (cmd) {
|
|
195
|
+
case "init": cmdInit(); break;
|
|
196
|
+
case "publish": cmdPublish(); break;
|
|
197
|
+
case "validate": await cmdValidate(); break;
|
|
198
|
+
case "keygen": cmdKeygen(); break;
|
|
199
|
+
case "-h": case "--help": case "help": case undefined: console.log(USAGE); break;
|
|
200
|
+
default: die(`Unknown command: ${cmd}\n\n${USAGE}`);
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
die(`Error: ${e.message}`);
|
|
204
|
+
}
|
|
205
|
+
})();
|
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// core.mjs — pure functions behind the llms-skills CLI.
|
|
2
|
+
//
|
|
3
|
+
// These reproduce, byte-for-byte, what scripts/generate.py and scripts/validate.py
|
|
4
|
+
// produce, so a publisher can generate/verify the exact artifacts a runtime (mcpwasm,
|
|
5
|
+
// the MCP server, agents.txt) already consumes — without cloning this repo or having
|
|
6
|
+
// Python. The byte-identity is enforced by cli/test.mjs against the Python output.
|
|
7
|
+
//
|
|
8
|
+
// No external dependencies: ed25519 via node:crypto, hashing via node:crypto.
|
|
9
|
+
|
|
10
|
+
import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign, generateKeyPairSync } from "node:crypto";
|
|
11
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { join, dirname, resolve as pathResolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
// ---- hashing (CRLF normalized to LF, identical to generate.py/validate.py) ----
|
|
15
|
+
export function sha256NormalizedBuf(buf) {
|
|
16
|
+
// latin1 is a 1:1 byte<->char map, so this CRLF->LF replace is byte-exact.
|
|
17
|
+
const normalized = Buffer.from(buf.toString("latin1").replaceAll("\r\n", "\n"), "latin1");
|
|
18
|
+
return createHash("sha256").update(normalized).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
export function sha256NormalizedFile(path) {
|
|
21
|
+
return sha256NormalizedBuf(readFileSync(path));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- YAML frontmatter (flat key: value, same subset generate.py handles) ----
|
|
25
|
+
export function parseFrontmatter(text) {
|
|
26
|
+
if (!text.startsWith("---")) return {};
|
|
27
|
+
const parts = text.split("---");
|
|
28
|
+
if (parts.length < 3) return {};
|
|
29
|
+
const body = parts[1].trim();
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const line of body.split(/\r?\n/)) {
|
|
32
|
+
const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/);
|
|
33
|
+
if (m) result[m[1]] = m[2].trim().replace(/^"|"$/g, "");
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const REQUIRED_FRONTMATTER = ["name", "description", "version", "license"];
|
|
39
|
+
|
|
40
|
+
// ---- load skills from a manifest (mirrors generate.py load_skills) ----
|
|
41
|
+
export function loadSkills(manifest, repoRoot) {
|
|
42
|
+
const skills = [];
|
|
43
|
+
for (const entry of manifest.published) {
|
|
44
|
+
const path = join(repoRoot, entry.path);
|
|
45
|
+
if (!existsSync(path)) throw new Error(`SKILL.md not found: ${entry.path}`);
|
|
46
|
+
const fm = parseFrontmatter(readFileSync(path, "utf8"));
|
|
47
|
+
for (const key of REQUIRED_FRONTMATTER) {
|
|
48
|
+
if (!(key in fm)) throw new Error(`${entry.path}: frontmatter missing '${key}'`);
|
|
49
|
+
}
|
|
50
|
+
let toolUrl = null, toolSha256 = null;
|
|
51
|
+
if (entry.tool) {
|
|
52
|
+
if (!entry.tool_url) throw new Error(`${entry.path}: declares 'tool' without 'tool_url' (both required together)`);
|
|
53
|
+
const toolPath = join(repoRoot, entry.tool);
|
|
54
|
+
if (!existsSync(toolPath)) throw new Error(`tool.js not found: ${entry.tool}`);
|
|
55
|
+
toolUrl = entry.tool_url;
|
|
56
|
+
toolSha256 = sha256NormalizedFile(toolPath);
|
|
57
|
+
}
|
|
58
|
+
skills.push({
|
|
59
|
+
name: fm.name, description: fm.description, version: fm.version, license: fm.license,
|
|
60
|
+
homepage: fm.homepage || null, url: entry.url, summary: entry.summary,
|
|
61
|
+
sha256: sha256NormalizedFile(path), path, toolUrl, toolSha256,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return skills;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function loadMemory(manifest, repoRoot) {
|
|
68
|
+
const memory = manifest.memory;
|
|
69
|
+
if (!memory) return null;
|
|
70
|
+
for (const key of ["snapshot_path", "snapshot_url", "format"]) {
|
|
71
|
+
if (!memory[key]) throw new Error(`manifest.memory needs '${key}'`);
|
|
72
|
+
}
|
|
73
|
+
const snapshotPath = join(repoRoot, memory.snapshot_path);
|
|
74
|
+
if (!existsSync(snapshotPath)) throw new Error(`snapshot not found: ${memory.snapshot_path}`);
|
|
75
|
+
return { snapshot_url: memory.snapshot_url, format: memory.format, snapshot_sha256: sha256NormalizedFile(snapshotPath) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- render: ## Skills section (compact JSON, exact key order of generate.py) ----
|
|
79
|
+
export function renderSkillsSection(manifest, skills) {
|
|
80
|
+
const lines = ["## Skills", "", manifest.section_intro, ""];
|
|
81
|
+
for (const s of skills) {
|
|
82
|
+
const meta = { version: s.version, license: s.license, sha256: s.sha256 };
|
|
83
|
+
if (s.toolUrl && s.toolSha256) { meta.tool = s.toolUrl; meta.tool_sha256 = s.toolSha256; }
|
|
84
|
+
lines.push(`- [${s.name}](${s.url}): ${s.summary} <!-- skill: ${JSON.stringify(meta)} -->`);
|
|
85
|
+
}
|
|
86
|
+
return lines.join("\n") + "\n";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderSkillsMemoryLine(memory) {
|
|
90
|
+
if (!memory) return "";
|
|
91
|
+
const meta = { snapshot: memory.snapshot_url, snapshot_sha256: memory.snapshot_sha256, format: memory.format };
|
|
92
|
+
return `<!-- skills-memory: ${JSON.stringify(meta)} -->\n\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MEMORY_LINE_RE = /^<!--\s*skills-memory:\s*\{.*?\}\s*-->\s*$/;
|
|
96
|
+
|
|
97
|
+
export function renderLlmsTxt(current, section, memoryLine = "") {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const line of current.split(/\r?\n/)) {
|
|
100
|
+
if (/^##\s+skills\s*$/i.test(line.trim())) break;
|
|
101
|
+
if (MEMORY_LINE_RE.test(line.trim())) continue;
|
|
102
|
+
out.push(line);
|
|
103
|
+
}
|
|
104
|
+
while (out.length && out[out.length - 1].trim() === "") out.pop();
|
|
105
|
+
return out.join("\n") + "\n\n" + memoryLine + section;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- ed25519 (matches generate.py's raw-32-byte + PKCS8 handling) ----
|
|
109
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
110
|
+
|
|
111
|
+
export function privateKeyFromSeed(seed32) {
|
|
112
|
+
const der = Buffer.concat([PKCS8_ED25519_PREFIX, seed32]);
|
|
113
|
+
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
114
|
+
}
|
|
115
|
+
export function deriveDemoKey(seedStr) {
|
|
116
|
+
return privateKeyFromSeed(createHash("sha256").update(seedStr, "utf8").digest());
|
|
117
|
+
}
|
|
118
|
+
export function privateKeyFromPem(pem) {
|
|
119
|
+
return createPrivateKey({ key: pem, format: "pem" });
|
|
120
|
+
}
|
|
121
|
+
export function publicKeyRawB64(privateKey) {
|
|
122
|
+
const spki = createPublicKey(privateKey).export({ type: "spki", format: "der" });
|
|
123
|
+
return spki.subarray(spki.length - 32).toString("base64"); // last 32 bytes = raw ed25519 pubkey
|
|
124
|
+
}
|
|
125
|
+
export function signB64(privateKey, msgBuf) {
|
|
126
|
+
return cryptoSign(null, msgBuf, privateKey).toString("base64");
|
|
127
|
+
}
|
|
128
|
+
export function generateKeyPair() {
|
|
129
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
130
|
+
return {
|
|
131
|
+
privatePem: privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
132
|
+
publicB64: publicKeyRawB64(privateKey),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// signing config: {private_key_path} (real, offline) or {demo_seed} (reproducible demo)
|
|
137
|
+
export function loadSigningKey(manifest, repoRoot) {
|
|
138
|
+
const signing = manifest.signing;
|
|
139
|
+
if (!signing) return null;
|
|
140
|
+
if (signing.private_key_path) return privateKeyFromPem(readFileSync(join(repoRoot, signing.private_key_path)));
|
|
141
|
+
if (signing.demo_seed) return deriveDemoKey(signing.demo_seed);
|
|
142
|
+
throw new Error("manifest.signing needs 'private_key_path' or 'demo_seed'");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function signSkills(skills, privateKey) {
|
|
146
|
+
for (const s of skills) {
|
|
147
|
+
const normalized = Buffer.from(readFileSync(s.path).toString("latin1").replaceAll("\r\n", "\n"), "latin1");
|
|
148
|
+
s.signature = signB64(privateKey, normalized);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- render: .well-known/agent-skills/index.json (superset of agentskills.io 0.2.0) ----
|
|
153
|
+
const AGENTSKILLS_DISCOVERY_SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
|
154
|
+
|
|
155
|
+
export function renderIndex(skills, signingKeyB64) {
|
|
156
|
+
const items = skills.map((s) => {
|
|
157
|
+
const item = { name: s.name, type: "skill-md", description: s.description, url: s.url, digest: `sha256:${s.sha256}` };
|
|
158
|
+
item.version = s.version;
|
|
159
|
+
item.license = s.license;
|
|
160
|
+
if (s.homepage) item.homepage = s.homepage;
|
|
161
|
+
item.sha256 = s.sha256;
|
|
162
|
+
if (s.toolUrl && s.toolSha256) { item.tool = s.toolUrl; item.tool_sha256 = s.toolSha256; }
|
|
163
|
+
if (s.signature) item.signature = s.signature;
|
|
164
|
+
return item;
|
|
165
|
+
});
|
|
166
|
+
const doc = { $schema: AGENTSKILLS_DISCOVERY_SCHEMA };
|
|
167
|
+
if (signingKeyB64) { doc.signing_alg = "ed25519"; doc.signing_key = signingKeyB64; }
|
|
168
|
+
doc.skills = items;
|
|
169
|
+
return JSON.stringify(doc, null, 2) + "\n";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- validate (port of validate.py validate_llms_txt) ----
|
|
173
|
+
function resolveSkillPath(skillUrl, source) {
|
|
174
|
+
if (/^https?:\/\//.test(skillUrl)) return skillUrl;
|
|
175
|
+
if (/^https?:\/\//.test(source)) return new URL(skillUrl, source).href;
|
|
176
|
+
const baseDir = dirname(source);
|
|
177
|
+
return pathResolve(baseDir, skillUrl.replace(/^\/+/, ""));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function validateLlmsTxt(source, text) {
|
|
181
|
+
const errors = [], warnings = [];
|
|
182
|
+
const err = (message, line = "") => errors.push({ message, line });
|
|
183
|
+
const warn = (message, line = "") => warnings.push({ message, line });
|
|
184
|
+
|
|
185
|
+
// origin memory line (optional)
|
|
186
|
+
const memMatch = text.split(/\r?\n/).map((l) => l.trim().match(MEMORY_CAPTURE_RE)).find(Boolean);
|
|
187
|
+
if (memMatch) validateMemory(memMatch[1], source, err, warn);
|
|
188
|
+
|
|
189
|
+
// find ## Skills section
|
|
190
|
+
const lines = text.split(/\r?\n/);
|
|
191
|
+
let inSkills = false;
|
|
192
|
+
const skillLines = [];
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
const t = line.trim();
|
|
195
|
+
if (/^##\s+skills\s*$/i.test(t)) { inSkills = true; continue; }
|
|
196
|
+
if (inSkills && /^##\s+/i.test(t)) break;
|
|
197
|
+
if (inSkills) skillLines.push(line);
|
|
198
|
+
}
|
|
199
|
+
if (!inSkills) { err("No ## Skills section found"); return { errors, warnings }; }
|
|
200
|
+
if (skillLines.every((l) => !l.trim())) warn("## Skills section exists but is empty");
|
|
201
|
+
|
|
202
|
+
// group items (lines starting with "- ")
|
|
203
|
+
const items = [];
|
|
204
|
+
let cur = null;
|
|
205
|
+
for (const line of skillLines) {
|
|
206
|
+
const t = line.trim();
|
|
207
|
+
if (!t) continue;
|
|
208
|
+
if (t.startsWith("- ")) { if (cur) items.push(cur); cur = [t]; }
|
|
209
|
+
else if (cur) cur.push(t);
|
|
210
|
+
}
|
|
211
|
+
if (cur) items.push(cur);
|
|
212
|
+
if (!items.length) warn("No items found in ## Skills");
|
|
213
|
+
|
|
214
|
+
for (const itemLines of items) {
|
|
215
|
+
const raw = itemLines.join(" ");
|
|
216
|
+
const m = raw.match(/^-\s*\[([^\]]+)\]\s*\(([^)]+)\)\s*:\s*([\s\S]+?)(?:\s*<!--\s*skill:\s*(\{[\s\S]*?\})\s*-->)?$/i);
|
|
217
|
+
if (!m) { err("Invalid skill entry format", raw.slice(0, 80)); continue; }
|
|
218
|
+
const url = m[2].trim(), desc = m[3].trim(), metaRaw = m[4];
|
|
219
|
+
let metaParsed = null;
|
|
220
|
+
if (metaRaw) {
|
|
221
|
+
try {
|
|
222
|
+
metaParsed = JSON.parse(metaRaw);
|
|
223
|
+
if ("version" in metaParsed && !/^\d+\.\d+\.\d+$/.test(String(metaParsed.version)))
|
|
224
|
+
warn(`Non-semantic version: ${metaParsed.version}`, raw.slice(0, 80));
|
|
225
|
+
if ("sha256" in metaParsed && !/^[a-fA-F0-9]{64}$/.test(String(metaParsed.sha256)))
|
|
226
|
+
err("Invalid SHA-256 (must be 64 hex chars)", raw.slice(0, 80));
|
|
227
|
+
const hasTool = "tool" in metaParsed, hasToolSha = "tool_sha256" in metaParsed;
|
|
228
|
+
if (hasTool && !hasToolSha) err("'tool' declared without 'tool_sha256' (both required together)", raw.slice(0, 80));
|
|
229
|
+
if (hasToolSha && !hasTool) err("'tool_sha256' declared without 'tool' (both required together)", raw.slice(0, 80));
|
|
230
|
+
if (hasToolSha && !/^[a-fA-F0-9]{64}$/.test(String(metaParsed.tool_sha256)))
|
|
231
|
+
err("Invalid tool_sha256 (must be 64 hex chars)", raw.slice(0, 80));
|
|
232
|
+
} catch (e) { err(`Invalid metadata JSON: ${e.message}`, raw.slice(0, 80)); }
|
|
233
|
+
}
|
|
234
|
+
const resolved = resolveSkillPath(url, source);
|
|
235
|
+
if (!/^https?:\/\//.test(resolved)) {
|
|
236
|
+
if (!existsSync(resolved)) { err(`Skill file not found: ${resolved}`, raw.slice(0, 80)); }
|
|
237
|
+
else {
|
|
238
|
+
if (metaParsed && metaParsed.sha256) {
|
|
239
|
+
const actual = sha256NormalizedFile(resolved);
|
|
240
|
+
if (actual !== metaParsed.sha256) err(`SHA-256 mismatch: declared ${metaParsed.sha256}, actual ${actual}`, raw.slice(0, 80));
|
|
241
|
+
}
|
|
242
|
+
if (metaParsed && metaParsed.tool && metaParsed.tool_sha256) {
|
|
243
|
+
const toolResolved = resolveSkillPath(metaParsed.tool, source);
|
|
244
|
+
if (!/^https?:\/\//.test(toolResolved)) {
|
|
245
|
+
if (!existsSync(toolResolved)) err(`tool.js not found: ${toolResolved}`, raw.slice(0, 80));
|
|
246
|
+
else {
|
|
247
|
+
const actual = sha256NormalizedFile(toolResolved);
|
|
248
|
+
if (actual !== metaParsed.tool_sha256) err(`tool_sha256 mismatch: declared ${metaParsed.tool_sha256}, actual ${actual}`, raw.slice(0, 80));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const fm = parseFrontmatter(readFileSync(resolved, "utf8"));
|
|
253
|
+
if (!Object.keys(fm).length) err("Skill without valid YAML frontmatter");
|
|
254
|
+
else for (const key of REQUIRED_FRONTMATTER) if (!(key in fm)) err(`Frontmatter missing '${key}'`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!desc || desc.length < 10) warn("Description too short", raw.slice(0, 80));
|
|
258
|
+
}
|
|
259
|
+
return { errors, warnings };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const MEMORY_CAPTURE_RE = /^<!--\s*skills-memory:\s*(.*?)\s*-->\s*$/;
|
|
263
|
+
function validateMemory(memJson, source, err, warn) {
|
|
264
|
+
let meta;
|
|
265
|
+
try { meta = JSON.parse(memJson); }
|
|
266
|
+
catch (e) { err(`skills-memory: invalid JSON: ${e.message}`); return; }
|
|
267
|
+
let ok = true;
|
|
268
|
+
for (const key of ["snapshot", "snapshot_sha256", "format"]) {
|
|
269
|
+
if (typeof meta[key] !== "string") { err(`skills-memory: missing or invalid '${key}' (must be string)`); ok = false; }
|
|
270
|
+
}
|
|
271
|
+
if (!ok) return;
|
|
272
|
+
if (!/^[a-fA-F0-9]{64}$/.test(meta.snapshot_sha256)) err("skills-memory: invalid snapshot_sha256 (must be 64 hex chars)");
|
|
273
|
+
if (meta.format !== "minimemory-okf-v1") warn(`skills-memory: format '${meta.format}' is not the only one recognized today (minimemory-okf-v1)`);
|
|
274
|
+
const resolved = resolveSkillPath(meta.snapshot, source);
|
|
275
|
+
if (!/^https?:\/\//.test(resolved)) {
|
|
276
|
+
if (!existsSync(resolved)) err(`skills-memory: snapshot not found: ${resolved}`);
|
|
277
|
+
else if (/^[a-fA-F0-9]{64}$/.test(meta.snapshot_sha256)) {
|
|
278
|
+
const actual = sha256NormalizedFile(resolved);
|
|
279
|
+
if (actual !== meta.snapshot_sha256) err(`skills-memory: snapshot_sha256 mismatch: declared ${meta.snapshot_sha256}, actual ${actual}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rckflr/llms-skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command publisher CLI for the llms.txt Skills standard: scaffold, hash, sign, and sync the ## Skills section + .well-known/agent-skills/index.json. Byte-identical to the reference Python generator; zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mauricio Perera <mauricio.perera@gmail.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MauricioPerera/llms-txt-skills.git",
|
|
11
|
+
"directory": "cli"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://mauricioperera.github.io/llms-txt-skills/",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/MauricioPerera/llms-txt-skills/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": ["llms.txt", "agent-skills", "skills", "mcp", "publisher", "cli", "ed25519", "sha256"],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"llms-skills": "bin/llms-skills.mjs"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./lib/core.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin/llms-skills.mjs",
|
|
29
|
+
"lib/core.mjs",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node test.mjs"
|
|
37
|
+
}
|
|
38
|
+
}
|