@synap-core/cli 1.11.0 → 1.12.2

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 (452) hide show
  1. package/README.md +23 -198
  2. package/dist/build-info.json +6 -0
  3. package/dist/commands/agent-run.js +3 -2
  4. package/dist/commands/agent-run.js.map +1 -1
  5. package/dist/commands/agents.d.ts +33 -17
  6. package/dist/commands/agents.js +203 -237
  7. package/dist/commands/agents.js.map +1 -1
  8. package/dist/commands/app.d.ts +143 -0
  9. package/dist/commands/app.js +523 -0
  10. package/dist/commands/app.js.map +1 -0
  11. package/dist/commands/automate.d.ts +21 -0
  12. package/dist/commands/automate.js +42 -0
  13. package/dist/commands/automate.js.map +1 -0
  14. package/dist/commands/automation-schema.test.d.ts +16 -0
  15. package/dist/commands/automation-schema.test.js +77 -0
  16. package/dist/commands/automation-schema.test.js.map +1 -0
  17. package/dist/commands/automation.d.ts +12 -0
  18. package/dist/commands/automation.js +80 -35
  19. package/dist/commands/automation.js.map +1 -1
  20. package/dist/commands/bridge-setup.d.ts +14 -1
  21. package/dist/commands/bridge-setup.js +392 -128
  22. package/dist/commands/bridge-setup.js.map +1 -1
  23. package/dist/commands/cap-connections.d.ts +4 -4
  24. package/dist/commands/cap-connections.js +14 -14
  25. package/dist/commands/cap-connections.js.map +1 -1
  26. package/dist/commands/capability.d.ts +2 -0
  27. package/dist/commands/capability.js +218 -168
  28. package/dist/commands/capability.js.map +1 -1
  29. package/dist/commands/catalog.d.ts +275 -0
  30. package/dist/commands/catalog.js +481 -0
  31. package/dist/commands/catalog.js.map +1 -0
  32. package/dist/commands/cell.d.ts +155 -8
  33. package/dist/commands/cell.js +416 -40
  34. package/dist/commands/cell.js.map +1 -1
  35. package/dist/commands/centrality.js +1 -13
  36. package/dist/commands/centrality.js.map +1 -1
  37. package/dist/commands/connect.d.ts +38 -13
  38. package/dist/commands/connect.js +68 -167
  39. package/dist/commands/connect.js.map +1 -1
  40. package/dist/commands/connections.d.ts +3 -2
  41. package/dist/commands/connections.js +95 -25
  42. package/dist/commands/connections.js.map +1 -1
  43. package/dist/commands/context.js +7 -1
  44. package/dist/commands/context.js.map +1 -1
  45. package/dist/commands/data-extra.d.ts +34 -0
  46. package/dist/commands/data-extra.js +193 -23
  47. package/dist/commands/data-extra.js.map +1 -1
  48. package/dist/commands/data.d.ts +120 -2
  49. package/dist/commands/data.js +293 -20
  50. package/dist/commands/data.js.map +1 -1
  51. package/dist/commands/diagnose.d.ts +66 -4
  52. package/dist/commands/diagnose.js +285 -28
  53. package/dist/commands/diagnose.js.map +1 -1
  54. package/dist/commands/doc.d.ts +11 -4
  55. package/dist/commands/doc.js +39 -14
  56. package/dist/commands/doc.js.map +1 -1
  57. package/dist/commands/doctor.d.ts +112 -3
  58. package/dist/commands/doctor.js +657 -32
  59. package/dist/commands/doctor.js.map +1 -1
  60. package/dist/commands/explain.js +5 -5
  61. package/dist/commands/explain.js.map +1 -1
  62. package/dist/commands/import.d.ts +368 -13
  63. package/dist/commands/import.js +1064 -54
  64. package/dist/commands/import.js.map +1 -1
  65. package/dist/commands/init.d.ts +20 -9
  66. package/dist/commands/init.js +433 -973
  67. package/dist/commands/init.js.map +1 -1
  68. package/dist/commands/keys.d.ts +1 -0
  69. package/dist/commands/keys.js +15 -2
  70. package/dist/commands/keys.js.map +1 -1
  71. package/dist/commands/knowledge.d.ts +22 -6
  72. package/dist/commands/knowledge.js +117 -55
  73. package/dist/commands/knowledge.js.map +1 -1
  74. package/dist/commands/lens.d.ts +37 -1
  75. package/dist/commands/lens.js +192 -71
  76. package/dist/commands/lens.js.map +1 -1
  77. package/dist/commands/market-authoring.d.ts +29 -2
  78. package/dist/commands/market-authoring.js +348 -7
  79. package/dist/commands/market-authoring.js.map +1 -1
  80. package/dist/commands/market-official.d.ts +58 -0
  81. package/dist/commands/market-official.js +84 -0
  82. package/dist/commands/market-official.js.map +1 -0
  83. package/dist/commands/market.d.ts +400 -16
  84. package/dist/commands/market.js +1802 -146
  85. package/dist/commands/market.js.map +1 -1
  86. package/dist/commands/mcp.d.ts +60 -25
  87. package/dist/commands/mcp.js +251 -64
  88. package/dist/commands/mcp.js.map +1 -1
  89. package/dist/commands/open.d.ts +39 -9
  90. package/dist/commands/open.js +80 -25
  91. package/dist/commands/open.js.map +1 -1
  92. package/dist/commands/playbook.d.ts +42 -0
  93. package/dist/commands/playbook.js +162 -0
  94. package/dist/commands/playbook.js.map +1 -0
  95. package/dist/commands/pod.js +1 -1
  96. package/dist/commands/pod.js.map +1 -1
  97. package/dist/commands/pods.d.ts +5 -0
  98. package/dist/commands/pods.js +97 -116
  99. package/dist/commands/pods.js.map +1 -1
  100. package/dist/commands/proposals-actions.d.ts +62 -0
  101. package/dist/commands/proposals-actions.js +143 -8
  102. package/dist/commands/proposals-actions.js.map +1 -1
  103. package/dist/commands/proposals-await.d.ts +68 -0
  104. package/dist/commands/proposals-await.js +248 -0
  105. package/dist/commands/proposals-await.js.map +1 -0
  106. package/dist/commands/proton-setup.d.ts +73 -0
  107. package/dist/commands/proton-setup.js +577 -0
  108. package/dist/commands/proton-setup.js.map +1 -0
  109. package/dist/commands/providers.d.ts +23 -0
  110. package/dist/commands/providers.js +126 -1
  111. package/dist/commands/providers.js.map +1 -1
  112. package/dist/commands/raycast.js +6 -2
  113. package/dist/commands/raycast.js.map +1 -1
  114. package/dist/commands/rule.d.ts +150 -0
  115. package/dist/commands/rule.js +447 -0
  116. package/dist/commands/rule.js.map +1 -0
  117. package/dist/commands/session-wait.d.ts +111 -0
  118. package/dist/commands/session-wait.js +209 -0
  119. package/dist/commands/session-wait.js.map +1 -0
  120. package/dist/commands/sessions.d.ts +52 -0
  121. package/dist/commands/sessions.js +478 -58
  122. package/dist/commands/sessions.js.map +1 -1
  123. package/dist/commands/skill-manage.d.ts +21 -0
  124. package/dist/commands/skill-manage.js +136 -19
  125. package/dist/commands/skill-manage.js.map +1 -1
  126. package/dist/commands/skills.js +16 -0
  127. package/dist/commands/skills.js.map +1 -1
  128. package/dist/commands/status.d.ts +1 -4
  129. package/dist/commands/status.js +5 -34
  130. package/dist/commands/status.js.map +1 -1
  131. package/dist/commands/statusline.backoff.test.d.ts +13 -0
  132. package/dist/commands/statusline.backoff.test.js +100 -0
  133. package/dist/commands/statusline.backoff.test.js.map +1 -0
  134. package/dist/commands/statusline.js +16 -9
  135. package/dist/commands/statusline.js.map +1 -1
  136. package/dist/commands/sync-status.d.ts +12 -0
  137. package/dist/commands/sync-status.js +125 -0
  138. package/dist/commands/sync-status.js.map +1 -0
  139. package/dist/commands/templates-retire.d.ts +34 -0
  140. package/dist/commands/templates-retire.js +192 -0
  141. package/dist/commands/templates-retire.js.map +1 -0
  142. package/dist/commands/tools.js +6 -21
  143. package/dist/commands/tools.js.map +1 -1
  144. package/dist/commands/tracks.d.ts +51 -0
  145. package/dist/commands/tracks.js +291 -0
  146. package/dist/commands/tracks.js.map +1 -0
  147. package/dist/commands/update.d.ts +3 -11
  148. package/dist/commands/update.js +5 -134
  149. package/dist/commands/update.js.map +1 -1
  150. package/dist/commands/upload.d.ts +41 -3
  151. package/dist/commands/upload.js +120 -18
  152. package/dist/commands/upload.js.map +1 -1
  153. package/dist/commands/vendor.d.ts +28 -0
  154. package/dist/commands/vendor.js +110 -0
  155. package/dist/commands/vendor.js.map +1 -0
  156. package/dist/commands/versions.js +6 -0
  157. package/dist/commands/versions.js.map +1 -1
  158. package/dist/commands/view.js +10 -5
  159. package/dist/commands/view.js.map +1 -1
  160. package/dist/commands/whoami.d.ts +23 -3
  161. package/dist/commands/whoami.js +169 -23
  162. package/dist/commands/whoami.js.map +1 -1
  163. package/dist/commands/worker.d.ts +122 -0
  164. package/dist/commands/worker.js +435 -0
  165. package/dist/commands/worker.js.map +1 -0
  166. package/dist/commands/worker.test.d.ts +1 -0
  167. package/dist/commands/worker.test.js +116 -0
  168. package/dist/commands/worker.test.js.map +1 -0
  169. package/dist/commands/workspace-ops.d.ts +35 -0
  170. package/dist/commands/workspace-ops.js +153 -0
  171. package/dist/commands/workspace-ops.js.map +1 -0
  172. package/dist/index.d.ts +6 -6
  173. package/dist/index.js +900 -180
  174. package/dist/index.js.map +1 -1
  175. package/dist/lib/agent-connections.d.ts +31 -0
  176. package/dist/lib/agent-connections.js +33 -0
  177. package/dist/lib/agent-connections.js.map +1 -0
  178. package/dist/lib/agent-detect.d.ts +27 -0
  179. package/dist/lib/agent-detect.js +71 -0
  180. package/dist/lib/agent-detect.js.map +1 -0
  181. package/dist/lib/agent-door.d.ts +138 -0
  182. package/dist/lib/agent-door.js +426 -0
  183. package/dist/lib/agent-door.js.map +1 -0
  184. package/dist/lib/agents-config.d.ts +2 -0
  185. package/dist/lib/agents-config.js +4 -9
  186. package/dist/lib/agents-config.js.map +1 -1
  187. package/dist/lib/approval-poll.d.ts +31 -2
  188. package/dist/lib/approval-poll.js +20 -7
  189. package/dist/lib/approval-poll.js.map +1 -1
  190. package/dist/lib/atomic-file.d.ts +48 -0
  191. package/dist/lib/atomic-file.js +129 -0
  192. package/dist/lib/atomic-file.js.map +1 -0
  193. package/dist/lib/auth.d.ts +14 -9
  194. package/dist/lib/auth.js +44 -61
  195. package/dist/lib/auth.js.map +1 -1
  196. package/dist/lib/batch-approval.d.ts +85 -0
  197. package/dist/lib/batch-approval.js +170 -0
  198. package/dist/lib/batch-approval.js.map +1 -0
  199. package/dist/lib/capture-lane.d.ts +6 -5
  200. package/dist/lib/capture-lane.js +6 -5
  201. package/dist/lib/capture-lane.js.map +1 -1
  202. package/dist/lib/capture-structure.d.ts +230 -11
  203. package/dist/lib/capture-structure.js +251 -13
  204. package/dist/lib/capture-structure.js.map +1 -1
  205. package/dist/lib/catalog-templates.d.ts +59 -0
  206. package/dist/lib/catalog-templates.js +126 -0
  207. package/dist/lib/catalog-templates.js.map +1 -0
  208. package/dist/lib/cli-build.d.ts +19 -0
  209. package/dist/lib/cli-build.js +37 -0
  210. package/dist/lib/cli-build.js.map +1 -0
  211. package/dist/lib/conversation-headers.d.ts +18 -0
  212. package/dist/lib/conversation-headers.js +32 -0
  213. package/dist/lib/conversation-headers.js.map +1 -0
  214. package/dist/lib/cp-packages.d.ts +167 -11
  215. package/dist/lib/cp-packages.js +253 -21
  216. package/dist/lib/cp-packages.js.map +1 -1
  217. package/dist/lib/credential-class.d.ts +117 -0
  218. package/dist/lib/credential-class.js +116 -0
  219. package/dist/lib/credential-class.js.map +1 -0
  220. package/dist/lib/describe-lens.d.ts +35 -0
  221. package/dist/lib/describe-lens.js +40 -0
  222. package/dist/lib/describe-lens.js.map +1 -1
  223. package/dist/lib/device-connect.d.ts +125 -0
  224. package/dist/lib/device-connect.js +256 -0
  225. package/dist/lib/device-connect.js.map +1 -0
  226. package/dist/lib/directory-lens.d.ts +99 -0
  227. package/dist/lib/directory-lens.js +187 -0
  228. package/dist/lib/directory-lens.js.map +1 -0
  229. package/dist/lib/exporter-coverage.d.ts +71 -0
  230. package/dist/lib/exporter-coverage.js +97 -0
  231. package/dist/lib/exporter-coverage.js.map +1 -0
  232. package/dist/lib/hub-client.d.ts +29 -8
  233. package/dist/lib/hub-client.js +328 -55
  234. package/dist/lib/hub-client.js.map +1 -1
  235. package/dist/lib/init-connect.d.ts +41 -0
  236. package/dist/lib/init-connect.js +75 -0
  237. package/dist/lib/init-connect.js.map +1 -0
  238. package/dist/lib/init-exit.d.ts +24 -0
  239. package/dist/lib/init-exit.js +41 -0
  240. package/dist/lib/init-exit.js.map +1 -0
  241. package/dist/lib/init-flow.d.ts +25 -0
  242. package/dist/lib/init-flow.js +40 -0
  243. package/dist/lib/init-flow.js.map +1 -0
  244. package/dist/lib/init-handoff.d.ts +16 -0
  245. package/dist/lib/init-handoff.js +19 -0
  246. package/dist/lib/init-handoff.js.map +1 -0
  247. package/dist/lib/init-output.d.ts +18 -0
  248. package/dist/lib/init-output.js +43 -0
  249. package/dist/lib/init-output.js.map +1 -0
  250. package/dist/lib/init-pod.d.ts +45 -0
  251. package/dist/lib/init-pod.js +36 -0
  252. package/dist/lib/init-pod.js.map +1 -0
  253. package/dist/lib/installed.d.ts +210 -11
  254. package/dist/lib/installed.js +234 -36
  255. package/dist/lib/installed.js.map +1 -1
  256. package/dist/lib/key-approval.d.ts +57 -0
  257. package/dist/lib/key-approval.js +117 -0
  258. package/dist/lib/key-approval.js.map +1 -0
  259. package/dist/lib/key-diagnosis.d.ts +56 -0
  260. package/dist/lib/key-diagnosis.js +227 -0
  261. package/dist/lib/key-diagnosis.js.map +1 -0
  262. package/dist/lib/key-doctor.d.ts +136 -0
  263. package/dist/lib/key-doctor.js +286 -0
  264. package/dist/lib/key-doctor.js.map +1 -0
  265. package/dist/lib/key-locations.d.ts +120 -0
  266. package/dist/lib/key-locations.js +326 -0
  267. package/dist/lib/key-locations.js.map +1 -0
  268. package/dist/lib/key-source.d.ts +66 -0
  269. package/dist/lib/key-source.js +106 -0
  270. package/dist/lib/key-source.js.map +1 -0
  271. package/dist/lib/key-state.d.ts +70 -0
  272. package/dist/lib/key-state.js +115 -0
  273. package/dist/lib/key-state.js.map +1 -0
  274. package/dist/lib/kind-package.d.ts +181 -0
  275. package/dist/lib/kind-package.js +679 -0
  276. package/dist/lib/kind-package.js.map +1 -0
  277. package/dist/lib/knowledge-title.d.ts +24 -0
  278. package/dist/lib/knowledge-title.js +46 -0
  279. package/dist/lib/knowledge-title.js.map +1 -0
  280. package/dist/lib/knowledge-title.test.d.ts +1 -0
  281. package/dist/lib/knowledge-title.test.js +34 -0
  282. package/dist/lib/knowledge-title.test.js.map +1 -0
  283. package/dist/lib/machine-instance.d.ts +64 -0
  284. package/dist/lib/machine-instance.js +142 -0
  285. package/dist/lib/machine-instance.js.map +1 -0
  286. package/dist/lib/mcp-bridge.d.ts +42 -0
  287. package/dist/lib/mcp-bridge.js +147 -0
  288. package/dist/lib/mcp-bridge.js.map +1 -0
  289. package/dist/lib/mcp-bridge.test.d.ts +1 -0
  290. package/dist/lib/mcp-bridge.test.js +90 -0
  291. package/dist/lib/mcp-bridge.test.js.map +1 -0
  292. package/dist/lib/mcp-call.d.ts +30 -0
  293. package/dist/lib/mcp-call.js +107 -0
  294. package/dist/lib/mcp-call.js.map +1 -0
  295. package/dist/lib/mcp-snippets.d.ts +53 -0
  296. package/dist/lib/mcp-snippets.js +82 -0
  297. package/dist/lib/mcp-snippets.js.map +1 -0
  298. package/dist/lib/next-steps.d.ts +8 -0
  299. package/dist/lib/next-steps.js +31 -1
  300. package/dist/lib/next-steps.js.map +1 -1
  301. package/dist/lib/package-wire.d.ts +179 -0
  302. package/dist/lib/package-wire.js +68 -0
  303. package/dist/lib/package-wire.js.map +1 -0
  304. package/dist/lib/pair-link.d.ts +38 -0
  305. package/dist/lib/pair-link.js +68 -0
  306. package/dist/lib/pair-link.js.map +1 -0
  307. package/dist/lib/pod-wide.d.ts +40 -0
  308. package/dist/lib/pod-wide.js +46 -0
  309. package/dist/lib/pod-wide.js.map +1 -0
  310. package/dist/lib/pod.d.ts +66 -112
  311. package/dist/lib/pod.js +134 -412
  312. package/dist/lib/pod.js.map +1 -1
  313. package/dist/lib/project-ref.d.ts +9 -3
  314. package/dist/lib/project-ref.js +10 -4
  315. package/dist/lib/project-ref.js.map +1 -1
  316. package/dist/lib/review-credential.d.ts +119 -0
  317. package/dist/lib/review-credential.js +147 -0
  318. package/dist/lib/review-credential.js.map +1 -0
  319. package/dist/lib/safe-open.d.ts +49 -0
  320. package/dist/lib/safe-open.js +119 -0
  321. package/dist/lib/safe-open.js.map +1 -0
  322. package/dist/lib/session-lens.d.ts +40 -1
  323. package/dist/lib/session-lens.js +54 -4
  324. package/dist/lib/session-lens.js.map +1 -1
  325. package/dist/lib/superwhisper-import.d.ts +8 -0
  326. package/dist/lib/superwhisper-import.js +14 -7
  327. package/dist/lib/superwhisper-import.js.map +1 -1
  328. package/dist/lib/superwhisper-title.test.d.ts +1 -0
  329. package/dist/lib/superwhisper-title.test.js +18 -0
  330. package/dist/lib/superwhisper-title.test.js.map +1 -0
  331. package/dist/lib/targets.d.ts +134 -10
  332. package/dist/lib/targets.js +962 -258
  333. package/dist/lib/targets.js.map +1 -1
  334. package/dist/lib/template-file.d.ts +4 -0
  335. package/dist/lib/template-file.js.map +1 -1
  336. package/dist/lib/template-lifecycle.d.ts +217 -0
  337. package/dist/lib/template-lifecycle.js +433 -0
  338. package/dist/lib/template-lifecycle.js.map +1 -0
  339. package/dist/lib/template-steps.d.ts +88 -0
  340. package/dist/lib/template-steps.js +118 -0
  341. package/dist/lib/template-steps.js.map +1 -0
  342. package/dist/lib/time-ago.d.ts +2 -0
  343. package/dist/lib/time-ago.js +15 -0
  344. package/dist/lib/time-ago.js.map +1 -0
  345. package/dist/utils/logger.js +5 -2
  346. package/dist/utils/logger.js.map +1 -1
  347. package/package.json +23 -17
  348. package/skills/README.md +12 -10
  349. package/skills/agent-os/SKILL.md +214 -99
  350. package/skills/manifest.json +1 -1
  351. package/skills/onboard/SKILL.md +55 -26
  352. package/skills/synap/README.md +5 -8
  353. package/skills/synap/SKILL.md +1086 -283
  354. package/skills/synap/_order.txt +7 -0
  355. package/skills/synap/_skill.yaml +1 -1
  356. package/skills/synap/automations.md +15 -0
  357. package/skills/synap/capture.md +5 -0
  358. package/skills/synap/cli-operations.md +24 -18
  359. package/skills/synap/concepts.md +28 -0
  360. package/skills/synap/content-os.md +10 -1
  361. package/skills/synap/creative-loop.md +15 -10
  362. package/skills/synap/crm.md +21 -52
  363. package/skills/synap/document-embeds.md +141 -47
  364. package/skills/synap/escalation-ladder.md +20 -10
  365. package/skills/synap/focus-sessions.md +47 -21
  366. package/skills/synap/from-intent.md +142 -0
  367. package/skills/synap/governance-rules.md +4 -4
  368. package/skills/synap/governance.md +10 -7
  369. package/skills/synap/hand-off.md +33 -0
  370. package/skills/synap/inline-patterns.md +15 -7
  371. package/skills/synap/lenses.md +33 -15
  372. package/skills/synap/linking.md +41 -40
  373. package/skills/synap/mcp-remote-servers.md +64 -0
  374. package/skills/synap/mental-model.md +17 -15
  375. package/skills/synap/operating-mode-rules.md +1 -1
  376. package/skills/synap/processes.md +118 -0
  377. package/skills/synap/quick-reference.md +5 -1
  378. package/skills/synap/reading.md +3 -1
  379. package/skills/synap/reflexes.md +12 -42
  380. package/skills/synap/viewframe-cells.md +38 -5
  381. package/skills/synap/work-flow.md +1 -1
  382. package/skills/synap/work-model.md +96 -0
  383. package/skills/synap/worked-examples.md +4 -5
  384. package/skills/synap/workspace-design.md +14 -10
  385. package/skills/synap/writes.md +97 -24
  386. package/skills/synap/writing-titles.md +63 -0
  387. package/skills/synap-app/SKILL.md +2 -2
  388. package/skills/synap-app/_skill.yaml +1 -1
  389. package/skills/synap-app/install-commands.md +1 -1
  390. package/skills/synap-design/SKILL.md +474 -0
  391. package/skills/synap-design/_order.txt +4 -0
  392. package/skills/synap-design/_skill.yaml +21 -0
  393. package/skills/synap-design/loop.md +134 -0
  394. package/skills/synap-design/overview.md +44 -0
  395. package/skills/synap-design/principles.md +130 -0
  396. package/skills/synap-design/system.md +133 -0
  397. package/skills/synap-market/SKILL.md +306 -16
  398. package/skills/synap-market/_skill.yaml +1 -1
  399. package/skills/synap-market/author-and-publish.md +145 -8
  400. package/skills/synap-market/governance-and-catalog.md +7 -0
  401. package/skills/synap-market/install.md +79 -1
  402. package/skills/synap-market/overview.md +74 -6
  403. package/skills/synap-schema/SKILL.md +172 -10
  404. package/skills/synap-schema/_order.txt +2 -0
  405. package/skills/synap-schema/_skill.yaml +4 -2
  406. package/skills/synap-schema/common-mistakes.md +4 -2
  407. package/skills/synap-schema/discover-profiles.md +1 -1
  408. package/skills/synap-schema/entity-scope.md +15 -3
  409. package/skills/synap-schema/extend-first.md +29 -0
  410. package/skills/synap-schema/extend-vs-create.md +1 -1
  411. package/skills/synap-schema/intent-vocabulary-ssot.md +111 -0
  412. package/skills/synap-schema/property-types.md +1 -1
  413. package/skills/synap-schema/relations.md +1 -1
  414. package/skills/synap-ui/SKILL.md +19 -12
  415. package/skills/synap-ui/_skill.yaml +1 -1
  416. package/skills/synap-ui/ai-companion.md +1 -1
  417. package/skills/synap-ui/arranging-bento.md +2 -0
  418. package/skills/synap-ui/bento-layouts.md +10 -10
  419. package/skills/synap-ui/bento-recipes.md +313 -543
  420. package/skills/synap-ui/four-builds.md +5 -0
  421. package/skills/synap-ui/view-types.md +2 -2
  422. package/skills/synap-ui/widget-catalog.md +22 -278
  423. package/templates/automations/bookmark-enrichment.automation.json +290 -13
  424. package/templates/automations/url-bookmark-capture.automation.json +73 -11
  425. package/templates/skills/authoring-playbooks/SKILL.md +6 -3
  426. package/templates/skills/authoring-playbooks/reference/stages-contract.md +15 -0
  427. package/templates/skills/authoring-playbooks/reference/worked-example.md +3 -0
  428. package/dist/commands/infra.d.ts +0 -21
  429. package/dist/commands/infra.js +0 -262
  430. package/dist/commands/infra.js.map +0 -1
  431. package/dist/commands/template.d.ts +0 -42
  432. package/dist/commands/template.js +0 -191
  433. package/dist/commands/template.js.map +0 -1
  434. package/dist/lib/browser-auth.d.ts +0 -35
  435. package/dist/lib/browser-auth.js +0 -170
  436. package/dist/lib/browser-auth.js.map +0 -1
  437. package/dist/lib/openclaw.d.ts +0 -32
  438. package/dist/lib/openclaw.js +0 -164
  439. package/dist/lib/openclaw.js.map +0 -1
  440. package/dist/lib/seed.d.ts +0 -13
  441. package/dist/lib/seed.js +0 -135
  442. package/dist/lib/seed.js.map +0 -1
  443. package/node_modules/@synap/hub-rest-client/dist/index.cjs +0 -1261
  444. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +0 -1647
  445. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +0 -1647
  446. package/node_modules/@synap/hub-rest-client/dist/index.js +0 -1231
  447. package/node_modules/@synap/hub-rest-client/package.json +0 -45
  448. package/node_modules/@synap/hub-rest-client/src/client.ts +0 -1631
  449. package/node_modules/@synap/hub-rest-client/src/errors.ts +0 -30
  450. package/node_modules/@synap/hub-rest-client/src/index.ts +0 -153
  451. package/node_modules/@synap/hub-rest-client/src/setup.ts +0 -133
  452. package/node_modules/@synap/hub-rest-client/src/types.ts +0 -1299
