@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 +21 -0
- package/README.md +112 -0
- package/dist/cli.js +55 -0
- package/dist/gem/agentcorePublish.js +91 -0
- package/dist/gem/agentcoreRun.js +85 -0
- package/dist/gem/archive.js +185 -0
- package/dist/gem/archiveFs.js +28 -0
- package/dist/gem/archiveTar.js +66 -0
- package/dist/gem/buildGem.js +88 -0
- package/dist/gem/checks.js +28 -0
- package/dist/gem/credentials.js +34 -0
- package/dist/gem/deploy.js +35 -0
- package/dist/gem/deployRecord.js +24 -0
- package/dist/gem/introspect.js +247 -0
- package/dist/gem/mcpProxy.js +53 -0
- package/dist/gem/publish.js +58 -0
- package/dist/gem/recents.js +39 -0
- package/dist/gem/redact.js +42 -0
- package/dist/gem/registry.js +233 -0
- package/dist/gem/registryGithub.js +74 -0
- package/dist/gem/run.js +322 -0
- package/dist/gem/targets.js +578 -0
- package/dist/gem/testbed.js +103 -0
- package/dist/gem/testbedFlavors.js +287 -0
- package/dist/gem/toml.js +120 -0
- package/dist/gem/types.js +1 -0
- package/dist/gem/workspaces.js +93 -0
- package/dist/gem.controller.js +518 -0
- package/dist/gem.tools.js +103 -0
- package/dist/index.js +59 -0
- package/dist/pickFolder.js +36 -0
- package/dist/public/index.html +1465 -0
- package/dist/publish.js +130 -0
- package/dist/resolveDir.js +26 -0
- package/dist/schemas.js +407 -0
- package/package.json +72 -0
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
|
+
}
|