@synap-core/cli 1.7.0 → 1.8.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.
Files changed (115) hide show
  1. package/dist/commands/agent-run.d.ts +47 -0
  2. package/dist/commands/agent-run.js +302 -0
  3. package/dist/commands/agent-run.js.map +1 -0
  4. package/dist/commands/artifact.d.ts +26 -0
  5. package/dist/commands/artifact.js +76 -0
  6. package/dist/commands/artifact.js.map +1 -0
  7. package/dist/commands/automation.d.ts +47 -0
  8. package/dist/commands/automation.js +600 -0
  9. package/dist/commands/automation.js.map +1 -0
  10. package/dist/commands/cell.d.ts +55 -0
  11. package/dist/commands/cell.js +277 -0
  12. package/dist/commands/cell.js.map +1 -0
  13. package/dist/commands/connect.js +41 -7
  14. package/dist/commands/connect.js.map +1 -1
  15. package/dist/commands/connections.js +0 -16
  16. package/dist/commands/connections.js.map +1 -1
  17. package/dist/commands/connectors.d.ts +31 -0
  18. package/dist/commands/connectors.js +276 -0
  19. package/dist/commands/connectors.js.map +1 -0
  20. package/dist/commands/context.d.ts +22 -0
  21. package/dist/commands/context.js +151 -0
  22. package/dist/commands/context.js.map +1 -0
  23. package/dist/commands/data-extra.d.ts +13 -0
  24. package/dist/commands/data-extra.js +161 -0
  25. package/dist/commands/data-extra.js.map +1 -0
  26. package/dist/commands/data.d.ts +13 -9
  27. package/dist/commands/data.js +213 -109
  28. package/dist/commands/data.js.map +1 -1
  29. package/dist/commands/doc.d.ts +39 -0
  30. package/dist/commands/doc.js +130 -0
  31. package/dist/commands/doc.js.map +1 -0
  32. package/dist/commands/events.d.ts +26 -0
  33. package/dist/commands/events.js +68 -0
  34. package/dist/commands/events.js.map +1 -0
  35. package/dist/commands/explain.d.ts +21 -0
  36. package/dist/commands/explain.js +248 -0
  37. package/dist/commands/explain.js.map +1 -0
  38. package/dist/commands/graph.d.ts +18 -0
  39. package/dist/commands/graph.js +73 -0
  40. package/dist/commands/graph.js.map +1 -0
  41. package/dist/commands/home.d.ts +12 -0
  42. package/dist/commands/home.js +54 -0
  43. package/dist/commands/home.js.map +1 -0
  44. package/dist/commands/init.d.ts +0 -2
  45. package/dist/commands/init.js +62 -102
  46. package/dist/commands/init.js.map +1 -1
  47. package/dist/commands/keys.d.ts +9 -0
  48. package/dist/commands/keys.js +79 -0
  49. package/dist/commands/keys.js.map +1 -0
  50. package/dist/commands/knowledge.d.ts +32 -18
  51. package/dist/commands/knowledge.js +301 -55
  52. package/dist/commands/knowledge.js.map +1 -1
  53. package/dist/commands/observe.d.ts +9 -0
  54. package/dist/commands/observe.js +90 -0
  55. package/dist/commands/observe.js.map +1 -0
  56. package/dist/commands/open.d.ts +15 -0
  57. package/dist/commands/open.js +44 -0
  58. package/dist/commands/open.js.map +1 -0
  59. package/dist/commands/pods.d.ts +5 -0
  60. package/dist/commands/pods.js +85 -37
  61. package/dist/commands/pods.js.map +1 -1
  62. package/dist/commands/proposals-actions.d.ts +7 -0
  63. package/dist/commands/proposals-actions.js +40 -0
  64. package/dist/commands/proposals-actions.js.map +1 -0
  65. package/dist/commands/providers.d.ts +38 -0
  66. package/dist/commands/providers.js +170 -0
  67. package/dist/commands/providers.js.map +1 -0
  68. package/dist/commands/sessions.d.ts +31 -0
  69. package/dist/commands/sessions.js +224 -0
  70. package/dist/commands/sessions.js.map +1 -0
  71. package/dist/commands/skills-commands.d.ts +26 -0
  72. package/dist/commands/skills-commands.js +186 -0
  73. package/dist/commands/skills-commands.js.map +1 -0
  74. package/dist/commands/skills.d.ts +26 -0
  75. package/dist/commands/skills.js +195 -0
  76. package/dist/commands/skills.js.map +1 -0
  77. package/dist/commands/status.js +68 -30
  78. package/dist/commands/status.js.map +1 -1
  79. package/dist/commands/subscribe.d.ts +19 -0
  80. package/dist/commands/subscribe.js +91 -0
  81. package/dist/commands/subscribe.js.map +1 -0
  82. package/dist/commands/view.d.ts +57 -0
  83. package/dist/commands/view.js +194 -0
  84. package/dist/commands/view.js.map +1 -0
  85. package/dist/index.d.ts +3 -4
  86. package/dist/index.js +812 -206
  87. package/dist/index.js.map +1 -1
  88. package/dist/lib/agent-templates.d.ts +25 -0
  89. package/dist/lib/agent-templates.js +148 -0
  90. package/dist/lib/agent-templates.js.map +1 -0
  91. package/dist/lib/agents-config.d.ts +4 -1
  92. package/dist/lib/agents-config.js +19 -2
  93. package/dist/lib/agents-config.js.map +1 -1
  94. package/dist/lib/capture-lane.d.ts +49 -0
  95. package/dist/lib/capture-lane.js +79 -0
  96. package/dist/lib/capture-lane.js.map +1 -0
  97. package/dist/lib/hub-client.d.ts +6 -0
  98. package/dist/lib/hub-client.js +31 -3
  99. package/dist/lib/hub-client.js.map +1 -1
  100. package/dist/lib/pod.d.ts +14 -1
  101. package/dist/lib/pod.js +24 -2
  102. package/dist/lib/pod.js.map +1 -1
  103. package/dist/lib/skills-installer.d.ts +1 -1
  104. package/dist/lib/skills-installer.js +1 -1
  105. package/dist/lib/skills-installer.js.map +1 -1
  106. package/dist/lib/targets.d.ts +17 -1
  107. package/dist/lib/targets.js +713 -63
  108. package/dist/lib/targets.js.map +1 -1
  109. package/package.json +2 -1
  110. package/skills/synap/SKILL.md +357 -82
  111. package/skills/synap-ui/SKILL.md +12 -9
  112. package/dist/lib/templates.d.ts +0 -11
  113. package/dist/lib/templates.js +0 -13
  114. package/dist/lib/templates.js.map +0 -1
  115. package/dist/templates/agent-os.json +0 -3090
