@ninemind/agentgem 0.2.0 → 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.
package/README.md CHANGED
@@ -39,7 +39,9 @@ call exactly the same thing.
39
39
  larger agents with a single re-resolved lock, not a pile of overlapping config.
40
40
  - **Workflow-aware recommendations** — [Analyze](docs/analyze.md) scans your agent's
41
41
  session history to see which skills, MCP servers, and hooks you actually use, and
42
- suggests ready-to-build Gems grouped by recurring workflow.
42
+ suggests ready-to-build Gems grouped by recurring workflow. It also **distills brand-new
43
+ draft skills** from the procedures you repeat by hand — review them and fold them
44
+ straight into a Gem.
43
45
  - **Deploy targets** — Eve and OpenAI Sandbox (code-gen), Flue (materialize, deployable to
44
46
  Cloudflare), and Bedrock AgentCore (managed backend); code-gen targets share a common
45
47
  `compose` step.
@@ -5,11 +5,9 @@
5
5
  // cluster/name/justify a Gem. The agent only ranks and explains — its output is
6
6
  // re-validated against the inventory (the source of truth), and any failure
7
7
  // degrades to a deterministic frequency-based recommendation. Never throws.
8
- import { spawn } from "node:child_process";
9
- import { mkdirSync } from "node:fs";
10
8
  import { join } from "node:path";
11
- import { Readable, Writable } from "node:stream";
12
9
  import { agentgemHome } from "../resolveDir.js";
10
+ import { connectAcpAdapter } from "./acpSession.js";
13
11
  // Instructions are a boolean on ProjectSelection, not a named include.
14
12
  const SELECTABLE = ["skill", "mcp_server", "hook"];
15
13
  // Pinned Claude ACP adapter (npm: @agentclientprotocol/claude-agent-acp).
@@ -21,8 +19,10 @@ export const CLAUDE_AGENT = { id: "claude-code", name: "Claude Code", command: [
21
19
  // over the JSON brief, so its cwd is irrelevant to the result.
22
20
  export function analysisWorkspace() { return join(agentgemHome(), ".agentgem", "analysis"); }
23
21
  let testConnectFn = null;
24
- /** Test-only seam: route recommendWorkflow through an in-process fake agent. */
22
+ /** Test-only seam: route recommendWorkflow + distillWorkflow through an in-process fake agent. */
25
23
  export function setConnectFnForTests(fn) { testConnectFn = fn; }
24
+ /** The active test connect fn (or null). distillWorkflow shares this seam. */
25
+ export function currentTestConnectFn() { return testConnectFn; }
26
26
  // ── Deterministic analysis (fallback + the agent's baseline) ─────────────────
27
27
  // One frequency-based candidate. Multi-candidate splitting is the agent's value-add;
28
28
  // the deterministic fallback stays a single coherent Gem.
@@ -49,7 +49,7 @@ export function deterministicAnalysis(signal) {
49
49
  include,
50
50
  confidence: "medium",
51
51
  }] : [];
52
- return { candidates, gaps };
52
+ return { candidates, gaps, distilled: [] };
53
53
  }
54
54
  /**
55
55
  * Map a validated candidate to a GemSelection. Global artifacts (root===null)
@@ -154,7 +154,7 @@ export function validateAnalysis(raw, inv, signal) {
154
154
  if (!candidates.length)
155
155
  return fallback;
156
156
  const gaps = Array.isArray(obj.gaps) ? obj.gaps.filter((g) => typeof g === "string") : fallback.gaps;
157
- return { candidates, gaps };
157
+ return { candidates, gaps, distilled: [] };
158
158
  }
159
159
  // ── The agent run ────────────────────────────────────────────────────────────
160
160
  const GROUNDING = (signalJson, inventoryJson) => `You recommend reusable "Gems" — bundles of installed artifacts for a recurring workflow.\n` +
@@ -226,78 +226,34 @@ export async function recommendWorkflow(signal, inv, opts = {}) {
226
226
  }
227
227
  }
228
228
  /**
229
- * Real connect: spawn the ACP adapter and bridge stdio via the SDK. Wrapped so
230
- * the rest of the module is SDK-agnostic. Mirrors agentback console-chat's
231
- * defaultConnectFn, minus the workspace PATH walk and permission routing — this
232
- * agent runs in plan mode and we auto-deny any permission request.
233
- *
234
- * NEEDS LIVE VALIDATION: stdio bridging + set_mode against claude-agent-acp.
229
+ * Real connect: route through the shared adapter plumbing in plan mode with
230
+ * permissions auto-denied (the recommender must never run tools), aggregating
231
+ * only the agent's message text into a string.
235
232
  */
