@ninemind/agentgem 0.1.1 → 0.3.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.
@@ -1,3 +1,4 @@
1
+ import { channelScaffold } from "./channels.js";
1
2
  import { tomlMcpServers } from "./toml.js";
2
3
  import { stdioProxyRunner, PROXY_BASE_PORT, PROXY_HOST } from "./mcpProxy.js";
3
4
  export function safePathSegment(name) {
@@ -19,6 +20,17 @@ const fluePascal = (name) => flueName(name).split("-").filter(Boolean).map((w) =
19
20
  // Flue skills live under src/ (the agent file is src/agents/<name>.ts and imports ../skills/...).
20
21
  const skillFlueMd = (a) => ({ [`src/skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
21
22
  const rendered = (files) => ({ files, skipped: [] });
23
+ // MCP header secrets -> [headerName, envVarName] entries, Authorization first. Shared by the HTTP/SSE
24
+ // renderers (flue / openai-sandbox / a2a); the env-var NAME is emitted, never a value. Callers that
25
+ // require header-only auth check separately for a non-`headers.` secret (the unsupported case).
26
+ const headerSecretEntries = (refs) => {
27
+ const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
28
+ return [
29
+ ...(authorization ? [["Authorization", authorization.name]] : []),
30
+ ...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
31
+ .map((r) => [r.location.slice("headers.".length), r.name]),
32
+ ];
33
+ };
22
34
  // ── shared convention renderers ──
23
35
  const skillSkillMd = (a) => ({ [`skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
24
36
  const skillDescriptionMd = (a) => ({ [`skills/${safePathSegment(a.name)}/DESCRIPTION.md`]: a.content });
@@ -220,13 +232,7 @@ function escapeTemplate(s) {
220
232
  // env var name, never a value). stdio -> a localhost connection plus a generated proxy runner under
221
233
  // proxies/ that bridges the stdio server to HTTP (same mechanism as Eve).
222
234
  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
- ];
235
+ const headerEntries = headerSecretEntries(server.secretRefs ?? []);
230
236
  const transport = server.transport === "sse" ? `,\n transport: "sse"` : "";
231
237
  const headers = headerEntries.length
232
238
  ? `,\n headers: { ${headerEntries.map(([h, env]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(env)}]!`).join(", ")} }`
@@ -339,11 +345,7 @@ const sandboxMcpServer = (s) => {
339
345
  const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
340
346
  if (unsupported)
341
347
  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
- ];
348
+ const headerEntries = headerSecretEntries(refs);
347
349
  const requestInit = headerEntries.length
348
350
  ? `, requestInit: { headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} } }`
349
351
  : "";
@@ -475,10 +477,10 @@ const evePackageJson = (gemName) => JSON.stringify({
475
477
  type: "module",
476
478
  imports: { "#*": "./agent/*", "#evals/*": "./evals/*" },
477
479
  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" },
480
+ dependencies: { "@vercel/connect": "0.2.2", ai: "7.0.2", eve: "^0.15.0", microsandbox: "^0.5.0", zod: "4.4.3" },
479
481
  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
+ overrides: { ai: "7.0.2" },
483
+ resolutions: { ai: "7.0.2" },
482
484
  engines: { node: "24.x" },
483
485
  }, null, 2) + "\n";
484
486
  // Cross-cutting scaffold: the files `eve init` provides so the rendered agent/ source is runnable.
@@ -499,6 +501,255 @@ const eveComposeProject = (gem, opts = {}) => {
499
501
  }
500
502
  return rendered(files);
501
503
  };
504
+ // Eve channel files: one agent/channels/<name>.ts per declared channel, from the platform registry
505
+ // scaffold. "eve" is reserved for the always-on web/auth channel that eveComposeProject emits.
506
+ const channelEve = (channels) => {
507
+ const files = {};
508
+ const skipped = [];
509
+ for (const c of channels) {
510
+ const seg = eveSegment(c.name);
511
+ const path = `agent/channels/${seg}.ts`;
512
+ if (seg === "eve") {
513
+ skipped.push({ artifact: c.name, type: "channel", reason: "channel name 'eve' is reserved for the web channel" });
514
+ continue;
515
+ }
516
+ if (path in files) {
517
+ skipped.push({ artifact: c.name, type: "channel", reason: `path collision with an earlier channel at ${path}` });
518
+ continue;
519
+ }
520
+ files[path] = channelScaffold(c.platform);
521
+ }
522
+ return { files, skipped };
523
+ };
524
+ // ── A2A (Agent2Agent) target ──
525
+ // Card primitive: materialize(gem, "a2a") emits a runtime-free Agent Card derived from the gem — the
526
+ // A2A discovery surface, publishable to the registry. The Card is the part native to AgentGem's
527
+ // "describe an agent" mission; a runnable A2A server is a planned opt-in flavor (MaterializeOpts).
528
+ const A2A_PROTOCOL_VERSION = "0.3.0";
529
+ const a2aSkillCard = (a) => ({
530
+ id: safePathSegment(a.name),
531
+ name: a.name,
532
+ description: a.description?.trim() || `The ${a.name} skill.`,
533
+ tags: ["skill"],
534
+ });
535
+ // A one-line card description from an instruction artifact: prefer the first non-empty *prose* line
536
+ // (instruction files usually open with a throwaway "# Title" heading); fall back to the de-headed
537
+ // first line if the doc is headings-only. Only ATX headings ("# " … "###### ") count as headings, so a
538
+ // prose line that merely starts with '#' (e.g. "#launch") is kept. Bounded so the card carries a label,
539
+ // not a paragraph.
540
+ const a2aFirstLine = (s) => {
541
+ const lines = s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
542
+ const line = lines.find((l) => !/^#{1,6}\s/.test(l)) ?? lines[0]?.replace(/^#+\s*/, "") ?? "";
543
+ return line.length > 200 ? line.slice(0, 197).replace(/\s+\S*$/, "") + "…" : line;
544
+ };
545
+ // Pure Gem -> AgentCard projection. Skills advertise as A2A skills (metadata, not bodies); the first
546
+ // instruction line becomes the card description; a skill-less Gem gets a synthesized `chat` skill
547
+ // (A2A requires >=1). Emits no secret values (skills/instructions carry none post-redaction).
548
+ export const a2aAgentCard = (gem) => {
549
+ const skills = gem.artifacts.filter((a) => a.type === "skill");
550
+ const instr = gem.artifacts.filter((a) => a.type === "instructions");
551
+ const cardSkills = skills.map(a2aSkillCard);
552
+ return {
553
+ protocolVersion: A2A_PROTOCOL_VERSION,
554
+ name: gem.name,
555
+ description: a2aFirstLine(instr[0]?.content ?? "") || `An agent packaged by AgentGem from ${skills.length} skill(s).`,
556
+ version: "0.1.0",
557
+ // Non-resolving placeholder (RFC 6761 reserved TLD): a published card must NOT carry a localhost url
558
+ // a consumer would dial against its own machine. Server mode rebinds this from PUBLIC_URL at boot.
559
+ url: "https://set-public-url.invalid/a2a/jsonrpc",
560
+ capabilities: { streaming: false, pushNotifications: false },
561
+ defaultInputModes: ["text"],
562
+ defaultOutputModes: ["text"],
563
+ skills: cardSkills.length ? cardSkills
564
+ : [{ id: "chat", name: "chat", description: `Converse with ${gem.name}.`, tags: ["chat"] }],
565
+ };
566
+ };
567
+ // ── A2A server mode (opt-in via MaterializeOpts.a2aServer) ──
568
+ // Runtime is Vercel AI SDK v7 (`ai` + `@ai-sdk/mcp`), vendor-neutral via the gateway model string —
569
+ // deliberately NOT @openai/agents, so sandboxMcpServer is not reused (a2aMcpClient is its analogue).
570
+ const A2A_MODEL = "anthropic/claude-sonnet-4-6";
571
+ const a2aMcpClient = (s) => {
572
+ const url = typeof s.config.url === "string" ? s.config.url : "";
573
+ if (/^https?:\/\//.test(url)) {
574
+ const refs = s.secretRefs ?? [];
575
+ const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
576
+ if (unsupported)
577
+ return { skip: `A2A (AI SDK) cannot map secret at ${unsupported.location}` };
578
+ const headerEntries = headerSecretEntries(refs);
579
+ const headers = headerEntries.length
580
+ ? `, headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} }`
581
+ : "";
582
+ const type = s.transport === "sse" ? "sse" : "http";
583
+ return { code: ` createMCPClient({ transport: { type: ${JSON.stringify(type)}, url: ${JSON.stringify(url)}${headers} } }),`, stdio: false };
584
+ }
585
+ if (s.transport === "stdio" && typeof s.config.command === "string") {
586
+ const args = Array.isArray(s.config.args) ? s.config.args.filter((a) => typeof a === "string") : [];
587
+ const envNames = (s.secretRefs ?? []).map((r) => r.name);
588
+ const envStr = envNames.length ? `, env: { ${envNames.map((n) => `${JSON.stringify(n)}: process.env[${JSON.stringify(n)}]!`).join(", ")} }` : "";
589
+ return { code: ` createMCPClient({ transport: new Experimental_StdioMCPTransport({ command: ${JSON.stringify(s.config.command)}, args: ${JSON.stringify(args)}${envStr} }) }),`, stdio: true };
590
+ }
591
+ return { skip: `${s.transport} MCP has no usable URL or stdio command` };
592
+ };
593
+ // A2A projects authenticate via plain process.env. Deliberately NOT agentcoreSecretsMd (its
594
+ // `agentcore add credential` / ${arn:...} body is wrong for an A2A/AI-SDK project).
595
+ const a2aSecretsMd = (secrets) => {
596
+ const model = `## Model access\n\nThe agent calls \`${A2A_MODEL}\` via the AI SDK. Set \`AI_GATEWAY_API_KEY\` ` +
597
+ `(Vercel AI Gateway) or a direct provider key (e.g. \`ANTHROPIC_API_KEY\`).\n`;
598
+ const access = `## Access control (optional)\n\nSet \`A2A_API_KEY\` to require \`Authorization: Bearer <key>\` on the ` +
599
+ `agent's JSON-RPC/REST routes. Agent Card discovery (the \`.well-known\` endpoint) stays open.\n`;
600
+ const mcp = secrets.length
601
+ ? `## MCP credentials\n\nSet these before \`npm start\` (e.g. a \`.env\` file):\n\n` +
602
+ `${secrets.map((s) => `- \`${s.name}\` (for ${s.artifact} at ${s.location})`).join("\n")}\n`
603
+ : `## MCP credentials\n\nThis agent declares no MCP secrets.\n`;
604
+ return `# Secrets\n\n${model}\n${access}\n${mcp}`;
605
+ };
606
+ const a2aPackageJson = (gemName) => JSON.stringify({
607
+ name: safePathSegment(gemName).toLowerCase(), version: "0.1.0", private: true, type: "module",
608
+ scripts: { build: "tsc", start: "node dist/server.js", dev: "tsx src/server.ts" },
609
+ // Verified pins: ai v7 GA pairs with @ai-sdk/mcp v2 GA (both on @ai-sdk/provider@4); @a2a-js/sdk 0.3.x.
610
+ dependencies: { "@a2a-js/sdk": "^0.3.13", ai: "7.0.2", "@ai-sdk/mcp": "2.0.0", express: "^5", uuid: "^11" },
611
+ devDependencies: { "@types/express": "^5", "@types/node": "^24", tsx: "^4", typescript: "^5" },
612
+ }, null, 2) + "\n";
613
+ // The runnable A2A server: an AI SDK `streamText` tool loop behind the @a2a-js/sdk JSON-RPC handler.
614
+ // Streams incrementally via the A2A task lifecycle (submitted -> working -> artifact-update* ->
615
+ // completed); the same executor serves message/send (aggregated) and message/stream (SSE). The served
616
+ // card advertises streaming: true and rebinds `url` from PUBLIC_URL (the static card carries neither).
617
+ const a2aServerTs = (system, clientCodes, usesStdio) => {
618
+ const mcpImports = clientCodes.length
619
+ ? `import { createMCPClient } from "@ai-sdk/mcp";\n${usesStdio ? `import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";\n` : ""}`
620
+ : "";
621
+ const bootBlock = clientCodes.length
622
+ ? `const mcpClients = await Promise.all([\n${clientCodes.join("\n")}\n]);
623
+ const tools = Object.assign({}, ...(await Promise.all(mcpClients.map((c) => c.tools()))));
624
+ for (const sig of ["SIGINT", "SIGTERM"] as const)
625
+ process.on(sig, () => { Promise.allSettled(mcpClients.map((c) => c.close())).finally(() => process.exit(0)); });`
626
+ : `const tools = {};`;
627
+ return `import express from "express";
628
+ import { streamText, stepCountIs } from "ai";
629
+ ${mcpImports}import { type AgentCard, AGENT_CARD_PATH } from "@a2a-js/sdk";
630
+ import { type AgentExecutor, type RequestContext, type ExecutionEventBus,
631
+ DefaultRequestHandler, InMemoryTaskStore, InMemoryPushNotificationStore, DefaultPushNotificationSender } from "@a2a-js/sdk/server";
632
+ import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from "@a2a-js/sdk/server/express";
633
+ import { v4 as uuid } from "uuid";
634
+ import cardBase from "../agent-card.json" with { type: "json" };
635
+
636
+ const MODEL = ${JSON.stringify(A2A_MODEL)};
637
+ const SYSTEM = \`${escapeTemplate(system)}\`;
638
+
639
+ const port = Number(process.env.PORT ?? 41241);
640
+ const baseUrl = process.env.PUBLIC_URL ?? \`http://localhost:\${port}\`;
641
+ const API_KEY = process.env.A2A_API_KEY; // when set, require \`Authorization: Bearer <key>\` on the RPC/REST routes
642
+ const card: AgentCard = { ...(cardBase as AgentCard), url: \`\${baseUrl}/a2a/jsonrpc\`,
643
+ capabilities: { ...(cardBase as AgentCard).capabilities, streaming: true, pushNotifications: true },
644
+ additionalInterfaces: [
645
+ { url: \`\${baseUrl}/a2a/jsonrpc\`, transport: "JSONRPC" },
646
+ { url: \`\${baseUrl}/a2a/rest\`, transport: "HTTP+JSON" },
647
+ ],
648
+ ...(API_KEY ? { securitySchemes: { bearer: { type: "http", scheme: "bearer" } }, security: [{ bearer: [] }] } : {}) };
649
+
650
+ ${bootBlock}
651
+
652
+ // Streaming executor: drive the tool loop and publish A2A task-lifecycle + artifact-update events.
653
+ class GemExecutor implements AgentExecutor {
654
+ private inflight = new Map<string, AbortController>();
655
+ async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
656
+ const { taskId, contextId, userMessage, task } = ctx;
657
+ const text = (userMessage.parts ?? []).filter((p: any) => p.kind === "text").map((p: any) => p.text).join("\\n").trim();
658
+ // Guard: an A2A message may carry no text parts (file/data only). streamText rejects an empty
659
+ // prompt, so reply directly instead of failing the request.
660
+ if (!text) {
661
+ bus.publish({ kind: "message", messageId: uuid(), role: "agent", contextId,
662
+ parts: [{ kind: "text", text: "Please include a text message for the agent." }] });
663
+ bus.finished();
664
+ return;
665
+ }
666
+ const ac = new AbortController();
667
+ this.inflight.set(taskId, ac);
668
+ if (!task) bus.publish({ kind: "task", id: taskId, contextId, status: { state: "submitted", timestamp: new Date().toISOString() }, history: [userMessage] });
669
+ bus.publish({ kind: "status-update", taskId, contextId, status: { state: "working", timestamp: new Date().toISOString() }, final: false });
670
+ const artifactId = uuid();
671
+ let started = false;
672
+ try {
673
+ const result = streamText({ model: MODEL, system: SYSTEM, tools, stopWhen: stepCountIs(10), prompt: text, abortSignal: ac.signal });
674
+ for await (const delta of result.textStream) {
675
+ bus.publish({ kind: "artifact-update", taskId, contextId, append: started, lastChunk: false,
676
+ artifact: { artifactId, name: "response", parts: [{ kind: "text", text: delta }] } });
677
+ started = true;
678
+ }
679
+ // Only close an artifact that was actually opened (empty/tool-only completions stream nothing).
680
+ if (started) bus.publish({ kind: "artifact-update", taskId, contextId, append: true, lastChunk: true, artifact: { artifactId, parts: [] } });
681
+ bus.publish({ kind: "status-update", taskId, contextId, status: { state: "completed", timestamp: new Date().toISOString() }, final: true });
682
+ } catch (err) {
683
+ const state = ac.signal.aborted ? "canceled" : "failed";
684
+ bus.publish({ kind: "status-update", taskId, contextId, status: { state, timestamp: new Date().toISOString() }, final: true });
685
+ } finally {
686
+ this.inflight.delete(taskId);
687
+ bus.finished();
688
+ }
689
+ }
690
+ cancelTask = async (taskId: string): Promise<void> => { this.inflight.get(taskId)?.abort(); };
691
+ }
692
+
693
+ const pushStore = new InMemoryPushNotificationStore();
694
+ const requestHandler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new GemExecutor(),
695
+ undefined, pushStore, new DefaultPushNotificationSender(pushStore));
696
+ const app = express();
697
+ // Discovery (the /.well-known Agent Card) stays open; gate only the invocation routes when A2A_API_KEY is set.
698
+ const requireAuth: express.RequestHandler = (req, res, next) => {
699
+ if (!API_KEY || req.headers.authorization === \`Bearer \${API_KEY}\`) return next();
700
+ res.status(401).json({ error: "unauthorized" });
701
+ };
702
+ app.use("/a2a", requireAuth);
703
+ app.use(\`/\${AGENT_CARD_PATH}\`, agentCardHandler({ agentCardProvider: requestHandler }));
704
+ app.use("/a2a/jsonrpc", jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
705
+ app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
706
+ app.listen(port, () => console.log(\`A2A agent "\${card.name}" listening on :\${port}\`));
707
+ `;
708
+ };
709
+ // A2A is wholly compose-driven: per-type renderers are no-ops (so materialize never auto-skip-reports),
710
+ // and compose owns ALL skip reporting for both modes. Hooks are never expressible by A2A (card or
711
+ // server). MCP is not expressible by a *Card* (card-only -> all MCP skipped), but the *server* wires it
712
+ // (server mode -> only unmappable MCP skipped). This keeps compatibility() honest: card-only reflects
713
+ // that a Card carries identity + skills, not MCP/hooks, instead of over-claiming full support.
714
+ const a2aComposeProject = (gem, opts = {}) => {
715
+ const files = { "agent-card.json": JSON.stringify(a2aAgentCard(gem), null, 2) + "\n" };
716
+ const mcps = gem.artifacts.filter((a) => a.type === "mcp_server");
717
+ const hooks = gem.artifacts.filter((a) => a.type === "hook");
718
+ const hookSkips = hooks.map((h) => ({ artifact: h.name, type: "hook", reason: "A2A has no hook concept" }));
719
+ if (!opts.a2aServer) {
720
+ // Card-only: an Agent Card represents identity + skills, not MCP servers or hooks.
721
+ const cardSkips = mcps.map((s) => ({ artifact: s.name, type: "mcp_server", reason: "an Agent Card cannot express MCP servers (materialize with a2aServer to wire them)" }));
722
+ return { files, skipped: [...cardSkips, ...hookSkips] };
723
+ }
724
+ const skills = gem.artifacts.filter((a) => a.type === "skill");
725
+ const instr = gem.artifacts.filter((a) => a.type === "instructions");
726
+ // AI SDK has no skills primitive -> fold skill bodies (frontmatter-stripped) into the system prompt.
727
+ const instrText = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
728
+ const skillText = skills.map((s) => `## Skill: ${s.name}\n\n${stripYamlFrontmatter(s.content)}`).join("\n\n---\n\n");
729
+ const system = [instrText, skillText].filter(Boolean).join("\n\n---\n\n");
730
+ // Server mode: the server wires MCP, so only UNMAPPABLE MCP is skipped; hooks remain unsupported.
731
+ const skipped = [...hookSkips];
732
+ const clientCodes = [];
733
+ let usesStdio = false;
734
+ for (const s of mcps) {
735
+ const r = a2aMcpClient(s);
736
+ if ("skip" in r) {
737
+ skipped.push({ artifact: s.name, type: "mcp_server", reason: r.skip });
738
+ continue;
739
+ }
740
+ clientCodes.push(r.code);
741
+ usesStdio ||= r.stdio;
742
+ }
743
+ return {
744
+ files: {
745
+ ...files,
746
+ "src/server.ts": a2aServerTs(system, clientCodes, usesStdio),
747
+ "package.json": a2aPackageJson(gem.name),
748
+ "SECRETS.md": a2aSecretsMd(gem.requiredSecrets),
749
+ },
750
+ skipped,
751
+ };
752
+ };
502
753
  // ── targets compose the shared renderers (convergence is literal, not duplicated) ──