@@ -11,13 +11,126 @@
11
11
  import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
- import { exec, execFileSync } from "node:child_process";
14
+ import { execFileSync } from "node:child_process";
15
+ import { openExternalUrl } from "./safe-open.js";
15
16
  import chalk from "chalk";
16
17
  import ora from "ora";
17
18
  import prompts from "prompts";
18
19
  import { log } from "../utils/logger.js";
20
+ import { writeSecretFile, copySecretBackup, ensureSecretDir } from "./atomic-file.js";
21
+ import { redactKey } from "./mcp-snippets.js";
19
22
  import { installSkills, getDeliverableSkills } from "./skills-installer.js";
20
- import { resolveUserId, hubPatch, } from "./hub-client.js";
23
+ import { TARGET_KEY_MINT, takePreApprovedKey } from "./batch-approval.js";
24
+ import { agentTypeOfSurface, machineInstanceId, machineInstanceLabel } from "./machine-instance.js";
25
+ import { resolveUserId, hubGet, hubPatch, HubError, } from "./hub-client.js";
26
+ /**
27
+ * Verify that a pod's `/mcp` endpoint actually serves Synap tools for THIS key.
28
+ *
29
+ * Two JSON-RPC calls: `initialize` (confirms the endpoint is a Synap MCP server,
30
+ * not a proxy default page — it checks `serverInfo.name === "synap-mcp-server"`)
31
+ * and `tools/list` (confirms the key authenticates and sees tools).
32
+ *
33
+ * This is the post-install gate `synap connect` runs after writing a client
34
+ * config. Without it, a wrong key, a workspace the agent isn't enrolled in, or
35
+ * a pod that is up but not serving MCP all surface three steps later when the
36
+ * user reports "it says it works but it doesn't" — which is exactly the failure
37
+ * that motivated this. `synap mcp verify` is the manual version of the same
38
+ * check.
39
+ */
40
+ export async function verifyMcpConnection(podUrl, apiKey, opts) {
41
+ const podBase = podUrl.replace(/\/$/, "");
42
+ const url = `${podBase}/mcp`;
43
+ const timeout = opts?.timeoutMs ?? 20_000;
44
+ const headers = {
45
+ "Content-Type": "application/json",
46
+ Accept: "application/json, text/event-stream",
47
+ Authorization: `Bearer ${apiKey}`,
48
+ };
49
+ // 1. initialize — is this a Synap MCP server?
50
+ let initRes;
51
+ try {
52
+ initRes = await fetch(url, {
53
+ method: "POST",
54
+ headers,
55
+ body: JSON.stringify({
56
+ jsonrpc: "2.0",
57
+ id: 1,
58
+ method: "initialize",
59
+ params: {
60
+ protocolVersion: "2024-11-05",
61
+ capabilities: {},
62
+ clientInfo: { name: "synap-cli", version: "1.0.0" },
63
+ },
64
+ }),
65
+ signal: AbortSignal.timeout(timeout),
66
+ });
67
+ }
68
+ catch (err) {
69
+ return { ok: false, error: `Could not reach pod /mcp: ${err.message}` };
70
+ }
71
+ if (!initRes.ok) {
72
+ return { ok: false, error: `Pod /mcp returned HTTP ${initRes.status}` };
73
+ }
74
+ let initBody;
75
+ try {
76
+ initBody = (await initRes.json());
77
+ }
78
+ catch {
79
+ return { ok: false, error: "Pod /mcp returned a non-JSON body — is this a Synap pod?" };
80
+ }
81
+ if (initBody.error) {
82
+ return { ok: false, error: `MCP initialize rejected: ${initBody.error.message ?? "unknown error"}` };
83
+ }
84
+ const serverName = initBody.result?.serverInfo?.name;
85
+ if (serverName !== "synap-mcp-server") {
86
+ return {
87
+ ok: false,
88
+ error: `Pod /mcp is not a Synap MCP server (serverInfo.name = ${JSON.stringify(serverName)}) — the URL may point at a proxy or a non-Synap service.`,
89
+ };
90
+ }
91
+ // 2. tools/list — does this key authenticate and see tools?
92
+ let listRes;
93
+ try {
94
+ listRes = await fetch(url, {
95
+ method: "POST",
96
+ headers,
97
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
98
+ signal: AbortSignal.timeout(timeout),
99
+ });
100
+ }
101
+ catch (err) {
102
+ return { ok: false, error: `Could not reach pod /mcp for tools/list: ${err.message}` };
103
+ }
104
+ if (!listRes.ok) {
105
+ return { ok: false, error: `Pod /mcp tools/list returned HTTP ${listRes.status}` };
106
+ }
107
+ let listBody;
108
+ try {
109
+ listBody = (await listRes.json());
110
+ }
111
+ catch {
112
+ return { ok: false, error: "Pod /mcp tools/list returned a non-JSON body." };
113
+ }
114
+ if (listBody.error) {
115
+ return { ok: false, error: `MCP tools/list rejected: ${listBody.error.message ?? "unknown error"}` };
116
+ }
117
+ const tools = Array.isArray(listBody.result?.tools) ? listBody.result.tools : [];
118
+ const toolCount = tools.length;
119
+ if (toolCount === 0) {
120
+ return {
121
+ ok: false,
122
+ error: `Pod /mcp authenticated this key but exposed ZERO tools — the key may be scoped ` +
123
+ `to a workspace the agent isn't enrolled in, or the pod is misconfigured.`,
124
+ };
125
+ }
126
+ if (opts?.expectToolCount && toolCount < opts.expectToolCount) {
127
+ return {
128
+ ok: false,
129
+ error: `Pod /mcp exposed only ${toolCount} tool(s), expected at least ${opts.expectToolCount}. The connection is degraded — re-run 'synap connect'.`,
130
+ };
131
+ }
132
+ return { ok: true, toolCount };
133
+ }
21
134
  const CLAUDE_DESKTOP_MACOS = path.join(os.homedir(), "Library", "Application Support", "Claude");
