axiom-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,117 @@
1
+ # axiom (CLI)
2
+
3
+ Two ways to run it — pick one:
4
+
5
+ ```
6
+ npx axiom-skills <command> # zero install — must be the full package name, "npx axiom ..." resolves to a
7
+ # different, unrelated package (axiomhq's own SDK is published as "axiom")
8
+ npm install -g axiom-skills # then just: axiom <command>
9
+ ```
10
+
11
+ That distinction matters more than it looks: `npx <name>` fetches the
12
+ package *literally named* `<name>` — it doesn't search for "whichever
13
+ package happens to expose a bin called that." This package's bin is
14
+ named `axiom` for the short form after a global install, but `npx axiom
15
+ ...` would try to run the actual, unrelated `axiom` package that's
16
+ already published on npm (axiomhq's own SDK) — so `package.json` also
17
+ exposes a second bin, `axiom-skills`, matching the package's own name,
18
+ specifically so `npx axiom-skills ...` resolves correctly cold.
19
+
20
+ ```
21
+ axiom registry https://registry.acme.dev # once — no account needed
22
+ axiom find "browser testing"
23
+ axiom add acme/my-skill # -> .agents/skills/my-skill/
24
+ axiom add acme/my-skill@1.2.0 # pin a version
25
+ axiom install # reproduce a project's exact skill set
26
+ axiom update acme/my-skill # or `axiom update` for everything
27
+ axiom list
28
+
29
+ axiom register --invite <code> # or no --invite at all if you're the registry's first-ever account
30
+ axiom publish ./my-skill # reads ./my-skill/SKILL.md
31
+ axiom invite # admin only — mint a code for the next publisher
32
+ axiom whoami
33
+ ```
34
+
35
+ (Swap `axiom` for `npx axiom-skills` in any of the above if you'd rather
36
+ not install it globally — same commands, same flags.)
37
+
38
+ `SKILL.md` needs frontmatter with at least `name` and `description`:
39
+
40
+ ```markdown
41
+ ---
42
+ name: pdf-report-builder
43
+ description: Build branded PDF reports from a CSV or JSON data file.
44
+ version: 1.0.0
45
+ ---
46
+
47
+ # PDF Report Builder
48
+ ...
49
+ ```
50
+
51
+ ## Browsing and installing is public
52
+
53
+ `axiom find`, `axiom add`, `axiom install`, and `axiom update` don't need
54
+ an account — skills.sh-style. `axiom registry <url>` is the entire setup:
55
+ it just points the CLI at a registry, the way `git remote add origin`
56
+ points at a repo, no credentials involved.
57
+
58
+ ## Accounts — admin-controlled, not open self-service
59
+
60
+ `axiom register` isn't a free-for-all like a typical `npm adduser` — the
61
+ *first* account on a fresh registry becomes admin automatically (someone
62
+ has to be able to issue the first invite), and every account after that
63
+ needs a single-use invite code (`--invite <code>`, or the CLI prompts for
64
+ one). Handing out invites is `axiom invite` (admin only) — run it with no
65
+ flags to mint a fresh code, or `--list` to see which ones are outstanding
66
+ vs. already used. Trying to register without a valid invite (once the
67
+ registry already has an admin) comes back as a clear 400, not a silent
68
+ failure.
69
+
70
+ `axiom login` is for a second machine, or anyone else on the team who
71
+ already has credentials — by default it prompts for username/password
72
+ against the registry's `/v1/login`, or pass `--token` to use a pre-issued
73
+ token directly (handy for CI). Either way, the result is a registry URL +
74
+ token written to a local config file once — every later command already
75
+ knows where "origin" is, the same job `.git/config`'s `[remote "origin"]`
76
+ does. This is the *only* thing an account is for here: publishing needs an
77
+ owner to check ownership and immutability against, `axiom whoami` needs an
78
+ identity to report, `axiom invite` needs to know you're an admin —
79
+ browsing/installing needs none of it.
80
+
81
+ ## Reproducible installs — axiom-skills.json / axiom-skills-lock.json
82
+
83
+ `axiom add` doesn't just download a skill — it also records it, the same
84
+ way `npm install foo` touches both `package.json` and `package-lock.json`:
85
+
86
+ - **`axiom-skills.json`** — the manifest. What this project depends on,
87
+ hand-editable, meant to be committed. `{ "skills": { "acme/my-skill":
88
+ "1.2.0" } }`.
89
+ - **`axiom-skills-lock.json`** — the lockfile. The exact version *and* a
90
+ `sha256` hash of the tarball that was actually installed, written by the
91
+ CLI, also meant to be committed.
92
+
93
+ After cloning a project that has both files, run **`axiom install`**: it
94
+ downloads exactly what's in the lockfile and verifies each tarball's hash
95
+ before extracting it. If the registry ever served something that doesn't
96
+ match — a corrupted download, or a version that got re-resolved
97
+ differently than expected — install fails loudly instead of extracting
98
+ something nobody reviewed. It does *not* go looking for anything newer;
99
+ that's what `axiom update` is for.
100
+
101
+ **`axiom update [owner/name]`** re-resolves one skill (or all of them, with
102
+ no argument) to the registry's current latest — or to `--version <v>` if
103
+ you want to pin a specific one — and moves both files forward. Nothing
104
+ else touches the lockfile once it exists; `install` only ever reproduces
105
+ it, `update` is the one command that deliberately changes it.
106
+
107
+ ```
108
+ axiom add acme/my-skill # writes/updates axiom-skills.json + lock
109
+ git add axiom-skills.json axiom-skills-lock.json
110
+ git commit -m "add acme/my-skill"
111
+
112
+ # teammate, after cloning:
113
+ axiom install # gets exactly acme/my-skill@<locked version>
114
+
115
+ # later, deliberately:
116
+ axiom update acme/my-skill # -> latest, or --version 2.0.0 to pin
117
+ ```
package/bin/axiom.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { publish } from "../src/commands/publish.js";
4
+ import { add } from "../src/commands/add.js";
5
+ import { install } from "../src/commands/install.js";
6
+ import { update } from "../src/commands/update.js";
7
+ import { find } from "../src/commands/find.js";
8
+ import { list } from "../src/commands/list.js";
9
+ import { login } from "../src/commands/login.js";
10
+ import { register } from "../src/commands/register.js";
11
+ import { registry } from "../src/commands/registry.js";
12
+ import { invite } from "../src/commands/invite.js";
13
+ import { whoami } from "../src/commands/whoami.js";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("axiom")
19
+ .description("Publish and install agent Skills from your team's registry")
20
+ .version("0.1.0");
21
+
22
+ program
23
+ .command("registry <url>")
24
+ .description("Point the CLI at a registry — no account needed for browsing/installing")
25
+ .action(registry);
26
+
27
+ program
28
+ .command("register")
29
+ .description("Create an account on a registry and log in as it — needs an invite code (except the registry's very first account, which becomes admin)")
30
+ .option("--registry <url>", "registry API URL")
31
+ .option("--invite <code>", "invite code from an admin")
32
+ .action(register);
33
+
34
+ program
35
+ .command("login")
36
+ .description("Log in to a registry with username/password (or --token) and store your credentials")
37
+ .option("--registry <url>", "registry API URL")
38
+ .option("--token <token>", "use a pre-issued token instead of username/password")
39
+ .action(login);
40
+
41
+ program
42
+ .command("invite")
43
+ .description("Admin only: mint a single-use invite code (or --list to see outstanding/used ones)")
44
+ .option("--list", "list invite codes instead of minting a new one")
45
+ .action(invite);
46
+
47
+ program
48
+ .command("publish [dir]")
49
+ .description("Pack and publish a skill directory (default: current directory)")
50
+ .action((dir) => publish(dir || "."));
51
+
52
+ program
53
+ .command("add <skill>")
54
+ .description("Install a skill, e.g. `axiom add acme/pdf-report-builder@1.2.0` — also records it in axiom-skills.json / axiom-skills-lock.json")
55
+ .option("--dir <path>", "install target", ".agents/skills")
56
+ .action(add);
57
+
58
+ program
59
+ .command("install")
60
+ .description("Install every skill in axiom-skills.json, reproducibly — uses axiom-skills-lock.json's exact versions + integrity when present")
61
+ .option("--dir <path>", "install target", ".agents/skills")
62
+ .action(install);
63
+
64
+ program
65
+ .command("update [skill]")
66
+ .description("Update one skill (or all, if omitted) to the latest version — or --version <v> — and refresh the lockfile")
67
+ .option("--dir <path>", "install target", ".agents/skills")
68
+ .option("--version <version>", "pin to a specific version instead of latest")
69
+ .action(update);
70
+
71
+ program
72
+ .command("find <query>")
73
+ .description("Search the registry")
74
+ .action(find);
75
+
76
+ program
77
+ .command("list")
78
+ .description("List skills installed locally")
79
+ .option("--dir <path>", "skills directory", ".agents/skills")
80
+ .action(list);
81
+
82
+ program
83
+ .command("whoami")
84
+ .description("Show the currently logged-in user and registry")
85
+ .action(whoami);
86
+
87
+ program.parseAsync(process.argv);
@@ -0,0 +1,97 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import * as tar from "tar";
6
+
7
+ export const PORT = 8792;
8
+ export const BASE = `http://127.0.0.1:${PORT}`;
9
+
10
+ // owner/name -> available versions. Mutable so a test can add a version to
11
+ // simulate "a newer release showed up" and confirm `axiom update` finds it.
12
+ export const store = {
13
+ "alice/greeting-skill": ["1.0.0"],
14
+ };
15
+
16
+ function compareVersions(a, b) {
17
+ const pa = a.split(".").map(Number);
18
+ const pb = b.split(".").map(Number);
19
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
20
+ return 0;
21
+ }
22
+ const latestOf = (versions) => [...versions].sort(compareVersions).at(-1);
23
+
24
+ const tarballs = new Map();
25
+ async function buildTarball(name, version) {
26
+ const key = `${name}@${version}`;
27
+ if (tarballs.has(key)) return tarballs.get(key);
28
+
29
+ const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "axiom-fixture-"));
30
+ fs.writeFileSync(
31
+ path.join(srcDir, "SKILL.md"),
32
+ `---\nname: ${name}\ndescription: Test fixture.\nversion: ${version}\n---\n\nHello from ${version}.\n`
33
+ );
34
+ const tmpFile = path.join(os.tmpdir(), `axiom-fixture-${key.replace("/", "-")}.tgz`);
35
+ await tar.create({ gzip: true, file: tmpFile, cwd: srcDir }, ["SKILL.md"]);
36
+ const buf = fs.readFileSync(tmpFile);
37
+ fs.unlinkSync(tmpFile);
38
+ fs.rmSync(srcDir, { recursive: true, force: true });
39
+ tarballs.set(key, buf);
40
+ return buf;
41
+ }
42
+
43
+ export function startFakeRegistry() {
44
+ const server = http.createServer(async (req, res) => {
45
+ const url = new URL(req.url, BASE);
46
+
47
+ let m = url.pathname.match(/^\/v1\/search$/);
48
+ if (m && req.method === "GET") {
49
+ const q = (url.searchParams.get("q") || "").toLowerCase();
50
+ const results = Object.entries(store)
51
+ .filter(([key]) => !q || key.toLowerCase().includes(q))
52
+ .map(([key, versions]) => {
53
+ const [owner, name] = key.split("/");
54
+ return { owner, name, latest: latestOf(versions) };
55
+ });
56
+ return json(res, 200, results);
57
+ }
58
+
59
+ m = url.pathname.match(/^\/v1\/skills\/([^/]+)\/([^/]+)$/);
60
+ if (m && req.method === "GET") {
61
+ const [, owner, name] = m;
62
+ const versions = store[`${owner}/${name}`];
63
+ if (!versions) return json(res, 404, { error: "not found" });
64
+
65
+ const requested = url.searchParams.get("version");
66
+ const version = requested || latestOf(versions);
67
+ if (!versions.includes(version)) {
68
+ return json(res, 404, { error: `no version ${version}`, availableVersions: versions });
69
+ }
70
+ return json(res, 200, {
71
+ owner,
72
+ name,
73
+ version,
74
+ downloadUrl: `${BASE}/tarball/${name}/${version}.tgz`,
75
+ expiresIn: 300,
76
+ availableVersions: versions,
77
+ });
78
+ }
79
+
80
+ m = url.pathname.match(/^\/tarball\/([^/]+)\/([^/]+)\.tgz$/);
81
+ if (m && req.method === "GET") {
82
+ const [, name, version] = m;
83
+ const buf = await buildTarball(name, version);
84
+ res.writeHead(200, { "content-type": "application/gzip" });
85
+ return res.end(buf);
86
+ }
87
+
88
+ res.writeHead(404);
89
+ res.end("not found");
90
+ });
91
+ return new Promise((resolve) => server.listen(PORT, () => resolve(server)));
92
+ }
93
+
94
+ function json(res, status, body) {
95
+ res.writeHead(status, { "content-type": "application/json" });
96
+ res.end(JSON.stringify(body));
97
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "axiom-skills",
3
+ "version": "0.1.0",
4
+ "description": "CLI for the Axiom Skills registry — publish and install agent SKILL.md packages, backed by S3.",
5
+ "type": "module",
6
+ "bin": {
7
+ "axiom": "bin/axiom.js",
8
+ "axiom-skills": "bin/axiom.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "license": "MIT",
14
+ "dependencies": {
15
+ "chalk": "^5.3.0",
16
+ "commander": "^12.1.0",
17
+ "conf": "^13.0.1",
18
+ "node-fetch": "^3.3.2",
19
+ "prompts": "^2.4.2",
20
+ "tar": "^7.4.3",
21
+ "yaml": "^2.5.0"
22
+ }
23
+ }
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ import chalk from "chalk";
3
+ import { fetchAndExtract } from "../lib/skillInstall.js";
4
+ import { readManifest, writeManifest, readLockfile, writeLockfile } from "../lib/manifest.js";
5
+
6
+ // Accepts "owner/name", "owner/name@version", or bare "name" (owner defaults
7
+ // to "community", mirroring skills.sh's public-registry convention).
8
+ function parseSpec(spec) {
9
+ const [ownerName, version] = spec.split("@");
10
+ const parts = ownerName.split("/");
11
+ const [owner, name] = parts.length === 2 ? parts : ["community", parts[0]];
12
+ return { owner, name, version };
13
+ }
14
+
15
+ export async function add(spec, opts) {
16
+ const { owner, name, version } = parseSpec(spec);
17
+ const key = `${owner}/${name}`;
18
+
19
+ const { resolvedVersion, integrity } = await fetchAndExtract({ owner, name, version }, opts.dir);
20
+ console.log(chalk.green(`✓ installed ${key}@${resolvedVersion} -> ${path.join(opts.dir, name)}`));
21
+
22
+ // The point of add isn't just "download it" — it's "make this project
23
+ // depend on it," so a teammate who runs `axiom install` after cloning
24
+ // gets the exact same thing. axiom-skills.json is what was asked for;
25
+ // axiom-skills-lock.json is what was actually verified and installed.
26
+ const manifest = readManifest();
27
+ manifest.skills[key] = resolvedVersion;
28
+ writeManifest(manifest);
29
+
30
+ const lock = readLockfile();
31
+ lock.skills[key] = { version: resolvedVersion, integrity };
32
+ writeLockfile(lock);
33
+
34
+ console.log(chalk.dim(` recorded in axiom-skills.json and axiom-skills-lock.json`));
35
+ }
@@ -0,0 +1,15 @@
1
+ import chalk from "chalk";
2
+ import { search } from "../lib/api.js";
3
+
4
+ export async function find(query) {
5
+ const results = await search(query);
6
+ if (!results.length) {
7
+ console.log(`No skills matched "${query}".`);
8
+ return;
9
+ }
10
+ // No metadata store means no stored description to show — just what
11
+ // exists in the bucket: owner/name and the newest version found there.
12
+ for (const r of results) {
13
+ console.log(`${chalk.bold(`${r.owner}/${r.name}`)} ${chalk.dim(`v${r.latest}`)}`);
14
+ }
15
+ }
@@ -0,0 +1,42 @@
1
+ import chalk from "chalk";
2
+ import { fetchAndExtract } from "../lib/skillInstall.js";
3
+ import { readManifest, readLockfile, writeLockfile } from "../lib/manifest.js";
4
+
5
+ export async function install(opts) {
6
+ const manifest = readManifest();
7
+ const keys = Object.keys(manifest.skills);
8
+ if (!keys.length) {
9
+ console.log("No skills in axiom-skills.json — nothing to install. Run `axiom add <owner/name>` first.");
10
+ return;
11
+ }
12
+
13
+ const lock = readLockfile();
14
+ let lockChanged = false;
15
+
16
+ for (const key of keys) {
17
+ const [owner, name] = key.split("/");
18
+ const locked = lock.skills[key];
19
+
20
+ // A locked entry means "install exactly this" — the whole reason this
21
+ // command exists. Only fall back to the manifest's version (which
22
+ // might just say "latest") when there's nothing locked yet, e.g. a
23
+ // skill someone added to axiom-skills.json by hand without running
24
+ // `axiom add`.
25
+ const version = locked ? locked.version : manifest.skills[key];
26
+ const expectedIntegrity = locked?.integrity;
27
+
28
+ const { resolvedVersion, integrity } = await fetchAndExtract(
29
+ { owner, name, version: version === "latest" ? undefined : version },
30
+ opts.dir,
31
+ expectedIntegrity
32
+ );
33
+ console.log(chalk.green(`✓ ${key}@${resolvedVersion}`));
34
+
35
+ if (!locked || locked.version !== resolvedVersion || locked.integrity !== integrity) {
36
+ lock.skills[key] = { version: resolvedVersion, integrity };
37
+ lockChanged = true;
38
+ }
39
+ }
40
+
41
+ if (lockChanged) writeLockfile(lock);
42
+ }
@@ -0,0 +1,29 @@
1
+ import chalk from "chalk";
2
+ import { createInviteRemote, listInvitesRemote } from "../lib/api.js";
3
+
4
+ export async function invite(opts) {
5
+ try {
6
+ if (opts.list) {
7
+ const invites = await listInvitesRemote();
8
+ if (!invites.length) {
9
+ console.log("No invites yet — run `axiom invite` to mint one.");
10
+ return;
11
+ }
12
+ for (const inv of invites) {
13
+ const status = inv.used ? chalk.dim("used") : chalk.green("unused");
14
+ console.log(`${inv.code} ${status} (from ${inv.createdBy})`);
15
+ }
16
+ return;
17
+ }
18
+
19
+ const { code } = await createInviteRemote();
20
+ console.log(chalk.green(`✓ invite code: ${code}`));
21
+ console.log(chalk.dim(" Single-use — send it to the person you want to give publisher access to."));
22
+ } catch (err) {
23
+ // Same reasoning as everywhere else in this CLI: a 403 here almost
24
+ // always means "you're logged in, but not an admin," so let the
25
+ // server's own message carry that instead of guessing at it here.
26
+ console.error(chalk.red("Couldn't manage invites."), err.message);
27
+ process.exit(1);
28
+ }
29
+ }
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readSkillFrontmatter } from "../lib/skillFile.js";
4
+
5
+ export function list(opts) {
6
+ if (!fs.existsSync(opts.dir)) {
7
+ console.log(`No skills installed yet (looked in ${opts.dir}).`);
8
+ return;
9
+ }
10
+ for (const entry of fs.readdirSync(opts.dir)) {
11
+ const full = path.join(opts.dir, entry);
12
+ if (!fs.statSync(full).isDirectory()) continue;
13
+ try {
14
+ const meta = readSkillFrontmatter(full);
15
+ console.log(`${entry} v${meta.version}`);
16
+ } catch {
17
+ console.log(`${entry} (no SKILL.md)`);
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,67 @@
1
+ import prompts from "prompts";
2
+ import chalk from "chalk";
3
+ import { setConfig } from "../lib/config.js";
4
+ import { loginRemote, whoamiRemote } from "../lib/api.js";
5
+
6
+ export async function login(opts) {
7
+ const answers = await prompts([
8
+ {
9
+ type: opts.registry ? null : "text",
10
+ name: "registry",
11
+ message: "Registry URL",
12
+ initial: "https://registry.acme.dev",
13
+ },
14
+ // --token skips username/password entirely — for CI or anywhere you
15
+ // already have a token (e.g. one printed by `axiom register`) and
16
+ // just need the CLI to pick it up without a live login exchange.
17
+ {
18
+ type: opts.token ? null : "text",
19
+ name: "username",
20
+ message: "Username",
21
+ },
22
+ {
23
+ type: opts.token ? null : "password",
24
+ name: "password",
25
+ message: "Password",
26
+ },
27
+ ]);
28
+
29
+ const registry = (opts.registry || answers.registry || "").replace(/\/+$/, "");
30
+ if (!registry) {
31
+ console.error("Login cancelled — need a registry URL.");
32
+ process.exit(1);
33
+ }
34
+
35
+ if (opts.token) {
36
+ // Pre-issued token path: save it, then confirm it's actually valid by
37
+ // asking the registry who it belongs to (also fills in `username`,
38
+ // which is what `axiom publish` uses as the default owner).
39
+ setConfig({ registry, token: opts.token });
40
+ try {
41
+ const { username } = await whoamiRemote();
42
+ setConfig({ username });
43
+ console.log(chalk.green(`✓ logged in as ${username} on ${registry}`));
44
+ } catch (err) {
45
+ setConfig({ registry: null, token: null });
46
+ console.error(chalk.red("Login failed — registry rejected that token."), err.message);
47
+ process.exit(1);
48
+ }
49
+ return;
50
+ }
51
+
52
+ const username = answers.username;
53
+ const password = answers.password;
54
+ if (!username || !password) {
55
+ console.error("Login cancelled — need a username and password.");
56
+ process.exit(1);
57
+ }
58
+
59
+ try {
60
+ const result = await loginRemote(registry, { username, password });
61
+ setConfig({ registry, token: result.token, username: result.username });
62
+ console.log(chalk.green(`✓ logged in as ${result.username} on ${registry}`));
63
+ } catch (err) {
64
+ console.error(chalk.red("Login failed — wrong username/password, or registry unreachable."), err.message);
65
+ process.exit(1);
66
+ }
67
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import * as tar from "tar";
6
+ import { readSkillFrontmatter } from "../lib/skillFile.js";
7
+ import { requestUploadUrl, uploadToPresignedUrl } from "../lib/api.js";
8
+ import { getConfig } from "../lib/config.js";
9
+
10
+ export async function publish(dir) {
11
+ const { username } = getConfig();
12
+ const meta = readSkillFrontmatter(dir);
13
+ const owner = meta.owner || username;
14
+
15
+ console.log(`Packing ${chalk.bold(`${owner}/${meta.name}@${meta.version}`)} from ${dir} ...`);
16
+
17
+ const tmpFile = path.join(os.tmpdir(), `axiom-${meta.name}-${meta.version}.tgz`);
18
+ await tar.create(
19
+ {
20
+ gzip: true,
21
+ file: tmpFile,
22
+ cwd: dir,
23
+ filter: (p) => !/(^|\/)(node_modules|\.git)(\/|$)/.test(p),
24
+ },
25
+ fs.readdirSync(dir)
26
+ );
27
+ const { size } = fs.statSync(tmpFile);
28
+ console.log(chalk.green(`✓ packed ${fs.readdirSync(dir).length} entries (${(size / 1024).toFixed(1)} kB)`));
29
+
30
+ // 1. ask the registry where to put it (presigned S3 PUT URL)
31
+ const { uploadUrl, key } = await requestUploadUrl({
32
+ owner,
33
+ name: meta.name,
34
+ version: meta.version,
35
+ });
36
+
37
+ // 2. upload straight to S3 — that's the whole publish. No index/database
38
+ // to update: S3 is what `axiom add` and `axiom find` look at directly.
39
+ await uploadToPresignedUrl(uploadUrl, tmpFile);
40
+ fs.unlinkSync(tmpFile);
41
+ console.log(chalk.green(`✓ uploaded to s3://${key}`));
42
+
43
+ console.log(chalk.green(`✓ published ${owner}/${meta.name}@${meta.version}`));
44
+ console.log(` Install it with ${chalk.cyan(`axiom add ${owner}/${meta.name}`)}`);
45
+ }
@@ -0,0 +1,74 @@
1
+ import prompts from "prompts";
2
+ import chalk from "chalk";
3
+ import { setConfig } from "../lib/config.js";
4
+ import { registerRemote } from "../lib/api.js";
5
+
6
+ export async function register(opts) {
7
+ const first = await prompts([
8
+ {
9
+ type: opts.registry ? null : "text",
10
+ name: "registry",
11
+ message: "Registry URL",
12
+ initial: "https://registry.acme.dev",
13
+ },
14
+ {
15
+ type: "text",
16
+ name: "username",
17
+ message: "Choose a username",
18
+ },
19
+ {
20
+ type: "password",
21
+ name: "password",
22
+ message: "Choose a password (min 8 characters)",
23
+ },
24
+ ]);
25
+
26
+ const registry = (opts.registry || first.registry || "").replace(/\/+$/, "");
27
+
28
+ if (!registry || !first.username || !first.password) {
29
+ console.error("Registration cancelled.");
30
+ process.exit(1);
31
+ }
32
+
33
+ const second = await prompts({
34
+ type: "password",
35
+ name: "confirm",
36
+ message: "Confirm password",
37
+ validate: (value) =>
38
+ value === first.password ? true : "Passwords don't match",
39
+ });
40
+
41
+ if (second.confirm === undefined) {
42
+ console.error("Registration cancelled.");
43
+ process.exit(1);
44
+ }
45
+
46
+ // Every account after the registry's first needs one.
47
+ const inviteAnswers = await prompts({
48
+ type: opts.invite ? null : "text",
49
+ name: "inviteCode",
50
+ message:
51
+ "Invite code (leave blank only if this is a brand-new registry with no admin yet)",
52
+ });
53
+
54
+ const inviteCode = opts.invite || inviteAnswers.inviteCode || undefined;
55
+
56
+ try {
57
+ const { username, token } = await registerRemote(registry, {
58
+ username: first.username,
59
+ password: first.password,
60
+ inviteCode,
61
+ });
62
+
63
+ setConfig({ registry, token, username });
64
+
65
+ console.log(
66
+ chalk.green(
67
+ `✓ account created — logged in as ${username} on ${registry}`
68
+ )
69
+ );
70
+ } catch (err) {
71
+ console.error(chalk.red("Registration failed."), err.message);
72
+ process.exit(1);
73
+ }
74
+ }
@@ -0,0 +1,13 @@
1
+ import chalk from "chalk";
2
+ import { setConfig } from "../lib/config.js";
3
+
4
+ // The "no login required" on-ramp — skills.sh-style. `axiom login`/`axiom
5
+ // register` also set this as a side effect, but neither is needed just to
6
+ // browse and install: this is the whole setup for that.
7
+ export function registry(url) {
8
+ const normalized = url.replace(/\/+$/, "");
9
+ setConfig({ registry: normalized });
10
+ console.log(chalk.green(`✓ registry set to ${normalized}`));
11
+ console.log(chalk.dim(" axiom find / axiom add / axiom install work now — no account needed."));
12
+ console.log(chalk.dim(" Run `axiom register` or `axiom login` only when you want to `axiom publish`."));
13
+ }
@@ -0,0 +1,51 @@
1
+ import chalk from "chalk";
2
+ import { fetchAndExtract } from "../lib/skillInstall.js";
3
+ import { readManifest, writeManifest, readLockfile, writeLockfile } from "../lib/manifest.js";
4
+
5
+ export async function update(name, opts) {
6
+ const manifest = readManifest();
7
+
8
+ if (name && !manifest.skills[name]) {
9
+ const known = Object.keys(manifest.skills);
10
+ console.error(
11
+ `"${name}" isn't in axiom-skills.json.` +
12
+ (known.length ? ` Known skills: ${known.join(", ")}` : " (axiom-skills.json has no skills yet)")
13
+ );
14
+ process.exit(1);
15
+ }
16
+
17
+ const keys = name ? [name] : Object.keys(manifest.skills);
18
+ if (!keys.length) {
19
+ console.log("Nothing to update — axiom-skills.json has no skills yet.");
20
+ return;
21
+ }
22
+
23
+ const lock = readLockfile();
24
+
25
+ for (const key of keys) {
26
+ const [owner, skillName] = key.split("/");
27
+ const previousVersion = lock.skills[key]?.version || manifest.skills[key];
28
+
29
+ // Deliberately ignores whatever's currently locked — that's the
30
+ // difference from `axiom install`. Update means "go get something
31
+ // newer (or the pinned --version)," not "reproduce exactly what's
32
+ // locked." No expectedIntegrity is passed for the same reason: there's
33
+ // nothing to verify against yet, the new download IS the new truth.
34
+ const { resolvedVersion, integrity } = await fetchAndExtract(
35
+ { owner, name: skillName, version: opts.version },
36
+ opts.dir
37
+ );
38
+
39
+ manifest.skills[key] = resolvedVersion;
40
+ lock.skills[key] = { version: resolvedVersion, integrity };
41
+
42
+ if (previousVersion === resolvedVersion) {
43
+ console.log(chalk.dim(`= ${key}@${resolvedVersion} (already up to date)`));
44
+ } else {
45
+ console.log(chalk.green(`↑ ${key} ${previousVersion} → ${resolvedVersion}`));
46
+ }
47
+ }
48
+
49
+ writeManifest(manifest);
50
+ writeLockfile(lock);
51
+ }
@@ -0,0 +1,14 @@
1
+ import chalk from "chalk";
2
+ import { getConfig } from "../lib/config.js";
3
+ import { whoamiRemote } from "../lib/api.js";
4
+
5
+ export async function whoami() {
6
+ const { registry } = getConfig();
7
+ try {
8
+ const { username } = await whoamiRemote();
9
+ console.log(`${chalk.bold(username)} on ${registry}`);
10
+ } catch (err) {
11
+ console.error(chalk.red("Not logged in or token is invalid."), err.message);
12
+ process.exit(1);
13
+ }
14
+ }
package/src/lib/api.js ADDED
@@ -0,0 +1,89 @@
1
+ import fetch from "node-fetch";
2
+ import fs from "node:fs";
3
+ import { requireConfig, requireRegistry } from "./config.js";
4
+
5
+ async function rawCall(registry, path, { method = "GET", body, headers = {} } = {}) {
6
+ const res = await fetch(`${registry}${path}`, {
7
+ method,
8
+ headers: { "content-type": "application/json", ...headers },
9
+ body: body ? JSON.stringify(body) : undefined,
10
+ });
11
+ if (!res.ok) {
12
+ const text = await res.text().catch(() => "");
13
+ throw new Error(`${method} ${path} -> ${res.status} ${text}`);
14
+ }
15
+ return res.json();
16
+ }
17
+
18
+ async function call(path, opts = {}) {
19
+ const { registry, token } = requireConfig();
20
+ return rawCall(registry, path, {
21
+ ...opts,
22
+ headers: { authorization: `Bearer ${token}`, ...(opts.headers || {}) },
23
+ });
24
+ }
25
+
26
+ // Same shape as call(), but for the routes the server doesn't gate behind
27
+ // auth at all (search, skill/download resolution) — no token attached, no
28
+ // login required, just "where's the registry."
29
+ async function publicCall(path, opts = {}) {
30
+ const { registry } = requireRegistry();
31
+ return rawCall(registry, path, opts);
32
+ }
33
+
34
+ // register/login happen before there's a token to send, so they bypass
35
+ // requireConfig() (which would fail with "not logged in") and take the
36
+ // registry URL directly instead.
37
+ export function registerRemote(registry, { username, password, inviteCode }) {
38
+ return rawCall(registry, "/v1/register", { method: "POST", body: { username, password, inviteCode } });
39
+ }
40
+
41
+ export function loginRemote(registry, { username, password }) {
42
+ return rawCall(registry, "/v1/login", { method: "POST", body: { username, password } });
43
+ }
44
+
45
+ // Step 1 of publish: ask the API where to upload. It hands back a short-lived
46
+ // presigned S3 PUT URL — the CLI never needs S3 credentials of its own.
47
+ export function requestUploadUrl({ owner, name, version }) {
48
+ return call(`/v1/skills/${owner}/${name}/versions`, {
49
+ method: "POST",
50
+ body: { version },
51
+ });
52
+ }
53
+
54
+ // Step 2: the CLI PUTs the tarball straight to S3 using the presigned URL.
55
+ // That's the whole publish — there's no separate "confirm" call, because
56
+ // there's no index to update. The next `axiom add` just lists the bucket.
57
+ export async function uploadToPresignedUrl(uploadUrl, filePath) {
58
+ const body = fs.readFileSync(filePath);
59
+ const res = await fetch(uploadUrl, {
60
+ method: "PUT",
61
+ headers: { "content-type": "application/gzip" },
62
+ body,
63
+ });
64
+ if (!res.ok) throw new Error(`Upload to S3 failed: ${res.status}`);
65
+ }
66
+
67
+ export function getSkill({ owner, name, version }) {
68
+ const q = version ? `?version=${encodeURIComponent(version)}` : "";
69
+ return publicCall(`/v1/skills/${owner}/${name}${q}`);
70
+ }
71
+
72
+ export function search(query) {
73
+ return publicCall(`/v1/search?q=${encodeURIComponent(query)}`);
74
+ }
75
+
76
+ export function whoamiRemote() {
77
+ return call(`/v1/whoami`);
78
+ }
79
+
80
+ // Admin-only on the server; the CLI doesn't know or check role itself, it
81
+ // just makes the call and lets a 403 speak for itself if the logged-in
82
+ // account isn't an admin.
83
+ export function createInviteRemote() {
84
+ return call(`/v1/invites`, { method: "POST" });
85
+ }
86
+
87
+ export function listInvitesRemote() {
88
+ return call(`/v1/invites`);
89
+ }
@@ -0,0 +1,51 @@
1
+ import Conf from "conf";
2
+
3
+ // Lives at ~/.config/axiom-skills-nodejs/config.json (platform-appropriate).
4
+ // This is the "git remote" / ~/.npmrc equivalent: once you `axiom login`,
5
+ // every other command just reads this instead of asking you for a URL again.
6
+ const store = new Conf({
7
+ projectName: "axiom-skills",
8
+ defaults: {
9
+ registry: null,
10
+ token: null,
11
+ username: null,
12
+ },
13
+ });
14
+
15
+ export function getConfig() {
16
+ return {
17
+ registry: store.get("registry"),
18
+ token: store.get("token"),
19
+ username: store.get("username"),
20
+ };
21
+ }
22
+
23
+ export function setConfig({ registry, token, username }) {
24
+ if (registry !== undefined) store.set("registry", registry);
25
+ if (token !== undefined) store.set("token", token);
26
+ if (username !== undefined) store.set("username", username);
27
+ }
28
+
29
+ export function requireConfig() {
30
+ const cfg = getConfig();
31
+ if (!cfg.registry || !cfg.token) {
32
+ console.error(
33
+ "Not logged in — needed for `axiom publish` / `axiom whoami`. Run `axiom register` (new account) or `axiom login` (existing one)."
34
+ );
35
+ process.exit(1);
36
+ }
37
+ return cfg;
38
+ }
39
+
40
+ // Browsing and installing skills is public — skills.sh-style, no account
41
+ // needed — so this only requires knowing *where* the registry is, not who
42
+ // you are. Set with `axiom registry <url>`, or as a side effect of
43
+ // `axiom login`/`axiom register` if you're doing that anyway.
44
+ export function requireRegistry() {
45
+ const cfg = getConfig();
46
+ if (!cfg.registry) {
47
+ console.error("No registry configured. Run `axiom registry <url>` first.");
48
+ process.exit(1);
49
+ }
50
+ return cfg;
51
+ }
@@ -0,0 +1,10 @@
1
+ import crypto from "node:crypto";
2
+
3
+ // Not SRI (that's base64 + supports multiple algorithms) — just a plain
4
+ // sha256 hex digest of the tarball bytes, prefixed so the lockfile is
5
+ // self-describing if the algorithm ever changes. Good enough for "did I
6
+ // get the exact same bytes a teammate already tested," which is all a
7
+ // lockfile needs to guarantee.
8
+ export function computeIntegrity(buffer) {
9
+ return `sha256-${crypto.createHash("sha256").update(buffer).digest("hex")}`;
10
+ }
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // axiom-skills.json is the "package.json" of this pair — what the project
5
+ // wants, hand-editable, meant to be committed. axiom-skills-lock.json is
6
+ // the "package-lock.json" — exact resolved version + a content hash per
7
+ // skill, written by the CLI, also meant to be committed. Together they're
8
+ // what makes `axiom install` after a fresh clone reproducible instead of
9
+ // "whatever's latest today."
10
+ const MANIFEST_FILE = "axiom-skills.json";
11
+ const LOCK_FILE = "axiom-skills-lock.json";
12
+
13
+ function readJSON(file, fallback) {
14
+ if (!fs.existsSync(file)) return fallback;
15
+ return JSON.parse(fs.readFileSync(file, "utf8"));
16
+ }
17
+
18
+ function writeJSON(file, value) {
19
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
20
+ }
21
+
22
+ export function readManifest(cwd = process.cwd()) {
23
+ const manifest = readJSON(path.join(cwd, MANIFEST_FILE), { skills: {} });
24
+ manifest.skills = manifest.skills || {};
25
+ return manifest;
26
+ }
27
+
28
+ export function writeManifest(manifest, cwd = process.cwd()) {
29
+ writeJSON(path.join(cwd, MANIFEST_FILE), manifest);
30
+ }
31
+
32
+ export function readLockfile(cwd = process.cwd()) {
33
+ const lock = readJSON(path.join(cwd, LOCK_FILE), { lockfileVersion: 1, skills: {} });
34
+ lock.skills = lock.skills || {};
35
+ return lock;
36
+ }
37
+
38
+ export function writeLockfile(lock, cwd = process.cwd()) {
39
+ writeJSON(path.join(cwd, LOCK_FILE), lock);
40
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+
5
+ // Mirrors registry-api/src/lib/version.js's SEMVER_RE — kept as a small
6
+ // duplicated regex rather than a shared package, since these are two
7
+ // separate npm-published things. Checking here means a bad version fails
8
+ // before packing/uploading a tarball, not after a round trip to the API.
9
+ const SEMVER_RE =
10
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
11
+
12
+ // SKILL.md starts with YAML frontmatter, same convention skills.sh and
13
+ // Claude's own Skills use:
14
+ //
15
+ // ---
16
+ // name: pdf-report-builder
17
+ // description: Build branded PDF reports from a data file.
18
+ // version: 1.0.0
19
+ // ---
20
+ export function readSkillFrontmatter(dir) {
21
+ const file = path.join(dir, "SKILL.md");
22
+ if (!fs.existsSync(file)) {
23
+ throw new Error(`No SKILL.md found in ${dir}`);
24
+ }
25
+ const raw = fs.readFileSync(file, "utf8");
26
+ const match = raw.match(/^---\n([\s\S]*?)\n---/);
27
+ if (!match) {
28
+ throw new Error(`SKILL.md in ${dir} has no YAML frontmatter block`);
29
+ }
30
+ const meta = YAML.parse(match[1]);
31
+ for (const field of ["name", "description"]) {
32
+ if (!meta[field]) throw new Error(`SKILL.md is missing required field "${field}"`);
33
+ }
34
+ meta.version = meta.version || "0.0.0";
35
+ if (!SEMVER_RE.test(meta.version)) {
36
+ throw new Error(`SKILL.md has an invalid version "${meta.version}" (expected semver, e.g. 1.2.3)`);
37
+ }
38
+ return meta;
39
+ }
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fetch from "node-fetch";
5
+ import * as tar from "tar";
6
+ import { getSkill } from "./api.js";
7
+ import { computeIntegrity } from "./integrity.js";
8
+
9
+ // Resolves owner/name[@version] against the registry, downloads the
10
+ // tarball, and extracts it into <targetDir>/<name>. Returns the version
11
+ // the registry actually resolved to (useful when `version` was omitted
12
+ // and the server picked "latest") plus the tarball's content hash.
13
+ //
14
+ // If expectedIntegrity is given, a mismatch throws instead of installing
15
+ // — that's the difference between `axiom add` (nothing to check against
16
+ // yet) and `axiom install` (must match what's in the lockfile). A bad
17
+ // match means either the registry served something other than what was
18
+ // locked, or the download was corrupted in transit; either way it's not
19
+ // safe to extract and run.
20
+ export async function fetchAndExtract({ owner, name, version }, targetDir, expectedIntegrity) {
21
+ const { downloadUrl, version: resolvedVersion } = await getSkill({ owner, name, version });
22
+
23
+ const res = await fetch(downloadUrl);
24
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
25
+ const buf = Buffer.from(await res.arrayBuffer());
26
+
27
+ const integrity = computeIntegrity(buf);
28
+ if (expectedIntegrity && integrity !== expectedIntegrity) {
29
+ throw new Error(
30
+ `Integrity check failed for ${owner}/${name}@${resolvedVersion}: ` +
31
+ `axiom-skills-lock.json expects ${expectedIntegrity}, got ${integrity}. Refusing to install.`
32
+ );
33
+ }
34
+
35
+ const dir = path.join(targetDir, name);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const tmpFile = path.join(os.tmpdir(), `axiom-${name}-${resolvedVersion}-${process.pid}.tgz`);
38
+ fs.writeFileSync(tmpFile, buf);
39
+ await tar.extract({ file: tmpFile, cwd: dir });
40
+ fs.unlinkSync(tmpFile);
41
+
42
+ return { resolvedVersion, integrity };
43
+ }
@@ -0,0 +1,95 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { startFakeRegistry, store, BASE } from "../fixtures/fake-registry.mjs";
7
+
8
+ // Isolate the CLI's `conf`-backed config store from the real machine's
9
+ // ~/.config — env-paths (which `conf` uses) honors XDG_CONFIG_HOME on
10
+ // Linux. Must happen before config.js is ever imported, hence the dynamic
11
+ // imports below instead of static ones (static imports are hoisted above
12
+ // any code in this file, which would set the env var too late).
13
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "axiom-config-"));
14
+ process.env.XDG_CONFIG_HOME = fakeHome;
15
+
16
+ const { setConfig } = await import("../src/lib/config.js");
17
+ const { search } = await import("../src/lib/api.js");
18
+ const { add } = await import("../src/commands/add.js");
19
+ const { install } = await import("../src/commands/install.js");
20
+ const { update } = await import("../src/commands/update.js");
21
+ const { readManifest, readLockfile, writeLockfile } = await import("../src/lib/manifest.js");
22
+
23
+ test("add / install / update — manifest, lockfile, integrity", async (t) => {
24
+ const server = await startFakeRegistry();
25
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "axiom-project-"));
26
+ const skillsDir = path.join(projectDir, ".agents", "skills");
27
+ process.chdir(projectDir);
28
+
29
+ // Deliberately no token — browsing and installing skills is public now
30
+ // (skills.sh-style), so `axiom registry <url>` is the whole setup; an
31
+ // account is only for `axiom publish`. This is the actual scenario the
32
+ // whole test file runs under, not a one-off case: every add/install/
33
+ // update below has to work without ever calling setConfig({ token }).
34
+ setConfig({ registry: BASE });
35
+
36
+ await t.test("search works with no account configured at all", async () => {
37
+ const results = await search("greeting");
38
+ assert.deepEqual(results, [{ owner: "alice", name: "greeting-skill", latest: "1.0.0" }]);
39
+ });
40
+
41
+ await t.test("add records the exact resolved version in manifest + lockfile", async () => {
42
+ await add("alice/greeting-skill@1.0.0", { dir: skillsDir });
43
+
44
+ const manifest = readManifest(projectDir);
45
+ assert.equal(manifest.skills["alice/greeting-skill"], "1.0.0");
46
+
47
+ const lock = readLockfile(projectDir);
48
+ assert.equal(lock.skills["alice/greeting-skill"].version, "1.0.0");
49
+ assert.match(lock.skills["alice/greeting-skill"].integrity, /^sha256-[0-9a-f]{64}$/);
50
+
51
+ const skillMd = fs.readFileSync(path.join(skillsDir, "greeting-skill", "SKILL.md"), "utf8");
52
+ assert.match(skillMd, /Hello from 1\.0\.0/);
53
+ });
54
+
55
+ await t.test("install reproduces the locked version, not whatever is latest now", async () => {
56
+ // A newer version appears in the registry after `add` ran...
57
+ store["alice/greeting-skill"] = ["1.0.0", "1.1.0"];
58
+
59
+ // ...but a fresh `axiom install` (simulating a teammate's clone) must
60
+ // still get exactly what's locked: 1.0.0, not 1.1.0.
61
+ fs.rmSync(skillsDir, { recursive: true, force: true });
62
+ await install({ dir: skillsDir });
63
+
64
+ const skillMd = fs.readFileSync(path.join(skillsDir, "greeting-skill", "SKILL.md"), "utf8");
65
+ assert.match(skillMd, /Hello from 1\.0\.0/);
66
+
67
+ const lock = readLockfile(projectDir);
68
+ assert.equal(lock.skills["alice/greeting-skill"].version, "1.0.0");
69
+ });
70
+
71
+ await t.test("update moves the lock forward to the new latest", async () => {
72
+ await update("alice/greeting-skill", { dir: skillsDir });
73
+
74
+ const manifest = readManifest(projectDir);
75
+ assert.equal(manifest.skills["alice/greeting-skill"], "1.1.0");
76
+
77
+ const lock = readLockfile(projectDir);
78
+ assert.equal(lock.skills["alice/greeting-skill"].version, "1.1.0");
79
+
80
+ const skillMd = fs.readFileSync(path.join(skillsDir, "greeting-skill", "SKILL.md"), "utf8");
81
+ assert.match(skillMd, /Hello from 1\.1\.0/);
82
+ });
83
+
84
+ await t.test("install refuses a tarball that doesn't match the lockfile's integrity", async () => {
85
+ const lock = readLockfile(projectDir);
86
+ lock.skills["alice/greeting-skill"].integrity = "sha256-" + "0".repeat(64);
87
+ writeLockfile(lock, projectDir);
88
+
89
+ await assert.rejects(() => install({ dir: skillsDir }), /Integrity check failed/);
90
+ });
91
+
92
+ server.close();
93
+ fs.rmSync(projectDir, { recursive: true, force: true });
94
+ fs.rmSync(fakeHome, { recursive: true, force: true });
95
+ });