503
754
  export const TARGET_REGISTRY = {
504
755
  claude: { id: "claude", label: "Claude", skill: skillSkillMd, instructions: instructionsClaudeMd, mcp: mcpDotMcpJson, hook: hooksSettingsJson },
@@ -506,7 +757,7 @@ export const TARGET_REGISTRY = {
506
757
  agents: { id: "agents", label: "Agents", skill: skillSkillMd, instructions: instructionsAgentsMd },
507
758
  hermes: { id: "hermes", label: "Hermes", skill: skillDescriptionMd, instructions: instructionsSoulMd },
508
759
  // 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 },
760
+ eve: { id: "eve", label: "Eve", skill: skillEveMd, instructions: concatInstructions("agent/instructions.md"), mcp: mcpEveConnections, channel: channelEve, compose: eveComposeProject },
510
761
  // Flue project layout. Skills reuse SKILL.md; instructions fold into the composed agent file (no
511
762
  // standalone file -> the empty instructions renderer marks them handled, not skipped). MCP added in Task 2.
512
763
  flue: { id: "flue", label: "Flue", skill: skillFlueMd, instructions: () => ({}), mcp: mcpFlueConnections, compose: flueComposeAgent },
@@ -516,6 +767,9 @@ export const TARGET_REGISTRY = {
516
767
  // AgentCore harness project (app/<gem>/harness.json + container-baked skills). Instructions/MCP
517
768
  // fold into the composed harness.json; stdio MCP is reported skipped by compose; hooks unsupported.
518
769
  agentcore: { id: "agentcore", label: "AgentCore", skill: skillAgentcoreMd, instructions: () => ({}), mcp: () => ({ files: {}, skipped: [] }), compose: agentcoreComposeProject },
770
+ // A2A Agent Card primitive. Wholly compose-driven (all per-type renderers no-op); compose emits the
771
+ // runtime-free agent-card.json. Card-only mode reports nothing skipped.
772
+ a2a: { id: "a2a", label: "A2A", skill: () => ({}), instructions: () => ({}), mcp: () => ({ files: {}, skipped: [] }), hook: () => ({}), compose: a2aComposeProject },
519
773
  };
520
774
  export function materialize(gem, target, opts = {}) {
521
775
  const spec = TARGET_REGISTRY[target];
@@ -535,6 +789,7 @@ export function materialize(gem, target, opts = {}) {
535
789
  const mcp = gem.artifacts.filter((a) => a.type === "mcp_server");
536
790
  const instr = gem.artifacts.filter((a) => a.type === "instructions");
537
791
  const hooks = gem.artifacts.filter((a) => a.type === "hook");
792
+ const channels = gem.artifacts.filter((a) => a.type === "channel");
538
793
  if (spec.skill)
539
794
  for (const s of skills)
540
795
  merge(spec.skill(s), s.name, "skill");
@@ -561,6 +816,15 @@ export function materialize(gem, target, opts = {}) {
561
816
  else
562
817
  skipAll(hooks, "hook");
563
818
  }
819
+ if (channels.length) {
820
+ if (spec.channel) {
821
+ const result = spec.channel(channels);
822
+ merge(result.files, channels.map((c) => c.name).join(", "), "channel");
823
+ skipped.push(...result.skipped);
824
+ }
825
+ else
826
+ skipAll(channels, "channel");
827
+ }
564
828
  if (spec.compose) {
565
829
  const result = spec.compose(gem, opts);
566
830
  merge(result.files, "(composed agent)", "instructions"); // collisions reported; agent file derives from instructions+skills
@@ -133,6 +133,7 @@ export function discoverProjects(dirs) {
133
133
  }
134
134
  }
135
135
  return [...best.values()]
136
+ .filter((p) => !p.path.includes("/.agentgem/")) // hide agentgem's own internal workspaces (e.g. the analysis cwd)
136
137
  .sort((a, b) => b.lastUsedMs - a.lastUsedMs)
137
138
  .map((p) => ({
138
139
  path: p.path,
Binary file
@@ -8,6 +8,7 @@ import { mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync } fr
8
8
  import { materialize, compatibility, TARGET_REGISTRY, safePathSegment } from "./targets.js";
9
9
  import { writeGemArchive, readGemArchive } from "./archive.js";
10
10
  import { writeArchiveDir, readArchiveDir } from "./archiveFs.js";
11
+ import { InvalidInputError } from "./inputError.js";
11
12
  const TARGETS_DIR = ".targets";
12
13
  export function workspacesRoot() {
13
14
  const home = process.env.AGENTGEM_HOME ?? join(homedir(), ".agentgem");
@@ -18,7 +19,7 @@ export function workspacesRoot() {
18
19
  export function workspaceName(name) {
19
20
  const seg = safePathSegment(name);
20
21
  if (seg !== name)
21
- throw new Error(`invalid workspace name '${name}' — use only [A-Za-z0-9._-], no separators`);
22
+ throw new InvalidInputError(`invalid workspace name '${name}' — use only [A-Za-z0-9._-], no separators`);
22
23
  return seg;
23
24
  }
24
25
  export function workspaceDir(name) {
@@ -73,12 +74,12 @@ export function readWorkspace(name) {
73
74
  const gem = readGemArchive(files); // verifies the lock
74
75
  return { ...summary(workspaceName(name), files["gem.json"], dir), files, compatibility: compatibility(gem) };
75
76
  }
76
- export function renderTarget(name, target) {
77
+ export function renderTarget(name, target, opts = {}) {
77
78
  const dir = workspaceDir(name);
78
79
  if (!existsSync(join(dir, "gem.json")))
79
80
  throw new Error(`no workspace '${name}'`);
80
81
  const gem = readGemArchive(readArchiveDir(dir));
81
- const { files, skipped } = materialize(gem, target);
82
+ const { files, skipped } = materialize(gem, target, opts);
82
83
  const out = join(dir, TARGETS_DIR, target);
83
84
  rmSync(out, { recursive: true, force: true }); // clear stale renders
84
85
  mkdirSync(out, { recursive: true });