22
135
  const CLAUDE_DESKTOP_WINDOWS = path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "Claude");
23
136
  const CLAUDE_DESKTOP_LINUX = path.join(os.homedir(), ".config", "Claude");
@@ -66,6 +179,7 @@ function raycastMcpConfigPath() {
66
179
  export const TARGETS = {
67
180
  "claude-code": {
68
181
  name: "claude-code",
182
+ keyStore: { format: "claude-code", path: () => path.join(os.homedir(), ".claude", "settings.json") },
69
183
  label: "Claude Code",
70
184
  supports: { skills: true, mcp: true },
71
185
  skillsDir: () => path.join(os.homedir(), ".claude", "skills"),
@@ -73,6 +187,7 @@ export const TARGETS = {
73
187
  },
74
188
  "claude-desktop": {
75
189
  name: "claude-desktop",
190
+ keyStore: { format: "json", path: () => path.join(claudeDesktopBaseDir(), "claude_desktop_config.json") },
76
191
  label: "Claude Desktop",
77
192
  // Claude Desktop reads Agent Skills from its app-support skills/ dir
78
193
  // (alongside claude_desktop_config.json). The "upload via claude.ai" path
@@ -85,49 +200,65 @@ export const TARGETS = {
85
200
  },
86
201
  cursor: {
87
202
  name: "cursor",
203
+ keyStore: { format: "json", path: () => path.join(os.homedir(), ".cursor", "mcp.json") },
88
204
  label: "Cursor",
89
205
  supports: { skills: false, mcp: true },
90
206
  mcpConfigPath: () => path.join(os.homedir(), ".cursor", "mcp.json"),
91
207
  },
92
208
  raycast: {
93
209
  name: "raycast",
210
+ keyStore: { format: "json", path: () => raycastMcpConfigPath() },
94
211
  label: "Raycast",
95
- description: "Native MCP server (via mcp-remote bridge) — all Synap tools in Raycast AI",
212
+ description: "Native Synap extension (@synap) catalog — full MCP opt-in via --with-mcp",
96
213
  supports: { skills: false, mcp: true },
97
214
  },
98
- openclaw: {
99
- name: "openclaw",
100
- label: "OpenClaw",
101
- supports: { skills: true, mcp: false },
102
- },
103
215
  openwebui: {
104
216
  name: "openwebui",
217
+ keyStore: { none: "registered in Open WebUI through its API — no local file" },
105
218
  label: "Open WebUI",
106
219
  description: "Register Synap as a model source and tool server in Open WebUI",
107
220
  supports: { skills: false, mcp: true },
108
221
  },
109
222
  codex: {
110
223
  name: "codex",
224
+ keyStore: { format: "toml", path: () => path.join(os.homedir(), ".codex", "config.toml") },
111
225
  label: "OpenAI Codex",
112
226
  description: "OpenAI Codex CLI — MCP server + instructions",
113
227
  supports: { skills: true, mcp: true },
114
- mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.yaml"),
228
+ mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.toml"),
115
229
  skillsDir: () => path.join(os.homedir(), ".codex"),
116
230
  },
231
+ chatgpt: {
232
+ name: "chatgpt",
233
+ keyStore: { none: "pasted into ChatGPT on the web — no local file" },
234
+ label: "ChatGPT (API key)",
235
+ description: "ChatGPT connector (web) — API-key auth to your pod's /mcp (provisions under generic surface)",
236
+ supports: { skills: false, mcp: true },
237
+ },
238
+ "chatgpt-oauth": {
239
+ name: "chatgpt-oauth",
240
+ keyStore: { none: "OAuth through the control plane — no key" },
241
+ label: "ChatGPT (OAuth, via Control Plane)",
242
+ description: "ChatGPT connector via Synap Control Plane OAuth — multi-pod, no key to paste",
243
+ supports: { skills: false, mcp: true },
244
+ },
117
245
  opencode: {
118
246
  name: "opencode",
247
+ keyStore: { format: "json", path: () => path.join(os.homedir(), ".config", "opencode", "opencode.json") },
119
248
  label: "opencode",
120
249
  description: "Synap IS as AI provider + Synap pod as MCP tools in ~/.config/opencode/opencode.json",
121
250
  supports: { skills: false, mcp: true },
122
251
  },
123
252
  aider: {
124
253
  name: "aider",
254
+ keyStore: { none: "aider gets the Intelligence Service provider, not a pod MCP key" },
125
255
  label: "aider",
126
256
  description: "Write Synap IS as the OpenAI-compatible provider in ~/.aider.conf.yml",
127
257
  supports: { skills: false, mcp: false },
128
258
  },
129
259
  windsurf: {
130
260
  name: "windsurf",
261
+ keyStore: { format: "json", path: () => path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json") },
131
262
  label: "Windsurf",
132
263
  description: "Codeium Windsurf editor — MCP config at ~/.codeium/windsurf/mcp_config.json",
133
264
  supports: { skills: false, mcp: true },
@@ -135,29 +266,57 @@ export const TARGETS = {
135
266
  },
136
267
  goose: {
137
268
  name: "goose",
269
+ keyStore: { format: "yaml-goose", path: () => path.join(os.homedir(), ".config", "goose", "config.yaml") },
138
270
  label: "Goose",
139
271
  description: "Block's AI agent — MCP extension in ~/.config/goose/config.yaml",
140
272
  supports: { skills: false, mcp: true },
141
273
  },
142
274
  zed: {
143
275
  name: "zed",
276
+ keyStore: { format: "jsonc", path: () => zedSettingsPath() },
144
277
  label: "Zed",
145
278
  description: "Zed editor — context_servers entry in Zed settings.json (via mcp-remote)",
146
279
  supports: { skills: false, mcp: true },
147
280
  },
148
281
  vscode: {
149
282
  name: "vscode",
283
+ keyStore: { format: "json", path: () => path.join(vsCodeUserDataDir(), "mcp.json") },
150
284
  label: "VS Code (Kilo Code / Cline / Continue / Copilot)",
151
285
  description: "VS Code native MCP — one config read by Kilo Code, Cline, Continue, and Copilot",
152
286
  supports: { skills: false, mcp: true },
153
287
  },
288
+ grok: {
289
+ name: "grok",
290
+ keyStore: { format: "toml", path: () => path.join(os.homedir(), ".grok", "config.toml") },
291
+ label: "Grok (Grok Build TUI)",
292
+ description: "xAI Grok Build — provisions an agent key and writes [mcp_servers.*] into ~/.grok/config.toml",
293
+ supports: { skills: false, mcp: true },
294
+ mcpConfigPath: () => path.join(os.homedir(), ".grok", "config.toml"),
295
+ },
154
296
  generic: {
155
297
  name: "generic",
298
+ keyStore: { none: "printed for you to paste — no file the CLI writes" },
156
299
  label: "Generic MCP Client",
157
300
  description: "Output MCP connection config for any MCP-compatible client",
158
301
  supports: { skills: false, mcp: true },
159
302
  },
160
303
  };
304
+ /**
305
+ * Every client file that may hold a key, for `synap doctor` — DERIVED from
306
+ * `TARGETS[*].keyStore`, never a second hand list. The surface is the CLI's
307
+ * key slot name (`agentKeysByPod.<surface>`), which is the target name.
308
+ */
309
+ export function clientKeyFiles() {
310
+ const out = [];
311
+ for (const t of Object.values(TARGETS)) {
312
+ if ("none" in t.keyStore)
313
+ continue;
314
+ const file = t.keyStore.path();
315
+ if (file)
316
+ out.push({ surface: t.name, tool: t.label, file, format: t.keyStore.format });
317
+ }
318
+ return out;
319
+ }
161
320
  export function isTargetName(value) {
162
321
  return value in TARGETS;
163
322
  }
@@ -181,13 +340,19 @@ export function buildMcpUrl(podUrl, workspaceId, projectId) {
181
340
  * Shared preamble for MCP surface installers: provision a dedicated agent key,
182
341
  * enroll the agent into the caller's workspaces, and resolve the scoped MCP URL.
183
342
  */
184
- async function prepareMcpSurface(cfg, agentType) {
185
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentType);
343
+ async function prepareMcpSurface(cfg, agentType, opts) {
344
+ // Idempotent by default: `synap connect` is re-runnable. Without it, every
345
+ // connect mints a NEW inactive key + a fresh approval request on the pod,
346
+ // even for a surface that is already connected — which is why the pod admin
347
+ // site keeps showing "validate this proposal" on every reconnect. The pod
348
+ // returns `alreadyValid` for an existing key; we recover the plaintext from
349
+ // local storage and reuse it, rotating only on positive invalidation.
350
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentType, { idempotent: opts?.idempotent ?? true });
186
351
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
187
352
  return { effectiveApiKey, agentUserId, mcpUrl: buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId) };
188
353
  }
189
354
  // ─── Agent context configuration ──────────────────────────────────────────────
190
- export async function configureAgentContext(podUrl, apiKey, agentType, agentUserId, info) {
355
+ export async function configureAgentContext(podUrl, apiKey, agentType, agentUserId, info, opts) {
191
356
  const { AGENT_TEMPLATES, getTemplate } = await import("./agent-templates.js");
192
357
  log.blank();
193
358
  log.heading("Configure your agent");
@@ -209,7 +374,7 @@ export async function configureAgentContext(podUrl, apiKey, agentType, agentUser
209
374
  // create`) still get a routing file — just with the MINIMAL default template,
210
375
  // since there's no TTY to prompt on.
211
376
  let templateId;
212
- if (process.stdin.isTTY) {
377
+ if (process.stdin.isTTY && opts?.prompt !== false) {
213
378
  ({ templateId } = await prompts({
214
379
  type: "select",
215
380
  name: "templateId",
@@ -235,17 +400,16 @@ export async function configureAgentContext(podUrl, apiKey, agentType, agentUser
235
400
  setAgentWorkspaceRouting({ memoryWorkspaceId: undefined, productWorkspaceIds: [] });
236
401
  // Always write to ~/.synap/contexts/<surface>.md
237
402
  const contextDir = path.join(os.homedir(), ".synap", "contexts");
238
- if (!fs.existsSync(contextDir))
239
- fs.mkdirSync(contextDir, { recursive: true });
403
+ ensureSecretDir(contextDir);
240
404
  const globalContextPath = path.join(contextDir, `${agentType}.md`);
241
- fs.writeFileSync(globalContextPath, content, { mode: 0o600 });
405
+ writeSecretFile(globalContextPath, content, { mode: 0o600 });
242
406
  // For skills-capable surfaces, also write alongside SKILL.md
243
407
  const skillsDir = info?.skillsDir?.();
244
408
  if (skillsDir) {
245
409
  const synapSkillDir = path.join(skillsDir, "synap");
246
410
  if (!fs.existsSync(synapSkillDir))
247
411
  fs.mkdirSync(synapSkillDir, { recursive: true });
248
- fs.writeFileSync(path.join(synapSkillDir, "CONTEXT.md"), content, { mode: 0o600 });
412
+ writeSecretFile(path.join(synapSkillDir, "CONTEXT.md"), content, { mode: 0o600 });
249
413
  log.success(`Agent context written to ${synapSkillDir}/CONTEXT.md`);
250
414
  }
251
415
  else {
@@ -273,7 +437,7 @@ export function listTargets() {
273
437
  * Run the install flow for a target. Returns true on success.
274
438
  * Throws only on unexpected errors — recoverable failures are logged + return false.
275
439
  */
276
- export async function installForTarget(target, cfg) {
440
+ export async function installForTarget(target, cfg, opts) {
277
441
  const info = TARGETS[target];
278
442
  if (!info) {
279
443
  log.error(`Unknown target: ${target}`);
@@ -294,15 +458,18 @@ export async function installForTarget(target, cfg) {
294
458
  case "raycast":
295
459
  result = await installRaycast(cfg);
296
460
  break;
297
- case "openclaw":
298
- result = await installOpenclaw(cfg);
299
- break;
300
461
  case "openwebui":
301
462
  result = await installOpenWebUI(cfg);
302
463
  break;
303
464
  case "codex":
304
465
  result = await installCodex(info, cfg);
305
466
  break;
467
+ case "chatgpt":
468
+ result = await installChatGpt(cfg);
469
+ break;
470
+ case "chatgpt-oauth":
471
+ result = await installChatGptOAuth(cfg);
472
+ break;
306
473
  case "opencode":
307
474
  result = await installOpencode(cfg);
308
475
  break;
@@ -321,22 +488,58 @@ export async function installForTarget(target, cfg) {
321
488
  case "vscode":
322
489
  result = await installVSCode(cfg);
323
490
  break;
491
+ case "grok":
492
+ result = await installGrok(info, cfg);
493
+ break;
324
494
  case "generic":
325
495
  result = await installGeneric(cfg);
326
496
  break;
327
497
  }
328
- // Post-install: configure agent workspace context for all successful MCP installs.
329
- if (result && info.supports.mcp && target !== "generic") {
498
+ // Post-install: agent context wizard for MCP targets. Skip Raycast — native
499
+ // and --with-mcp use the extension catalog / opt-in MCP; CONTEXT.md has no Raycast consumer.
500
+ if (result && info.supports.mcp && target !== "generic" && target !== "raycast") {
330
501
  const { getSurfaceAgentKey } = await import("./pod.js");
331
502
  const saved = getSurfaceAgentKey(target);
332
503
  const agentUserId = saved?.agentUserId ?? "";
333
504
  try {
334
- await configureAgentContext(cfg.podUrl, cfg.apiKey, target, agentUserId, info);
505
+ await configureAgentContext(cfg.podUrl, cfg.apiKey, target, agentUserId, info, {
506
+ prompt: !cfg.unattended,
507
+ });
335
508
  }
336
509
  catch (err) {
337
510
  log.warn(`Agent context wizard failed: ${err instanceof Error ? err.message : String(err)}`);
338
511
  }
339
512
  }
513
+ // ── Post-install verify (the missing gate) ────────────────────────────────
514
+ // The config is written and the agent key is stored — but until this point
515
+ // nothing has checked that the client will actually load Synap tools. A key
516
+ // scoped to a workspace the agent isn't enrolled in, a pod that is up but not
517
+ // serving MCP, or a config the client ignores all surface three steps later
518
+ // when the user reports "it says it works but it doesn't". Verify against the
519
+ // pod's /mcp with the AGENT key (the one the client will use), not the human
520
+ // profile key — those are different identities and only one is in the config.
521
+ if (result && info.supports.mcp && opts?.verify !== false) {
522
+ const { getSurfaceAgentKey } = await import("./pod.js");
523
+ const saved = getSurfaceAgentKey(target, cfg.podUrl);
524
+ const verifyKey = saved?.hubApiKey ?? cfg.apiKey;
525
+ const verifySpinner = ora(`Verifying ${info.label} can reach Synap…`).start();
526
+ try {
527
+ const v = await verifyMcpConnection(cfg.podUrl, verifyKey, { timeoutMs: 20_000 });
528
+ if (v.ok) {
529
+ verifySpinner.succeed(`${info.label} verified — ${v.toolCount} Synap tool(s) reachable at ${cfg.podUrl}/mcp`);
530
+ }
531
+ else {
532
+ verifySpinner.warn(`${info.label} install completed but verification FAILED.`);
533
+ log.warn(` ${v.error}`);
534
+ log.dim(` The client config was written, but the agent key couldn't reach Synap. ` +
535
+ `Re-run 'synap connect --target=${target}' after fixing it.`);
536
+ }
537
+ }
538
+ catch (err) {
539
+ verifySpinner.warn(`${info.label} verification errored.`);
540
+ log.warn(` ${err.message}`);
541
+ }
542
+ }
340
543
  return result;
341
544
  }
342
545
  // ─── Claude Code ─────────────────────────────────────────────────────────────
@@ -346,7 +549,7 @@ async function installClaudeCode(info, cfg) {
346
549
  // commands (capture, recall, search, create, etc.) — not via MCP tool calls.
347
550
  // Skills give it Synap knowledge; the agent key lets it call the CLI directly.
348
551
  // MCP is available as an opt-in for users who prefer the tool-call interface.
349
- const { mode } = await prompts({
552
+ const { mode } = cfg.unattended ? { mode: "both" } : await prompts({
350
553
  type: "select",
351
554
  name: "mode",
352
555
  message: "What do you want to install?",
@@ -489,74 +692,23 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
489
692
  const me = await meRes.json();
490
693
  if (!me.id)
491
694
  throw new Error("/api/hub/users/me returned no user ID — is the pod running?");
492
- const agentSetupRes = await fetch(`${podBase}/api/hub/setup/agent`, {
493
- method: "POST",
494
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
495
- body: JSON.stringify({ agentType: "claude-code", idempotent: true }),
496
- signal: AbortSignal.timeout(30000),
695
+ // ONE door for the key: `provisionAgentKey` takes a key approved with this
696
+ // computer (device flow), resumes a request already filed from here, reuses
697
+ // the stored live key, or files a new request — with this computer's
698
+ // instance label — and WAITS for its approval. It never returns a pending
699
+ // key. (This function used to POST /setup/agent itself and wrote the still-
700
+ // pending key into every config — the 2026-10-12 incident.)
701
+ const provisioned = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-code", {
702
+ idempotent: true,
703
+ surfaceName: "claude-code",
704
+ fallbackCandidate: settings.env?.["SYNAP_HUB_API_KEY"],
497
705
  });
498
- if (!agentSetupRes.ok) {
499
- const text = await agentSetupRes.text().catch(() => agentSetupRes.statusText);
500
- throw new Error(credentialError("claude-code", agentSetupRes.status, text));
501
- }
502
- const agentSetup = await agentSetupRes.json();
503
- if (agentSetup.alreadyValid) {
504
- // Idempotent path — pod says the agent key is still valid but doesn't re-transmit it.
505
- // Resolution priority: stored agentKey config > settings.json env var.
506
- // Self-heal: verify the candidate resolves to the expected agent user, not the human user.
507
- const { getSurfaceAgentKey: readSurfaceKey } = await import("./pod.js");
508
- const storedKey = readSurfaceKey("claude-code");
509
- const candidate = storedKey?.hubApiKey ??
510
- settings.env?.["SYNAP_HUB_API_KEY"] ??
511
- null;
512
- const expectedAgentUserId = agentSetup.agentUserId ?? storedKey?.agentUserId;
513
- let candidateIsValid = false;
514
- if (candidate && expectedAgentUserId) {
515
- try {
516
- // Use auth/status — it returns the key's actual owner userId.
517
- // For agent keys this is the agent userId, not the human userId.
518
- // users/me always returns the human owner regardless of key type.
519
- const verifyRes = await fetch(`${podBase}/api/hub/auth/status`, {
520
- headers: { Authorization: `Bearer ${candidate}` },
521
- signal: AbortSignal.timeout(30000),
522
- });
523
- if (verifyRes.ok) {
524
- const verifyStatus = await verifyRes.json();
525
- candidateIsValid = verifyStatus.userId === expectedAgentUserId;
526
- }
527
- }
528
- catch { /* reprovision below */ }
529
- }
530
- if (candidateIsValid && candidate) {
531
- effectiveApiKey = candidate;
532
- }
533
- else {
534
- // Self-heal: stored key is stale, wrong identity, or never persisted.
535
- // Reprovision without idempotent flag to get a fresh agent key.
536
- const reproRes = await fetch(`${podBase}/api/hub/setup/agent`, {
537
- method: "POST",
538
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
539
- body: JSON.stringify({ agentType: "claude-code" }),
540
- signal: AbortSignal.timeout(30000),
541
- });
542
- if (reproRes.ok) {
543
- const repro = await reproRes.json();
544
- effectiveApiKey = repro.hubApiKey ?? candidate ?? cfg.apiKey;
545
- if (repro.agentUserId)
546
- agentSetup.agentUserId = repro.agentUserId;
547
- }
548
- else {
549
- // Reprovision failed — fall back to whatever we have, warn clearly
550
- effectiveApiKey = candidate ?? cfg.apiKey;
551
- log.warn("Could not reprovision agent key — using fallback. Run `synap connect --target=claude-code` to fix.");
552
- }
553
- }
554
- }
555
- else {
556
- if (!agentSetup.hubApiKey)
557
- throw new Error("setup/agent succeeded but returned no hubApiKey — check pod logs.");
558
- effectiveApiKey = agentSetup.hubApiKey;
559
- }
706
+ effectiveApiKey = provisioned.hubApiKey;
707
+ const agentSetup = { agentUserId: provisioned.agentUserId, alreadyValid: provisioned.reused };
708
+ // The last gate before ANY file is written: the pod must not say this key is
709
+ // pending, revoked, expired or unknown. A refused key changes nothing here.
710
+ const { assertKeyWritable } = await import("./key-state.js");
711
+ await assertKeyWritable(cfg.podUrl, effectiveApiKey);
560
712
  // Persist the provisioned key to agentKeys["claude-code"] so SYNAP_AGENT=claude-code
561
713
  // works in CLI sessions outside of this env (e.g. terminal, not just inside Claude Code).
562
714
  const { setSurfaceAgentKey: saveSurfaceKey } = await import("./pod.js");
@@ -564,9 +716,16 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
564
716
  // Enroll the new agent user into the caller's workspaces using the HUMAN key
565
717
  // (before switching to the agent key). Scoped to one workspace when chosen.
566
718
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentSetup.agentUserId ?? "", cfg.workspaceId);
567
- // Set per-agent governance — prompt once per agent, idempotent.
568
- if (agentSetup.agentUserId) {
569
- await ensureAgentGovernance(cfg, agentSetup.agentUserId);
719
+ // Set per-agent governance — read before write, so it never overwrites a
720
+ // choice. Unattended (`synap init`) writes NOTHING: a new agent follows the
721
+ // POD default ("reversible writes act", founder 2026-09-28). Interactive
722
+ // `synap connect` still offers the presets, and only for an agent nobody
723
+ // configured.
724
+ if (agentSetup.agentUserId && !cfg.unattended) {
725
+ await ensureAgentGovernance(cfg, agentSetup.agentUserId, undefined, {
726
+ onlyIfUnset: true,
727
+ knownAgent: !!agentSetup.alreadyValid,
728
+ });
570
729
  }
571
730
  const env = (settings.env ?? {});
572
731
  env["SYNAP_POD_URL"] = cfg.podUrl;
@@ -597,7 +756,7 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
597
756
  if (!fs.existsSync(settingsDir)) {
598
757
  fs.mkdirSync(settingsDir, { recursive: true });
599
758
  }
600
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
759
+ writeSecretFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
601
760
  // ── Register the MCP server where Claude Code actually reads it ───────────
602
761
  // The official `claude mcp add` writes to the user-scope registry that
603
762
  // `claude mcp list` + the runtime consult — unlike settings.json mcpServers,
@@ -605,32 +764,122 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
605
764
  // synap tools.
606
765
  if (writeMcp) {
607
766
  const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
608
- registerClaudeCodeMcp(mcpUrl, effectiveApiKey);
767
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
768
+ registerClaudeCodeMcp(mcpUrl, effectiveApiKey, serverName);
609
769
  }
610
770
  }
611
771
  /**
612
772
  * Register the Synap MCP server with Claude Code via the official CLI, into the
613
773
  * user-scope registry (`claude mcp list` truth). Idempotent: drops any prior
614
- * entry first. Best-effort: if the `claude` binary isn't on PATH, prints the
615
- * exact command to run by hand rather than failing the whole connect.
774
+ * entry first (legacy `"synap"` and the pod-derived name). Best-effort: if the
775
+ * `claude` binary isn't on PATH, prints the exact command to run by hand
776
+ * rather than failing the whole connect.
777
+ *
778
+ * Server name is pod-derived (`mcpServerNameFromPodUrl`) so multi-pod setups
779
+ * (perso + team) do not overwrite each other — same pattern as Grok/Cursor.
616
780
  */
617
- function registerClaudeCodeMcp(mcpUrl, apiKey) {
781
+ function registerClaudeCodeMcp(mcpUrl, apiKey, serverName) {
618
782
  const header = `Authorization: Bearer ${apiKey}`;
783
+ const bridge = claudeCodeMcpTransport() === "bridge";
619
784
  try {
785
+ // Remove legacy single-name entry and any prior pod-derived name.
786
+ for (const name of new Set(["synap", serverName])) {
787
+ try {
788
+ execFileSync("claude", ["mcp", "remove", name], { stdio: "ignore" });
789
+ }
790
+ catch {
791
+ /* no prior entry — fine */
792
+ }
793
+ }
794
+ execFileSync("claude", bridge
795
+ ? // Stdio: Claude Code hands a stdio server its own session id, which
796
+ // the bridge forwards — see lib/mcp-bridge.ts. The key rides in the
797
+ // server's env, as the header does for http.
798
+ ["mcp", "add", "--scope", "user", "-e", `SYNAP_MCP_API_KEY=${apiKey}`, serverName, "--", ...synapBridgeCommand(mcpUrl)]
799
+ : ["mcp", "add", "--transport", "http", serverName, mcpUrl, "--header", header, "--scope", "user"], { stdio: "ignore" });
800
+ log.success(`MCP server '${serverName}' registered with Claude Code (\`claude mcp add\`, user scope${bridge ? ", via the synap stdio bridge" : ""}). Relaunch Claude Code to load the tools.`);
801
+ if (serverName !== "synap") {
802
+ log.dim("Multi-pod: connect each profile separately — server names are derived from the pod host so they don't overwrite:");
803
+ log.dim(" synap connect --target=claude-code --name=perso");
804
+ log.dim(" synap connect --target=claude-code --name=team");
805
+ }
806
+ }
807
+ catch {
808
+ // Never print the key (terminal scrollback / shell history). Claude Code
809
+ // expands ${VAR} in an http header at launch, so the printed command names
810
+ // an env var; the key itself goes to a 0600 file the user sources.
811
+ const keyFile = path.join(os.homedir(), ".synap", "mcp-keys", `${serverName.replace(/[^A-Za-z0-9_-]/g, "_")}.env`);
620
812
  try {
621
- execFileSync("claude", ["mcp", "remove", "synap"], { stdio: "ignore" });
813
+ ensureSecretDir(path.dirname(keyFile));
814
+ writeSecretFile(keyFile, `export SYNAP_MCP_KEY='${apiKey}'\n`);
815
+ log.dim("Could not run `claude mcp add` (is the claude CLI on PATH?). Register it by hand (the key is in a 0600 file, not on this screen):");
816
+ log.dim(` source ${keyFile}`);
817
+ log.dim(` claude mcp add --transport http ${serverName} ${mcpUrl} --header 'Authorization: Bearer \${SYNAP_MCP_KEY}' --scope user`);
622
818
  }
623
819
  catch {
624
- /* no prior entry — fine */
820
+ log.dim("Could not run `claude mcp add` (is the claude CLI on PATH?) and could not save the key file — run `synap mcp url` instead.");
625
821
  }
626
- execFileSync("claude", ["mcp", "add", "--transport", "http", "synap", mcpUrl, "--header", header, "--scope", "user"], { stdio: "ignore" });
627
- log.success("MCP server registered with Claude Code (`claude mcp add`, user scope). Relaunch Claude Code to load the synap tools.");
822
+ }
823
+ }
824
+ /**
825
+ * How Claude Code reaches the pod's MCP: `"http"` (direct) or `"bridge"` (the
826
+ * synap stdio bridge, which tells the pod which Claude Code conversation each
827
+ * call comes from, so work can be reopened in it). Stored in Claude Code's own
828
+ * settings env (`SYNAP_MCP_TRANSPORT`), which `writeClaudeCodeEnv` merges, so a
829
+ * re-connect or `synap pods use` keeps the choice. Default: http.
830
+ */
831
+ export function claudeCodeMcpTransport() {
832
+ try {
833
+ const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude", "settings.json"), "utf-8"));
834
+ return settings.env?.["SYNAP_MCP_TRANSPORT"] === "bridge" ? "bridge" : "http";
628
835
  }
629
836
  catch {
630
- log.dim("Could not run `claude mcp add` (is the claude CLI on PATH?). Register it by hand:");
631
- log.dim(` claude mcp add --transport http synap ${mcpUrl} --header "${header}" --scope user`);
837
+ return "http";
632
838
  }
633
839
  }
840
+ /**
841
+ * Switch Claude Code's MCP transport and re-register the server with the key
842
+ * and pod `writeClaudeCodeEnv` already stored. Returns the server name, or
843
+ * null when Claude Code was never connected (nothing to switch).
844
+ */
845
+ export function setClaudeCodeMcpTransport(transport) {
846
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
847
+ let settings;
848
+ try {
849
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
850
+ }
851
+ catch {
852
+ return null;
853
+ }
854
+ const env = (settings.env ?? {});
855
+ const podUrl = env["SYNAP_POD_URL"];
856
+ const apiKey = env["SYNAP_HUB_API_KEY"];
857
+ if (!podUrl || !apiKey)
858
+ return null;
859
+ if (transport === "bridge")
860
+ env["SYNAP_MCP_TRANSPORT"] = "bridge";
861
+ else
862
+ delete env["SYNAP_MCP_TRANSPORT"];
863
+ settings.env = env;
864
+ writeSecretFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
865
+ const serverName = mcpServerNameFromPodUrl(podUrl);
866
+ registerClaudeCodeMcp(buildMcpUrl(podUrl, env["SYNAP_WORKSPACE_ID"], env["SYNAP_PROJECT_ID"]), apiKey, serverName);
867
+ return serverName;
868
+ }
869
+ /**
870
+ * The command Claude Code spawns for the bridge: THIS CLI by absolute path (the
871
+ * one running `use-bridge` is the one known to have the bridge — a `synap` on
872
+ * PATH may be an older install without it), unless this run is a throwaway
873
+ * `npx` copy, whose cache path can vanish; then the `synap` on PATH.
874
+ */
875
+ function synapBridgeCommand(mcpUrl) {
876
+ const args = ["mcp", "bridge", "--url", mcpUrl];
877
+ const self = process.argv[1];
878
+ if (self && !self.includes(`${path.sep}_npx${path.sep}`)) {
879
+ return [process.execPath, fs.realpathSync(self), ...args];
880
+ }
881
+ return ["synap", ...args];
882
+ }
634
883
  // ─── Agent key provisioning helper ──────────────────────────────────────────
635
884
  /**
636
885
  * Build an actionable error for a failed agent-key provision. A 401 here almost
@@ -649,40 +898,80 @@ function credentialError(agentType, status, body) {
649
898
  return (`Failed to provision agent key for ${agentType} (HTTP ${status}): ${body}\n` +
650
899
  `If this persists, re-authenticate with \`synap login --reconnect\`.`);
651
900
  }
652
- function openBrowserUrl(url) {
653
- const safe = url.replace(/"/g, "%22");
654
- const cmd = process.platform === "darwin" ? `open "${safe}"` :
655
- process.platform === "win32" ? `start "" "${safe}"` :
656
- `xdg-open "${safe}"`;
657
- exec(cmd, () => { });
901
+ /** Open an http(s) URL in the browser — argv only, never a shell (`safe-open.ts`). */
902
+ export function openBrowserUrl(url) {
903
+ openExternalUrl(url);
658
904
  }
659
- async function waitForKeyApproval(podBase, humanApiKey, pendingToken, agentType) {
660
- // Shared poll loop (also backs CP `synap login`) — see lib/approval-poll.ts.
661
- const { pollForApproval } = await import("./approval-poll.js");
662
- await pollForApproval({
663
- url: `${podBase}/api/hub/setup/agent/pending/${pendingToken}`,
664
- headers: { Authorization: `Bearer ${humanApiKey}` },
665
- isApproved: (d) => d.status === "active",
666
- isRejected: (d) => d.status === "rejected",
667
- onApproved: () => undefined,
668
- rejectedError: `Agent key for ${agentType} was rejected in the browser. Run connect again to re-request.`,
669
- timeoutError: `Timed out waiting for approval of ${agentType} agent key (120s). ` +
670
- `Open the review URL manually or run connect again.`,
671
- });
905
+ /** The pod answered a key mint with something unusable (e.g. no agent id). */
906
+ export class AgentKeyMintError extends Error {
907
+ agentType;
908
+ constructor(agentType, message) {
909
+ super(message);
910
+ this.agentType = agentType;
911
+ this.name = "AgentKeyMintError";
912
+ }
672
913
  }
673
914
  /**
674
915
  * Provision an agent-owned API key for the given surface via POST /api/hub/setup/agent.
675
916
  * When requireApproval is true the key is created inactive; the user must approve in the
676
917
  * browser before this function returns.
677
918
  * Throws on failure — callers must not silently fall back to a human key.
919
+ *
920
+ * @param surfaceName The CLI surface name (e.g. "chatgpt", "codex") — used for
921
+ * idempotent key lookup in local storage. If omitted, `agentType`
922
+ * is used. This handles the case where the pod's agentType
923
+ * differs from the CLI surface (e.g. ChatGPT provisions under
924
+ * "generic" but the CLI surface is "chatgpt").
678
925
  */
679
- export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requireApproval = true, idempotent = false, } = {}) {
926
+ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requireApproval = true, idempotent = false, surfaceName, deferApproval = false, instanceId, fallbackCandidate, } = {}) {
927
+ // `synap init` already minted — and the person approved — this key in one
928
+ // step with the others (`approveAgentKeysAtOnce`): hand it over, mint nothing.
929
+ if (!deferApproval) {
930
+ const pre = takePreApprovedKey(agentType);
931
+ if (pre)
932
+ return { ...pre, reused: false };
933
+ }
934
+ const surface = surfaceName ?? agentType;
935
+ const instance = instanceId ?? machineInstanceId(surface);
936
+ const podBase = podUrl.replace(/\/$/, "");
937
+ const pod = await import("./pod.js");
938
+ // A request already filed from this computer is RESUMED, never filed again:
939
+ // one approval, one key (2026-10-12: three orphaned requests in one evening).
940
+ const recorded = pod.getPendingAgentKey(surface, podUrl);
941
+ if (recorded) {
942
+ const { classifyRecordedPending } = await import("./key-approval.js");
943
+ const now = await classifyRecordedPending(recorded);
944
+ if (now.kind === "active") {
945
+ pod.clearPendingAgentKey(surface, podUrl);
946
+ return { hubApiKey: recorded.hubApiKey, agentUserId: recorded.agentUserId, reused: true };
947
+ }
948
+ if (now.kind === "pending" || now.kind === "unknown") {
949
+ if (deferApproval) {
950
+ return {
951
+ hubApiKey: recorded.hubApiKey,
952
+ agentUserId: recorded.agentUserId,
953
+ reused: true,
954
+ pending: { pendingToken: recorded.keyId, reviewUrl: now.state.approvalUrl ?? recorded.approvalUrl },
955
+ };
956
+ }
957
+ await awaitApprovalOrThrow({
958
+ podUrl,
959
+ humanApiKey,
960
+ agentType,
961
+ surface,
962
+ hubApiKey: recorded.hubApiKey,
963
+ pendingToken: recorded.keyId,
964
+ approvalUrl: now.state.approvalUrl ?? recorded.approvalUrl,
965
+ });
966
+ return { hubApiKey: recorded.hubApiKey, agentUserId: recorded.agentUserId, reused: true };
967
+ }
968
+ pod.clearPendingAgentKey(surface, podUrl); // dead: file a fresh request below
969
+ }
680
970
  // `idempotent: true` reuses the pod's existing valid key for this agentType
681
971
  // instead of minting a fresh one + revoking the old — required by callers whose
682
972
  // running process holds a long-lived key (e.g. `bridge-setup`), so re-running
683
973
  // setup doesn't break a live bridge. Default false preserves the mint-fresh
684
974
  // behavior every other caller (connect / pods / init) already relied on.
685
- const podBase = podUrl.replace(/\/$/, "");
686
975
  let res;
687
976
  try {
688
977
  res = await fetch(`${podBase}/api/hub/setup/agent`, {
@@ -691,7 +980,15 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
691
980
  "Content-Type": "application/json",
692
981
  Authorization: `Bearer ${humanApiKey}`,
693
982
  },
694
- body: JSON.stringify({ agentType, idempotent, requireApproval }),
983
+ // instanceLabel: this computer's name, so the connection reads
984
+ // "Claude Code on <computer>" wherever the person decides about it.
985
+ body: JSON.stringify({
986
+ agentType,
987
+ idempotent,
988
+ requireApproval,
989
+ instanceId: instance,
990
+ instanceLabel: machineInstanceLabel(),
991
+ }),
695
992
  signal: AbortSignal.timeout(30000),
696
993
  });
697
994
  }
@@ -716,9 +1013,11 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
716
1013
  // no rotation, no redeploy. We mint fresh ONLY when no valid local key exists
717
1014
  // (e.g. a brand-new machine). This generalizes the claude-code self-heal.
718
1015
  if (!body.hubApiKey && body.alreadyValid && idempotent) {
719
- const { getSurfaceAgentKey } = await import("./pod.js");
720
- const stored = getSurfaceAgentKey(agentType);
721
- const candidate = stored?.hubApiKey;
1016
+ // Use surfaceName for local storage lookup if provided; otherwise agentType.
1017
+ // This handles cases where pod agentType ≠ CLI surface (e.g. ChatGPT uses
1018
+ // "generic" but CLI surface is "chatgpt").
1019
+ const stored = pod.getSurfaceAgentKey(surface);
1020
+ const candidate = stored?.hubApiKey ?? fallbackCandidate;
722
1021
  const expectedAgentUserId = body.agentUserId ?? stored?.agentUserId;
723
1022
  if (candidate && expectedAgentUserId) {
724
1023
  // auth/status returns the KEY's owner userId (the agent's, for agent keys).
@@ -757,6 +1056,9 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
757
1056
  return provisionAgentKey(podUrl, humanApiKey, agentType, {
758
1057
  requireApproval,
759
1058
  idempotent: false,
1059
+ surfaceName,
1060
+ deferApproval,
1061
+ instanceId: instance,
760
1062
  });
761
1063
  }
762
1064
  if (!body.hubApiKey) {
@@ -764,22 +1066,75 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
764
1066
  `This is a server-side bug — check the pod logs.`);
765
1067
  }
766
1068
  if (body.requiresApproval && body.pendingToken && body.reviewUrl) {
767
- log.info(`\n Opening your pod to review this agent key request…`);
768
- log.dim(` ${body.reviewUrl}`);
769
- openBrowserUrl(body.reviewUrl);
770
- process.stdout.write(chalk.dim(" Waiting for approval"));
771
- const ticker = setInterval(() => process.stdout.write(chalk.dim(".")), 2000);
772
- try {
773
- await waitForKeyApproval(podBase, humanApiKey, body.pendingToken, agentType);
1069
+ // The pre-approved key is handed to an installer that enrolls and scopes
1070
+ // by this id: an empty one would connect an agent the pod cannot name.
1071
+ if (!body.agentUserId) {
1072
+ throw new AgentKeyMintError(agentType, `The pod minted a ${agentType} key but named no agent for it.`);
774
1073
  }
775
- finally {
776
- clearInterval(ticker);
777
- process.stdout.write("\n");
1074
+ // Recorded BEFORE any wait: an interrupted or timed-out wait resumes this
1075
+ // same request next time instead of filing another.
1076
+ pod.setPendingAgentKey(surface, {
1077
+ hubApiKey: body.hubApiKey,
1078
+ agentUserId: body.agentUserId,
1079
+ podUrl,
1080
+ keyId: body.pendingToken,
1081
+ approvalUrl: body.reviewUrl,
1082
+ instanceId: instance,
1083
+ createdAt: new Date().toISOString(),
1084
+ });
1085
+ if (deferApproval) {
1086
+ return {
1087
+ hubApiKey: body.hubApiKey,
1088
+ agentUserId: body.agentUserId,
1089
+ reused: false,
1090
+ pending: { pendingToken: body.pendingToken, reviewUrl: body.reviewUrl },
1091
+ };
778
1092
  }
1093
+ await awaitApprovalOrThrow({
1094
+ podUrl,
1095
+ humanApiKey,
1096
+ agentType,
1097
+ surface,
1098
+ hubApiKey: body.hubApiKey,
1099
+ pendingToken: body.pendingToken,
1100
+ approvalUrl: body.reviewUrl,
1101
+ });
779
1102
  log.success(` Approved! Writing ${agentType} agent key.`);
1103
+ return { hubApiKey: body.hubApiKey, agentUserId: body.agentUserId, reused: false };
1104
+ }
1105
+ // Minted live (no approval asked). Still never hand out a key the pod says
1106
+ // is unusable — callers write what this returns into configs.
1107
+ if (!deferApproval) {
1108
+ const { assertKeyWritable } = await import("./key-state.js");
1109
+ await assertKeyWritable(podUrl, body.hubApiKey);
780
1110
  }
781
1111
  return { hubApiKey: body.hubApiKey, agentUserId: body.agentUserId ?? "", reused: false };
782
1112
  }
1113
+ /**
1114
+ * Open the approval page and wait for THIS pending key to go live. Approved →
1115
+ * the recorded request is cleared. Rejected → cleared, and a plain error.
1116
+ * Timed out → the request stays recorded and `AgentKeyAwaitingApprovalError`
1117
+ * names the link and the resume command: nothing is written anywhere.
1118
+ */
1119
+ async function awaitApprovalOrThrow(args) {
1120
+ const { waitForKeyLive, openAndWait, AgentKeyAwaitingApprovalError } = await import("./key-approval.js");
1121
+ const pod = await import("./pod.js");
1122
+ const outcome = await openAndWait(args.agentType, args.approvalUrl, args.podUrl, () => waitForKeyLive({
1123
+ podUrl: args.podUrl,
1124
+ pendingKey: args.hubApiKey,
1125
+ pendingToken: args.pendingToken,
1126
+ humanApiKey: args.humanApiKey,
1127
+ }), openBrowserUrl);
1128
+ if (outcome === "approved") {
1129
+ pod.clearPendingAgentKey(args.surface, args.podUrl);
1130
+ return;
1131
+ }
1132
+ if (outcome === "rejected") {
1133
+ pod.clearPendingAgentKey(args.surface, args.podUrl);
1134
+ throw new Error(`The ${args.agentType} connection was declined on the pod. Run connect again to ask again.`);
1135
+ }
1136
+ throw new AgentKeyAwaitingApprovalError(args.agentType, args.approvalUrl);
1137
+ }
783
1138
  /**
784
1139
  * Enroll an agent user into the caller's workspaces via POST /api/hub/workspaces/enroll-agent.
785
1140
  * Non-fatal — logs on failure so connect flow is never blocked by enrollment errors.
@@ -837,15 +1192,23 @@ async function installClaudeDesktop(info, cfg) {
837
1192
  // stdio MCP messages into HTTPS calls against the pod's /mcp endpoint.
838
1193
  //
839
1194
  // Reference: https://www.npmjs.com/package/mcp-remote
840
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-desktop");
1195
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-desktop", { idempotent: true });
841
1196
  const { setSurfaceAgentKey: saveDesktopKey } = await import("./pod.js");
842
1197
  saveDesktopKey("claude-desktop", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
843
1198
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
844
1199
  const desktopMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
845
- writeMcpServerEntry(mcpPath, "synap", {
1200
+ // Pod-derived name so connecting a SECOND pod adds an entry instead of
1201
+ // overwriting the first (same rule as Claude Code / Cursor / Grok).
1202
+ const desktopServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1203
+ writeMcpServerEntry(mcpPath, desktopServerName, {
846
1204
  command: "npx",
847
- args: ["-y", "mcp-remote", desktopMcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
1205
+ // The header value rides in env (this file is 0600), never in the
1206
+ // mcp-remote process's argv where any local `ps` can read it.
1207
+ args: ["-y", "mcp-remote", desktopMcpUrl, "--header", "Authorization:${SYNAP_AUTH}"],
1208
+ env: { SYNAP_AUTH: `Bearer ${effectiveApiKey}` },
848
1209
  });
1210
+ if (desktopServerName !== "synap")
1211
+ removeMcpServerEntry(mcpPath, "synap");
849
1212
  log.blank();
850
1213
  log.success(`MCP server 'synap' added to ${path.relative(os.homedir(), mcpPath)}`);
851
1214
  log.dim("The entry uses 'npx mcp-remote' as a stdio bridge — Claude Desktop spawns it.");
@@ -880,19 +1243,32 @@ async function installCursor(info, cfg) {
880
1243
  const mcpPath = info.mcpConfigPath?.();
881
1244
  if (!mcpPath)
882
1245
  return false;
883
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "cursor");
1246
+ const mint = TARGET_KEY_MINT.cursor;
1247
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, mint.agentType, { idempotent: mint.idempotent });
884
1248
  const { setSurfaceAgentKey: saveCursorKey } = await import("./pod.js");
885
1249
  saveCursorKey("cursor", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
886
1250
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
887
1251
  const cursorMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
888
- writeMcpServerEntry(mcpPath, "synap", {
1252
+ // Pod-derived name so multi-pod connects don't overwrite each other (peer of Grok).
1253
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1254
+ writeMcpServerEntry(mcpPath, serverName, {
889
1255
  url: cursorMcpUrl,
890
1256
  headers: { Authorization: `Bearer ${effectiveApiKey}` },
891
1257
  });
1258
+ // Drop legacy single-name entry when we wrote a pod-qualified name, so reconnects
1259
+ // don't leave a stale "synap" next to "synap-thearch".
1260
+ if (serverName !== "synap") {
1261
+ removeMcpServerEntry(mcpPath, "synap");
1262
+ }
892
1263
  log.blank();
893
- log.success(`MCP server 'synap' added to ${path.relative(os.homedir(), mcpPath)}`);
1264
+ log.success(`MCP server '${serverName}' added to ${path.relative(os.homedir(), mcpPath)}`);
894
1265
  if (cfg.workspaceId)
895
1266
  log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
1267
+ if (serverName !== "synap") {
1268
+ log.dim("Multi-pod: connect each profile separately — server names are derived from the pod host so they don't overwrite:");
1269
+ log.dim(" synap connect --target=cursor --name=perso");
1270
+ log.dim(" synap connect --target=cursor --name=team");
1271
+ }
896
1272
  log.dim("Restart Cursor. The synap tool set will appear in agent mode.");
897
1273
  log.blank();
898
1274
  log.info("Cursor reads Claude-format skills from ~/.claude/skills/ too.");
@@ -901,9 +1277,9 @@ async function installCursor(info, cfg) {
901
1277
  }
902
1278
  // ─── Raycast ─────────────────────────────────────────────────────────────────
903
1279
  async function installRaycast(cfg) {
904
- // Raycast reads credentials from ~/.synap/config.json (Tier 0) for its native
905
- // extension commands, AND from mcp-config.json for the MCP server. Persist the
906
- // workspace choice so both paths see the right scope.
1280
+ // Default path: native Synap extension (@synap) is the catalog. Persist
1281
+ // workspace + provision a dedicated raycast agent key for extension writes.
1282
+ // Full MCP (mcp-remote → mcp-config.json) is opt-in via cfg.withMcp.
907
1283
  const { setActiveWorkspaceId, clearActiveWorkspaceId } = await import("./pod.js");
908
1284
  if (cfg.workspaceId) {
909
1285
  setActiveWorkspaceId(cfg.workspaceId);
@@ -911,22 +1287,31 @@ async function installRaycast(cfg) {
911
1287
  else {
912
1288
  clearActiveWorkspaceId();
913
1289
  }
914
- // Provision a dedicated "raycast" agent identity + resolve the scoped MCP URL.
915
1290
  const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "raycast");
916
1291
  const { setSurfaceAgentKey } = await import("./pod.js");
917
1292
  setSurfaceAgentKey("raycast", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
918
- // ── Write the Raycast MCP server config (merge, never clobber) ─────────────
1293
+ log.success("Raycast agent key stored for the Synap extension (@synap).");
1294
+ log.dim("The native extension is your catalog — open Synap in Raycast (or run Connect there) if needed.");
1295
+ log.blank();
1296
+ log.dim(` Pod: ${cfg.podUrl}`);
1297
+ log.dim(` Scope: ${cfg.workspaceId ? `workspace ${cfg.workspaceId}` : "all workspaces (pod-wide)"}`);
1298
+ if (!cfg.withMcp) {
1299
+ log.blank();
1300
+ log.dim("Full MCP (mcp-remote) is opt-in: synap connect --target=raycast --with-mcp");
1301
+ return true;
1302
+ }
1303
+ // ── Opt-in power door: write Raycast MCP server config (merge, never clobber) ─
919
1304
  // Raycast's native MCP is stdio-only — bridge the HTTP /mcp endpoint with the
920
1305
  // `mcp-remote` npm package (npx spawns it). Same bridge as Claude Desktop/Zed.
1306
+ // Tool names will overlap the @synap extension catalog.
1307
+ log.blank();
1308
+ const raycastServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1309
+ log.info(`Installing full MCP power door via mcp-remote as '${raycastServerName}' — names overlap @synap.`);
921
1310
  const synapServerEntry = {
922
1311
  command: "npx",
923
- args: [
924
- "-y",
925
- "mcp-remote",
926
- mcpUrl,
927
- "--header",
928
- `Authorization: Bearer ${effectiveApiKey}`,
929
- ],
1312
+ args: ["-y", "mcp-remote", mcpUrl, "--header", "Authorization:${SYNAP_AUTH}"],
1313
+ // Header value via env, not argv (see Claude Desktop above).
1314
+ env: { SYNAP_AUTH: `Bearer ${effectiveApiKey}` },
930
1315
  };
931
1316
  // The mcp-config.json lives in an opaque UUID folder Raycast creates the first
932
1317
  // time "Manage MCP Servers" is opened. If it already exists we merge into it;
@@ -941,16 +1326,18 @@ async function installRaycast(cfg) {
941
1326
  }
942
1327
  catch {
943
1328
  try {
944
- fs.copyFileSync(existingPath, `${existingPath}.bak`);
1329
+ copySecretBackup(existingPath, `${existingPath}.bak`);
945
1330
  }
946
1331
  catch { /* best-effort */ }
947
1332
  }
948
1333
  mcpConfig.mcpServers = mcpConfig.mcpServers ?? {};
949
- mcpConfig.mcpServers["synap"] = synapServerEntry;
950
- fs.writeFileSync(existingPath, JSON.stringify(mcpConfig, null, 2) + "\n", { mode: 0o600 });
1334
+ mcpConfig.mcpServers[raycastServerName] = synapServerEntry;
1335
+ if (raycastServerName !== "synap")
1336
+ delete mcpConfig.mcpServers["synap"];
1337
+ writeSecretFile(existingPath, JSON.stringify(mcpConfig, null, 2) + "\n", { mode: 0o600 });
951
1338
  wrote = true;
952
1339
  log.success(`Synap MCP server added to Raycast: ${existingPath}`);
953
- log.dim("Reopen Raycast → 'Manage MCP Servers' → 'synap' is connected with all tools.");
1340
+ log.dim(`Reopen Raycast → 'Manage MCP Servers' → '${raycastServerName}' is connected with all tools.`);
954
1341
  log.dim("First call spawns 'npx mcp-remote' (needs Node on PATH).");
955
1342
  }
956
1343
  catch (err) {
@@ -961,43 +1348,11 @@ async function installRaycast(cfg) {
961
1348
  log.info("To add Synap to Raycast as an MCP server:");
962
1349
  log.dim(" 1. Open Raycast → run 'Manage MCP Servers'");
963
1350
  log.dim(" 2. Action 'Show Config File in Finder' → open mcp-config.json");
964
- log.dim(" 3. Paste the 'synap' entry below into \"mcpServers\", then save:");
1351
+ log.dim(` 3. Paste the '${raycastServerName}' entry below into "mcpServers", then save:`);
965
1352
  log.blank();
966
- log.dim(JSON.stringify({ mcpServers: { synap: synapServerEntry } }, null, 2));
1353
+ log.dim(JSON.stringify({ mcpServers: { [raycastServerName]: synapServerEntry } }, null, 2));
967
1354
  log.blank();
968
- log.dim(" (Re-running `synap connect --target=raycast` after step 2 writes it for you.)");
969
- }
970
- log.blank();
971
- log.dim(` Pod: ${cfg.podUrl}`);
972
- log.dim(` Scope: ${cfg.workspaceId ? `workspace ${cfg.workspaceId}` : "all workspaces (pod-wide)"}`);
973
- return true;
974
- }
975
- // ─── OpenClaw (wraps existing flow with saved pod config) ───────────────────
976
- async function installOpenclaw(cfg) {
977
- const { detectOpenClaw, readOpenClawConfig, writeOpenClawConfig, setConfigValue } = await import("./openclaw.js");
978
- const oc = detectOpenClaw();
979
- if (!oc.found) {
980
- log.warn("OpenClaw not detected on this machine.");
981
- log.dim(" Install it first: https://openclaw.dev");
982
- log.dim(" Or run `synap init` to set up from scratch.");
983
- return false;
984
- }
985
- const ocConfig = readOpenClawConfig() ?? {};
986
- setConfigValue(ocConfig, "synap.podUrl", cfg.podUrl);
987
- setConfigValue(ocConfig, "synap.hubApiKey", cfg.apiKey);
988
- if (cfg.workspaceId)
989
- setConfigValue(ocConfig, "synap.workspaceId", cfg.workspaceId);
990
- if (cfg.agentUserId)
991
- setConfigValue(ocConfig, "synap.agentUserId", cfg.agentUserId);
992
- writeOpenClawConfig(ocConfig);
993
- if (cfg.agentUserId) {
994
- await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, cfg.agentUserId, cfg.workspaceId);
995
- }
996
- log.success("Wrote OpenClaw config");
997
- log.blank();
998
- log.info("Install the skills:");
999
- for (const name of cfg.skills ?? getDeliverableSkills()) {
1000
- log.dim(` openclaw skills install ${name}`);
1355
+ log.dim(" (Re-running `synap connect --target=raycast --with-mcp` after step 2 writes it for you.)");
1001
1356
  }
1002
1357
  return true;
1003
1358
  }
@@ -1013,12 +1368,10 @@ async function installOpenWebUI(cfg) {
1013
1368
  log.info("2. MCP tool server:");
1014
1369
  log.dim(" Admin → Tools → Add Tool Server");
1015
1370
  log.dim(` URL: ${mcpUrl}`);
1016
- log.dim(` Header: Authorization: Bearer ${cfg.apiKey}`);
1371
+ log.dim(` Header: Authorization: Bearer <key — saved in ~/.synap/openwebui-mcp.json (0600)>`);
1017
1372
  log.blank();
1018
1373
  const synapDir = path.join(os.homedir(), ".synap");
1019
- if (!fs.existsSync(synapDir)) {
1020
- fs.mkdirSync(synapDir, { recursive: true });
1021
- }
1374
+ ensureSecretDir(synapDir);
1022
1375
  const refFile = path.join(synapDir, "openwebui-mcp.json");
1023
1376
  const refData = {
1024
1377
  modelSource: { baseUrl: `http://${podHost}/v1` },
@@ -1027,15 +1380,139 @@ async function installOpenWebUI(cfg) {
1027
1380
  headers: { Authorization: `Bearer ${cfg.apiKey}` },
1028
1381
  },
1029
1382
  };
1030
- fs.writeFileSync(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1383
+ writeSecretFile(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1031
1384
  log.success(`Connection details saved to ~/.synap/openwebui-mcp.json`);
1032
1385
  return true;
1033
1386
  }
1387
+ // ─── ChatGPT (web) ───────────────────────────────────────────────────────────
1388
+ /**
1389
+ * ChatGPT connects to Synap in TWO ways. This target wires the API-key path
1390
+ * (the one the CLI can automate) and documents the OAuth path.
1391
+ *
1392
+ * API key: ChatGPT's "Add custom connector" accepts a URL + an
1393
+ * `Authorization: Bearer <key>` header. That is the pod's standard MCP form,
1394
+ * so the connection is the same one every other target uses — just pointed at
1395
+ * the pod directly.
1396
+ *
1397
+ * OAuth: ChatGPT also supports OAuth (no header field). The pod's /mcp is
1398
+ * Bearer-only, so OAuth for a SINGLE pod isn't available — it lives on the
1399
+ * Control Plane (`https://api.synap.live/mcp`), which is an OAuth MCP server
1400
+ * exposing `list_pods` / `connect_pod` / 14 proxied `pod__*` tools. That path
1401
+ * is multi-pod and managed; `synap mcp connect-claude` prints it for
1402
+ * claude.ai, and the same URL works for ChatGPT's OAuth connector.
1403
+ *
1404
+ * Agent type: the pod only mints keys for known surface agent types
1405
+ * (`SURFACE_AGENT_TYPE_REQUIRED`), so a ChatGpt agent provisions under
1406
+ * "generic" — a dedicated identity, never the human profile key.
1407
+ */
1408
+ async function installChatGpt(cfg) {
1409
+ // ChatGPT provisions under agentType "generic" (not a known surface type),
1410
+ // so we store under surface name "chatgpt" for the user-facing CLI.
1411
+ // The pod's SURFACE_AGENT_TYPE_REQUIRED guard allows "generic" but not "chatgpt".
1412
+ // For idempotent reuse: pass surfaceName="chatgpt" so the local lookup finds
1413
+ // the stored key, while the pod call uses agentType="generic".
1414
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentTypeOfSurface("chatgpt"), { idempotent: true, surfaceName: "chatgpt" });
1415
+ const { setSurfaceAgentKey } = await import("./pod.js");
1416
+ setSurfaceAgentKey("chatgpt", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1417
+ await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1418
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1419
+ const synapDir = path.join(os.homedir(), ".synap");
1420
+ ensureSecretDir(synapDir);
1421
+ const refFile = path.join(synapDir, "chatgpt-mcp.json");
1422
+ const refData = {
1423
+ mcpToolServer: {
1424
+ url: mcpUrl,
1425
+ headers: { Authorization: `Bearer ${effectiveApiKey}` },
1426
+ },
1427
+ oauthAlternative: {
1428
+ url: "https://api.synap.live/mcp",
1429
+ note: "ChatGPT OAuth connector (no header field). Multi-pod: list_pods, " +
1430
+ "connect_pod, then proxied pod__* tools. Same URL as claude.ai.",
1431
+ },
1432
+ };
1433
+ writeSecretFile(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1434
+ log.heading("Connecting ChatGPT → " + cfg.podUrl);
1435
+ log.blank();
1436
+ log.info("Path 1 — API key (this CLI can wire it):");
1437
+ log.blank();
1438
+ log.info(" ChatGPT → Settings → Connectors → Add custom connector");
1439
+ log.info(` Name: Synap`);
1440
+ log.info(` URL: ${mcpUrl}`);
1441
+ log.info(` Auth: API key`);
1442
+ log.info(` API key: ${redactKey(effectiveApiKey)} (full key: ~/.synap/chatgpt-mcp.json, 0600)`);
1443
+ log.blank();
1444
+ log.dim(" ChatGPT shows the key field inline — paste it there. If your build only " +
1445
+ "shows URL + a header, set Authorization: Bearer <key>.");
1446
+ log.blank();
1447
+ log.info("Path 2 — OAuth (multi-pod, no key to paste):");
1448
+ log.blank();
1449
+ log.info(" ChatGPT → Settings → Connectors → Add custom connector");
1450
+ log.info(" URL: https://api.synap.live/mcp");
1451
+ log.info(" Auth: OAuth (leave API key blank)");
1452
+ log.blank();
1453
+ log.dim(" After OAuth, ask ChatGPT to 'list my Synap pods' (list_pods) and connect " +
1454
+ "one (connect_pod). Same flow as claude.ai — see `synap mcp connect-claude`.");
1455
+ log.blank();
1456
+ log.success(`Connection details saved to ~/.synap/chatgpt-mcp.json`);
1457
+ log.dim("ChatGPT caches connectors per workspace — remove and re-add if you switch pods.");
1458
+ return true;
1459
+ }
1460
+ // ─── ChatGPT (web) — OAuth via Control Plane ──────────────────────────────────
1461
+ /**
1462
+ * ChatGPT OAuth via the Control Plane — same URL as claude.ai.
1463
+ *
1464
+ * The CP's `/mcp` endpoint is OAuth-only (ES256 access tokens, RFC 9728
1465
+ * `WWW-Authenticate` discovery). This path is MULTI-POD: after OAuth, the
1466
+ * user sees `list_pods` → `connect_pod` to pick a pod, then gets 14 proxied
1467
+ * `pod__*` tools. No API key is pasted; the CP stores an encrypted grant.
1468
+ *
1469
+ * Use this when:
1470
+ * - You have multiple pods and want to switch between them in ChatGPT
1471
+ * - You prefer OAuth over API-key auth
1472
+ * - You want the managed CP proxy (revoke in one place, no pod restarts)
1473
+ */
1474
+ async function installChatGptOAuth(cfg) {
1475
+ // Use the CP origin from the pod (or managed fallback)
1476
+ const { deriveCpOrigin } = await import("../commands/mcp.js");
1477
+ const { cpOrigin } = deriveCpOrigin(cfg.podUrl);
1478
+ const url = `${cpOrigin.replace(/\/$/, "")}/mcp`;
1479
+ log.heading("Connecting ChatGPT (OAuth) via Control Plane");
1480
+ log.blank();
1481
+ log.info("This uses the SAME OAuth URL as claude.ai — the CP is the single broker.");
1482
+ log.blank();
1483
+ log.info("ChatGPT → Settings → Connectors → Add custom connector");
1484
+ log.info(` Name: Synap (Control Plane)`);
1485
+ log.info(` URL: ${url}`);
1486
+ log.info(` Auth: OAuth (leave API key blank)`);
1487
+ log.blank();
1488
+ log.info("After connecting, ask ChatGPT to:");
1489
+ log.info(" 1. 'list my Synap pods' (list_pods)");
1490
+ log.info(" 2. 'connect pod <your-pod>' (connect_pod)");
1491
+ log.blank();
1492
+ log.dim("The CP stores an encrypted grant. Revoke in pod-admin → My Connections.");
1493
+ log.blank();
1494
+ log.dim("See `synap mcp connect-claude` for the identical claude.ai flow.");
1495
+ // Save a reference file for the user
1496
+ const synapDir = path.join(os.homedir(), ".synap");
1497
+ ensureSecretDir(synapDir);
1498
+ const refFile = path.join(synapDir, "chatgpt-oauth.json");
1499
+ const refData = {
1500
+ oauthUrl: url,
1501
+ note: "ChatGPT OAuth connector via Synap Control Plane. Multi-pod: list_pods, connect_pod, then proxied pod__* tools.",
1502
+ };
1503
+ writeSecretFile(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1504
+ log.success(`Connection details saved to ~/.synap/chatgpt-oauth.json`);
1505
+ return true;
1506
+ }
1034
1507
  // ─── Windsurf ────────────────────────────────────────────────────────────────
1035
1508
  async function installWindsurf(info, cfg) {
1036
1509
  const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
1037
1510
  const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "windsurf");
1038
- writeMcpServerEntry(configPath, "synap", { url: mcpUrl, headers: { Authorization: `Bearer ${effectiveApiKey}` } });
1511
+ // Pod-derived name — a second pod must not overwrite the first.
1512
+ const windsurfServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1513
+ writeMcpServerEntry(configPath, windsurfServerName, { url: mcpUrl, headers: { Authorization: `Bearer ${effectiveApiKey}` } });
1514
+ if (windsurfServerName !== "synap")
1515
+ removeMcpServerEntry(configPath, "synap");
1039
1516
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1040
1517
  save("windsurf", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1041
1518
  log.success(`Windsurf MCP config updated: ${configPath}`);
@@ -1077,7 +1554,7 @@ async function installGoose(cfg) {
1077
1554
  else {
1078
1555
  updated = (existing ? existing.trimEnd() + "\n\n" : "") + "extensions:\n" + block + "\n";
1079
1556
  }
1080
- fs.writeFileSync(configPath, updated, { mode: 0o600 });
1557
+ writeSecretFile(configPath, updated, { mode: 0o600 });
1081
1558
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1082
1559
  save("goose", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1083
1560
  log.success(`Goose config updated: ${configPath}`);
@@ -1112,16 +1589,21 @@ async function installZed(cfg) {
1112
1589
  catch { /* malformed — start from scratch */ }
1113
1590
  }
1114
1591
  const contextServers = (settings.context_servers ?? {});
1115
- contextServers["synap"] = {
1592
+ // Pod-derived name — a second pod must not overwrite the first.
1593
+ const zedServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1594
+ if (zedServerName !== "synap")
1595
+ delete contextServers["synap"];
1596
+ contextServers[zedServerName] = {
1116
1597
  source: "custom",
1117
1598
  command: {
1118
1599
  path: "npx",
1119
- args: ["-y", "mcp-remote@latest", mcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
1600
+ args: ["-y", "mcp-remote@latest", mcpUrl, "--header", "Authorization:${SYNAP_AUTH}"],
1601
+ env: { SYNAP_AUTH: `Bearer ${effectiveApiKey}` },
1120
1602
  },
1121
1603
  settings: {},
1122
1604
  };
1123
1605
  settings.context_servers = contextServers;
1124
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
1606
+ writeSecretFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
1125
1607
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1126
1608
  save("zed", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1127
1609
  log.success(`Zed settings updated: ${settingsPath}`);
@@ -1152,12 +1634,16 @@ async function installVSCode(cfg) {
1152
1634
  catch { /* ignore */ }
1153
1635
  }
1154
1636
  existing.servers = existing.servers ?? {};
1155
- existing.servers["synap"] = {
1637
+ // Pod-derived name — a second pod must not overwrite the first.
1638
+ const vscodeServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1639
+ if (vscodeServerName !== "synap")
1640
+ delete existing.servers["synap"];
1641
+ existing.servers[vscodeServerName] = {
1156
1642
  type: "http",
1157
1643
  url: mcpUrl,
1158
1644
  headers: { Authorization: `Bearer ${effectiveApiKey}` },
1159
1645
  };
1160
- fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
1646
+ writeSecretFile(mcpJsonPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
1161
1647
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1162
1648
  save("vscode", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1163
1649
  log.success(`VS Code MCP config updated: ${mcpJsonPath}`);
@@ -1165,39 +1651,149 @@ async function installVSCode(cfg) {
1165
1651
  log.dim("Reload VS Code (or open the MCP panel) to activate.");
1166
1652
  return true;
1167
1653
  }
1654
+ /**
1655
+ * Derive a stable MCP server name from the pod URL so multi-pod setups
1656
+ * (perso + team) don't overwrite each other. e.g. pod.thearch.synap.live → synap-thearch
1657
+ */
1658
+ function mcpServerNameFromPodUrl(podUrl, fallback = "synap") {
1659
+ try {
1660
+ const host = new URL(podUrl).hostname; // pod.thearch.synap.live
1661
+ const parts = host.split(".").filter(Boolean);
1662
+ if (parts[0] === "pod" && parts[1])
1663
+ return `synap-${parts[1]}`;
1664
+ if (parts[0])
1665
+ return `synap-${parts[0]}`;
1666
+ }
1667
+ catch {
1668
+ /* ignore */
1669
+ }
1670
+ return fallback;
1671
+ }
1672
+ /** Merge/replace [mcp_servers.<name>] in a Grok config.toml without a TOML dep. */
1673
+ function writeGrokTomlMcpServer(configPath, serverName, opts) {
1674
+ const dir = path.dirname(configPath);
1675
+ if (!fs.existsSync(dir)) {
1676
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1677
+ }
1678
+ let existing = "";
1679
+ if (fs.existsSync(configPath)) {
1680
+ try {
1681
+ existing = fs.readFileSync(configPath, "utf-8");
1682
+ }
1683
+ catch {
1684
+ /* start fresh */
1685
+ }
1686
+ }
1687
+ // Strip any previous section for this server (header through next top-level section)
1688
+ const sectionHeader = `[mcp_servers.${serverName}]`;
1689
+ const sectionRe = new RegExp(
1690
+ // eslint-disable-next-line no-useless-escape
1691
+ `\\n?\\[mcp_servers\\.${serverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
1692
+ let next = existing.replace(sectionRe, "").replace(/\n{3,}/g, "\n\n").trimEnd();
1693
+ const block = [
1694
+ "",
1695
+ sectionHeader,
1696
+ `url = "${opts.url}"`,
1697
+ `headers = { Authorization = "Bearer ${opts.bearer}" }`,
1698
+ `enabled = true`,
1699
+ `startup_timeout_sec = 30`,
1700
+ "",
1701
+ ].join("\n");
1702
+ next = (next ? next + "\n" : "") + block;
1703
+ writeSecretFile(configPath, next.endsWith("\n") ? next : next + "\n", {
1704
+ mode: 0o600,
1705
+ });
1706
+ }
1707
+ // ─── Grok (Grok Build TUI) ───────────────────────────────────────────────────
1708
+ async function installGrok(info, cfg) {
1709
+ const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".grok", "config.toml");
1710
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1711
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "grok");
1712
+ writeGrokTomlMcpServer(configPath, serverName, {
1713
+ url: mcpUrl,
1714
+ bearer: effectiveApiKey,
1715
+ });
1716
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1717
+ save("grok", {
1718
+ hubApiKey: effectiveApiKey,
1719
+ agentUserId,
1720
+ podUrl: cfg.podUrl,
1721
+ });
1722
+ log.success(`Grok MCP config updated: ${configPath}`);
1723
+ log.info(` Server table: [mcp_servers.${serverName}]`);
1724
+ log.info(` URL: ${mcpUrl}`);
1725
+ log.dim(" Agent key provisioned (not the human profile key).");
1726
+ log.blank();
1727
+ if (serverName !== "synap") {
1728
+ log.dim("Multi-pod: connect each profile separately — server names are derived from the pod host so they don't overwrite:");
1729
+ log.dim(" synap connect --target=grok --name=perso");
1730
+ log.dim(" synap connect --target=grok --name=team");
1731
+ }
1732
+ log.dim("Restart Grok (or open a new session) and run: grok mcp doctor");
1733
+ return true;
1734
+ }
1168
1735
  async function installGeneric(cfg) {
1169
- const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1736
+ // Provision a real agent key (same as other MCP surfaces) — never paste the
1737
+ // human profile key into MCP configs (agents can't approve; human keys on MCP
1738
+ // write path are rejected by the pod).
1739
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "generic");
1740
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1170
1741
  const httpConfig = {
1171
- synap: {
1742
+ [serverName]: {
1172
1743
  url: mcpUrl,
1173
1744
  transport: "http",
1174
- headers: { Authorization: `Bearer ${cfg.apiKey}` },
1745
+ headers: { Authorization: `Bearer ${effectiveApiKey}` },
1175
1746
  },
1176
1747
  };
1177
- log.info("Universal MCP connection config:");
1748
+ log.info("Universal MCP connection config (agent key):");
1178
1749
  log.blank();
1179
1750
  log.info("HTTP direct:");
1180
- log.dim(JSON.stringify(httpConfig, null, 2));
1751
+ // Redacted on screen; the real key is in ~/.synap/mcp-config.json (0600).
1752
+ const shown = redactKey(effectiveApiKey);
1753
+ log.dim(JSON.stringify({ [serverName]: { ...httpConfig[serverName], headers: { Authorization: `Bearer ${shown}` } } }, null, 2));
1181
1754
  log.blank();
1182
- log.info("stdio bridge (mcp-remote):");
1183
- log.dim(` command: npx -y mcp-remote ${mcpUrl} --header "Authorization: Bearer ${cfg.apiKey}"`);
1755
+ log.info("Grok Build (~/.grok/config.toml):");
1756
+ log.dim(`[mcp_servers.${serverName}]`);
1757
+ log.dim(`url = "${mcpUrl}"`);
1758
+ log.dim(`headers = { Authorization = "Bearer ${shown}" }`);
1184
1759
  log.blank();
1185
- log.info("Environment variable form:");
1186
- log.dim(` MCP_SERVER_URL=${mcpUrl}`);
1187
- log.dim(` MCP_API_KEY=${cfg.apiKey}`);
1760
+ log.info("stdio bridge (mcp-remote) — key via env, not argv:");
1761
+ log.dim(` command: npx -y mcp-remote ${mcpUrl} --header "Authorization:\${SYNAP_AUTH}"`);
1762
+ log.dim(` env: SYNAP_AUTH="Bearer ${shown}"`);
1763
+ log.dim(" Real key: ~/.synap/mcp-config.json (0600).");
1188
1764
  log.blank();
1189
1765
  const synapDir = path.join(os.homedir(), ".synap");
1190
- if (!fs.existsSync(synapDir)) {
1191
- fs.mkdirSync(synapDir, { recursive: true });
1192
- }
1766
+ ensureSecretDir(synapDir);
1193
1767
  const configFile = path.join(synapDir, "mcp-config.json");
1194
- fs.writeFileSync(configFile, JSON.stringify(httpConfig, null, 2) + "\n", { mode: 0o600 });
1195
- log.success(`MCP config written to ~/.synap/mcp-config.json`);
1768
+ // Merge with existing so dual-pod generic installs don't clobber each other
1769
+ let existing = {};
1770
+ if (fs.existsSync(configFile)) {
1771
+ try {
1772
+ existing = JSON.parse(fs.readFileSync(configFile, "utf-8"));
1773
+ }
1774
+ catch {
1775
+ /* replace */
1776
+ }
1777
+ }
1778
+ const merged = { ...existing, ...httpConfig };
1779
+ writeSecretFile(configFile, JSON.stringify(merged, null, 2) + "\n", {
1780
+ mode: 0o600,
1781
+ });
1782
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1783
+ save("generic", {
1784
+ hubApiKey: effectiveApiKey,
1785
+ agentUserId,
1786
+ podUrl: cfg.podUrl,
1787
+ });
1788
+ log.success(`MCP config written to ~/.synap/mcp-config.json (server: ${serverName})`);
1789
+ log.dim("Prefer: synap connect --target=grok to auto-write ~/.grok/config.toml");
1196
1790
  return true;
1197
1791
  }
1198
1792
  // ─── Codex ───────────────────────────────────────────────────────────────────
1199
1793
  async function installCodex(info, cfg) {
1200
- const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codex", "config.yaml");
1794
+ // Codex reads MCP servers from ~/.codex/config.toml (not config.yaml).
1795
+ // HTTP MCP format: [mcp_servers.synap] with url, headers, type="http"
1796
+ const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codex", "config.toml");
1201
1797
  const configDir = path.dirname(configPath);
1202
1798
  if (!fs.existsSync(configDir)) {
1203
1799
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -1211,27 +1807,36 @@ async function installCodex(info, cfg) {
1211
1807
  catch { /* ignore */ }
1212
1808
  }
1213
1809
  // Build MCP server entry
1214
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "codex");
1810
+ const mint = TARGET_KEY_MINT.codex;
1811
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, mint.agentType, { idempotent: mint.idempotent });
1812
+ const { setSurfaceAgentKey } = await import("./pod.js");
1813
+ setSurfaceAgentKey("codex", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1215
1814
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1216
1815
  const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1217
- // Inject/replace the synap mcpServers block using simple string manipulation
1218
- // (avoid requiring a YAML parser dependency)
1816
+ // Build TOML block for HTTP MCP server
1219
1817
  const mcpBlock = [
1220
- "mcpServers:",
1221
- ` - name: synap`,
1222
- ` url: "${mcpUrl}"`,
1223
- ` headers:`,
1224
- ` Authorization: "Bearer ${effectiveApiKey}"`,
1818
+ "",
1819
+ "[mcp_servers.synap]",
1820
+ `url = "${mcpUrl}"`,
1821
+ `http_headers = { Authorization = "Bearer ${effectiveApiKey}" }`,
1822
+ `startup_timeout_sec = 30`,
1823
+ `enabled = true`,
1824
+ "",
1225
1825
  ].join("\n");
1826
+ // Replace existing synap entry or append
1226
1827
  let updated;
1227
- if (/^mcpServers:/m.test(existing)) {
1228
- // Replace existing mcpServers block (everything from mcpServers: to the next top-level key or EOF)
1229
- updated = existing.replace(/^mcpServers:[\s\S]*?(?=\n[a-zA-Z]|\s*$)/m, mcpBlock);
1828
+ const sectionHeader = "[mcp_servers.synap]";
1829
+ const sectionRe = new RegExp(`\\n?\\[mcp_servers\\.synap\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
1830
+ if (existing.includes(sectionHeader)) {
1831
+ updated = existing.replace(sectionRe, "").replace(/\n{3,}/g, "\n\n").trimEnd();
1832
+ updated = (updated ? updated + "\n" : "") + mcpBlock;
1230
1833
  }
1231
1834
  else {
1232
- updated = existing ? `${existing.trimEnd()}\n\n${mcpBlock}\n` : `${mcpBlock}\n`;
1835
+ updated = existing ? `${existing.trimEnd()}\n${mcpBlock}\n` : mcpBlock;
1233
1836
  }
1234
- fs.writeFileSync(configPath, updated, { mode: 0o600 });
1837
+ if (!updated.endsWith("\n"))
1838
+ updated += "\n";
1839
+ writeSecretFile(configPath, updated, { mode: 0o600 });
1235
1840
  // Skills → append synap context to ~/.codex/instructions.md
1236
1841
  const instructionsPath = path.join(configDir, "instructions.md");
1237
1842
  const marker = "<!-- synap-skill -->";
@@ -1254,7 +1859,7 @@ async function installCodex(info, cfg) {
1254
1859
  "Always call orient (or list workspaces) first to discover workspace IDs.",
1255
1860
  "",
1256
1861
  ].join("\n");
1257
- fs.writeFileSync(instructionsPath, (instructions.trimEnd() + snippet).trimStart() + "\n", { mode: 0o600 });
1862
+ writeSecretFile(instructionsPath, (instructions.trimEnd() + snippet).trimStart() + "\n", { mode: 0o600 });
1258
1863
  }
1259
1864
  log.success(`Codex config updated: ${configPath}`);
1260
1865
  if (cfg.workspaceId) {
@@ -1333,7 +1938,7 @@ export async function installOpencode(cfg) {
1333
1938
  existing = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1334
1939
  }
1335
1940
  catch {
1336
- fs.copyFileSync(configPath, `${configPath}.bak`);
1941
+ copySecretBackup(configPath, `${configPath}.bak`);
1337
1942
  log.warn("Existing opencode.json unreadable — backed up to opencode.json.bak");
1338
1943
  }
1339
1944
  }
@@ -1345,12 +1950,16 @@ export async function installOpencode(cfg) {
1345
1950
  models,
1346
1951
  };
1347
1952
  // Provision an agent key so the MCP server runs under its own identity
1348
- const { hubApiKey: agentKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "opencode");
1953
+ const { hubApiKey: agentKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "opencode", { idempotent: true });
1349
1954
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1350
1955
  const podBase = cfg.podUrl.replace(/\/$/, "");
1351
1956
  const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1352
1957
  const mcpBlock = (existing.mcp ?? {});
1353
- mcpBlock["synap"] = { type: "remote", url: mcpUrl, headers: { Authorization: `Bearer ${agentKey}` } };
1958
+ // Pod-derived name — a second pod must not overwrite the first.
1959
+ const opencodeServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1960
+ if (opencodeServerName !== "synap")
1961
+ delete mcpBlock["synap"];
1962
+ mcpBlock[opencodeServerName] = { type: "remote", url: mcpUrl, headers: { Authorization: `Bearer ${agentKey}` } };
1354
1963
  const updated = {
1355
1964
  $schema: "https://opencode.ai/config.json",
1356
1965
  ...existing,
@@ -1358,7 +1967,7 @@ export async function installOpencode(cfg) {
1358
1967
  mcp: mcpBlock,
1359
1968
  ...(existing.model ? {} : { model: defaultModel }),
1360
1969
  };
1361
- fs.writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n", { mode: 0o600 });
1970
+ writeSecretFile(configPath, JSON.stringify(updated, null, 2) + "\n", { mode: 0o600 });
1362
1971
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1363
1972
  save("opencode", { hubApiKey: agentKey, agentUserId, podUrl: cfg.podUrl });
1364
1973
  log.success(`opencode configured: ~/.config/opencode/opencode.json`);
@@ -1393,7 +2002,7 @@ export async function installAider(cfg) {
1393
2002
  const updated = existing.includes(marker)
1394
2003
  ? existing.replace(/# synap-provider[\s\S]*?(?=\n[a-zA-Z#]|\s*$)/, block.trimEnd())
1395
2004
  : existing ? `${existing.trimEnd()}\n\n${block}` : block;
1396
- fs.writeFileSync(confPath, updated.trimEnd() + "\n", { mode: 0o600 });
2005
+ writeSecretFile(confPath, updated.trimEnd() + "\n", { mode: 0o600 });
1397
2006
  log.success(`${provider.name} configured in ~/.aider.conf.yml`);
1398
2007
  log.dim(` Base URL: ${provider.baseUrl}`);
1399
2008
  log.dim(` Model: openai/${defaultModel}`);
@@ -1415,16 +2024,55 @@ export function writeMcpServerEntry(configPath, serverName, server) {
1415
2024
  catch {
1416
2025
  // corrupt or unreadable — back up and start fresh
1417
2026
  const backup = `${configPath}.bak-${Date.now()}`;
1418
- fs.copyFileSync(configPath, backup);
2027
+ copySecretBackup(configPath, backup);
1419
2028
  log.warn(`Existing config unreadable; backed up to ${path.basename(backup)}`);
1420
2029
  }
1421
2030
  }
1422
2031
  config.mcpServers = config.mcpServers ?? {};
1423
2032
  config.mcpServers[serverName] = server;
1424
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", {
2033
+ writeSecretFile(configPath, JSON.stringify(config, null, 2) + "\n", {
1425
2034
  mode: 0o600,
1426
2035
  });
1427
2036
  }
2037
+ /** Best-effort remove of a named MCP server entry (e.g. legacy `"synap"`). */
2038
+ function removeMcpServerEntry(configPath, serverName) {
2039
+ if (!fs.existsSync(configPath))
2040
+ return;
2041
+ try {
2042
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
2043
+ if (!config.mcpServers?.[serverName])
2044
+ return;
2045
+ delete config.mcpServers[serverName];
2046
+ if (Object.keys(config.mcpServers).length === 0)
2047
+ delete config.mcpServers;
2048
+ writeSecretFile(configPath, JSON.stringify(config, null, 2) + "\n", {
2049
+ mode: 0o600,
2050
+ });
2051
+ }
2052
+ catch {
2053
+ /* leave config alone if unreadable */
2054
+ }
2055
+ }
2056
+ /** The prompt's first choice: write nothing, follow the pod default. */
2057
+ const POD_DEFAULT_CHOICE = "pod-default";
2058
+ /** The pod posture name each named preset maps to. */
2059
+ const PRESET_POSTURE = {
2060
+ creates: "create-with-undo",
2061
+ };
2062
+ /**
2063
+ * Read an agent's governance, or `null` when the pod has no such read door
2064
+ * (a pod older than it: 404/405) — "unknown", never "unset".
2065
+ */
2066
+ async function readAgentGovernance(agentUserId, hubCfg) {
2067
+ try {
2068
+ return (await hubGet(`/agent-users/${agentUserId}/governance`, {}, hubCfg));
2069
+ }
2070
+ catch (err) {
2071
+ if (err instanceof HubError && (err.status === 404 || err.status === 405))
2072
+ return null;
2073
+ throw err;
2074
+ }
2075
+ }
1428
2076
  // Governance presets — MUST stay in sync with GOVERNANCE_MODES in the backend
1429
2077
  // `@synap/governance-policy`. (Single-source TODO: have the backend resolve a
1430
2078
  // mode NAME so the CLI never re-states the autoApproveFor list — until then,
@@ -1465,11 +2113,37 @@ const GOVERNANCE_PRESETS = {
1465
2113
  writesRequireProposal: false,
1466
2114
  },
1467
2115
  };
1468
- export async function ensureAgentGovernance(cfg, agentUserId, presetMode) {
2116
+ export async function ensureAgentGovernance(cfg, agentUserId, presetMode, opts = {}) {
1469
2117
  const userId = await resolveUserId(cfg);
1470
2118
  const hubCfg = { ...cfg, userId };
1471
- // Non-interactive when a preset is passed (e.g. `--governance normal`);
1472
- // otherwise prompt (Normal pre-selected, Enter = <1s).
2119
+ let current = null;
2120
+ // A failed read (vs a pod that has no read door) is its own fact: the
2121
+ // strict fallback below is the same, but the message must not claim the
2122
+ // pod is old.
2123
+ let readFailed = false;
2124
+ try {
2125
+ current = await readAgentGovernance(agentUserId, hubCfg);
2126
+ }
2127
+ catch (err) {
2128
+ readFailed = true;
2129
+ log.warn(`Could not read agent approval: ${err.message}`);
2130
+ if (opts.onlyIfUnset)
2131
+ return; // unknown ⇒ never overwrite
2132
+ }
2133
+ if (opts.onlyIfUnset && current === null && opts.knownAgent) {
2134
+ log.dim("Kept this agent's approval settings.");
2135
+ return;
2136
+ }
2137
+ if (opts.onlyIfUnset && current?.configured) {
2138
+ log.dim(current.posture === "create-with-undo"
2139
+ ? 'Agent approval: "creates" — creating happens at once with Undo; changes and deletes wait for you.'
2140
+ : "Kept this agent's approval settings.");
2141
+ return;
2142
+ }
2143
+ // Non-interactive when a preset is passed (e.g. `--governance creates`);
2144
+ // otherwise prompt. "Follow the pod default" is pre-selected and writes
2145
+ // nothing: a new agent's trust is the pod's rule, a preset is only ever a
2146
+ // stricter (or looser) explicit choice.
1473
2147
  let mode = presetMode;
1474
2148
  if (!mode) {
1475
2149
  const res = await prompts({
@@ -1477,13 +2151,23 @@ export async function ensureAgentGovernance(cfg, agentUserId, presetMode) {
1477
2151
  name: "mode",
1478
2152
  message: "Governance — how should this agent's actions be gated?",
1479
2153
  choices: [
2154
+ {
2155
+ title: "Follow the pod default (recommended)",
2156
+ description: "Uses your pod's trust setting (Settings › Approvals); change it there anytime.",
2157
+ value: POD_DEFAULT_CHOICE,
2158
+ },
1480
2159
  {
1481
2160
  title: "Safe — every change requires your approval in Synap Studio",
1482
2161
  description: "Creates, updates, and deletes all go through proposals. Maximum control.",
1483
2162
  value: "safe",
1484
2163
  },
1485
2164
  {
1486
- title: "Normal — creating & editing are instant, deletes need approval (recommended)",
2165
+ title: "Creates — creating is instant with Undo; changes and deletes need approval",
2166
+ description: "Agents add new things at once and you can undo each one; edits, new fields, automations and deletes go through proposals.",
2167
+ value: "creates",
2168
+ },
2169
+ {
2170
+ title: "Normal — creating & editing are instant, deletes need approval",
1487
2171
  description: "Agents create and edit data immediately (edits merge, never wipe fields); deletes and full-content rewrites go through proposals.",
1488
2172
  value: "normal",
1489
2173
  },
@@ -1493,19 +2177,39 @@ export async function ensureAgentGovernance(cfg, agentUserId, presetMode) {
1493
2177
  value: "crazy",
1494
2178
  },
1495
2179
  ],
1496
- initial: 1,
2180
+ initial: 0,
1497
2181
  });
1498
2182
  if (!res.mode)
1499
2183
  return;
2184
+ if (res.mode === POD_DEFAULT_CHOICE) {
2185
+ log.dim("Agent approval: follows the pod default.");
2186
+ return;
2187
+ }
1500
2188
  mode = res.mode;
1501
2189
  }
1502
- const profile = GOVERNANCE_PRESETS[mode] ?? GOVERNANCE_PRESETS.normal;
2190
+ const posture = PRESET_POSTURE[mode];
2191
+ // A pod without the read door (`current === null` after a clean read) also
2192
+ // predates named postures: it would ignore `posture` and loosen the agent.
2193
+ // Fall back to the strictest legacy list rather than guess.
2194
+ const podKnowsPostures = current !== null;
2195
+ const legacyMode = mode === "creates" ? "safe" : mode;
2196
+ const profile = GOVERNANCE_PRESETS[legacyMode] ?? GOVERNANCE_PRESETS.safe;
1503
2197
  try {
1504
- await hubPatch(`/agent-users/${agentUserId}/governance`, {
1505
- autoApproveFor: profile.autoApproveFor,
1506
- writesRequireProposal: profile.writesRequireProposal,
1507
- }, hubCfg);
1508
- log.success(`Agent governance set to "${mode}"`);
2198
+ await hubPatch(`/agent-users/${agentUserId}/governance`, posture && podKnowsPostures
2199
+ ? { posture }
2200
+ : {
2201
+ autoApproveFor: profile.autoApproveFor,
2202
+ writesRequireProposal: profile.writesRequireProposal,
2203
+ }, hubCfg);
2204
+ log.success(posture && podKnowsPostures
2205
+ ? 'Agent approval: "creates". Creating happens at once with Undo; changes, new fields, automations and deletes wait for you.'
2206
+ : mode === "creates"
2207
+ ? readFailed
2208
+ ? 'Agent approval: "safe" — could not read this pod\'s approval settings, so every change waits for you.'
2209
+ : 'Agent approval: "safe" — this pod predates "creates", so every change waits for you.'
2210
+ : legacyMode === "normal"
2211
+ ? 'Agent approval: "normal". Creating and editing (including profile and property changes and new automations) happen at once; deletes wait for you.'
2212
+ : `Agent approval: "${legacyMode}".`);
1509
2213
  }
1510
2214
  catch (err) {
1511
2215
  log.warn(`Could not apply agent governance: ${err.message}`);