@synap-core/cli 1.6.1 → 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 (128) 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 +220 -109
  28. package/dist/commands/data.js.map +1 -1
  29. package/dist/commands/discover.d.ts +18 -0
  30. package/dist/commands/discover.js +71 -0
  31. package/dist/commands/discover.js.map +1 -0
  32. package/dist/commands/doc.d.ts +39 -0
  33. package/dist/commands/doc.js +130 -0
  34. package/dist/commands/doc.js.map +1 -0
  35. package/dist/commands/events.d.ts +26 -0
  36. package/dist/commands/events.js +68 -0
  37. package/dist/commands/events.js.map +1 -0
  38. package/dist/commands/explain.d.ts +21 -0
  39. package/dist/commands/explain.js +248 -0
  40. package/dist/commands/explain.js.map +1 -0
  41. package/dist/commands/graph.d.ts +18 -0
  42. package/dist/commands/graph.js +73 -0
  43. package/dist/commands/graph.js.map +1 -0
  44. package/dist/commands/home.d.ts +12 -0
  45. package/dist/commands/home.js +54 -0
  46. package/dist/commands/home.js.map +1 -0
  47. package/dist/commands/init.d.ts +0 -2
  48. package/dist/commands/init.js +62 -102
  49. package/dist/commands/init.js.map +1 -1
  50. package/dist/commands/keys.d.ts +9 -0
  51. package/dist/commands/keys.js +79 -0
  52. package/dist/commands/keys.js.map +1 -0
  53. package/dist/commands/knowledge.d.ts +32 -18
  54. package/dist/commands/knowledge.js +303 -53
  55. package/dist/commands/knowledge.js.map +1 -1
  56. package/dist/commands/observe.d.ts +9 -0
  57. package/dist/commands/observe.js +90 -0
  58. package/dist/commands/observe.js.map +1 -0
  59. package/dist/commands/open.d.ts +15 -0
  60. package/dist/commands/open.js +44 -0
  61. package/dist/commands/open.js.map +1 -0
  62. package/dist/commands/pods.d.ts +5 -0
  63. package/dist/commands/pods.js +85 -37
  64. package/dist/commands/pods.js.map +1 -1
  65. package/dist/commands/proposals-actions.d.ts +7 -0
  66. package/dist/commands/proposals-actions.js +40 -0
  67. package/dist/commands/proposals-actions.js.map +1 -0
  68. package/dist/commands/providers.d.ts +38 -0
  69. package/dist/commands/providers.js +170 -0
  70. package/dist/commands/providers.js.map +1 -0
  71. package/dist/commands/sessions.d.ts +31 -0
  72. package/dist/commands/sessions.js +224 -0
  73. package/dist/commands/sessions.js.map +1 -0
  74. package/dist/commands/skills-commands.d.ts +26 -0
  75. package/dist/commands/skills-commands.js +186 -0
  76. package/dist/commands/skills-commands.js.map +1 -0
  77. package/dist/commands/skills.d.ts +26 -0
  78. package/dist/commands/skills.js +195 -0
  79. package/dist/commands/skills.js.map +1 -0
  80. package/dist/commands/status.js +68 -30
  81. package/dist/commands/status.js.map +1 -1
  82. package/dist/commands/subscribe.d.ts +19 -0
  83. package/dist/commands/subscribe.js +91 -0
  84. package/dist/commands/subscribe.js.map +1 -0
  85. package/dist/commands/view.d.ts +57 -0
  86. package/dist/commands/view.js +194 -0
  87. package/dist/commands/view.js.map +1 -0
  88. package/dist/index.d.ts +3 -4
  89. package/dist/index.js +823 -206
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/agent-templates.d.ts +25 -0
  92. package/dist/lib/agent-templates.js +148 -0
  93. package/dist/lib/agent-templates.js.map +1 -0
  94. package/dist/lib/agents-config.d.ts +4 -1
  95. package/dist/lib/agents-config.js +19 -2
  96. package/dist/lib/agents-config.js.map +1 -1
  97. package/dist/lib/capture-lane.d.ts +49 -0
  98. package/dist/lib/capture-lane.js +79 -0
  99. package/dist/lib/capture-lane.js.map +1 -0
  100. package/dist/lib/hub-client.d.ts +14 -0
  101. package/dist/lib/hub-client.js +45 -3
  102. package/dist/lib/hub-client.js.map +1 -1
  103. package/dist/lib/pod.d.ts +22 -1
  104. package/dist/lib/pod.js +32 -2
  105. package/dist/lib/pod.js.map +1 -1
  106. package/dist/lib/skills-installer.d.ts +1 -1
  107. package/dist/lib/skills-installer.js +1 -1
  108. package/dist/lib/skills-installer.js.map +1 -1
  109. package/dist/lib/targets.d.ts +17 -1
  110. package/dist/lib/targets.js +744 -70
  111. package/dist/lib/targets.js.map +1 -1
  112. package/node_modules/@synap/hub-rest-client/dist/index.cjs +881 -0
  113. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +907 -0
  114. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +907 -0
  115. package/node_modules/@synap/hub-rest-client/dist/index.js +851 -0
  116. package/node_modules/@synap/hub-rest-client/package.json +45 -0
  117. package/node_modules/@synap/hub-rest-client/src/client.ts +1143 -0
  118. package/node_modules/@synap/hub-rest-client/src/errors.ts +30 -0
  119. package/node_modules/@synap/hub-rest-client/src/index.ts +111 -0
  120. package/node_modules/@synap/hub-rest-client/src/setup.ts +77 -0
  121. package/node_modules/@synap/hub-rest-client/src/types.ts +639 -0
  122. package/package.json +9 -2
  123. package/skills/synap/SKILL.md +374 -100
  124. package/skills/synap-ui/SKILL.md +12 -9
  125. package/dist/lib/templates.d.ts +0 -11
  126. package/dist/lib/templates.js +0 -13
  127. package/dist/lib/templates.js.map +0 -1
  128. package/dist/templates/agent-os.json +0 -3090
