@ninemind/agentgem 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ninemind.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ <p align="center">
2
+ <a href="https://agentgem.ninemind.ai"><img src="docs/banner.svg" alt="AgentGem — your agent works locally. Gem it." width="100%"></a>
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/ninemindai/agentgem/actions/workflows/ci.yml"><img src="https://github.com/ninemindai/agentgem/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
7
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-9a3324" alt="MIT license"></a>
8
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-LTS-1f6b4f" alt="Node LTS"></a>
9
+ <a href="https://agentback.dev"><img src="https://img.shields.io/badge/built_on-AgentBack-b08436" alt="Built on AgentBack"></a>
10
+ <a href="docs/concepts.md"><img src="https://img.shields.io/badge/MCP-native-211c15" alt="MCP-native"></a>
11
+ </p>
12
+
13
+ > A local web UI that introspects your coding-agent config, redacts secrets at
14
+ > capture, and builds a portable, composable **Gem**.
15
+ >
16
+ > **[agentgem.ninemind.ai](https://agentgem.ninemind.ai)**
17
+
18
+ AgentGem reads your coding-agent config — skills, MCP servers, and `CLAUDE.md` —
19
+ **redacts secrets the moment they're read**, and produces a **Gem**: a manifest + lock
20
+ archive you can publish to a GitHub-backed registry, merge with other Gems, and deploy to
21
+ several targets. A browser can't read `~/.claude` (it's sandboxed), so AgentGem runs a
22
+ small server on your machine; secrets never leave your device — what crosses any boundary
23
+ is a config *shape* with `<redacted>` in place of every sensitive value.
24
+
25
+ Built on [AgentBack](https://www.npmjs.com/org/agentback), ninemind's AI-native API/MCP
26
+ framework: every operation is defined once as a Zod contract and exposed as a REST
27
+ endpoint, an MCP tool, and an OpenAPI 3.1 document — so the web page and your local agent
28
+ call exactly the same thing.
29
+
30
+ ## What it provides
31
+
32
+ - **Secret-safe capture** — redaction by value and by key name, before anything reaches a
33
+ REST response, an MCP result, the live preview, or the built Gem.
34
+ - **A neutral Gem source** — a manifest + lock archive that isn't tied to any runtime.
35
+ Build once; install into a local testbed, merge, publish, or compile to a target without
36
+ re-reading raw config.
37
+ - **Composition** — the manifest/lock split lets small, focused Gems be reconciled into
38
+ larger agents with a single re-resolved lock, not a pile of overlapping config.
39
+ - **Deploy targets** — Eve and OpenAI Sandbox (code-gen), Flue (materialize, deployable to
40
+ Cloudflare), and Bedrock AgentCore (managed backend); code-gen targets share a common
41
+ `compose` step.
42
+ - **A GitHub-backed registry** — publish, resolve, merge, and install composable Gems over
43
+ the same archive format.
44
+ - **An agent-native path** — every operation is also an MCP tool, so your local agent can
45
+ build Gems over `/mcp` with no browser involved.
46
+
47
+ ## Usage
48
+
49
+ Requires Node.js ≥ 22 and a coding-agent config at `~/.claude`.
50
+
51
+ ```bash
52
+ npx @ninemind/agentgem # run without installing
53
+ # or install the `agentgem` command globally:
54
+ npm install -g @ninemind/agentgem
55
+ agentgem # → http://127.0.0.1:4317
56
+ agentgem --port 8080 # override the port (also honors $PORT)
57
+ ```
58
+
59
+ The server starts on `127.0.0.1` (default port `4317`) and prints:
60
+
61
+ ```
62
+ agentgem listening at http://127.0.0.1:4317
63
+ UI: http://127.0.0.1:4317/
64
+ API: http://127.0.0.1:4317/api/inventory · POST http://127.0.0.1:4317/api/gem
65
+ Explorer: http://127.0.0.1:4317/explorer/
66
+ MCP: http://127.0.0.1:4317/mcp
67
+ ```
68
+
69
+ | Path | What it is |
70
+ | ----------- | ------------------------------------------------------- |
71
+ | `/` | The Gem Builder web UI |
72
+ | `/explorer` | Swagger UI for the REST API (from the OpenAPI document) |
73
+ | `/mcp` | The MCP endpoint — the same contract, for your agent |
74
+
75
+ Open `/`, tick the skills / MCP servers / `CLAUDE.md` you want, name the Gem, and watch
76
+ the live `gem.json` render with secrets already shown as `<redacted>`. Download it — that
77
+ archive is what every target and the registry consume.
78
+
79
+ Append `?dir=/path/to/.claude` to introspect a config directory other than the
80
+ default `~/.claude`.
81
+
82
+ ### From source
83
+
84
+ To hack on AgentGem, clone the repo and use [pnpm](https://pnpm.io/) (AgentBack
85
+ uses legacy decorators, so it builds with `tsc`, then runs `dist/`):
86
+
87
+ ```bash
88
+ pnpm install
89
+ pnpm dev # build + start in one step (→ node dist/index.js)
90
+ pnpm test # tsc -b && vitest run — tests run against compiled dist/
91
+ pnpm clean # rm -rf dist *.tsbuildinfo (run before testing after renames/moves)
92
+ ```
93
+
94
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
95
+
96
+ ## Layering
97
+
98
+ Depends on AgentBack: `@agentback/core` (lifecycle), `@agentback/rest` +
99
+ `@agentback/rest-explorer` (HTTP + Swagger UI), `@agentback/mcp` + `@agentback/mcp-http`
100
+ (MCP over HTTP), and `@agentback/openapi` (the OpenAPI 3.1 document). The web UI, the REST
101
+ API, and the MCP endpoint are three boundaries over one set of Zod contracts —
102
+ `src/index.ts` wires them onto a single `RestApplication`.
103
+
104
+ For deeper reference, see [`docs/`](docs/index.md):
105
+ [getting started](docs/getting-started.md) ·
106
+ [concepts](docs/concepts.md) ·
107
+ [targets & deploy](docs/targets.md) ·
108
+ [registry](docs/registry.md).
109
+
110
+ ## License
111
+
112
+ [MIT](LICENSE) © ninemind.ai
package/dist/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // src/cli.ts — the `agentgem` command. A thin wrapper over run() in index.ts:
3
+ // parses a couple of flags and starts the local server. Published as the `bin`
4
+ // entry so `npx @ninemind/agentgem` and a global install both work.
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+ import { run } from "./index.js";
9
+ function version() {
10
+ try {
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version ?? "0.0.0";
13
+ }
14
+ catch {
15
+ return "0.0.0";
16
+ }
17
+ }
18
+ const HELP = `agentgem — build secret-safe, composable Gems from your coding-agent config
19
+
20
+ Usage:
21
+ agentgem [options]
22
+
23
+ Options:
24
+ -p, --port <n> Port to listen on (default: 4317, or $PORT)
25
+ -v, --version Print version and exit
26
+ -h, --help Show this help
27
+
28
+ Once running, open the printed URL (default http://127.0.0.1:4317/). Append
29
+ ?dir=/path/to/.claude to introspect a config directory other than ~/.claude.`;
30
+ async function main(argv) {
31
+ const has = (...names) => names.some((n) => argv.includes(n));
32
+ const opt = (...names) => {
33
+ for (const n of names) {
34
+ const i = argv.indexOf(n);
35
+ if (i >= 0)
36
+ return argv[i + 1];
37
+ }
38
+ return undefined;
39
+ };
40
+ if (has("-h", "--help"))
41
+ return void console.log(HELP);
42
+ if (has("-v", "--version"))
43
+ return void console.log(version());
44
+ const portArg = opt("-p", "--port");
45
+ const port = Number(portArg ?? process.env.PORT ?? 4317);
46
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
47
+ console.error(`agentgem: invalid port "${portArg}"`);
48
+ process.exit(1);
49
+ }
50
+ await run(port);
51
+ }
52
+ main(process.argv.slice(2)).catch((err) => {
53
+ console.error(err);
54
+ process.exit(1);
55
+ });
@@ -0,0 +1,91 @@
1
+ import { buildAgentcoreHarness } from "./targets.js";
2
+ // CreateHarness returns while the harness is still CREATING; these gate the poll-to-terminal loop.
3
+ const POLL_INTERVAL_MS = 3000;
4
+ const POLL_MAX_ATTEMPTS = 40;
5
+ const isTerminalHarnessStatus = (s) => /ready|fail/i.test(s);
6
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+ // CreateHarness harnessName pattern: [a-zA-Z][a-zA-Z0-9_]{0,39}
8
+ export function harnessNameFor(gem) {
9
+ let n = (gem.name || "agent").replace(/[^a-zA-Z0-9_]/g, "");
10
+ if (!/^[a-zA-Z]/.test(n))
11
+ n = "a" + n;
12
+ return n.slice(0, 40) || "agent";
13
+ }
14
+ export function buildCreateHarnessRequest(gem, opts) {
15
+ const { harness, skipped } = buildAgentcoreHarness(gem); // systemPrompt + tools + (path) skills + model
16
+ const skills = gem.artifacts.filter((a) => a.type === "skill");
17
+ for (const s of skills)
18
+ skipped.push({ artifact: s.name, type: "skill", reason: "AgentCore publish needs a git/s3 skill source; local skill not carried by the gem" });
19
+ const request = {
20
+ harnessName: harnessNameFor(gem),
21
+ executionRoleArn: opts.executionRoleArn,
22
+ model: harness.model,
23
+ };
24
+ if (harness.systemPrompt)
25
+ request.systemPrompt = harness.systemPrompt;
26
+ if (harness.tools)
27
+ request.tools = harness.tools;
28
+ // NOTE: harness.skills (local path-skills) are intentionally NOT forwarded — publish can't upload files.
29
+ return { request, skipped, vaultSecrets: gem.requiredSecrets };
30
+ }
31
+ export function agentcorePublishReady() {
32
+ const hasId = !!(process.env.AWS_ACCESS_KEY_ID || process.env.AWS_PROFILE);
33
+ const hasRegion = !!(process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION);
34
+ return hasId && hasRegion && !!process.env.AGENTCORE_EXECUTION_ROLE_ARN;
35
+ }
36
+ export function realAgentcoreControlClient() {
37
+ return {
38
+ async createHarness(req) {
39
+ // Lazy import so the SDK isn't loaded unless a real publish runs.
40
+ const { BedrockAgentCoreControlClient, CreateHarnessCommand } = await import("@aws-sdk/client-bedrock-agentcore-control");
41
+ const client = new BedrockAgentCoreControlClient({});
42
+ const out = await client.send(new CreateHarnessCommand(req));
43
+ const h = out.harness ?? {};
44
+ return {
45
+ arn: String(h.arn ?? ""), harnessId: String(h.harnessId ?? ""), harnessName: String(h.harnessName ?? ""),
46
+ harnessVersion: String(h.harnessVersion ?? ""), status: String(h.status ?? ""), failureReason: h.failureReason,
47
+ };
48
+ },
49
+ async getHarness(harnessId) {
50
+ const { BedrockAgentCoreControlClient, GetHarnessCommand } = await import("@aws-sdk/client-bedrock-agentcore-control");
51
+ const client = new BedrockAgentCoreControlClient({});
52
+ const out = await client.send(new GetHarnessCommand({ harnessId }));
53
+ const h = out.harness ?? {};
54
+ return { status: String(h.status ?? ""), harnessVersion: String(h.harnessVersion ?? ""), failureReason: h.failureReason };
55
+ },
56
+ async deleteHarness(harnessId) {
57
+ const { BedrockAgentCoreControlClient, DeleteHarnessCommand } = await import("@aws-sdk/client-bedrock-agentcore-control");
58
+ const c = new BedrockAgentCoreControlClient({});
59
+ await c.send(new DeleteHarnessCommand({ harnessId }));
60
+ },
61
+ };
62
+ }
63
+ export async function undeployAgentcoreHarness(rec, client) {
64
+ if (rec.harnessId)
65
+ await client.deleteHarness(rec.harnessId);
66
+ }
67
+ export function previewAgentcorePublish(gem) {
68
+ const roleArn = process.env.AGENTCORE_EXECUTION_ROLE_ARN || "arn:aws:iam::ACCOUNT:role/REPLACE_WITH_HARNESS_ROLE";
69
+ const { request, skipped, vaultSecrets } = buildCreateHarnessRequest(gem, { executionRoleArn: roleArn });
70
+ return { kind: "agentcore-harness", request, skipped, vaultSecrets };
71
+ }
72
+ export async function deployAgentcorePublish(gem, _requestId, client = realAgentcoreControlClient()) {
73
+ const roleArn = process.env.AGENTCORE_EXECUTION_ROLE_ARN;
74
+ if (!roleArn)
75
+ throw new Error("AGENTCORE_EXECUTION_ROLE_ARN is not set — cannot create an AgentCore harness (execution role required).");
76
+ const { request, skipped, vaultSecrets } = buildCreateHarnessRequest(gem, { executionRoleArn: roleArn });
77
+ const h = await client.createHarness(request);
78
+ // Poll GetHarness until the harness reaches a terminal state (READY or *FAILED*); CreateHarness
79
+ // returns while still CREATING. Polls immediately (no leading sleep) and stops on terminal/attempts.
80
+ let status = h.status;
81
+ let harnessVersion = h.harnessVersion;
82
+ for (let i = 0; i < POLL_MAX_ATTEMPTS && !isTerminalHarnessStatus(status); i++) {
83
+ const g = await client.getHarness(h.harnessId);
84
+ status = g.status;
85
+ harnessVersion = g.harnessVersion || harnessVersion;
86
+ if (isTerminalHarnessStatus(status))
87
+ break;
88
+ await sleep(POLL_INTERVAL_MS);
89
+ }
90
+ return { kind: "agentcore-harness", harnessArn: h.arn, harnessId: h.harnessId, harnessName: h.harnessName, harnessVersion, status, skipped, vaultSecrets };
91
+ }
@@ -0,0 +1,85 @@
1
+ // src/gem/agentcoreRun.ts
2
+ // Deploy a workspace's rendered AgentCore project via the `agentcore` CLI. Peer of run.ts;
3
+ // reuses its ProcessRunner injection so command/state logic is unit-testable without a real CLI/AWS.
4
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { workspaceDir } from "./workspaces.js";
7
+ import { readGemArchive } from "./archive.js";
8
+ import { readArchiveDir, writeArchiveDir } from "./archiveFs.js";
9
+ import { materialize } from "./targets.js";
10
+ import { pushLog, runToEnd, realRunner } from "./run.js";
11
+ // Resolve the agentcore CLI: an explicit AGENTCORE_BIN, else the first `agentcore` on PATH.
12
+ export function resolveAgentcoreBin() {
13
+ const explicit = process.env.AGENTCORE_BIN;
14
+ if (explicit && existsSync(explicit))
15
+ return explicit;
16
+ for (const dir of (process.env.PATH ?? "").split(":")) {
17
+ if (!dir)
18
+ continue;
19
+ const p = join(dir, "agentcore");
20
+ if (existsSync(p))
21
+ return p;
22
+ }
23
+ return null;
24
+ }
25
+ export function agentcoreReadiness() {
26
+ const hasId = !!(process.env.AWS_ACCESS_KEY_ID || process.env.AWS_PROFILE);
27
+ const hasRegion = !!(process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION);
28
+ return { cli: !!resolveAgentcoreBin(), awsCreds: hasId && hasRegion };
29
+ }
30
+ // `agentcore deploy` prints the created harness ARN (and/or a console URL). Prefer the ARN.
31
+ export function parseAgentcoreEndpoint(lines) {
32
+ for (const l of lines) {
33
+ const arn = l.match(/arn:aws:bedrock-agentcore:[^\s"']+harness[^\s"']*/);
34
+ if (arn)
35
+ return arn[0];
36
+ }
37
+ for (const l of lines) {
38
+ const u = l.match(/https?:\/\/[^\s"']+/);
39
+ if (u)
40
+ return u[0];
41
+ }
42
+ return undefined;
43
+ }
44
+ // Re-render the workspace's gem to the agentcore target into a stable .run/agentcore dir.
45
+ export async function ensureAgentcoreProject(name, _runner, _log) {
46
+ const dir = workspaceDir(name);
47
+ if (!existsSync(join(dir, "gem.json")))
48
+ throw new Error(`no workspace '${name}'`);
49
+ const gem = readGemArchive(readArchiveDir(dir));
50
+ const { files } = materialize(gem, "agentcore");
51
+ const runDir = join(dir, ".run", "agentcore");
52
+ rmSync(runDir, { recursive: true, force: true }); // drop stale renders
53
+ mkdirSync(runDir, { recursive: true });
54
+ writeArchiveDir(runDir, files);
55
+ return runDir;
56
+ }
57
+ const registry = new Map();
58
+ export async function deployAgentcore(name, runner = realRunner) {
59
+ const bin = resolveAgentcoreBin();
60
+ if (!bin)
61
+ throw new Error("agentcore CLI not found — install `@aws/agentcore@preview` or set AGENTCORE_BIN.");
62
+ if (!agentcoreReadiness().awsCreds)
63
+ throw new Error("AWS credentials/region not configured (set AWS_PROFILE or AWS_ACCESS_KEY_ID + AWS_REGION).");
64
+ const state = { state: "deploying", logTail: [] };
65
+ registry.set(name, state);
66
+ try {
67
+ const runDir = await ensureAgentcoreProject(name, runner, state.logTail);
68
+ const code = await runToEnd(runner, bin, ["deploy"], runDir, process.env, state.logTail);
69
+ if (code !== 0) {
70
+ state.state = "failed";
71
+ return state;
72
+ }
73
+ state.url = parseAgentcoreEndpoint(state.logTail);
74
+ state.state = "idle";
75
+ return state;
76
+ }
77
+ catch (err) {
78
+ state.state = "failed";
79
+ pushLog(state.logTail, err instanceof Error ? err.message : String(err));
80
+ return state;
81
+ }
82
+ }
83
+ export function getAgentcoreStatus(name) {
84
+ return registry.get(name) ?? { state: "idle", logTail: [] };
85
+ }
@@ -0,0 +1,185 @@
1
+ import { createHash } from "node:crypto";
2
+ import { safePathSegment } from "./targets.js";
3
+ export const ARCHIVE_FORMAT_VERSION = 1;
4
+ const MANIFEST_PATH = "gem.json";
5
+ const LOCK_PATH = "gem.lock";
6
+ function sha256(s) {
7
+ return "sha256:" + createHash("sha256").update(s, "utf8").digest("hex");
8
+ }
9
+ // Deterministic JSON: object keys sorted recursively, arrays keep order.
10
+ function stableStringify(value) {
11
+ if (Array.isArray(value))
12
+ return "[" + value.map(stableStringify).join(",") + "]";
13
+ if (value && typeof value === "object") {
14
+ const keys = Object.keys(value).sort();
15
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") + "}";
16
+ }
17
+ return JSON.stringify(value);
18
+ }
19
+ function fileHash(p, content) {
20
+ if (p === MANIFEST_PATH)
21
+ return sha256(stableStringify(JSON.parse(content)));
22
+ return sha256(content);
23
+ }
24
+ export function computeLock(files) {
25
+ const paths = Object.keys(files).filter((p) => p !== LOCK_PATH).sort();
26
+ const fileDigests = {};
27
+ const manifestCanonical = MANIFEST_PATH in files ? stableStringify(JSON.parse(files[MANIFEST_PATH])) : "";
28
+ for (const p of paths) {
29
+ fileDigests[p] = fileHash(p, files[p]);
30
+ }
31
+ const fileLines = paths.map((p) => `${p}:${fileDigests[p]}`).join("\n");
32
+ const gemDigest = sha256(manifestCanonical + "\n" + fileLines);
33
+ return { formatVersion: ARCHIVE_FORMAT_VERSION, files: fileDigests, gemDigest, signature: null };
34
+ }
35
+ export function verifyLock(files, lock) {
36
+ const present = Object.keys(files).filter((p) => p !== LOCK_PATH);
37
+ const mismatches = [];
38
+ for (const p of present)
39
+ if (p in lock.files && fileHash(p, files[p]) !== lock.files[p])
40
+ mismatches.push(p);
41
+ const missing = Object.keys(lock.files).filter((p) => !(p in files));
42
+ const extra = present.filter((p) => !(p in lock.files));
43
+ let ok = mismatches.length === 0 && missing.length === 0 && extra.length === 0;
44
+ if (ok && computeLock(files).gemDigest !== lock.gemDigest) {
45
+ mismatches.push("gemDigest");
46
+ ok = false;
47
+ }
48
+ return { ok, mismatches, missing, extra };
49
+ }
50
+ export function writeGemArchive(gem, opts = {}) {
51
+ const files = {};
52
+ const skipped = [];
53
+ const artifacts = [];
54
+ const withExt = (s, ext) => (s.endsWith(ext) ? s : s + ext);
55
+ const place = (path, content, name, type) => {
56
+ if (path in files) {
57
+ skipped.push({ artifact: name, type, reason: `path collision with an earlier ${type} at ${path}` });
58
+ return false;
59
+ }
60
+ files[path] = content;
61
+ return true;
62
+ };
63
+ for (const a of gem.artifacts) {
64
+ const seg = safePathSegment(a.name);
65
+ if (a.type === "skill") {
66
+ const path = `skills/${seg}/SKILL.md`;
67
+ if (place(path, a.content, a.name, "skill")) {
68
+ const e = { type: "skill", name: a.name, path, source: a.source };
69
+ if (a.description !== undefined)
70
+ e.description = a.description;
71
+ artifacts.push(e);
72
+ }
73
+ }
74
+ else if (a.type === "instructions") {
75
+ const path = `instructions/${withExt(seg, ".md")}`;
76
+ if (place(path, a.content, a.name, "instructions"))
77
+ artifacts.push({ type: "instructions", name: a.name, path });
78
+ }
79
+ else if (a.type === "mcp_server") {
80
+ const path = `mcp/${withExt(seg, ".json")}`;
81
+ const body = { transport: a.transport, config: a.config };
82
+ if (a.source !== undefined)
83
+ body.source = a.source;
84
+ if (a.secretRefs !== undefined)
85
+ body.secretRefs = a.secretRefs;
86
+ if (place(path, JSON.stringify(body, null, 2), a.name, "mcp_server"))
87
+ artifacts.push({ type: "mcp_server", name: a.name, path });
88
+ }
89
+ else {
90
+ const path = `hooks/${withExt(seg, ".json")}`;
91
+ const body = { event: a.event, config: a.config };
92
+ if (a.matcher !== undefined)
93
+ body.matcher = a.matcher;
94
+ if (a.source !== undefined)
95
+ body.source = a.source;
96
+ if (a.secretRefs !== undefined)
97
+ body.secretRefs = a.secretRefs;
98
+ if (place(path, JSON.stringify(body, null, 2), a.name, "hook"))
99
+ artifacts.push({ type: "hook", name: a.name, path });
100
+ }
101
+ }
102
+ const checks = [];
103
+ for (const c of gem.checks) {
104
+ const path = `checks/${withExt(safePathSegment(c.name), ".json")}`;
105
+ if (path in files)
106
+ throw new Error(`check path collision: '${c.name}' sanitizes to ${path}, already taken`);
107
+ files[path] = JSON.stringify(c, null, 2);
108
+ checks.push({ name: c.name, path });
109
+ }
110
+ const manifest = {
111
+ formatVersion: ARCHIVE_FORMAT_VERSION,
112
+ name: gem.name,
113
+ version: opts.version ?? "0.1.0",
114
+ createdFrom: gem.createdFrom,
115
+ artifacts,
116
+ requiredSecrets: gem.requiredSecrets,
117
+ checks,
118
+ ...(opts.dependencies && opts.dependencies.length ? { dependencies: opts.dependencies } : {}),
119
+ };
120
+ files[MANIFEST_PATH] = JSON.stringify(manifest, null, 2);
121
+ files[LOCK_PATH] = JSON.stringify(computeLock(files), null, 2);
122
+ return { files, skipped };
123
+ }
124
+ export function readGemArchive(files) {
125
+ const manifestRaw = files[MANIFEST_PATH];
126
+ if (manifestRaw === undefined)
127
+ throw new Error("archive missing gem.json");
128
+ const lockRaw = files[LOCK_PATH];
129
+ if (lockRaw === undefined)
130
+ throw new Error("archive missing gem.lock");
131
+ const manifest = JSON.parse(manifestRaw);
132
+ const lock = JSON.parse(lockRaw);
133
+ const v = verifyLock(files, lock);
134
+ if (!v.ok) {
135
+ throw new Error(`gem.lock verification failed — mismatches:[${v.mismatches.join(",")}] missing:[${v.missing.join(",")}] extra:[${v.extra.join(",")}]`);
136
+ }
137
+ const body = (path) => {
138
+ const c = files[path];
139
+ if (c === undefined)
140
+ throw new Error(`manifest references missing file ${path}`);
141
+ return c;
142
+ };
143
+ const artifacts = manifest.artifacts.map((e) => {
144
+ if (e.type === "skill") {
145
+ const a = { type: "skill", name: e.name, source: e.source ?? "standalone", content: body(e.path) };
146
+ if (e.description !== undefined)
147
+ a.description = e.description;
148
+ return a;
149
+ }
150
+ if (e.type === "instructions") {
151
+ return { type: "instructions", name: e.name, content: body(e.path) };
152
+ }
153
+ if (e.type === "mcp_server") {
154
+ const o = JSON.parse(body(e.path));
155
+ const a = { type: "mcp_server", name: e.name, transport: o.transport, config: o.config };
156
+ if (o.source !== undefined)
157
+ a.source = o.source;
158
+ if (o.secretRefs !== undefined)
159
+ a.secretRefs = o.secretRefs;
160
+ return a;
161
+ }
162
+ const o = JSON.parse(body(e.path));
163
+ const a = { type: "hook", name: e.name, event: o.event, config: o.config };
164
+ if (o.matcher !== undefined)
165
+ a.matcher = o.matcher;
166
+ if (o.source !== undefined)
167
+ a.source = o.source;
168
+ if (o.secretRefs !== undefined)
169
+ a.secretRefs = o.secretRefs;
170
+ return a;
171
+ });
172
+ const checks = manifest.checks.map((c) => JSON.parse(body(c.path)));
173
+ return { name: manifest.name, createdFrom: manifest.createdFrom, artifacts, checks, requiredSecrets: manifest.requiredSecrets };
174
+ }
175
+ export function readGemMeta(files) {
176
+ const manifestRaw = files["gem.json"];
177
+ if (manifestRaw === undefined)
178
+ throw new Error("archive missing gem.json");
179
+ const lockRaw = files["gem.lock"];
180
+ if (lockRaw === undefined)
181
+ throw new Error("archive missing gem.lock");
182
+ const manifest = JSON.parse(manifestRaw);
183
+ const lock = JSON.parse(lockRaw);
184
+ return { name: manifest.name, version: manifest.version, dependencies: manifest.dependencies ?? [], gemDigest: lock.gemDigest };
185
+ }
@@ -0,0 +1,28 @@
1
+ // src/gem/archiveFs.ts
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ // Write each relative path under `root`, creating parent dirs. Overwrites existing files.
5
+ export function writeArchiveDir(root, files) {
6
+ for (const [rel, content] of Object.entries(files)) {
7
+ const abs = join(root, rel);
8
+ mkdirSync(dirname(abs), { recursive: true });
9
+ writeFileSync(abs, content, "utf8");
10
+ }
11
+ }
12
+ // Read every file under `root` into a FileTree keyed by POSIX-style relative path.
13
+ export function readArchiveDir(root) {
14
+ const files = {};
15
+ const walk = (d) => {
16
+ for (const entry of readdirSync(d)) {
17
+ if (d === root && entry.startsWith("."))
18
+ continue; // skip .targets/, .git/, etc. (archive files are never dot-prefixed)
19
+ const abs = join(d, entry);
20
+ if (statSync(abs).isDirectory())
21
+ walk(abs);
22
+ else
23
+ files[relative(root, abs).split(sep).join("/")] = readFileSync(abs, "utf8");
24
+ }
25
+ };
26
+ walk(root);
27
+ return files;
28
+ }
@@ -0,0 +1,66 @@
1
+ // src/gem/archiveTar.ts
2
+ // Bundle a FileTree into a single .tar.gz buffer and back — the archive's transport/shipping form
3
+ // (the directory tree is the canonical form). Dependency-free: a minimal POSIX ustar writer/reader
4
+ // over node:zlib gzip. In-process only (no disk/network), so the pure core stays pure.
5
+ import { gzipSync, gunzipSync } from "node:zlib";
6
+ const BLOCK = 512;
7
+ // Write `value` as a NUL-terminated octal ASCII field of width `len` at `offset`.
8
+ function writeOctal(buf, value, offset, len) {
9
+ const s = value.toString(8).padStart(len - 1, "0").slice(-(len - 1));
10
+ buf.write(s + "\0", offset, "latin1");
11
+ }
12
+ // Read a NUL-trimmed string field of width `len` at `offset`.
13
+ function readStr(buf, offset, len) {
14
+ const slice = buf.subarray(offset, offset + len);
15
+ const nul = slice.indexOf(0);
16
+ return slice.subarray(0, nul === -1 ? len : nul).toString("utf8");
17
+ }
18
+ export function packTar(files) {
19
+ const blocks = [];
20
+ for (const path of Object.keys(files).sort()) {
21
+ const content = Buffer.from(files[path], "utf8");
22
+ const header = Buffer.alloc(BLOCK);
23
+ header.write(path, 0, "utf8"); // name (paths are short; prefix field unused)
24
+ writeOctal(header, 0o644, 100, 8); // mode
25
+ writeOctal(header, 0, 108, 8); // uid
26
+ writeOctal(header, 0, 116, 8); // gid
27
+ writeOctal(header, content.length, 124, 12); // size
28
+ writeOctal(header, 0, 136, 12); // mtime (fixed 0 -> deterministic header)
29
+ header[156] = 0x30; // typeflag '0' = regular file
30
+ header.write("ustar\0", 257, "latin1"); // magic
31
+ header.write("00", 263, "latin1"); // version
32
+ // checksum: sum all 512 bytes with the chksum field as spaces, then write it back.
33
+ for (let i = 148; i < 156; i++)
34
+ header[i] = 0x20;
35
+ let sum = 0;
36
+ for (let i = 0; i < BLOCK; i++)
37
+ sum += header[i];
38
+ header.write(sum.toString(8).padStart(6, "0").slice(-6) + "\0 ", 148, "latin1");
39
+ blocks.push(header, content);
40
+ const pad = (BLOCK - (content.length % BLOCK)) % BLOCK;
41
+ if (pad)
42
+ blocks.push(Buffer.alloc(pad));
43
+ }
44
+ blocks.push(Buffer.alloc(BLOCK * 2)); // two zero blocks terminate the archive
45
+ return gzipSync(Buffer.concat(blocks));
46
+ }
47
+ export function unpackTar(buf) {
48
+ const tar = gunzipSync(buf);
49
+ const files = {};
50
+ let off = 0;
51
+ while (off + BLOCK <= tar.length) {
52
+ const name = readStr(tar, off, 100);
53
+ if (name === "")
54
+ break; // zero block => end of archive
55
+ const prefix = readStr(tar, off + 345, 155);
56
+ const full = prefix ? `${prefix}/${name}` : name;
57
+ const size = parseInt(readStr(tar, off + 124, 12).trim() || "0", 8);
58
+ const typeflag = tar[off + 156];
59
+ off += BLOCK;
60
+ if (typeflag === 0x30 || typeflag === 0) {
61
+ files[full] = tar.subarray(off, off + size).toString("utf8");
62
+ }
63
+ off += Math.ceil(size / BLOCK) * BLOCK;
64
+ }
65
+ return files;
66
+ }