@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 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
+ }