236
233
  export const defaultConnectFn = async (descriptor) => {
237
- const { client, ndJsonStream } = await import("@agentclientprotocol/sdk");
238
- const [bin, ...args] = descriptor.command;
239
- const child = spawn(bin, args, { stdio: ["pipe", "pipe", "inherit"], env: process.env });
240
- await new Promise((resolve, reject) => {
241
- child.once("spawn", () => resolve());
242
- child.once("error", (e) => reject(new Error(`failed to spawn ${bin}: ${e.message}`)));
243
- });
244
- const app = client({ name: "agentgem-workflow-recommender" });
245
- // Auto-deny any permission request — the recommender must not run tools.
246
- app.onRequest?.("session/request_permission", async () => ({ outcome: { outcome: "cancelled" } }));
247
- const input = Readable.toWeb(child.stdout);
248
- const output = Writable.toWeb(child.stdin);
249
- const connection = app.connect(ndJsonStream(output, input));
250
- const agentCtx = connection.agent;
234
+ const raw = await connectAcpAdapter(descriptor, { clientName: "agentgem-workflow-recommender", permission: "deny" });
251
235
  const ctx = {
252
236
  async open(cwd) {
253
- try {
254
- mkdirSync(cwd, { recursive: true });
255
- }
256
- catch { /* best-effort */ }
257
- const session = await agentCtx.buildSession(cwd).start();
258
- const sessionId = session.sessionId;
237
+ const session = await raw.open(cwd);
259
238
  return {
260
- async setMode(mode) {
261
- try {
262
- await agentCtx.request("session/set_mode", { sessionId, modeId: mode });
263
- }
264
- catch { /* best-effort */ }
265
- },
239
+ setMode: (mode) => session.setMode(mode),
266
240
  async promptText(text, onDelta) {
267
241
  let out = "";
268
- void session.prompt(text);
269
- for (;;) {
270
- const msg = await session.nextUpdate();
271
- if (msg.kind === "stop")
272
- break;
273
- if (msg.kind === "session_update" && msg.update?.sessionUpdate === "agent_message_chunk") {
274
- const block = msg.update.content;
242
+ await session.prompt(text, (u) => {
243
+ const update = u;
244
+ if (update?.sessionUpdate === "agent_message_chunk") {
245
+ const block = update.content;
275
246
  if (block?.type === "text" && typeof block.text === "string") {
276
247
  out += block.text;
277
248
  onDelta?.(block.text);
278
249
  }
279
250
  }
280
- }
251
+ });
281
252
  return out;
282
253
  },
283
- dispose() { try {
284
- session.dispose?.();
285
- }
286
- catch { /* ignore */ } },
254
+ dispose: () => session.dispose(),
287
255
  };
288
256
  },
289
257
  };
290
- return {
291
- ctx,
292
- close: () => {
293
- try {
294
- connection.close();
295
- }
296
- catch { /* ignore */ }
297
- try {
298
- child.kill();
299
- }
300
- catch { /* ignore */ }
301
- },
302
- };
258
+ return { ctx, close: raw.close };
303
259
  };
@@ -0,0 +1,156 @@
1
+ // src/gem/acpRun.ts
2
+ //
3
+ // Runs a materialized Gem by driving a locally-installed ACP coding agent (Claude)
4
+ // against a task, and captures what the agent DID — its message text plus the
5
+ // trace of tool invocations. This is the trust-inversion of acpRecommender:
6
+ // recommender runner (here)
7
+ // ─────────── ─────────────
8
+ // neutral analysisWorkspace() → the materialized testbed dir
9
+ // mode "plan" (never edits) → a tool-capable mode (agent uses the Gem)
10
+ // captures agent_message_chunk → also captures tool_call updates
11
+ //
12
+ // As with the recommender, the SDK details live behind a single connectFn seam so
13
+ // tests inject a plain fake. Unlike the recommender there is no deterministic
14
+ // fallback — a failed run is a real outcome the caller (e.g. verification) needs,
15
+ // so we never throw: failures surface as { ok:false, error }.
16
+ //
17
+ // NOTE (consolidation): the ACP façade is duplicated from acpRecommender on purpose
18
+ // while this path is prototyped. Once both are proven, the two connectFns should be
19
+ // unified into a shared acpSession module.
20
+ import { connectAcpAdapter } from "./acpSession.js";
21
+ import { selectRunBackend, envPermission } from "./sandbox.js"; // values used at call-time (safe ESM cycle)
22
+ export function createAccumulator() {
23
+ return { text: "", toolCalls: [] };
24
+ }
25
+ export function applyUpdate(acc, update, handlers) {
26
+ switch (update.sessionUpdate) {
27
+ case "agent_message_chunk": {
28
+ const block = update.content;
29
+ if (block?.type === "text" && typeof block.text === "string") {
30
+ acc.text += block.text;
31
+ handlers?.onDelta?.(block.text);
32
+ }
33
+ return;
34
+ }
35
+ case "tool_call": {
36
+ if (!update.toolCallId)
37
+ return;
38
+ const tool = {
39
+ toolCallId: update.toolCallId,
40
+ title: update.title ?? "",
41
+ kind: update.kind,
42
+ status: update.status,
43
+ };
44
+ acc.toolCalls.push(tool);
45
+ handlers?.onToolCall?.(tool); // fires once, on start — final status lands in the result
46
+ return;
47
+ }
48
+ case "tool_call_update": {
49
+ const existing = acc.toolCalls.find((t) => t.toolCallId === update.toolCallId);
50
+ if (!existing)
51
+ return; // update for a tool we never saw start — ignore
52
+ if (update.status !== undefined)
53
+ existing.status = update.status;
54
+ if (update.kind !== undefined)
55
+ existing.kind = update.kind;
56
+ if (update.title !== undefined && update.title !== "")
57
+ existing.title = update.title;
58
+ return;
59
+ }
60
+ default:
61
+ return;
62
+ }
63
+ }
64
+ // Pinned Claude ACP adapter, same binary the recommender spawns.
65
+ export const CLAUDE_RUN_AGENT = { id: "claude-code", name: "Claude Code", command: ["claude-agent-acp"] };
66
+ // The default non-plan mode: lets the agent actually invoke the Gem's tools. The
67
+ // recommender pins "plan"; the runner pins its counterpart so the trust-inversion
68
+ // is explicit rather than incidental.
69
+ export const DEFAULT_RUN_MODE = "default";
70
+ // Default prompt timeout. Generous — a real Gem run can drive the agent through
71
+ // several tool calls — but bounded so a wedged agent can't hang the caller.
72
+ export const DEFAULT_RUN_TIMEOUT_MS = 300_000;
73
+ function withTimeout(p, ms) {
74
+ return new Promise((resolve, reject) => {
75
+ const timer = setTimeout(() => reject(new Error(`agent run timed out after ${ms}ms`)), ms);
76
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
77
+ });
78
+ }
79
+ // Test seam: route runGemWithAgent through an in-process fake agent (mirrors
80
+ // acpRecommender.setConnectFnForTests). Lets the REST/SSE surface be exercised
81
+ // without spawning a real coding agent.
82
+ let testConnectFn = null;
83
+ export function setRunConnectFnForTests(fn) { testConnectFn = fn; }
84
+ /** True when a fake agent is injected — callers skip adapter resolution/fetch. */
85
+ export function hasTestConnectFn() { return testConnectFn !== null; }
86
+ /**
87
+ * Drive a local ACP agent against `task` inside the already-materialized `dir`,
88
+ * returning what it did. Never throws — connection/spawn failures come back as
89
+ * { ok:false, error }.
90
+ */
91
+ export async function runGemWithAgent(opts) {
92
+ const explicit = opts.connectFn ?? testConnectFn;
93
+ const selected = explicit ? null : selectRunBackend(opts.dir);
94
+ const connectFn = explicit ?? selected.connectFn;
95
+ const sandbox = selected
96
+ ? { backend: selected.backend.id, isolated: selected.backend.isolated }
97
+ : { backend: "injected", isolated: false };
98
+ const mode = opts.mode ?? DEFAULT_RUN_MODE;
99
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
100
+ let conn = null;
101
+ let handle = null;
102
+ try {
103
+ conn = await connectFn(opts.descriptor ?? CLAUDE_RUN_AGENT, null);
104
+ handle = await conn.ctx.open(opts.dir); // the testbed dir — NOT a neutral one
105
+ await handle.setMode(mode); // tool-capable — the agent uses the Gem
106
+ const result = await withTimeout(handle.prompt(opts.task, opts.onDelta, opts.onToolCall), timeoutMs);
107
+ return { ok: true, result, sandbox };
108
+ }
109
+ catch (err) {
110
+ return { ok: false, result: { text: "", toolCalls: [] }, error: err.message, sandbox };
111
+ }
112
+ finally {
113
+ try {
114
+ handle?.dispose();
115
+ }
116
+ catch { /* ignore */ }
117
+ try {
118
+ conn?.close();
119
+ }
120
+ catch { /* ignore */ }
121
+ }
122
+ }
123
+ /**
124
+ * The shared run-session façade: connect the ACP adapter with an explicit permission
125
+ * policy and fold each update into a RunResult via applyUpdate, capturing the tool
126
+ * trace. Backends in sandbox.ts call this with a wrapped descriptor (isolated => "allow")
127
+ * or the raw descriptor (child-spawn => env policy).
128
+ *
129
+ * SECURITY: On the isolated path (macos-seatbelt / linux-bubblewrap), auto-allow is
130
+ * safe by default — the OS-native FS boundary bounds the blast radius to the run dir
131
+ * and temp. On the child-spawn fallback, permission is "deny" unless
132
+ * AGENTGEM_GEM_RUN_AUTOALLOW=1 is set (env escape hatch, retained for trusted local
133
+ * sessions). Combined with the loopback origin guard and the server-derived run dir,
134
+ * this keeps a malicious browser tab from driving a fully-permissioned local agent.
135
+ */
136
+ export async function connectRunSession(descriptor, permission, _app) {
137
+ const raw = await connectAcpAdapter(descriptor, { clientName: "agentgem-gem-runner", permission });
138
+ const ctx = {
139
+ async open(cwd) {
140
+ const session = await raw.open(cwd);
141
+ return {
142
+ setMode: (mode) => session.setMode(mode),
143
+ async prompt(text, onDelta, onToolCall) {
144
+ const acc = createAccumulator();
145
+ await session.prompt(text, (u) => applyUpdate(acc, (u ?? {}), { onDelta, onToolCall }));
146
+ return acc;
147
+ },
148
+ dispose: () => session.dispose(),
149
+ };
150
+ },
151
+ };
152
+ return { ctx, close: raw.close };
153
+ }
154
+ // Back-compat: the unsandboxed child-spawn connect, env-gated via the single source of
155
+ // truth for the auto-allow flag (shared with sandbox.ts's child-spawn backend).
156
+ export const defaultRunConnectFn = (descriptor, app) => connectRunSession(descriptor, envPermission(), app);
@@ -0,0 +1,79 @@
1
+ // src/gem/acpSession.ts
2
+ //
3
+ // The shared ACP adapter plumbing used by BOTH the workflow recommender and the
4
+ // Gem runner: spawn the adapter binary, bridge stdio via the SDK, build a session,
5
+ // set its mode, and pump session updates until the turn stops. The two callers
6
+ // differ only in permission policy (deny vs allow) and how they fold updates
7
+ // (text-only string vs structured RunResult), so those stay in the callers — this
8
+ // module owns the boilerplate that was previously copy-pasted between them.
9
+ //
10
+ // NEEDS LIVE VALIDATION: stdio bridging against the real ACP adapter (covered by
11
+ // the runner + recommender live smokes, since both now route through here).
12
+ import { spawn } from "node:child_process";
13
+ import { mkdirSync } from "node:fs";
14
+ import { Readable, Writable } from "node:stream";
15
+ export async function connectAcpAdapter(descriptor, opts) {
16
+ const { client, ndJsonStream, PROTOCOL_VERSION } = await import("@agentclientprotocol/sdk");
17
+ const [bin, ...args] = descriptor.command;
18
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "inherit"], env: process.env });
19
+ await new Promise((resolve, reject) => {
20
+ child.once("spawn", () => resolve());
21
+ child.once("error", (e) => reject(new Error(`failed to spawn ${bin}: ${e.message}`)));
22
+ });
23
+ const app = client({ name: opts.clientName });
24
+ const reply = opts.permission === "allow"
25
+ ? { outcome: { outcome: "selected", optionId: "allow" } }
26
+ : { outcome: { outcome: "cancelled" } };
27
+ app.onRequest?.("session/request_permission", async () => reply);
28
+ const input = Readable.toWeb(child.stdout);
29
+ const output = Writable.toWeb(child.stdin);
30
+ const connection = app.connect(ndJsonStream(output, input));
31
+ const agentCtx = connection.agent;
32
+ // ACP requires an `initialize` handshake before any session/new. claude-agent-acp
33
+ // tolerated skipping it; codex-acp strictly rejects session/new with "Not
34
+ // initialized" (-32603) without it. We advertise no client capabilities we don't
35
+ // implement (no fs/terminal handlers) — both adapters write files directly.
36
+ await agentCtx.request("initialize", { protocolVersion: PROTOCOL_VERSION });
37
+ return {
38
+ async open(cwd) {
39
+ try {
40
+ mkdirSync(cwd, { recursive: true });
41
+ }
42
+ catch { /* best-effort */ }
43
+ const session = await agentCtx.buildSession(cwd).start();
44
+ const sessionId = session.sessionId;
45
+ return {
46
+ async setMode(mode) {
47
+ try {
48
+ await agentCtx.request("session/set_mode", { sessionId, modeId: mode });
49
+ }
50
+ catch { /* best-effort */ }
51
+ },
52
+ async prompt(text, onUpdate) {
53
+ void session.prompt(text);
54
+ for (;;) {
55
+ const msg = await session.nextUpdate();
56
+ if (msg.kind === "stop")
57
+ break;
58
+ if (msg.kind === "session_update")
59
+ onUpdate(msg.update);
60
+ }
61
+ },
62
+ dispose() { try {
63
+ session.dispose?.();
64
+ }
65
+ catch { /* ignore */ } },
66
+ };
67
+ },
68
+ close: () => {
69
+ try {
70
+ connection.close();
71
+ }
72
+ catch { /* ignore */ }
73
+ try {
74
+ child.kill();
75
+ }
76
+ catch { /* ignore */ }
77
+ },
78
+ };
79
+ }
@@ -10,7 +10,11 @@ import { join, dirname } from "node:path";
10
10
  import { agentgemHome } from "../resolveDir.js";