@@ -11,7 +11,9 @@
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";
16
+ import ora from "ora";
15
17
  import prompts from "prompts";
16
18
  import { log } from "../utils/logger.js";
17
19
  import { installSkills, SKILL_NAMES } from "./skills-installer.js";
@@ -79,6 +81,43 @@ export const TARGETS = {
79
81
  mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.yaml"),
80
82
  skillsDir: () => path.join(os.homedir(), ".codex"),
81
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
+ },
82
121
  generic: {
83
122
  name: "generic",
84
123
  label: "Generic MCP Client",
@@ -89,6 +128,217 @@ export const TARGETS = {
89
128
  export function isTargetName(value) {
90
129
  return value in TARGETS;
91
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
+ }
92
342
  export function listTargets() {
93
343
  log.heading("Supported targets");
94
344
  for (const t of Object.values(TARGETS)) {
@@ -111,51 +361,91 @@ export async function installForTarget(target, cfg) {
111
361
  return false;
112
362
  }
113
363
  log.heading(`Installing for ${info.label}`);
364
+ let result;
114
365
  switch (target) {
115
366
  case "claude-code":
116
- return installClaudeCode(info, cfg);
367
+ result = await installClaudeCode(info, cfg);
368
+ break;
117
369
  case "claude-desktop":
118
- return installClaudeDesktop(info, cfg);
370
+ result = await installClaudeDesktop(info, cfg);
371
+ break;
119
372
  case "cursor":
120
- return installCursor(info, cfg);
373
+ result = await installCursor(info, cfg);
374
+ break;
121
375
  case "raycast":
122
- return installRaycast(cfg);
376
+ result = await installRaycast(cfg);
377
+ break;
123
378
  case "openclaw":
124
- return installOpenclaw(cfg);
379
+ result = await installOpenclaw(cfg);
380
+ break;
125
381
  case "openwebui":
126
- return installOpenWebUI(cfg);
382
+ result = await installOpenWebUI(cfg);
383
+ break;
127
384
  case "codex":
128
- 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;
129
405
  case "generic":
130
- return installGeneric(cfg);
406
+ result = await installGeneric(cfg);
407
+ break;
131
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
+ }
420
+ }
421
+ return result;
132
422
  }
133
423
  // ─── Claude Code ─────────────────────────────────────────────────────────────
134
424
  async function installClaudeCode(info, cfg) {
135
425
  // ── What to install? ────────────────────────────────────────────────────
136
- // Skills = CLAUDE.md context files (instructions, schema, UI patterns)
137
- // MCP = live tool server (read/write entities, search, memory, etc.)
138
- // Both is the recommended default — skills give the model Synap knowledge,
139
- // 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.
140
430
  const { mode } = await prompts({
141
431
  type: "select",
142
432
  name: "mode",
143
433
  message: "What do you want to install?",
144
434
  choices: [
145
435
  {
146
- title: "Both — Skills + MCP tools (recommended)",
147
- description: "Skills give Claude context about Synap; MCP tools let it read & write data",
148
- 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",
149
439
  },
150
440
  {
151
- title: "MCP tools only",
152
- description: "Live tool server — search, create, update entities, memory, channels",
153
- 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",
154
444
  },
155
445
  {
156
- title: "Skills only",
157
- description: "Context files (CLAUDE.md) — no live data access",
158
- value: "skills",
446
+ title: "MCP server only",
447
+ description: "Tool-call interface only — skip skills and CLI setup",
448
+ value: "mcp",
159
449
  },
160
450
  ],
161
451
  initial: 0,
@@ -175,25 +465,21 @@ async function installClaudeCode(info, cfg) {
175
465
  }
176
466
  }
177
467
  }
178
- // ── 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.
179
471
  if (mode === "both" || mode === "mcp") {
180
472
  await writeClaudeCodeEnv(cfg);
181
- log.success("MCP server 'synap' added to ~/.claude/settings.json");
182
- if (cfg.workspaceId) {
183
- log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
184
- }
185
- else {
186
- log.dim("Not scoped — all workspaces accessible.");
187
- }
473
+ log.success("Agent key provisioned and MCP server registered in ~/.claude/settings.json");
188
474
  }
189
475
  else {
190
- // Skills-only: still write env vars (pod URL useful for skills referencing
191
- // SYNAP_POD_URL) but don't add the mcpServers entry
192
- 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.");
193
479
  }
194
480
  log.blank();
195
- log.success("Claude Code config updated.");
196
- 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.");
197
483
  return true;
198
484
  }
199
485
  /**
@@ -203,6 +489,7 @@ async function installClaudeCode(info, cfg) {
203
489
  */
204
490
  export async function resolveWorkspaceId(cfg) {
205
491
  let workspaceList = [];
492
+ const wsSpinner = ora("Fetching workspaces...").start();
206
493
  try {
207
494
  const res = await fetch(`${cfg.podUrl.replace(/\/$/, "")}/api/hub/workspaces`, {
208
495
  headers: { Authorization: `Bearer ${cfg.apiKey}` },
@@ -210,9 +497,15 @@ export async function resolveWorkspaceId(cfg) {
210
497
  if (res.ok) {
211
498
  const body = await res.json();
212
499
  workspaceList = body.workspaces ?? [];
500
+ wsSpinner.succeed(`Found ${workspaceList.length} workspace(s).`);
501
+ }
502
+ else {
503
+ wsSpinner.warn(`Could not fetch workspaces (HTTP ${res.status}) — proceeding without scoping.`);
213
504
  }
214
505
  }
215
- catch { /* pod unreachable — fall through */ }
506
+ catch (err) {
507
+ wsSpinner.warn(`Could not reach pod to fetch workspaces: ${err.message}`);
508
+ }
216
509
  // Single workspace or no workspaces — no need to prompt
217
510
  if (workspaceList.length <= 1) {
218
511
  return workspaceList[0]?.id ?? cfg.workspaceId;
@@ -278,7 +571,7 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
278
571
  const agentSetupRes = await fetch(`${podBase}/api/hub/setup/agent`, {
279
572
  method: "POST",
280
573
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
281
- body: JSON.stringify({ agentType: "claude-code", idempotent: false }),
574
+ body: JSON.stringify({ agentType: "claude-code", idempotent: true }),
282
575
  signal: AbortSignal.timeout(10000),
283
576
  });
284
577
  if (!agentSetupRes.ok) {
@@ -286,9 +579,67 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
286
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.`);
287
580
  }
288
581
  const agentSetup = await agentSetupRes.json();
289
- if (!agentSetup.hubApiKey)
290
- throw new Error("setup/agent succeeded but returned no hubApiKey — check pod logs.");
291
- 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 ?? "" });
292
643
  // Enroll the new agent user into the caller's workspaces using the HUMAN key
293
644
  // (before switching to the agent key). Scoped to one workspace when chosen.
294
645
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentSetup.agentUserId ?? "", cfg.workspaceId);
@@ -307,9 +658,7 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
307
658
  // ── MCP server entry (HTTP transport, native Claude Code format) ─────────
308
659
  if (writeMcp) {
309
660
  // Append ?workspaceId= when one is set so every tool call is pre-scoped.
310
- const mcpUrl = cfg.workspaceId
311
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
312
- : `${podBase}/mcp`;
661
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
313
662
  const mcpServers = (settings.mcpServers ?? {});
314
663
  mcpServers["synap"] = {
315
664
  url: mcpUrl,
@@ -323,12 +672,47 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
323
672
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
324
673
  }
325
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
+ }
326
709
  /**
327
710
  * Provision an agent-owned API key for the given surface via POST /api/hub/setup/agent.
328
- * 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.
329
713
  * Throws on failure — callers must not silently fall back to a human key.
330
714
  */
331
- async function provisionAgentKey(podUrl, humanApiKey, agentType) {
715
+ async function provisionAgentKey(podUrl, humanApiKey, agentType, { requireApproval = true } = {}) {
332
716
  const podBase = podUrl.replace(/\/$/, "");
333
717
  let res;
334
718
  try {
@@ -338,7 +722,7 @@ async function provisionAgentKey(podUrl, humanApiKey, agentType) {
338
722
  "Content-Type": "application/json",
339
723
  Authorization: `Bearer ${humanApiKey}`,
340
724
  },
341
- body: JSON.stringify({ agentType, idempotent: false }),
725
+ body: JSON.stringify({ agentType, idempotent: false, requireApproval }),
342
726
  signal: AbortSignal.timeout(10000),
343
727
  });
344
728
  }
@@ -356,6 +740,21 @@ async function provisionAgentKey(podUrl, humanApiKey, agentType) {
356
740
  throw new Error(`setup/agent succeeded but returned no hubApiKey for ${agentType}. ` +
357
741
  `This is a server-side bug — check the pod logs.`);
358
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
+ }
359
758
  return { hubApiKey: body.hubApiKey, agentUserId: body.agentUserId ?? "" };
360
759
  }
361
760
  /**
@@ -387,9 +786,15 @@ async function enrollAgentIfNeeded(podUrl, callerKey, agentUserId, workspaceId)
387
786
  log.dim(` Agent enrolled in ${data.enrolled.length} workspace(s).`);
388
787
  }
389
788
  }
789
+ else {
790
+ const text = await res.text().catch(() => res.statusText);
791
+ log.warn(` Workspace enrollment failed (HTTP ${res.status}): ${text}`);
792
+ log.dim(" Access can be added manually via pod settings.");
793
+ }
390
794
  }
391
- catch {
392
- // Non-fatal — workspace access can be added manually if enrollment fails
795
+ catch (err) {
796
+ log.warn(` Workspace enrollment unreachable: ${err.message}`);
797
+ log.dim(" Access can be added manually via pod settings.");
393
798
  }
394
799
  }
395
800
  // ─── Claude Desktop ──────────────────────────────────────────────────────────
@@ -404,10 +809,10 @@ async function installClaudeDesktop(info, cfg) {
404
809
  //
405
810
  // Reference: https://www.npmjs.com/package/mcp-remote
406
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 });
407
814
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
408
- const desktopMcpUrl = cfg.workspaceId
409
- ? `${cfg.podUrl.replace(/\/$/, "")}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
410
- : `${cfg.podUrl.replace(/\/$/, "")}/mcp`;
815
+ const desktopMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
411
816
  writeMcpServerEntry(mcpPath, "synap", {
412
817
  command: "npx",
413
818
  args: ["-y", "mcp-remote", desktopMcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
@@ -447,10 +852,10 @@ async function installCursor(info, cfg) {
447
852
  if (!mcpPath)
448
853
  return false;
449
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 });
450
857
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
451
- const cursorMcpUrl = cfg.workspaceId
452
- ? `${cfg.podUrl.replace(/\/$/, "")}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
453
- : `${cfg.podUrl.replace(/\/$/, "")}/mcp`;
858
+ const cursorMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
454
859
  writeMcpServerEntry(mcpPath, "synap", {
455
860
  url: cursorMcpUrl,
456
861
  headers: { Authorization: `Bearer ${effectiveApiKey}` },
@@ -469,16 +874,26 @@ async function installCursor(info, cfg) {
469
874
  async function installRaycast(cfg) {
470
875
  // Raycast reads credentials from ~/.synap/config.json (Tier 0 — highest priority).
471
876
  // Persist the workspace choice so Raycast immediately sees the right scope.
472
- const { setActiveWorkspaceId, clearActiveWorkspaceId } = await import("./pod.js");
877
+ const { setActiveWorkspaceId, clearActiveWorkspaceId, setSurfaceAgentKey } = await import("./pod.js");
473
878
  if (cfg.workspaceId) {
474
879
  setActiveWorkspaceId(cfg.workspaceId);
475
880
  }
476
881
  else {
477
882
  clearActiveWorkspaceId();
478
883
  }
479
- // Enroll the pod profile's agent user into the chosen workspace(s)
480
- if (cfg.agentUserId) {
481
- await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, cfg.agentUserId, cfg.workspaceId);
884
+ // Provision a dedicated named agent key for Raycast so its AI tools run as
885
+ // "raycast" agent identity (separate from the human user key used by UI commands).
886
+ const provisionSpinner = ora("Provisioning Raycast agent identity...").start();
887
+ try {
888
+ const agentKey = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "raycast");
889
+ setSurfaceAgentKey("raycast", agentKey);
890
+ provisionSpinner.text = "Enrolling agent in workspaces...";
891
+ await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentKey.agentUserId, cfg.workspaceId);
892
+ provisionSpinner.succeed("Raycast agent identity provisioned.");
893
+ }
894
+ catch (err) {
895
+ // Non-fatal: Raycast still works with the human key, agent provisioning is best-effort.
896
+ provisionSpinner.warn(`Could not provision dedicated Raycast agent key: ${err.message}`);
482
897
  }
483
898
  log.success("Credentials and workspace written to ~/.synap/config.json — Raycast picks them up automatically.");
484
899
  log.blank();
@@ -542,10 +957,7 @@ async function installOpenclaw(cfg) {
542
957
  }
543
958
  // ─── Open WebUI ──────────────────────────────────────────────────────────────
544
959
  async function installOpenWebUI(cfg) {
545
- const podBase = cfg.podUrl.replace(/\/$/, "");
546
- const mcpUrl = cfg.workspaceId
547
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
548
- : `${podBase}/mcp`;
960
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
549
961
  const podHost = new URL(cfg.podUrl).host;
550
962
  log.info("Open WebUI connects to Synap in two ways:");
551
963
  log.blank();
@@ -573,12 +985,142 @@ async function installOpenWebUI(cfg) {
573
985
  log.success(`Connection details saved to ~/.synap/openwebui-mcp.json`);
574
986
  return true;
575
987
  }
576
- // ─── 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
+ }
577
1122
  async function installGeneric(cfg) {
578
- const podBase = cfg.podUrl.replace(/\/$/, "");
579
- const mcpUrl = cfg.workspaceId
580
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
581
- : `${podBase}/mcp`;
1123
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
582
1124
  const httpConfig = {
583
1125
  synap: {
584
1126
  url: mcpUrl,
@@ -625,10 +1167,7 @@ async function installCodex(info, cfg) {
625
1167
  // Build MCP server entry
626
1168
  const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "codex");
627
1169
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
628
- const podBase = cfg.podUrl.replace(/\/$/, "");
629
- const mcpUrl = cfg.workspaceId
630
- ? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
631
- : `${podBase}/mcp`;
1170
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId);
632
1171
  // Inject/replace the synap mcpServers block using simple string manipulation
633
1172
  // (avoid requiring a YAML parser dependency)
634
1173
  const mcpBlock = [
@@ -682,6 +1221,141 @@ async function installCodex(info, cfg) {
682
1221
  log.dim("Restart Codex CLI to pick up the new MCP server.");
683
1222
  return true;
684
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 ───────────────────────────────────────────────────────
685
1359
  export function writeMcpServerEntry(configPath, serverName, server) {
686
1360
  const dir = path.dirname(configPath);
687
1361
  if (!fs.existsSync(dir)) {