@@ -11,6 +11,7 @@
11
11
  import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
+ import { exec } from "node:child_process";
14
15
  import chalk from "chalk";
15
16
  import ora from "ora";
16
17
  import prompts from "prompts";
@@ -80,6 +81,43 @@ export const TARGETS = {
80
81
  mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.yaml"),
81
82
  skillsDir: () => path.join(os.homedir(), ".codex"),
82
83
  },
84
+ opencode: {
85
+ name: "opencode",
86
+ label: "opencode",
87
+ description: "Synap IS as AI provider + Synap pod as MCP tools in ~/.config/opencode/opencode.json",
88
+ supports: { skills: false, mcp: true },
89
+ },
90
+ aider: {
91
+ name: "aider",
92
+ label: "aider",
93
+ description: "Write Synap IS as the OpenAI-compatible provider in ~/.aider.conf.yml",
94
+ supports: { skills: false, mcp: false },
95
+ },
96
+ windsurf: {
97
+ name: "windsurf",
98
+ label: "Windsurf",
99
+ description: "Codeium Windsurf editor — MCP config at ~/.codeium/windsurf/mcp_config.json",
100
+ supports: { skills: false, mcp: true },
101
+ mcpConfigPath: () => path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json"),
102
+ },
103
+ goose: {
104
+ name: "goose",
105
+ label: "Goose",
106
+ description: "Block's AI agent — MCP extension in ~/.config/goose/config.yaml",
107
+ supports: { skills: false, mcp: true },
108
+ },
109
+ zed: {
110
+ name: "zed",
111
+ label: "Zed",
112
+ description: "Zed editor — context_servers entry in Zed settings.json (via mcp-remote)",
113
+ supports: { skills: false, mcp: true },
114
+ },
115
+ vscode: {
116
+ name: "vscode",
117
+ label: "VS Code (Kilo Code / Cline / Continue / Copilot)",
118
+ description: "VS Code native MCP — one config read by Kilo Code, Cline, Continue, and Copilot",
119
+ supports: { skills: false, mcp: true },
120
+ },
83
121
  generic: {
84
122
  name: "generic",
85
123
  label: "Generic MCP Client",
@@ -90,6 +128,217 @@ export const TARGETS = {
90
128
  export function isTargetName(value) {
91
129
  return value in TARGETS;
92
130
  }
131
+ async function fetchWorkspaces(podUrl, apiKey) {
132
+ try {
133
+ const res = await fetch(`${podUrl.replace(/\/$/, "")}/api/hub/workspaces`, {
134
+ headers: { Authorization: `Bearer ${apiKey}` },
135
+ signal: AbortSignal.timeout(8000),
136
+ });
137
+ if (!res.ok)
138
+ return [];
139
+ const data = await res.json();
140
+ return Array.isArray(data) ? data : (data.workspaces ?? []);
141
+ }
142
+ catch {
143
+ return [];
144
+ }
145
+ }
146
+ async function provisionAgentWorkspace(podUrl, apiKey, agentUserId, workspaceName) {
147
+ try {
148
+ const res = await fetch(`${podUrl.replace(/\/$/, "")}/api/hub/workspaces/provision-agent`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
151
+ body: JSON.stringify({ agentUserId, workspaceName }),
152
+ signal: AbortSignal.timeout(10000),
153
+ });
154
+ if (!res.ok)
155
+ return null;
156
+ const data = await res.json();
157
+ const resolvedId = data.workspaceId ?? data.id ?? (data.workspace?.id);
158
+ if (data.workspace)
159
+ return data.workspace;
160
+ return resolvedId ? { id: resolvedId, name: data.name ?? workspaceName } : null;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /** Build the pod's /mcp URL, appending ?workspaceId= when a workspace is scoped. */
167
+ function buildMcpUrl(podUrl, workspaceId) {
168
+ const podBase = podUrl.replace(/\/$/, "");
169
+ return workspaceId
170
+ ? `${podBase}/mcp?workspaceId=${encodeURIComponent(workspaceId)}`
171
+ : `${podBase}/mcp`;
172
+ }
173
+ /**
174
+ * Shared preamble for MCP surface installers: provision a dedicated agent key,
175
+ * enroll the agent into the caller's workspaces, and resolve the scoped MCP URL.
176
+ */
177
+ async function prepareMcpSurface(cfg, agentType) {
178
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentType);
179
+ await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
180
+ return { effectiveApiKey, agentUserId, mcpUrl: buildMcpUrl(cfg.podUrl, cfg.workspaceId) };
181
+ }
182
+ // ─── Agent context configuration ──────────────────────────────────────────────
183
+ async function configureAgentContext(podUrl, apiKey, agentType, agentUserId, info) {
184
+ const { AGENT_TEMPLATES, getTemplate } = await import("./agent-templates.js");
185
+ log.blank();
186
+ log.heading("Configure your agent (3 steps)");
187
+ log.dim("Set up where this agent keeps its own memory, which team workspaces it writes to, and its skill template.");
188
+ // Fetch workspaces once — reused by step 1 and step 2 (no duplicate network calls)
189
+ const wsSpinner = ora("Fetching workspaces…").start();
190
+ const allWorkspaces = await fetchWorkspaces(podUrl, apiKey);
191
+ wsSpinner.stop();
192
+ // ── Step 1 · Agent memory workspace ──────────────────────────────────────
193
+ log.blank();
194
+ log.info(chalk.bold("Step 1 of 3 — Agent memory"));
195
+ log.dim("A private workspace for this agent's own notes, learned patterns, and session context.");
196
+ log.blank();
197
+ let memoryWorkspace;
198
+ const CREATE_SENTINEL = "__create__";
199
+ const SKIP_SENTINEL = "__skip__";
200
+ // Score workspaces by name relevance to "agent memory" — higher = better suggestion
201
+ function memoryScore(w) {
202
+ const n = w.name.toLowerCase();
203
+ return ["memory", "agent", "assistant", agentType.toLowerCase()]
204
+ .reduce((s, kw) => s + (n.includes(kw) ? 1 : 0), 0);
205
+ }
206
+ const bestMemIdx = allWorkspaces.length > 0
207
+ ? allWorkspaces.reduce((bi, w, i) => memoryScore(w) > memoryScore(allWorkspaces[bi]) ? i : bi, 0)
208
+ : -1;
209
+ const hasSuggestion = bestMemIdx >= 0 && memoryScore(allWorkspaces[bestMemIdx]) > 0;
210
+ const memoryChoices = [
211
+ ...allWorkspaces.map((w, i) => {
212
+ const hint = (i === bestMemIdx && hasSuggestion) ? chalk.green(" ← suggested") : "";
213
+ return { title: `${chalk.bold(w.name)} ${chalk.dim(w.id)}${hint}`, value: w.id };
214
+ }),
215
+ { title: chalk.cyan("Create a new workspace…"), value: CREATE_SENTINEL },
216
+ { title: chalk.dim("None — skip"), value: SKIP_SENTINEL },
217
+ ];
218
+ // Default: suggested workspace if one matches, otherwise "Create a new workspace"
219
+ const memoryDefaultIdx = hasSuggestion ? bestMemIdx : allWorkspaces.length;
220
+ const { memoryPickedId } = await prompts({
221
+ type: "select",
222
+ name: "memoryPickedId",
223
+ message: "Which workspace is this agent's private memory?",
224
+ choices: memoryChoices,
225
+ initial: memoryDefaultIdx,
226
+ });
227
+ if (memoryPickedId && memoryPickedId !== SKIP_SENTINEL) {
228
+ if (memoryPickedId === CREATE_SENTINEL) {
229
+ const defaultName = `${agentType} Memory`;
230
+ const { newName } = await prompts({
231
+ type: "text",
232
+ name: "newName",
233
+ message: "Workspace name:",
234
+ initial: defaultName,
235
+ validate: (v) => v.trim().length > 0 || "Name is required",
236
+ });
237
+ const finalName = (newName ?? "").trim() || defaultName;
238
+ if (!agentUserId) {
239
+ log.warn("Agent identity not available — cannot provision a workspace. Reconnect to try again.");
240
+ }
241
+ else {
242
+ const createSpinner = ora(`Creating "${finalName}"…`).start();
243
+ const ws = await provisionAgentWorkspace(podUrl, apiKey, agentUserId, finalName);
244
+ if (ws) {
245
+ memoryWorkspace = ws;
246
+ createSpinner.succeed(`Created: ${chalk.bold(ws.name)} ${chalk.dim(ws.id)}`);
247
+ }
248
+ else {
249
+ createSpinner.fail("Could not create workspace — check pod logs. You can set one up manually later.");
250
+ }
251
+ }
252
+ }
253
+ else {
254
+ memoryWorkspace = allWorkspaces.find((w) => w.id === memoryPickedId);
255
+ }
256
+ }
257
+ // ── Step 2 · Team / product workspaces ───────────────────────────────────
258
+ log.blank();
259
+ log.info(chalk.bold("Step 2 of 3 — Team workspaces"));
260
+ log.dim("Workspaces where the agent posts updates, tasks, and decisions for your team to see.");
261
+ log.blank();
262
+ // Reuse the already-fetched list — exclude whichever workspace was chosen as memory
263
+ const candidates = allWorkspaces.filter((w) => w.id !== memoryWorkspace?.id);
264
+ let productWorkspaces = [];
265
+ if (candidates.length === 0) {
266
+ log.dim("No other workspaces found — you can configure this later by re-running connect.");
267
+ }
268
+ else {
269
+ log.dim("Space to select/deselect, enter to confirm. Skip to leave unset.");
270
+ const { productIds } = await prompts({
271
+ type: "multiselect",
272
+ name: "productIds",
273
+ message: "Which workspace(s) should the agent write to?",
274
+ choices: candidates.map((w) => ({ title: `${w.name} ${chalk.dim(w.id)}`, value: w.id })),
275
+ });
276
+ productWorkspaces = candidates.filter((w) => (productIds ?? []).includes(w.id));
277
+ }
278
+ // ── Step 3 · Template ─────────────────────────────────────────────────────
279
+ log.blank();
280
+ log.info(chalk.bold("Step 3 of 3 — Agent template"));
281
+ log.dim("The template sets the agent's default behaviour and routing rules.");
282
+ log.blank();
283
+ const { templateId } = await prompts({
284
+ type: "select",
285
+ name: "templateId",
286
+ message: "Pick a template:",
287
+ choices: AGENT_TEMPLATES.map((t) => ({
288
+ title: `${t.label} ${chalk.dim(t.description)}`,
289
+ value: t.id,
290
+ })),
291
+ });
292
+ const template = getTemplate(templateId);
293
+ // ── Generate and write CONTEXT.md ─────────────────────────────────────────
294
+ const content = template.generateContext({
295
+ agentType,
296
+ podUrl,
297
+ memoryWorkspace,
298
+ productWorkspaces,
299
+ });
300
+ // Persist workspace routing to config — drives capture/recall defaults
301
+ const { setAgentWorkspaceRouting } = await import("./pod.js");
302
+ setAgentWorkspaceRouting({
303
+ memoryWorkspaceId: memoryWorkspace?.id,
304
+ productWorkspaceIds: productWorkspaces.map((w) => w.id),
305
+ });
306
+ // Always write to ~/.synap/contexts/<surface>.md
307
+ const contextDir = path.join(os.homedir(), ".synap", "contexts");
308
+ if (!fs.existsSync(contextDir))
309
+ fs.mkdirSync(contextDir, { recursive: true });
310
+ const globalContextPath = path.join(contextDir, `${agentType}.md`);
311
+ fs.writeFileSync(globalContextPath, content, { mode: 0o600 });
312
+ // For skills-capable surfaces, also write alongside SKILL.md
313
+ const skillsDir = info.skillsDir?.();
314
+ if (skillsDir) {
315
+ const synapSkillDir = path.join(skillsDir, "synap");
316
+ if (!fs.existsSync(synapSkillDir))
317
+ fs.mkdirSync(synapSkillDir, { recursive: true });
318
+ fs.writeFileSync(path.join(synapSkillDir, "CONTEXT.md"), content, { mode: 0o600 });
319
+ log.success(`Agent context written to ${synapSkillDir}/CONTEXT.md`);
320
+ }
321
+ else {
322
+ log.success(`Agent context written to ${globalContextPath}`);
323
+ log.dim("For MCP-only surfaces the context is available as a reference; load it manually if needed.");
324
+ }
325
+ // Summary
326
+ log.blank();
327
+ log.info(` Template ${chalk.bold(template.label)}`);
328
+ if (memoryWorkspace) {
329
+ log.info(` Memory ${chalk.bold(memoryWorkspace.name)} ${chalk.dim(memoryWorkspace.id)}`);
330
+ }
331
+ else {
332
+ log.info(` Memory ${chalk.dim("(none)")}`);
333
+ }
334
+ if (productWorkspaces.length > 0) {
335
+ log.info(` Product ${productWorkspaces.map((w) => chalk.bold(w.name)).join(", ")}`);
336
+ }
337
+ else {
338
+ log.info(` Product ${chalk.dim("(none)")}`);
339
+ }
340
+ log.blank();
341
+ }
93
342
  export function listTargets() {
94
343
  log.heading("Supported targets");
95
344
  for (const t of Object.values(TARGETS)) {
@@ -112,51 +361,91 @@ export async function installForTarget(target, cfg) {
112
361
  return false;
113
362
  }
114
363
  log.heading(`Installing for ${info.label}`);
364
+ let result;
115
365
  switch (target) {
116
366
  case "claude-code":
117
- return installClaudeCode(info, cfg);
367
+ result = await installClaudeCode(info, cfg);
368
+ break;
118
369
  case "claude-desktop":
119
- return installClaudeDesktop(info, cfg);
370
+ result = await installClaudeDesktop(info, cfg);
371
+ break;
120
372
  case "cursor":
121
- return installCursor(info, cfg);
373
+ result = await installCursor(info, cfg);
374
+ break;
122
375
  case "raycast":
123
- return installRaycast(cfg);
376
+ result = await installRaycast(cfg);
377
+ break;
124
378
  case "openclaw":
125
- return installOpenclaw(cfg);
379
+ result = await installOpenclaw(cfg);
380
+ break;
126
381
  case "openwebui":
127
- return installOpenWebUI(cfg);
382
+ result = await installOpenWebUI(cfg);
383
+ break;
128
384
  case "codex":
129
- return installCodex(info, cfg);
385
+ result = await installCodex(info, cfg);
386
+ break;
387
+ case "opencode":
388
+ result = await installOpencode(cfg);
389
+ break;
390
+ case "aider":
391
+ result = await installAider(cfg);
392
+ break;
393
+ case "windsurf":
394
+ result = await installWindsurf(info, cfg);
395
+ break;
396
+ case "goose":
397
+ result = await installGoose(cfg);
398
+ break;
399
+ case "zed":
400
+ result = await installZed(cfg);
401
+ break;
402
+ case "vscode":
403
+ result = await installVSCode(cfg);
404
+ break;
130
405
  case "generic":
131
- return installGeneric(cfg);
406
+ result = await installGeneric(cfg);
407
+ break;
408
+ }
409
+ // Post-install: configure agent workspace context for all successful MCP installs.
410
+ if (result && info.supports.mcp && target !== "generic") {
411
+ const { getSurfaceAgentKey } = await import("./pod.js");
412
+ const saved = getSurfaceAgentKey(target);
413
+ const agentUserId = saved?.agentUserId ?? "";
414
+ try {
415
+ await configureAgentContext(cfg.podUrl, cfg.apiKey, target, agentUserId, info);
416
+ }
417
+ catch (err) {
418
+ log.warn(`Agent context wizard failed: ${err instanceof Error ? err.message : String(err)}`);
419
+ }
132
420
  }
421
+ return result;
133
422
  }
134
423
  // ─── Claude Code ─────────────────────────────────────────────────────────────
135
424
  async function installClaudeCode(info, cfg) {
136
425
  // ── What to install? ────────────────────────────────────────────────────
137
- // Skills = CLAUDE.md context files (instructions, schema, UI patterns)
138
- // MCP = live tool server (read/write entities, search, memory, etc.)
139
- // Both is the recommended default — skills give the model Synap knowledge,
140
- // MCP gives it the actual tools to act on data.
426
+ // Claude Code is a CLI-first agent: it reads/writes Synap via `synap` shell
427
+ // commands (capture, recall, search, create, etc.) — not via MCP tool calls.
428
+ // Skills give it Synap knowledge; the agent key lets it call the CLI directly.
429
+ // MCP is available as an opt-in for users who prefer the tool-call interface.
141
430
  const { mode } = await prompts({
142
431
  type: "select",
143
432
  name: "mode",
144
433
  message: "What do you want to install?",
145
434
  choices: [
146
435
  {
147
- title: "Both — Skills + MCP tools (recommended)",
148
- description: "Skills give Claude context about Synap; MCP tools let it read & write data",
149
- value: "both",
436
+ title: "Skills + agent key (recommended)",
437
+ description: "Claude Code reads/writes Synap via synap CLI commands — no MCP overhead",
438
+ value: "skills",
150
439
  },
151
440
  {
152
- title: "MCP tools only",
153
- description: "Live tool server — search, create, update entities, memory, channels",
154
- value: "mcp",
441
+ title: "Skills + agent key + MCP server",
442
+ description: "Also registers a live MCP tool server (for users who prefer tool-call access)",
443
+ value: "both",
155
444
  },
156
445
  {
157
- title: "Skills only",
158
- description: "Context files (CLAUDE.md) — no live data access",
159
- value: "skills",
446
+ title: "MCP server only",
447
+ description: "Tool-call interface only — skip skills and CLI setup",
448
+ value: "mcp",
160
449
  },
161
450
  ],
162
451
  initial: 0,
@@ -176,25 +465,21 @@ async function installClaudeCode(info, cfg) {
176
465
  }
177
466
  }
178
467
  }
179
- // ── MCP ─────────────────────────────────────────────────────────────────
468
+ // ── Agent key + optional MCP ─────────────────────────────────────────────
469
+ // writeClaudeCodeEnv always provisions the agent key and writes env vars.
470
+ // writeMcp=true additionally registers the mcpServers entry.
180
471
  if (mode === "both" || mode === "mcp") {
181
472
  await writeClaudeCodeEnv(cfg);
182
- log.success("MCP server 'synap' added to ~/.claude/settings.json");
183
- if (cfg.workspaceId) {
184
- log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
185
- }
186
- else {
187
- log.dim("Not scoped — all workspaces accessible.");
188
- }
473
+ log.success("Agent key provisioned and MCP server registered in ~/.claude/settings.json");
189
474
  }
190
475
  else {
191
- // Skills-only: still write env vars (pod URL useful for skills referencing
192
- // SYNAP_POD_URL) but don't add the mcpServers entry
193
- await writeClaudeCodeEnv({ ...cfg, workspaceId: cfg.workspaceId }, { writeMcp: false });
476
+ await writeClaudeCodeEnv(cfg, { writeMcp: false });
477
+ log.success("Agent key provisioned and pod env vars written to ~/.claude/settings.json");
478
+ log.dim("Claude Code will use `synap` CLI commands to read/write the pod.");
194
479
  }
195
480
  log.blank();
196
- log.success("Claude Code config updated.");
197
- log.dim("Restart Claude Code (or open a new window) to pick up the MCP server and env vars.");
481
+ log.success("Claude Code configured.");
482
+ log.dim("Open a new Claude Code window to pick up the updated env vars.");
198
483
  return true;
199
484
  }
200
485
  /**
@@ -286,7 +571,7 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
286
571
  const agentSetupRes = await fetch(`${podBase}/api/hub/setup/agent`, {
287
572
  method: "POST",
288
573
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
289
- body: JSON.stringify({ agentType: "claude-code", idempotent: false }),
574
+ body: JSON.stringify({ agentType: "claude-code", idempotent: true }),
290
575
  signal: AbortSignal.timeout(10000),
291
576
  });
292
577
  if (!agentSetupRes.ok) {
@@ -294,9 +579,67 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
294
579
  throw new Error(`Failed to provision agent key for claude-code (HTTP ${agentSetupRes.status}): ${text}\nEnsure your API key has hub-protocol.write scope.`);
295
580
  }
296
581
  const agentSetup = await agentSetupRes.json();
297
- if (!agentSetup.hubApiKey)
298
- throw new Error("setup/agent succeeded but returned no hubApiKey — check pod logs.");
299
- effectiveApiKey = agentSetup.hubApiKey;
582
+ if (agentSetup.alreadyValid) {
583
+ // Idempotent path — pod says the agent key is still valid but doesn't re-transmit it.
584
+ // Resolution priority: stored agentKey config > settings.json env var.
585
+ // Self-heal: verify the candidate resolves to the expected agent user, not the human user.
586
+ const { getSurfaceAgentKey: readSurfaceKey } = await import("./pod.js");
587
+ const storedKey = readSurfaceKey("claude-code");
588
+ const candidate = storedKey?.hubApiKey ??
589
+ settings.env?.["SYNAP_HUB_API_KEY"] ??
590
+ null;
591
+ const expectedAgentUserId = agentSetup.agentUserId ?? storedKey?.agentUserId;
592
+ let candidateIsValid = false;
593
+ if (candidate && expectedAgentUserId) {
594
+ try {
595
+ // Use auth/status — it returns the key's actual owner userId.
596
+ // For agent keys this is the agent userId, not the human userId.
597
+ // users/me always returns the human owner regardless of key type.
598
+ const verifyRes = await fetch(`${podBase}/api/hub/auth/status`, {
599
+ headers: { Authorization: `Bearer ${candidate}` },
600
+ signal: AbortSignal.timeout(5000),
601
+ });
602
+ if (verifyRes.ok) {
603
+ const verifyStatus = await verifyRes.json();
604
+ candidateIsValid = verifyStatus.userId === expectedAgentUserId;
605
+ }
606
+ }
607
+ catch { /* reprovision below */ }
608
+ }
609
+ if (candidateIsValid && candidate) {
610
+ effectiveApiKey = candidate;
611
+ }
612
+ else {
613
+ // Self-heal: stored key is stale, wrong identity, or never persisted.
614
+ // Reprovision without idempotent flag to get a fresh agent key.
615
+ const reproRes = await fetch(`${podBase}/api/hub/setup/agent`, {
616
+ method: "POST",
617
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
618
+ body: JSON.stringify({ agentType: "claude-code" }),
619
+ signal: AbortSignal.timeout(10000),
620
+ });
621
+ if (reproRes.ok) {
622
+ const repro = await reproRes.json();
623
+ effectiveApiKey = repro.hubApiKey ?? candidate ?? cfg.apiKey;
624
+ if (repro.agentUserId)
625
+ agentSetup.agentUserId = repro.agentUserId;
626
+ }
627
+ else {
628
+ // Reprovision failed — fall back to whatever we have, warn clearly
629
+ effectiveApiKey = candidate ?? cfg.apiKey;
630
+ log.warn("Could not reprovision agent key — using fallback. Run `synap connect --target=claude-code` to fix.");
631
+ }
632
+ }
633
+ }
634
+ else {
635
+ if (!agentSetup.hubApiKey)
636
+ throw new Error("setup/agent succeeded but returned no hubApiKey — check pod logs.");
637
+ effectiveApiKey = agentSetup.hubApiKey;
638
+ }
639
+ // Persist the provisioned key to agentKeys["claude-code"] so SYNAP_AGENT=claude-code
640
+ // works in CLI sessions outside of this env (e.g. terminal, not just inside Claude Code).
641
+ const { setSurfaceAgentKey: saveSurfaceKey } = await import("./pod.js");
642
+ saveSurfaceKey("claude-code", { hubApiKey: effectiveApiKey, agentUserId: agentSetup.agentUserId ?? "" });
300
643
  // Enroll the new agent user into the caller's workspaces using the HUMAN key