11
11
  const MAX_ENTRIES = 50;
12
12
  function cachePath() { return join(agentgemHome(), ".agentgem", "analysis-cache.json"); }
13
- /** A cheap validity token: transcript count + newest mtime. New/updated session new token. */
13
+ // Bump on any change to what an analysis result contains (the token is otherwise
14
+ // content-blind). v2 = the payload now carries the `distilled` track, so v1 entries
15
+ // (which lack it) must not be served (proposal §8).
16
+ const TOKEN_VERSION = "v2";
17
+ /** A cheap validity token: version + transcript count + newest mtime. New/updated session → new token. */
14
18
  export function transcriptToken(paths) {
15
19
  let maxMs = 0;
16
20
  for (const p of paths) {
@@ -21,7 +25,7 @@ export function transcriptToken(paths) {
21
25
  }
22
26
  catch { /* gone — ignore */ }
23
27
  }
24
- return `${paths.length}:${Math.round(maxMs)}`;
28
+ return `${TOKEN_VERSION}:${paths.length}:${Math.round(maxMs)}`;
25
29
  }
26
30
  function readAll() {
27
31
  try {
@@ -86,6 +86,14 @@ export function writeGemArchive(gem, opts = {}) {
86
86
  if (place(path, JSON.stringify(body, null, 2), a.name, "mcp_server"))
87
87
  artifacts.push({ type: "mcp_server", name: a.name, path });
88
88
  }
89
+ else if (a.type === "channel") {
90
+ const path = `channels/${withExt(seg, ".json")}`;
91
+ const body = { platform: a.platform, secretRefs: a.secretRefs };
92
+ if (a.description !== undefined)
93
+ body.description = a.description;
94
+ if (place(path, JSON.stringify(body, null, 2), a.name, "channel"))
95
+ artifacts.push({ type: "channel", name: a.name, path });
96
+ }
89
97
  else {
90
98
  const path = `hooks/${withExt(seg, ".json")}`;
91
99
  const body = { event: a.event, config: a.config };
@@ -159,6 +167,15 @@ export function readGemArchive(files) {
159
167
  a.secretRefs = o.secretRefs;
160
168
  return a;
161
169
  }
170
+ if (e.type === "channel") {
171
+ const o = JSON.parse(body(e.path));
172
+ const a = { type: "channel", name: e.name, platform: o.platform, secretRefs: o.secretRefs };
173
+ if (o.description !== undefined)
174
+ a.description = o.description;
175
+ return a;
176
+ }
177
+ if (e.type !== "hook")
178
+ throw new Error(`unknown artifact type '${e.type}' in manifest`);
162
179
  const o = JSON.parse(body(e.path));
163
180
  const a = { type: "hook", name: e.name, event: o.event, config: o.config };
164
181
  if (o.matcher !== undefined)
@@ -0,0 +1,9 @@
1
+ // src/gem/binPath.ts
2
+ // Leaf helper: is `binName` resolvable on the current PATH? Shared by the adapter
3
+ // resolver (runGem) and the sandbox backend availability checks (sandbox), so neither
4
+ // hard-codes an absolute install path for a tool that distros place in different dirs.
5
+ import { existsSync } from "node:fs";
6
+ import { delimiter, join } from "node:path";
7
+ export function binOnPath(binName) {
8
+ return (process.env.PATH ?? "").split(delimiter).some((d) => d && existsSync(join(d, binName)));
9
+ }
@@ -1,4 +1,5 @@
1
1
  import { redactMcpConfig } from "./redact.js";
2
+ import { makeChannelArtifact } from "./channels.js";
2
3
  export function buildGem(inventory, selection, opts = {}) {
3
4
  const artifacts = [];
4
5
  const projects = inventory.projects ?? [];
@@ -68,9 +69,11 @@ export function buildGem(inventory, selection, opts = {}) {
68
69
  });
69
70
  artifacts.length = 0;
70
71
  artifacts.push(...guarded);
72
+ for (const ch of opts.channels ?? [])
73
+ artifacts.push(makeChannelArtifact(ch.platform, ch.name));
71
74
  const requiredSecrets = [];
72
75
  for (const a of artifacts) {
73
- if ((a.type === "mcp_server" || a.type === "hook") && a.secretRefs) {
76
+ if ((a.type === "mcp_server" || a.type === "hook" || a.type === "channel") && a.secretRefs) {
74
77
  for (const ref of a.secretRefs)
75
78
  requiredSecrets.push({ name: ref.name, artifact: a.name, location: ref.location });
76
79
  }
@@ -0,0 +1,29 @@
1
+ export const CHANNEL_REGISTRY = {
2
+ slack: { platform: "slack", label: "Slack", eveImport: "eve/channels/slack", factory: "slackChannel", secrets: ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"] },
3
+ telegram: { platform: "telegram", label: "Telegram", eveImport: "eve/channels/telegram", factory: "telegramChannel", secrets: ["TELEGRAM_BOT_TOKEN", "TELEGRAM_WEBHOOK_SECRET_TOKEN"] },
4
+ discord: { platform: "discord", label: "Discord", eveImport: "eve/channels/discord", factory: "discordChannel", secrets: ["DISCORD_BOT_TOKEN", "DISCORD_PUBLIC_KEY", "DISCORD_APPLICATION_ID"] },
5
+ teams: { platform: "teams", label: "Microsoft Teams", eveImport: "eve/channels/teams", factory: "teamsChannel", secrets: ["MICROSOFT_APP_ID", "MICROSOFT_APP_PASSWORD", "MICROSOFT_TENANT_ID"] },
6
+ twilio: { platform: "twilio", label: "Twilio", eveImport: "eve/channels/twilio", factory: "twilioChannel", secrets: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"] },
7
+ github: { platform: "github", label: "GitHub", eveImport: "eve/channels/github", factory: "githubChannel", secrets: ["GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "GITHUB_WEBHOOK_SECRET"] },
8
+ };
9
+ // The agent/channels/<name>.ts file Eve materialization emits. Eve channel factories read their
10
+ // secrets from the environment, so the scaffold is the import + a zero-arg factory default export.
11
+ // NOTE: twilioChannel() requires an `allowFrom` config; its scaffold includes the minimal required arg.
12
+ export function channelScaffold(platform) {
13
+ const spec = CHANNEL_REGISTRY[platform];
14
+ const envComment = `// Reads ${spec.secrets.join(", ")} from the environment (set them as project env vars).\n// See https://vercel.com/docs/eve/concepts#channels\n`;
15
+ if (platform === "twilio") {
16
+ return (`import { ${spec.factory} } from ${JSON.stringify(spec.eveImport)};\n\n` +
17
+ envComment +
18
+ `export default ${spec.factory}({\n allowFrom: "*", // restrict to your Twilio numbers in production\n});\n`);
19
+ }
20
+ return (`import { ${spec.factory} } from ${JSON.stringify(spec.eveImport)};\n\n` +
21
+ envComment +
22
+ `export default ${spec.factory}();\n`);
23
+ }
24
+ // Build a neutral channel artifact, resolving the platform's env-var secrets into secretRefs.
25
+ export function makeChannelArtifact(platform, name) {
26
+ const spec = CHANNEL_REGISTRY[platform];
27
+ const secretRefs = spec.secrets.map((s) => ({ name: s, location: `env.${s}` }));
28
+ return { type: "channel", name: name ?? platform, platform, secretRefs };
29
+ }
@@ -6,6 +6,7 @@
6
6
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { agentgemHome } from "../resolveDir.js";
9
+ import { InvalidInputError } from "./inputError.js";
9
10
  // Only these keys may be set/persisted via the API — never arbitrary env vars.
10
11
  export const CREDENTIAL_KEYS = ["ANTHROPIC_API_KEY", "VERCEL_TOKEN", "CLOUDFLARE_API_TOKEN"];
11
12
  export function credentialsEnvPath(home = agentgemHome()) {
@@ -16,9 +17,9 @@ export function credentialsEnvPath(home = agentgemHome()) {
16
17
  export function setCredential(key, value, home = agentgemHome()) {
17
18
  const v = value.trim();
18
19
  if (!v)
19
- throw new Error("credential value is empty");
20
+ throw new InvalidInputError("credential value is empty");
20
21
  if (/[\r\n]/.test(v))
21
- throw new Error("credential value must be a single line");
22
+ throw new InvalidInputError("credential value must be a single line");
22
23
  process.env[key] = v;
23
24
  const abs = credentialsEnvPath(home);
24
25
  const kept = (existsSync(abs) ? readFileSync(abs, "utf8") : "")