@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
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { tomlMcpServers } from "./toml.js";
|
|
2
|
+
import { stdioProxyRunner, PROXY_BASE_PORT, PROXY_HOST } from "./mcpProxy.js";
|
|
3
|
+
export function safePathSegment(name) {
|
|
4
|
+
const safe = name.normalize("NFKC").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
5
|
+
return safe === "." || safe === ".." || safe.length === 0 ? "unnamed" : safe;
|
|
6
|
+
}
|
|
7
|
+
// Eve derives skill/connection names from the filename and requires the segment to START with an
|
|
8
|
+
// alphanumeric character. Strip leading non-alphanumerics from the safe segment.
|
|
9
|
+
const eveSegment = (name) => safePathSegment(name).replace(/^[^A-Za-z0-9]+/, "") || "unnamed";
|
|
10
|
+
// Flue worker + agent-file name: lower-kebab, alphanumeric+dashes only (Cloudflare worker name rules).
|
|
11
|
+
const flueName = (name) => {
|
|
12
|
+
const s = name.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
13
|
+
return s.length ? s : "agent";
|
|
14
|
+
};
|
|
15
|
+
// Exported alias so run.ts and other callers share one source of truth for the worker name.
|
|
16
|
+
export function flueWorkerName(gemName) { return flueName(gemName); }
|
|
17
|
+
// PascalCase of the kebab name; flue derives the Durable Object class as `Flue<Pascal>Agent`.
|
|
18
|
+
const fluePascal = (name) => flueName(name).split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("") || "Agent";
|
|
19
|
+
// Flue skills live under src/ (the agent file is src/agents/<name>.ts and imports ../skills/...).
|
|
20
|
+
const skillFlueMd = (a) => ({ [`src/skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
|
|
21
|
+
const rendered = (files) => ({ files, skipped: [] });
|
|
22
|
+
// ── shared convention renderers ──
|
|
23
|
+
const skillSkillMd = (a) => ({ [`skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
|
|
24
|
+
const skillDescriptionMd = (a) => ({ [`skills/${safePathSegment(a.name)}/DESCRIPTION.md`]: a.content });
|
|
25
|
+
// Strip a leading YAML frontmatter block ("---\n … \n---\n") if present; return the body.
|
|
26
|
+
function stripYamlFrontmatter(content) {
|
|
27
|
+
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(content);
|
|
28
|
+
return m ? content.slice(m[0].length) : content;
|
|
29
|
+
}
|
|
30
|
+
// Eve authored-skill shape allows only description/metadata/license. Re-emit a clean description
|
|
31
|
+
// (from the artifact) over the original body; omit frontmatter entirely when there's no description
|
|
32
|
+
// (eve falls back to the first body line). JSON.stringify yields a safe double-quoted YAML scalar.
|
|
33
|
+
const skillEveMd = (a) => {
|
|
34
|
+
const body = stripYamlFrontmatter(a.content);
|
|
35
|
+
const desc = a.description?.trim();
|
|
36
|
+
const out = desc ? `---\ndescription: ${JSON.stringify(desc)}\n---\n${body}` : body;
|
|
37
|
+
return { [`agent/skills/${eveSegment(a.name)}.md`]: out };
|
|
38
|
+
};
|
|
39
|
+
// AgentCore path-skills live on the harness filesystem; emit each skill body under .agents/skills/<seg>/.
|
|
40
|
+
const skillAgentcoreMd = (a) => ({ [`.agents/skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
|
|
41
|
+
const eveConnection = (server, url) => {
|
|
42
|
+
const refs = server.secretRefs ?? [];
|
|
43
|
+
const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
|
|
44
|
+
const headerEntries = refs
|
|
45
|
+
.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
|
|
46
|
+
.map((r) => [r.location.slice("headers.".length), r.name]);
|
|
47
|
+
const auth = authorization
|
|
48
|
+
? `,\n auth: { getToken: async () => ({ token: process.env[${JSON.stringify(authorization.name)}]! }) }`
|
|
49
|
+
: "";
|
|
50
|
+
const headers = headerEntries.length
|
|
51
|
+
? `,\n headers: { ${headerEntries.map(([header, env]) => `${JSON.stringify(header)}: process.env[${JSON.stringify(env)}]!`).join(", ")} }`
|
|
52
|
+
: "";
|
|
53
|
+
return `import { defineMcpClientConnection } from "eve/connections";\n\nexport default defineMcpClientConnection({\n url: ${JSON.stringify(url)},\n description: ${JSON.stringify(server.name)}${auth}${headers},\n});\n`;
|
|
54
|
+
};
|
|
55
|
+
// Eve MCP connections: one TS file per http/sse server (auth reads the secret from an env var name,
|
|
56
|
+
// never a value). eve connections are URL-only, so stdio (and url-less http) servers are skipped.
|
|
57
|
+
const mcpEveConnections = (servers) => {
|
|
58
|
+
const files = {};
|
|
59
|
+
const skipped = [];
|
|
60
|
+
for (const s of servers) {
|
|
61
|
+
const segment = eveSegment(s.name);
|
|
62
|
+
const connectionPath = `agent/connections/${segment}.ts`;
|
|
63
|
+
if (connectionPath in files) {
|
|
64
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `path collision with an earlier mcp_server at ${connectionPath}` });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const url = typeof s.config.url === "string" ? s.config.url : "";
|
|
68
|
+
if (/^https?:\/\//.test(url)) {
|
|
69
|
+
const unsupportedSecret = (s.secretRefs ?? []).find((r) => !/^headers\./i.test(r.location));
|
|
70
|
+
if (unsupportedSecret) {
|
|
71
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `Eve cannot map secret at ${unsupportedSecret.location}` });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
files[connectionPath] = eveConnection(s, url);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `eve connections require an HTTP/SSE URL; ${s.transport} MCP unsupported` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { files, skipped };
|
|
81
|
+
};
|
|
82
|
+
// ── AgentCore harness renderers ──
|
|
83
|
+
const AGENTCORE_MODEL_ID = "global.anthropic.claude-sonnet-4-6";
|
|
84
|
+
// A token-vault placeholder for a secret header value. REGION/ACCOUNT are left as literal
|
|
85
|
+
// placeholders for the user to fill (SECRETS.md lists the `agentcore add credential` commands).
|
|
86
|
+
const agentcoreSecretRef = (name) => `\${arn:aws:bedrock-agentcore:REGION:ACCOUNT:token-vault/default/apikeycredentialprovider/${name}}`;
|
|
87
|
+
// http/sse MCP -> a remote_mcp tool. Secret header values become token-vault placeholders.
|
|
88
|
+
// stdio (and url-less http) servers are skipped: the harness is remote-URL only.
|
|
89
|
+
const agentcoreMcpTools = (servers) => {
|
|
90
|
+
const tools = [];
|
|
91
|
+
const skipped = [];
|
|
92
|
+
for (const s of servers) {
|
|
93
|
+
const url = typeof s.config.url === "string" ? s.config.url : "";
|
|
94
|
+
if (!/^https?:\/\//.test(url)) {
|
|
95
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `AgentCore remote_mcp requires an HTTP/SSE URL; ${s.transport === "stdio" ? "stdio MCP unsupported" : "no URL found"}` });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const refs = s.secretRefs ?? [];
|
|
99
|
+
const unsupportedSecret = refs.find((r) => !/^headers\./i.test(r.location));
|
|
100
|
+
if (unsupportedSecret) {
|
|
101
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `AgentCore cannot map secret at ${unsupportedSecret.location}` });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const headerEntries = refs
|
|
105
|
+
.filter((r) => /^headers\./i.test(r.location))
|
|
106
|
+
.map((r) => [r.location.slice("headers.".length), agentcoreSecretRef(r.name)]);
|
|
107
|
+
const remoteMcp = { url };
|
|
108
|
+
if (headerEntries.length)
|
|
109
|
+
remoteMcp.headers = Object.fromEntries(headerEntries);
|
|
110
|
+
tools.push({ type: "remote_mcp", name: s.name, config: { remoteMcp } });
|
|
111
|
+
}
|
|
112
|
+
return { tools, skipped };
|
|
113
|
+
};
|
|
114
|
+
// Assemble the harness.json object. model is always present; systemPrompt/tools/skills only when non-empty.
|
|
115
|
+
export const buildAgentcoreHarness = (gem) => {
|
|
116
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
117
|
+
const mcp = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
118
|
+
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
119
|
+
const { tools, skipped } = agentcoreMcpTools(mcp);
|
|
120
|
+
const harness = { model: { bedrockModelConfig: { modelId: AGENTCORE_MODEL_ID } } };
|
|
121
|
+
if (instr.length)
|
|
122
|
+
harness.systemPrompt = [{ text: instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n") }];
|
|
123
|
+
if (tools.length)
|
|
124
|
+
harness.tools = tools;
|
|
125
|
+
if (skills.length) {
|
|
126
|
+
// Dedupe by path: two skill names can collapse to the same safePathSegment, and the harness
|
|
127
|
+
// skills[] must not carry a duplicate path (the files are deduped by materialize's merge).
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const paths = [];
|
|
130
|
+
for (const s of skills) {
|
|
131
|
+
const path = `.agents/skills/${safePathSegment(s.name)}`;
|
|
132
|
+
if (seen.has(path))
|
|
133
|
+
continue;
|
|
134
|
+
seen.add(path);
|
|
135
|
+
paths.push({ path });
|
|
136
|
+
}
|
|
137
|
+
harness.skills = paths;
|
|
138
|
+
}
|
|
139
|
+
return { harness, skipped };
|
|
140
|
+
};
|
|
141
|
+
// ── AgentCore project scaffold (harness.json + deployment files) ──
|
|
142
|
+
const AGENTCORE_DOCKERFILE = `# AgentCore harness custom image: bakes local skills onto the harness filesystem
|
|
143
|
+
# so the harness.json path-skills resolve. Build with: agentcore deploy --build Container
|
|
144
|
+
FROM public.ecr.aws/bedrock-agentcore/harness-base:latest
|
|
145
|
+
COPY .agents/skills/ .agents/skills/
|
|
146
|
+
`;
|
|
147
|
+
// Matches the shape `agentcore create` scaffolds (schema v1): top-level name/version/managedBy +
|
|
148
|
+
// the resource arrays the CLI expects, with the single harness registered under `harnesses`.
|
|
149
|
+
const agentcoreProjectJson = (gemName) => {
|
|
150
|
+
const name = safePathSegment(gemName);
|
|
151
|
+
return JSON.stringify({
|
|
152
|
+
$schema: "https://schema.agentcore.aws.dev/v1/agentcore.json",
|
|
153
|
+
name,
|
|
154
|
+
version: 1,
|
|
155
|
+
managedBy: "CDK",
|
|
156
|
+
tags: { "agentcore:created-by": "agentgem", "agentcore:project-name": name },
|
|
157
|
+
runtimes: [], memories: [], knowledgeBases: [], credentials: [], evaluators: [],
|
|
158
|
+
onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], configBundles: [],
|
|
159
|
+
abTests: [], harnesses: [{ name, path: `app/${name}` }], datasets: [], payments: [],
|
|
160
|
+
}, null, 2) + "\n";
|
|
161
|
+
};
|
|
162
|
+
// `agentcore create` scaffolds an empty targets list; `agentcore deploy` resolves account/region from AWS creds.
|
|
163
|
+
const AGENTCORE_AWS_TARGETS = "[]\n";
|
|
164
|
+
const agentcoreSecretsMd = (secrets) => {
|
|
165
|
+
if (!secrets.length)
|
|
166
|
+
return `# Secrets\n\nThis agent declares no secrets.\n`;
|
|
167
|
+
const lines = secrets.map((s) => `- \`${s.name}\` (for ${s.artifact} at ${s.location}):\n \`\`\`\n agentcore add credential --type api-key --name ${s.name} --api-key <value>\n \`\`\``);
|
|
168
|
+
return `# Secrets\n\nRegister each credential in AgentCore Identity, then replace \`REGION\`/\`ACCOUNT\` in the \`\${arn:...}\` placeholders in \`app/<agent>/harness.json\`:\n\n${lines.join("\n")}\n`;
|
|
169
|
+
};
|
|
170
|
+
// Cross-cutting scaffold in the AgentCore CLI's project format (verified against `agentcore create`):
|
|
171
|
+
// harness.json uses model {provider,modelId} and carries no inline prompt — the system prompt lives in
|
|
172
|
+
// a sibling system-prompt.md. (The raw CreateHarness API shape, used by the publish backend, differs.)
|
|
173
|
+
export const agentcoreComposeProject = (gem) => {
|
|
174
|
+
const seg = safePathSegment(gem.name);
|
|
175
|
+
const { harness, skipped } = buildAgentcoreHarness(gem);
|
|
176
|
+
const systemPrompt = harness.systemPrompt?.[0]?.text ?? "You are a helpful assistant.";
|
|
177
|
+
const harnessJson = {
|
|
178
|
+
name: seg,
|
|
179
|
+
model: { provider: "bedrock", modelId: AGENTCORE_MODEL_ID },
|
|
180
|
+
tools: harness.tools ?? [],
|
|
181
|
+
skills: harness.skills ?? [],
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
files: {
|
|
185
|
+
[`app/${seg}/harness.json`]: JSON.stringify(harnessJson, null, 2) + "\n",
|
|
186
|
+
[`app/${seg}/system-prompt.md`]: systemPrompt + "\n",
|
|
187
|
+
"agentcore/agentcore.json": agentcoreProjectJson(gem.name),
|
|
188
|
+
"agentcore/aws-targets.json": AGENTCORE_AWS_TARGETS,
|
|
189
|
+
"Dockerfile": AGENTCORE_DOCKERFILE,
|
|
190
|
+
"SECRETS.md": agentcoreSecretsMd(gem.requiredSecrets),
|
|
191
|
+
},
|
|
192
|
+
skipped,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
// Multiple instruction artifacts concatenate into the target's single canonical file,
|
|
196
|
+
// each under a "## <name>" separator so provenance survives.
|
|
197
|
+
const concatInstructions = (file) => (all) => ({ [file]: all.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n") });
|
|
198
|
+
const instructionsClaudeMd = concatInstructions("CLAUDE.md");
|
|
199
|
+
const instructionsAgentsMd = concatInstructions("AGENTS.md");
|
|
200
|
+
const instructionsSoulMd = concatInstructions("SOUL.md");
|
|
201
|
+
const mcpDotMcpJson = (servers) => rendered({ ".mcp.json": JSON.stringify({ mcpServers: Object.fromEntries(servers.map((s) => [s.name, s.config])) }, null, 2) });
|
|
202
|
+
const mcpCodexToml = (servers) => rendered({ "config.toml": tomlMcpServers(servers) });
|
|
203
|
+
// Reconstruct settings.json's `.hooks` event map. HookArtifact.config IS the group object
|
|
204
|
+
// ({ matcher?, hooks: [...] }) captured by introspect, so we group those back under their event.
|
|
205
|
+
function hooksToEventMap(hooks) {
|
|
206
|
+
const out = {};
|
|
207
|
+
for (const h of hooks)
|
|
208
|
+
(out[h.event] ??= []).push(h.config);
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
const hooksSettingsJson = (hooks) => ({ "settings.json": JSON.stringify({ hooks: hooksToEventMap(hooks) }, null, 2) });
|
|
212
|
+
// Flue: a single src/agents/<gemname>.ts registers the agent. It imports each skill (from
|
|
213
|
+
// src/skills/<n>/SKILL.md bodies), folds instruction artifacts into the `instructions` string, and lists
|
|
214
|
+
// the skills. MCP connection files are emitted by the `mcp` renderer and imported by the agent file
|
|
215
|
+
// (flueComposeAgent), which awaits them and spreads their adapted tools into the agent's `tools`.
|
|
216
|
+
function escapeTemplate(s) {
|
|
217
|
+
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
218
|
+
}
|
|
219
|
+
// One TS factory per MCP server. http/sse -> a direct remote connection (auth reads the secret from an
|
|
220
|
+
// env var name, never a value). stdio -> a localhost connection plus a generated proxy runner under
|
|
221
|
+
// proxies/ that bridges the stdio server to HTTP (same mechanism as Eve).
|
|
222
|
+
const flueConnection = (server, url) => {
|
|
223
|
+
const refs = server.secretRefs ?? [];
|
|
224
|
+
const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
|
|
225
|
+
const headerEntries = [
|
|
226
|
+
...(authorization ? [["Authorization", authorization.name]] : []),
|
|
227
|
+
...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
|
|
228
|
+
.map((r) => [r.location.slice("headers.".length), r.name]),
|
|
229
|
+
];
|
|
230
|
+
const transport = server.transport === "sse" ? `,\n transport: "sse"` : "";
|
|
231
|
+
const headers = headerEntries.length
|
|
232
|
+
? `,\n headers: { ${headerEntries.map(([h, env]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(env)}]!`).join(", ")} }`
|
|
233
|
+
: "";
|
|
234
|
+
return `import { connectMcpServer } from "@flue/runtime";\n\nexport default () => connectMcpServer(${JSON.stringify(server.name)}, {\n url: ${JSON.stringify(url)}${transport}${headers},\n});\n`;
|
|
235
|
+
};
|
|
236
|
+
// Plan the src/connections/<seg>.ts files (and stdio src/proxies/) for the MCP servers, returning the segs
|
|
237
|
+
// that got a file in iteration order. Single source of truth: both the `mcp` renderer (which writes
|
|
238
|
+
// the files) and `compose` (which imports them into the agent) consume this, so the agent never
|
|
239
|
+
// imports a connection that wasn't emitted, nor strands one that was.
|
|
240
|
+
function flueConnectionFiles(servers) {
|
|
241
|
+
const files = {};
|
|
242
|
+
const emitted = [];
|
|
243
|
+
const skipped = [];
|
|
244
|
+
let port = PROXY_BASE_PORT;
|
|
245
|
+
for (const s of servers) {
|
|
246
|
+
const seg = safePathSegment(s.name);
|
|
247
|
+
const connectionPath = `src/connections/${seg}.ts`;
|
|
248
|
+
if (connectionPath in files) {
|
|
249
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `path collision with an earlier mcp_server at ${connectionPath}` });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const url = typeof s.config.url === "string" ? s.config.url : "";
|
|
253
|
+
if (/^https?:\/\//.test(url)) {
|
|
254
|
+
const unsupportedSecret = (s.secretRefs ?? []).find((r) => !/^headers\./i.test(r.location));
|
|
255
|
+
if (unsupportedSecret) {
|
|
256
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `Flue cannot map secret at ${unsupportedSecret.location}` });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
files[connectionPath] = flueConnection(s, url);
|
|
260
|
+
emitted.push(seg);
|
|
261
|
+
}
|
|
262
|
+
else if (s.transport === "stdio" && typeof s.config.command === "string") {
|
|
263
|
+
const p = port++;
|
|
264
|
+
const args = Array.isArray(s.config.args) ? s.config.args.filter((a) => typeof a === "string") : [];
|
|
265
|
+
// localhost proxy connection carries no auth headers (the proxy injects the secrets into the stdio process)
|
|
266
|
+
files[connectionPath] = flueConnection({ ...s, secretRefs: undefined }, `http://${PROXY_HOST}:${p}/mcp`);
|
|
267
|
+
files[`src/proxies/${seg}.mjs`] = stdioProxyRunner(s.name, s.config.command, args, (s.secretRefs ?? []).map((r) => r.name), p);
|
|
268
|
+
emitted.push(seg);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: `${s.transport} MCP has no usable URL or stdio command` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { files, emitted, skipped };
|
|
275
|
+
}
|
|
276
|
+
const mcpFlueConnections = (servers) => {
|
|
277
|
+
const { files, skipped } = flueConnectionFiles(servers);
|
|
278
|
+
return { files, skipped };
|
|
279
|
+
};
|
|
280
|
+
const flueComposeAgent = (gem) => {
|
|
281
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
282
|
+
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
283
|
+
const mcps = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
284
|
+
const skillImports = skills.map((s, i) => `import skill${i} from "../skills/${safePathSegment(s.name)}/SKILL.md" with { type: "skill" };`).join("\n");
|
|
285
|
+
const instructions = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
|
|
286
|
+
const skillList = skills.map((_, i) => `skill${i}`).join(", ");
|
|
287
|
+
// Wire the emitted MCP connections into the agent's tools. connectMcpServer is async, so when there
|
|
288
|
+
// are connections the initializer goes async, awaits the connection thunks, and spreads their adapted
|
|
289
|
+
// tools. The connections stay open for the agent's lifetime (no .close() — unlike a transient run()).
|
|
290
|
+
const { emitted } = flueConnectionFiles(mcps);
|
|
291
|
+
const connImports = emitted.map((seg, i) => `import conn${i} from "../connections/${seg}.ts";`).join("\n");
|
|
292
|
+
const importBlock = [skillImports, connImports].filter(Boolean).join("\n");
|
|
293
|
+
const fields = [`model: "anthropic/claude-sonnet-4-6",`, `instructions,`, `skills: [${skillList}],`];
|
|
294
|
+
const indent = (lines, n) => lines.map((l) => " ".repeat(n) + l).join("\n");
|
|
295
|
+
const initializer = emitted.length
|
|
296
|
+
? `createAgent(async () => {
|
|
297
|
+
const connections = await Promise.all([${emitted.map((_, i) => `conn${i}()`).join(", ")}]);
|
|
298
|
+
return {
|
|
299
|
+
${indent([...fields, `tools: connections.flatMap((c) => c.tools),`], 4)}
|
|
300
|
+
};
|
|
301
|
+
})`
|
|
302
|
+
: `createAgent(() => ({
|
|
303
|
+
${indent(fields, 2)}
|
|
304
|
+
}))`;
|
|
305
|
+
const file = `import { createAgent, type AgentRouteHandler } from "@flue/runtime";
|
|
306
|
+
${importBlock}${importBlock ? "\n" : ""}
|
|
307
|
+
export const route: AgentRouteHandler = async (_c, next) => next();
|
|
308
|
+
|
|
309
|
+
const instructions = \`${escapeTemplate(instructions)}\`;
|
|
310
|
+
|
|
311
|
+
export default ${initializer};
|
|
312
|
+
`;
|
|
313
|
+
const wname = flueWorkerName(gem.name); // single source of truth shared with run.ts's deploy record
|
|
314
|
+
const doClass = `Flue${fluePascal(gem.name)}Agent`;
|
|
315
|
+
const flueConfig = `import { defineConfig } from "@flue/cli/config";\nexport default defineConfig({ target: "cloudflare" });\n`;
|
|
316
|
+
const pkg = JSON.stringify({
|
|
317
|
+
name: wname, version: "0.1.0", private: true, type: "module",
|
|
318
|
+
scripts: { build: "flue build --target cloudflare", deploy: "wrangler deploy" },
|
|
319
|
+
dependencies: { "@flue/runtime": "^1.0.0-beta.2", valibot: "^1", agents: "^0.14.1" },
|
|
320
|
+
devDependencies: { "@flue/cli": "^1.0.0-beta.1", wrangler: "^4" },
|
|
321
|
+
}, null, 2) + "\n";
|
|
322
|
+
const wrangler = JSON.stringify({
|
|
323
|
+
name: wname,
|
|
324
|
+
compatibility_date: "2026-06-01",
|
|
325
|
+
compatibility_flags: ["nodejs_compat"],
|
|
326
|
+
migrations: [{ tag: "v1", new_sqlite_classes: ["FlueRegistry", doClass] }],
|
|
327
|
+
}, null, 2) + "\n";
|
|
328
|
+
return rendered({
|
|
329
|
+
[`src/agents/${wname}.ts`]: file,
|
|
330
|
+
"flue.config.ts": flueConfig,
|
|
331
|
+
"package.json": pkg,
|
|
332
|
+
"wrangler.jsonc": wrangler,
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
const sandboxMcpServer = (s) => {
|
|
336
|
+
const url = typeof s.config.url === "string" ? s.config.url : "";
|
|
337
|
+
if (/^https?:\/\//.test(url)) {
|
|
338
|
+
const refs = s.secretRefs ?? [];
|
|
339
|
+
const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
|
|
340
|
+
if (unsupported)
|
|
341
|
+
return { skip: `OpenAI sandbox cannot map secret at ${unsupported.location}` };
|
|
342
|
+
const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
|
|
343
|
+
const headerEntries = [
|
|
344
|
+
...(authorization ? [["Authorization", authorization.name]] : []),
|
|
345
|
+
...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization).map((r) => [r.location.slice("headers.".length), r.name]),
|
|
346
|
+
];
|
|
347
|
+
const requestInit = headerEntries.length
|
|
348
|
+
? `, requestInit: { headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} } }`
|
|
349
|
+
: "";
|
|
350
|
+
return { code: ` new MCPServerStreamableHttp({ name: ${JSON.stringify(s.name)}, url: ${JSON.stringify(url)}${requestInit} }),`, cls: "MCPServerStreamableHttp" };
|
|
351
|
+
}
|
|
352
|
+
if (s.transport === "stdio" && typeof s.config.command === "string") {
|
|
353
|
+
const args = Array.isArray(s.config.args) ? s.config.args.filter((a) => typeof a === "string") : [];
|
|
354
|
+
const envNames = (s.secretRefs ?? []).map((r) => r.name);
|
|
355
|
+
const argsStr = args.length ? `, args: ${JSON.stringify(args)}` : "";
|
|
356
|
+
const envStr = envNames.length ? `, env: { ${envNames.map((n) => `${JSON.stringify(n)}: process.env[${JSON.stringify(n)}]!`).join(", ")} }` : "";
|
|
357
|
+
return { code: ` new MCPServerStdio({ name: ${JSON.stringify(s.name)}, command: ${JSON.stringify(s.config.command)}${argsStr}${envStr} }),`, cls: "MCPServerStdio" };
|
|
358
|
+
}
|
|
359
|
+
return { skip: `${s.transport} MCP has no usable URL or stdio command` };
|
|
360
|
+
};
|
|
361
|
+
const sandboxComposeAgent = (gem) => {
|
|
362
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
363
|
+
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
364
|
+
const mcps = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
365
|
+
const instructions = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
|
|
366
|
+
const hasSkills = skills.length > 0;
|
|
367
|
+
const sandboxImport = hasSkills
|
|
368
|
+
? `import { SandboxAgent, Manifest, localDir, shell, filesystem, skills, compaction } from "@openai/agents/sandbox";`
|
|
369
|
+
: `import { SandboxAgent, Manifest, shell, filesystem, compaction } from "@openai/agents/sandbox";`;
|
|
370
|
+
const capabilities = hasSkills ? "[shell(), filesystem(), skills(), compaction()]" : "[shell(), filesystem(), compaction()]";
|
|
371
|
+
const manifestEntries = hasSkills ? `{ skills: localDir({ from: "skills", readOnly: true }) }` : "{}";
|
|
372
|
+
// Render MCP servers inline.
|
|
373
|
+
const skipped = [];
|
|
374
|
+
const serverCodes = [];
|
|
375
|
+
const usedClasses = new Set();
|
|
376
|
+
for (const s of mcps) {
|
|
377
|
+
const res = sandboxMcpServer(s);
|
|
378
|
+
if ("skip" in res) {
|
|
379
|
+
skipped.push({ artifact: s.name, type: "mcp_server", reason: res.skip });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
serverCodes.push(res.code);
|
|
383
|
+
usedClasses.add(res.cls);
|
|
384
|
+
}
|
|
385
|
+
const mcpImport = usedClasses.size ? `import { ${[...usedClasses].sort().join(", ")} } from "@openai/agents";\n` : "";
|
|
386
|
+
const mcpServers = serverCodes.length ? `\n mcpServers: [\n${serverCodes.join("\n")}\n ],` : "";
|
|
387
|
+
const file = `${sandboxImport}
|
|
388
|
+
${mcpImport}
|
|
389
|
+
export const agent = new SandboxAgent({
|
|
390
|
+
name: ${JSON.stringify(gem.name)},
|
|
391
|
+
model: "gpt-5.5",
|
|
392
|
+
instructions: \`${escapeTemplate(instructions)}\`,
|
|
393
|
+
capabilities: ${capabilities},
|
|
394
|
+
defaultManifest: new Manifest({ entries: ${manifestEntries} }),${mcpServers}
|
|
395
|
+
});
|
|
396
|
+
`;
|
|
397
|
+
return { files: { [`${safePathSegment(gem.name)}.agent.ts`]: file }, skipped };
|
|
398
|
+
};
|
|
399
|
+
// ── Eve runnable-project scaffold (templates pinned to eve 0.11.x, from `eve init`) ──
|
|
400
|
+
const EVE_TSCONFIG = `{
|
|
401
|
+
"compilerOptions": {
|
|
402
|
+
"target": "ES2022",
|
|
403
|
+
"module": "NodeNext",
|
|
404
|
+
"moduleResolution": "NodeNext",
|
|
405
|
+
"types": ["node"],
|
|
406
|
+
"strict": true,
|
|
407
|
+
"esModuleInterop": true,
|
|
408
|
+
"skipLibCheck": true,
|
|
409
|
+
"noEmit": true
|
|
410
|
+
},
|
|
411
|
+
"include": ["agent/**/*.ts", "evals/**/*.ts", ".eve/**/*.d.ts"]
|
|
412
|
+
}
|
|
413
|
+
`;
|
|
414
|
+
const EVE_AGENT_TS = `import { defineAgent } from "eve";
|
|
415
|
+
|
|
416
|
+
export default defineAgent({
|
|
417
|
+
model: "anthropic/claude-sonnet-4.6",
|
|
418
|
+
});
|
|
419
|
+
`;
|
|
420
|
+
// eve's channel auth posture. "placeholder" (default) is eve's secure scaffold — deployed agents
|
|
421
|
+
// reject non-Vercel-OIDC production requests until you wire a real provider. "public" uses none(),
|
|
422
|
+
// making the deployed agent (and its tools) reachable by anyone — handy for a demo / `eve dev`.
|
|
423
|
+
const eveChannelTs = (authMode) => authMode === "public"
|
|
424
|
+
? `import { eveChannel } from "eve/channels/eve";
|
|
425
|
+
import { localDev, none } from "eve/channels/auth";
|
|
426
|
+
|
|
427
|
+
export default eveChannel({
|
|
428
|
+
auth: [
|
|
429
|
+
localDev(),
|
|
430
|
+
// PUBLIC: anyone can reach this agent and call its tools (chosen at deploy time for a demo).
|
|
431
|
+
none(),
|
|
432
|
+
],
|
|
433
|
+
});
|
|
434
|
+
`
|
|
435
|
+
: `import { eveChannel } from "eve/channels/eve";
|
|
436
|
+
import { localDev, placeholderAuth, vercelOidc } from "eve/channels/auth";
|
|
437
|
+
|
|
438
|
+
export default eveChannel({
|
|
439
|
+
auth: [
|
|
440
|
+
// Open on localhost for \`eve dev\` and the REPL; ignored in production.
|
|
441
|
+
localDev(),
|
|
442
|
+
// Lets the eve TUI and your Vercel deployments reach the deployed agent.
|
|
443
|
+
vercelOidc(),
|
|
444
|
+
// This placeholder will not allow browser requests in production.
|
|
445
|
+
// Replace it with your app's auth provider, like Auth.js or Clerk,
|
|
446
|
+
// or use none() for a public demo.
|
|
447
|
+
placeholderAuth(),
|
|
448
|
+
],
|
|
449
|
+
});
|
|
450
|
+
`;
|
|
451
|
+
const EVE_GITIGNORE = `node_modules
|
|
452
|
+
.env*
|
|
453
|
+
.eve
|
|
454
|
+
.vercel
|
|
455
|
+
.workflow-data
|
|
456
|
+
.next
|
|
457
|
+
.output
|
|
458
|
+
.nitro
|
|
459
|
+
dist
|
|
460
|
+
.DS_Store
|
|
461
|
+
*.tsbuildinfo
|
|
462
|
+
`;
|
|
463
|
+
const EVE_VERCELIGNORE = `node_modules
|
|
464
|
+
.env*
|
|
465
|
+
.eve
|
|
466
|
+
.workflow-data
|
|
467
|
+
.next
|
|
468
|
+
.output
|
|
469
|
+
.nitro
|
|
470
|
+
dist
|
|
471
|
+
`;
|
|
472
|
+
const evePackageJson = (gemName) => JSON.stringify({
|
|
473
|
+
name: safePathSegment(gemName).toLowerCase(),
|
|
474
|
+
version: "0.0.0",
|
|
475
|
+
type: "module",
|
|
476
|
+
imports: { "#*": "./agent/*", "#evals/*": "./evals/*" },
|
|
477
|
+
scripts: { build: "eve build", dev: "eve dev", start: "eve start", typecheck: "tsgo" },
|
|
478
|
+
dependencies: { "@vercel/connect": "0.2.2", ai: "7.0.0-beta.178", eve: "^0.11.7", microsandbox: "^0.5.0", zod: "4.4.3" },
|
|
479
|
+
devDependencies: { "@types/node": "24.x", "@typescript/native-preview": "7.0.0-dev.20260523.1" },
|
|
480
|
+
overrides: { ai: "7.0.0-beta.178" },
|
|
481
|
+
resolutions: { ai: "7.0.0-beta.178" },
|
|
482
|
+
engines: { node: "24.x" },
|
|
483
|
+
}, null, 2) + "\n";
|
|
484
|
+
// Cross-cutting scaffold: the files `eve init` provides so the rendered agent/ source is runnable.
|
|
485
|
+
const eveComposeProject = (gem, opts = {}) => {
|
|
486
|
+
const files = {
|
|
487
|
+
"package.json": evePackageJson(gem.name),
|
|
488
|
+
"tsconfig.json": EVE_TSCONFIG,
|
|
489
|
+
"agent/agent.ts": EVE_AGENT_TS,
|
|
490
|
+
"agent/channels/eve.ts": eveChannelTs(opts.eveAuth ?? "placeholder"),
|
|
491
|
+
".gitignore": EVE_GITIGNORE,
|
|
492
|
+
".vercelignore": EVE_VERCELIGNORE,
|
|
493
|
+
};
|
|
494
|
+
// eve build's discovery REQUIRES agent/instructions.md. The instructions renderer only emits it
|
|
495
|
+
// when the gem has instruction artifacts, so for an instructions-less gem we emit a default here
|
|
496
|
+
// (compose runs last; when instructions exist their file is already present and we don't clobber it).
|
|
497
|
+
if (!gem.artifacts.some((a) => a.type === "instructions")) {
|
|
498
|
+
files["agent/instructions.md"] = `# ${gem.name}\n\nNo instructions were included in this Gem; edit this file to guide the agent.\n`;
|
|
499
|
+
}
|
|
500
|
+
return rendered(files);
|
|
501
|
+
};
|
|
502
|
+
// ── targets compose the shared renderers (convergence is literal, not duplicated) ──
|
|
503
|
+
export const TARGET_REGISTRY = {
|
|
504
|
+
claude: { id: "claude", label: "Claude", skill: skillSkillMd, instructions: instructionsClaudeMd, mcp: mcpDotMcpJson, hook: hooksSettingsJson },
|
|
505
|
+
codex: { id: "codex", label: "Codex", skill: skillSkillMd, instructions: instructionsAgentsMd, mcp: mcpCodexToml },
|
|
506
|
+
agents: { id: "agents", label: "Agents", skill: skillSkillMd, instructions: instructionsAgentsMd },
|
|
507
|
+
hermes: { id: "hermes", label: "Hermes", skill: skillDescriptionMd, instructions: instructionsSoulMd },
|
|
508
|
+
// Eve project layout (agent/...). Hooks are event-reacting code in Eve, not config -> unsupported.
|
|
509
|
+
eve: { id: "eve", label: "Eve", skill: skillEveMd, instructions: concatInstructions("agent/instructions.md"), mcp: mcpEveConnections, compose: eveComposeProject },
|
|
510
|
+
// Flue project layout. Skills reuse SKILL.md; instructions fold into the composed agent file (no
|
|
511
|
+
// standalone file -> the empty instructions renderer marks them handled, not skipped). MCP added in Task 2.
|
|
512
|
+
flue: { id: "flue", label: "Flue", skill: skillFlueMd, instructions: () => ({}), mcp: mcpFlueConnections, compose: flueComposeAgent },
|
|
513
|
+
// OpenAI Agents SDK SandboxAgent (single <gemname>.agent.ts). Skills reuse SKILL.md (seeded via the
|
|
514
|
+
// Manifest); instructions fold into the agent file. MCP is added inline in Task 2 (mcp renderer + compose).
|
|
515
|
+
"openai-sandbox": { id: "openai-sandbox", label: "OpenAI Sandbox", skill: skillSkillMd, instructions: () => ({}), mcp: () => ({ files: {}, skipped: [] }), compose: sandboxComposeAgent },
|
|
516
|
+
// AgentCore harness project (app/<gem>/harness.json + container-baked skills). Instructions/MCP
|
|
517
|
+
// fold into the composed harness.json; stdio MCP is reported skipped by compose; hooks unsupported.
|
|
518
|
+
agentcore: { id: "agentcore", label: "AgentCore", skill: skillAgentcoreMd, instructions: () => ({}), mcp: () => ({ files: {}, skipped: [] }), compose: agentcoreComposeProject },
|
|
519
|
+
};
|
|
520
|
+
export function materialize(gem, target, opts = {}) {
|
|
521
|
+
const spec = TARGET_REGISTRY[target];
|
|
522
|
+
const files = {};
|
|
523
|
+
const skipped = [];
|
|
524
|
+
const merge = (tree, artifact, type) => {
|
|
525
|
+
for (const [path, content] of Object.entries(tree)) {
|
|
526
|
+
if (path in files) {
|
|
527
|
+
skipped.push({ artifact, type, reason: `path collision with an earlier ${type} at ${path}` });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
files[path] = content;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const skipAll = (arr, type) => arr.forEach((a) => skipped.push({ artifact: a.name, type, reason: `${type} unsupported on ${target}` }));
|
|
534
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
535
|
+
const mcp = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
536
|
+
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
537
|
+
const hooks = gem.artifacts.filter((a) => a.type === "hook");
|
|
538
|
+
if (spec.skill)
|
|
539
|
+
for (const s of skills)
|
|
540
|
+
merge(spec.skill(s), s.name, "skill");
|
|
541
|
+
else
|
|
542
|
+
skipAll(skills, "skill");
|
|
543
|
+
if (instr.length) {
|
|
544
|
+
if (spec.instructions)
|
|
545
|
+
merge(spec.instructions(instr), instr.map((i) => i.name).join(", "), "instructions");
|
|
546
|
+
else
|
|
547
|
+
skipAll(instr, "instructions");
|
|
548
|
+
}
|
|
549
|
+
if (mcp.length) {
|
|
550
|
+
if (spec.mcp) {
|
|
551
|
+
const result = spec.mcp(mcp);
|
|
552
|
+
merge(result.files, mcp.map((m) => m.name).join(", "), "mcp_server");
|
|
553
|
+
skipped.push(...result.skipped);
|
|
554
|
+
}
|
|
555
|
+
else
|
|
556
|
+
skipAll(mcp, "mcp_server");
|
|
557
|
+
}
|
|
558
|
+
if (hooks.length) {
|
|
559
|
+
if (spec.hook)
|
|
560
|
+
merge(spec.hook(hooks), hooks.map((h) => h.name).join(", "), "hook");
|
|
561
|
+
else
|
|
562
|
+
skipAll(hooks, "hook");
|
|
563
|
+
}
|
|
564
|
+
if (spec.compose) {
|
|
565
|
+
const result = spec.compose(gem, opts);
|
|
566
|
+
merge(result.files, "(composed agent)", "instructions"); // collisions reported; agent file derives from instructions+skills
|
|
567
|
+
skipped.push(...result.skipped);
|
|
568
|
+
}
|
|
569
|
+
return { files, skipped };
|
|
570
|
+
}
|
|
571
|
+
export function compatibility(gem) {
|
|
572
|
+
const out = {};
|
|
573
|
+
for (const id of Object.keys(TARGET_REGISTRY)) {
|
|
574
|
+
const r = materialize(gem, id);
|
|
575
|
+
out[id] = { supported: gem.artifacts.length - r.skipped.length, skipped: r.skipped.length };
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
}
|