301
644
  // (before switching to the agent key). Scoped to one workspace when chosen.
302
645
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentSetup.agentUserId ?? "", cfg.workspaceId);
@@ -315,9 +658,7 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
315
658
  // ── MCP server entry (HTTP transport, native Claude Code format) ─────────
316
659
  if (writeMcp) {
317
660
  // Append ?workspaceId= when one is set so every tool call is pre-scoped.
318
- const mcpUrl = cfg.workspaceId
319
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
320
- : `${podBase}/mcp`;
661
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
321
662
  const mcpServers = (settings.mcpServers ?? {});
322
663
  mcpServers["synap"] = {
323
664
  url: mcpUrl,
@@ -331,12 +672,47 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
331
672
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
332
673
  }
333
674
  // ─── Agent key provisioning helper ──────────────────────────────────────────
675
+ function openBrowserUrl(url) {
676
+ const safe = url.replace(/"/g, "%22");
677
+ const cmd = process.platform === "darwin" ? `open "${safe}"` :
678
+ process.platform === "win32" ? `start "" "${safe}"` :
679
+ `xdg-open "${safe}"`;
680
+ exec(cmd, () => { });
681
+ }
682
+ async function waitForKeyApproval(podBase, humanApiKey, pendingToken, agentType) {
683
+ const pollUrl = `${podBase}/api/hub/setup/agent/pending/${pendingToken}`;
684
+ const deadline = Date.now() + 120_000;
685
+ while (Date.now() < deadline) {
686
+ await new Promise((r) => setTimeout(r, 2000));
687
+ let poll;
688
+ try {
689
+ poll = await fetch(pollUrl, {
690
+ headers: { Authorization: `Bearer ${humanApiKey}` },
691
+ signal: AbortSignal.timeout(5000),
692
+ });
693
+ }
694
+ catch {
695
+ continue;
696
+ }
697
+ if (!poll.ok)
698
+ continue;
699
+ const data = await poll.json();
700
+ if (data.status === "active")
701
+ return;
702
+ if (data.status === "rejected") {
703
+ throw new Error(`Agent key for ${agentType} was rejected in the browser. Run connect again to re-request.`);
704
+ }
705
+ }
706
+ throw new Error(`Timed out waiting for approval of ${agentType} agent key (120s). ` +
707
+ `Open the review URL manually or run connect again.`);
708
+ }
334
709
  /**
335
710
  * Provision an agent-owned API key for the given surface via POST /api/hub/setup/agent.
336
- * Returns both the key and the new agent's userId so callers can enroll it in workspaces.
711
+ * When requireApproval is true the key is created inactive; the user must approve in the
712
+ * browser before this function returns.
337
713
  * Throws on failure — callers must not silently fall back to a human key.
338
714
  */
339
- async function provisionAgentKey(podUrl, humanApiKey, agentType) {
715
+ async function provisionAgentKey(podUrl, humanApiKey, agentType, { requireApproval = true } = {}) {
340
716
  const podBase = podUrl.replace(/\/$/, "");
341
717
  let res;
342
718
  try {
@@ -346,7 +722,7 @@ async function provisionAgentKey(podUrl, humanApiKey, agentType) {
346
722
  "Content-Type": "application/json",
347
723
  Authorization: `Bearer ${humanApiKey}`,
348
724
  },
349
- body: JSON.stringify({ agentType, idempotent: false }),
725
+ body: JSON.stringify({ agentType, idempotent: false, requireApproval }),
350
726
  signal: AbortSignal.timeout(10000),
351
727
  });
352
728
  }
@@ -364,6 +740,21 @@ async function provisionAgentKey(podUrl, humanApiKey, agentType) {
364
740
  throw new Error(`setup/agent succeeded but returned no hubApiKey for ${agentType}. ` +
365
741
  `This is a server-side bug — check the pod logs.`);
366
742
  }
743
+ if (body.requiresApproval && body.pendingToken && body.reviewUrl) {
744
+ log.info(`\n Opening your pod to review this agent key request…`);
745
+ log.dim(` ${body.reviewUrl}`);
746
+ openBrowserUrl(body.reviewUrl);
747
+ process.stdout.write(chalk.dim(" Waiting for approval"));
748
+ const ticker = setInterval(() => process.stdout.write(chalk.dim(".")), 2000);
749
+ try {
750
+ await waitForKeyApproval(podBase, humanApiKey, body.pendingToken, agentType);
751
+ }
752
+ finally {
753
+ clearInterval(ticker);
754
+ process.stdout.write("\n");
755
+ }
756
+ log.success(` Approved! Writing ${agentType} agent key.`);
757
+ }
367
758
  return { hubApiKey: body.hubApiKey, agentUserId: body.agentUserId ?? "" };
368
759
  }
369
760
  /**
@@ -418,10 +809,10 @@ async function installClaudeDesktop(info, cfg) {
418
809
  //
419
810
  // Reference: https://www.npmjs.com/package/mcp-remote
420
811
  const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-desktop");
812
+ const { setSurfaceAgentKey: saveDesktopKey } = await import("./pod.js");
813
+ saveDesktopKey("claude-desktop", { hubApiKey: effectiveApiKey, agentUserId });
421
814
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
422
- const desktopMcpUrl = cfg.workspaceId
423
- ? `${cfg.podUrl.replace(/\/$/, "")}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
424
- : `${cfg.podUrl.replace(/\/$/, "")}/mcp`;
815
+ const desktopMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
425
816
  writeMcpServerEntry(mcpPath, "synap", {
426
817
  command: "npx",
427
818
  args: ["-y", "mcp-remote", desktopMcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
@@ -461,10 +852,10 @@ async function installCursor(info, cfg) {
461
852
  if (!mcpPath)
462
853
  return false;
463
854
  const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "cursor");
855
+ const { setSurfaceAgentKey: saveCursorKey } = await import("./pod.js");
856
+ saveCursorKey("cursor", { hubApiKey: effectiveApiKey, agentUserId });
464
857
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
465
- const cursorMcpUrl = cfg.workspaceId
466
- ? `${cfg.podUrl.replace(/\/$/, "")}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
467
- : `${cfg.podUrl.replace(/\/$/, "")}/mcp`;
858
+ const cursorMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
468
859
  writeMcpServerEntry(mcpPath, "synap", {
469
860
  url: cursorMcpUrl,
470
861
  headers: { Authorization: `Bearer ${effectiveApiKey}` },
@@ -566,10 +957,7 @@ async function installOpenclaw(cfg) {
566
957
  }
567
958
  // ─── Open WebUI ──────────────────────────────────────────────────────────────
568
959
  async function installOpenWebUI(cfg) {
569
- const podBase = cfg.podUrl.replace(/\/$/, "");
570
- const mcpUrl = cfg.workspaceId
571
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
572
- : `${podBase}/mcp`;
960
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
573
961
  const podHost = new URL(cfg.podUrl).host;
574
962
  log.info("Open WebUI connects to Synap in two ways:");
575
963
  log.blank();
@@ -597,12 +985,142 @@ async function installOpenWebUI(cfg) {
597
985
  log.success(`Connection details saved to ~/.synap/openwebui-mcp.json`);
598
986
  return true;
599
987
  }
600
- // ─── Generic MCP client ───────────────────────────────────────────────────────
988
+ // ─── Windsurf ────────────────────────────────────────────────────────────────
989
+ async function installWindsurf(info, cfg) {
990
+ const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
991
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "windsurf");
992
+ writeMcpServerEntry(configPath, "synap", { url: mcpUrl, headers: { Authorization: `Bearer ${effectiveApiKey}` } });
993
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
994
+ save("windsurf", { hubApiKey: effectiveApiKey, agentUserId });
995
+ log.success(`Windsurf MCP config updated: ${configPath}`);
996
+ log.dim("Restart Windsurf to pick up the new MCP server.");
997
+ return true;
998
+ }
999
+ // ─── Goose ───────────────────────────────────────────────────────────────────
1000
+ async function installGoose(cfg) {
1001
+ const configDir = path.join(os.homedir(), ".config", "goose");
1002
+ const configPath = path.join(configDir, "config.yaml");
1003
+ if (!fs.existsSync(configDir))
1004
+ fs.mkdirSync(configDir, { recursive: true });
1005
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "goose");
1006
+ const marker = " synap:";
1007
+ const block = [
1008
+ " synap:",
1009
+ " name: synap",
1010
+ " type: streamable_http",
1011
+ ` uri: "${mcpUrl}"`,
1012
+ " env_keys: []",
1013
+ " timeout: 300",
1014
+ ` headers:`,
1015
+ ` Authorization: "Bearer ${effectiveApiKey}"`,
1016
+ ].join("\n");
1017
+ let existing = "";
1018
+ if (fs.existsSync(configPath)) {
1019
+ try {
1020
+ existing = fs.readFileSync(configPath, "utf-8");
1021
+ }
1022
+ catch { /* ignore */ }
1023
+ }
1024
+ let updated;
1025
+ if (existing.includes(marker)) {
1026
+ updated = existing.replace(/ synap:[\s\S]*?(?=\n [a-zA-Z_]|\s*$)/, block);
1027
+ }
1028
+ else if (/^extensions:/m.test(existing)) {
1029
+ updated = existing.trimEnd() + "\n" + block + "\n";
1030
+ }
1031
+ else {
1032
+ updated = (existing ? existing.trimEnd() + "\n\n" : "") + "extensions:\n" + block + "\n";
1033
+ }
1034
+ fs.writeFileSync(configPath, updated, { mode: 0o600 });
1035
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1036
+ save("goose", { hubApiKey: effectiveApiKey, agentUserId });
1037
+ log.success(`Goose config updated: ${configPath}`);
1038
+ log.dim("Run `goose session` to pick up the new MCP extension.");
1039
+ return true;
1040
+ }
1041
+ // ─── Zed ─────────────────────────────────────────────────────────────────────
1042
+ function zedSettingsPath() {
1043
+ switch (process.platform) {
1044
+ case "darwin": return path.join(os.homedir(), "Library", "Application Support", "Zed", "settings.json");
1045
+ case "win32": return path.join(process.env.APPDATA ?? os.homedir(), "Zed", "settings.json");
1046
+ default: return path.join(os.homedir(), ".config", "zed", "settings.json");
1047
+ }
1048
+ }
1049
+ async function installZed(cfg) {
1050
+ const settingsPath = zedSettingsPath();
1051
+ const settingsDir = path.dirname(settingsPath);
1052
+ if (!fs.existsSync(settingsDir))
1053
+ fs.mkdirSync(settingsDir, { recursive: true });
1054
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "zed");
1055
+ let settings = {};
1056
+ if (fs.existsSync(settingsPath)) {
1057
+ try {
1058
+ const raw = fs.readFileSync(settingsPath, "utf-8");
1059
+ // Zed settings.json is JSONC — strip // line comments and /* block comments */
1060
+ // before parsing. Comments will not be preserved in the rewritten file.
1061
+ const stripped = raw
1062
+ .replace(/\/\*[\s\S]*?\*\//g, "")
1063
+ .replace(/\/\/[^\n]*/g, "");
1064
+ settings = JSON.parse(stripped);
1065
+ }
1066
+ catch { /* malformed — start from scratch */ }
1067
+ }
1068
+ const contextServers = (settings.context_servers ?? {});
1069
+ contextServers["synap"] = {
1070
+ source: "custom",
1071
+ command: {
1072
+ path: "npx",
1073
+ args: ["-y", "mcp-remote@latest", mcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
1074
+ },
1075
+ settings: {},
1076
+ };
1077
+ settings.context_servers = contextServers;
1078
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
1079
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1080
+ save("zed", { hubApiKey: effectiveApiKey, agentUserId });
1081
+ log.success(`Zed settings updated: ${settingsPath}`);
1082
+ log.dim("Note: Zed settings are JSONC — any existing // comments were removed during rewrite.");
1083
+ log.dim("Reload Zed to pick up the new context server (mcp-remote bridge).");
1084
+ return true;
1085
+ }
1086
+ // ─── VS Code (Kilo Code / Cline / Continue / Copilot) ────────────────────────
1087
+ function vsCodeUserDataDir() {
1088
+ switch (process.platform) {
1089
+ case "darwin": return path.join(os.homedir(), "Library", "Application Support", "Code", "User");
1090
+ case "win32": return path.join(process.env.APPDATA ?? os.homedir(), "Code", "User");
1091
+ default: return path.join(os.homedir(), ".config", "Code", "User");
1092
+ }
1093
+ }
1094
+ async function installVSCode(cfg) {
1095
+ const mcpJsonPath = path.join(vsCodeUserDataDir(), "mcp.json");
1096
+ const mcpDir = path.dirname(mcpJsonPath);
1097
+ if (!fs.existsSync(mcpDir))
1098
+ fs.mkdirSync(mcpDir, { recursive: true });
1099
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "vscode");
1100
+ // VS Code 1.99+ global MCP format uses "servers" (not "mcpServers")
1101
+ let existing = {};
1102
+ if (fs.existsSync(mcpJsonPath)) {
1103
+ try {
1104
+ existing = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
1105
+ }
1106
+ catch { /* ignore */ }
1107
+ }
1108
+ existing.servers = existing.servers ?? {};
1109
+ existing.servers["synap"] = {
1110
+ type: "http",
1111
+ url: mcpUrl,
1112
+ headers: { Authorization: `Bearer ${effectiveApiKey}` },
1113
+ };
1114
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
1115
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1116
+ save("vscode", { hubApiKey: effectiveApiKey, agentUserId });
1117
+ log.success(`VS Code MCP config updated: ${mcpJsonPath}`);
1118
+ log.dim("Picked up automatically by Kilo Code, Cline, Continue, and GitHub Copilot.");
1119
+ log.dim("Reload VS Code (or open the MCP panel) to activate.");
1120
+ return true;
1121
+ }
601
1122
  async function installGeneric(cfg) {
602
- const podBase = cfg.podUrl.replace(/\/$/, "");
603
- const mcpUrl = cfg.workspaceId
604
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
605
- : `${podBase}/mcp`;
1123
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
606
1124
  const httpConfig = {
607
1125
  synap: {
608
1126
  url: mcpUrl,
@@ -649,10 +1167,7 @@ async function installCodex(info, cfg) {
649
1167
  // Build MCP server entry
650
1168
  const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "codex");
651
1169
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
652
- const podBase = cfg.podUrl.replace(/\/$/, "");
653
- const mcpUrl = cfg.workspaceId
654
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
655
- : `${podBase}/mcp`;
1170
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
656
1171
  // Inject/replace the synap mcpServers block using simple string manipulation
657
1172
  // (avoid requiring a YAML parser dependency)
658
1173
  const mcpBlock = [
@@ -706,6 +1221,141 @@ async function installCodex(info, cfg) {
706
1221
  log.dim("Restart Codex CLI to pick up the new MCP server.");
707
1222
  return true;
708
1223
  }
1224
+ async function fetchCredentials(podUrl, apiKey) {
1225
+ const { fetchPodCredentials } = await import("../commands/providers.js");
1226
+ return fetchPodCredentials(podUrl, apiKey);
1227
+ }
1228
+ /** Prompt the user to pick a provider from the pod and return it with its resolved key. */
1229
+ async function pickProvider(podUrl, apiKey) {
1230
+ const spinner = ora("Fetching providers from pod vault...").start();
1231
+ let providers;
1232
+ try {
1233
+ providers = await fetchCredentials(podUrl, apiKey);
1234
+ spinner.stop();
1235
+ }
1236
+ catch (err) {
1237
+ spinner.fail(`Could not fetch providers: ${err.message}`);
1238
+ return null;
1239
+ }
1240
+ const withKeys = providers.filter((p) => p.apiKey);
1241
+ if (withKeys.length === 0) {
1242
+ log.warn("No providers with API keys found on this pod.");
1243
+ log.dim("Add providers and keys in pod admin → Intelligence → Providers.");
1244
+ return null;
1245
+ }
1246
+ if (withKeys.length === 1)
1247
+ return withKeys[0];
1248
+ const { choice } = await prompts({
1249
+ type: "select",
1250
+ name: "choice",
1251
+ message: "Which AI provider do you want to use?",
1252
+ choices: withKeys.map((p) => ({
1253
+ title: `${p.name} ${chalk.dim(p.providerId)}`,
1254
+ value: p.providerId,
1255
+ })),
1256
+ });
1257
+ return withKeys.find((p) => p.providerId === choice) ?? null;
1258
+ }
1259
+ export async function installOpencode(cfg) {
1260
+ const provider = await pickProvider(cfg.podUrl, cfg.apiKey);
1261
+ if (!provider || !provider.apiKey)
1262
+ return false;
1263
+ // Build model map from the chosen provider
1264
+ if (provider.models.length === 0) {
1265
+ log.warn(`No model IDs found for ${provider.name} — configure models in pod admin → Intelligence → Providers`);
1266
+ return false;
1267
+ }
1268
+ const models = {};
1269
+ let defaultModel = "";
1270
+ for (const m of provider.models) {
1271
+ const key = `${provider.providerId}/${m.id}`;
1272
+ models[key] = {
1273
+ name: m.id,
1274
+ ...(m.contextWindow ? { contextLength: m.contextWindow } : {}),
1275
+ };
1276
+ if (!defaultModel)
1277
+ defaultModel = key;
1278
+ }
1279
+ const configDir = path.join(os.homedir(), ".config", "opencode");
1280
+ const configPath = path.join(configDir, "opencode.json");
1281
+ if (!fs.existsSync(configDir)) {
1282
+ fs.mkdirSync(configDir, { recursive: true });
1283
+ }
1284
+ let existing = {};
1285
+ if (fs.existsSync(configPath)) {
1286
+ try {
1287
+ existing = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1288
+ }
1289
+ catch {
1290
+ fs.copyFileSync(configPath, `${configPath}.bak`);
1291
+ log.warn("Existing opencode.json unreadable — backed up to opencode.json.bak");
1292
+ }
1293
+ }
1294
+ const providerBlock = (existing.provider ?? {});
1295
+ providerBlock[provider.providerId] = {
1296
+ npm: "@ai-sdk/openai-compatible",
1297
+ name: provider.name,
1298
+ options: { baseURL: provider.baseUrl, apiKey: provider.apiKey },
1299
+ models,
1300
+ };
1301
+ // Provision an agent key so the MCP server runs under its own identity
1302
+ const { hubApiKey: agentKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "opencode");
1303
+ await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1304
+ const podBase = cfg.podUrl.replace(/\/$/, "");
1305
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
1306
+ const mcpBlock = (existing.mcp ?? {});
1307
+ mcpBlock["synap"] = { type: "remote", url: mcpUrl, headers: { Authorization: `Bearer ${agentKey}` } };
1308
+ const updated = {
1309
+ $schema: "https://opencode.ai/config.json",
1310
+ ...existing,
1311
+ provider: providerBlock,
1312
+ mcp: mcpBlock,
1313
+ ...(existing.model ? {} : { model: defaultModel }),
1314
+ };
1315
+ fs.writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n", { mode: 0o600 });
1316
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1317
+ save("opencode", { hubApiKey: agentKey, agentUserId });
1318
+ log.success(`opencode configured: ~/.config/opencode/opencode.json`);
1319
+ log.dim(` AI provider: ${provider.name} (${Object.keys(models).length} model(s))`);
1320
+ log.dim(` MCP tools: synap pod at ${podBase}`);
1321
+ log.blank();
1322
+ log.dim("Restart opencode to pick up the new provider and MCP server.");
1323
+ return true;
1324
+ }
1325
+ // ─── aider ───────────────────────────────────────────────────────────────────
1326
+ export async function installAider(cfg) {
1327
+ const provider = await pickProvider(cfg.podUrl, cfg.apiKey);
1328
+ if (!provider || !provider.apiKey)
1329
+ return false;
1330
+ const defaultModel = provider.models[0]?.id ?? "default";
1331
+ const confPath = path.join(os.homedir(), ".aider.conf.yml");
1332
+ let existing = "";
1333
+ if (fs.existsSync(confPath)) {
1334
+ try {
1335
+ existing = fs.readFileSync(confPath, "utf-8");
1336
+ }
1337
+ catch { /* ignore */ }
1338
+ }
1339
+ const marker = "# synap-provider";
1340
+ const block = [
1341
+ marker,
1342
+ `openai-api-base: ${provider.baseUrl}`,
1343
+ `openai-api-key: ${provider.apiKey}`,
1344
+ `model: openai/${defaultModel}`,
1345
+ "",
1346
+ ].join("\n");
1347
+ const updated = existing.includes(marker)
1348
+ ? existing.replace(/# synap-provider[\s\S]*?(?=\n[a-zA-Z#]|\s*$)/, block.trimEnd())
1349
+ : existing ? `${existing.trimEnd()}\n\n${block}` : block;
1350
+ fs.writeFileSync(confPath, updated.trimEnd() + "\n", { mode: 0o600 });
1351
+ log.success(`${provider.name} configured in ~/.aider.conf.yml`);
1352
+ log.dim(` Base URL: ${provider.baseUrl}`);
1353
+ log.dim(` Model: openai/${defaultModel}`);
1354
+ log.blank();
1355
+ log.dim("Aider picks up ~/.aider.conf.yml automatically.");
1356
+ return true;
1357
+ }
1358
+ // ─── MCP config writer ───────────────────────────────────────────────────────
709
1359
  export function writeMcpServerEntry(configPath, serverName, server) {
710
1360
  const dir = path.dirname(configPath);
711
1361
  if (!fs.existsSync(dir)) {