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 +117 -0
- package/bin/axiom.js +87 -0
- package/fixtures/fake-registry.mjs +97 -0
- package/package.json +23 -0
- package/src/commands/add.js +35 -0
- package/src/commands/find.js +15 -0
- package/src/commands/install.js +42 -0
- package/src/commands/invite.js +29 -0
- package/src/commands/list.js +20 -0
- package/src/commands/login.js +67 -0
- package/src/commands/publish.js +45 -0
- package/src/commands/register.js +74 -0
- package/src/commands/registry.js +13 -0
- package/src/commands/update.js +51 -0
- package/src/commands/whoami.js +14 -0
- package/src/lib/api.js +89 -0
- package/src/lib/config.js +51 -0
- package/src/lib/integrity.js +10 -0
- package/src/lib/manifest.js +40 -0
- package/src/lib/skillFile.js +39 -0
- package/src/lib/skillInstall.js +43 -0
- package/test/cli.test.js +95 -0
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
|
+
}
|
package/test/cli.test.js
ADDED
|
@@ -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
|
+
});
|