@synap-core/cli 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (533) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -198
  3. package/dist/build-info.json +6 -0
  4. package/dist/commands/agent-run.js +3 -2
  5. package/dist/commands/agent-run.js.map +1 -1
  6. package/dist/commands/agents.d.ts +32 -17
  7. package/dist/commands/agents.js +203 -237
  8. package/dist/commands/agents.js.map +1 -1
  9. package/dist/commands/app.d.ts +143 -0
  10. package/dist/commands/app.js +524 -0
  11. package/dist/commands/app.js.map +1 -0
  12. package/dist/commands/automate.d.ts +21 -0
  13. package/dist/commands/automate.js +42 -0
  14. package/dist/commands/automate.js.map +1 -0
  15. package/dist/commands/automation-schema.test.d.ts +16 -0
  16. package/dist/commands/automation-schema.test.js +77 -0
  17. package/dist/commands/automation-schema.test.js.map +1 -0
  18. package/dist/commands/automation.d.ts +12 -0
  19. package/dist/commands/automation.js +116 -66
  20. package/dist/commands/automation.js.map +1 -1
  21. package/dist/commands/box.d.ts +42 -0
  22. package/dist/commands/box.js +89 -0
  23. package/dist/commands/box.js.map +1 -0
  24. package/dist/commands/bridge-setup.d.ts +14 -1
  25. package/dist/commands/bridge-setup.js +395 -129
  26. package/dist/commands/bridge-setup.js.map +1 -1
  27. package/dist/commands/cap-connections.d.ts +4 -4
  28. package/dist/commands/cap-connections.js +14 -14
  29. package/dist/commands/cap-connections.js.map +1 -1
  30. package/dist/commands/capability.d.ts +3 -1
  31. package/dist/commands/capability.js +249 -176
  32. package/dist/commands/capability.js.map +1 -1
  33. package/dist/commands/catalog.d.ts +275 -0
  34. package/dist/commands/catalog.js +481 -0
  35. package/dist/commands/catalog.js.map +1 -0
  36. package/dist/commands/cell.d.ts +155 -8
  37. package/dist/commands/cell.js +416 -40
  38. package/dist/commands/cell.js.map +1 -1
  39. package/dist/commands/centrality.js +1 -13
  40. package/dist/commands/centrality.js.map +1 -1
  41. package/dist/commands/close-questions.d.ts +27 -0
  42. package/dist/commands/close-questions.js +49 -0
  43. package/dist/commands/close-questions.js.map +1 -0
  44. package/dist/commands/close-questions.test.d.ts +1 -0
  45. package/dist/commands/close-questions.test.js +52 -0
  46. package/dist/commands/close-questions.test.js.map +1 -0
  47. package/dist/commands/connect.d.ts +36 -13
  48. package/dist/commands/connect.js +68 -167
  49. package/dist/commands/connect.js.map +1 -1
  50. package/dist/commands/connections.d.ts +3 -2
  51. package/dist/commands/connections.js +95 -25
  52. package/dist/commands/connections.js.map +1 -1
  53. package/dist/commands/context.js +79 -44
  54. package/dist/commands/context.js.map +1 -1
  55. package/dist/commands/data-extra.d.ts +34 -0
  56. package/dist/commands/data-extra.js +203 -25
  57. package/dist/commands/data-extra.js.map +1 -1
  58. package/dist/commands/data.d.ts +120 -2
  59. package/dist/commands/data.js +295 -21
  60. package/dist/commands/data.js.map +1 -1
  61. package/dist/commands/diagnose.d.ts +66 -4
  62. package/dist/commands/diagnose.js +285 -28
  63. package/dist/commands/diagnose.js.map +1 -1
  64. package/dist/commands/doc.d.ts +11 -4
  65. package/dist/commands/doc.js +39 -14
  66. package/dist/commands/doc.js.map +1 -1
  67. package/dist/commands/doctor.d.ts +133 -3
  68. package/dist/commands/doctor.js +761 -41
  69. package/dist/commands/doctor.js.map +1 -1
  70. package/dist/commands/explain.js +5 -5
  71. package/dist/commands/explain.js.map +1 -1
  72. package/dist/commands/hooks.d.ts +19 -0
  73. package/dist/commands/hooks.js +70 -0
  74. package/dist/commands/hooks.js.map +1 -0
  75. package/dist/commands/import.d.ts +393 -13
  76. package/dist/commands/import.js +1150 -54
  77. package/dist/commands/import.js.map +1 -1
  78. package/dist/commands/init.d.ts +34 -9
  79. package/dist/commands/init.js +481 -981
  80. package/dist/commands/init.js.map +1 -1
  81. package/dist/commands/keys.d.ts +1 -0
  82. package/dist/commands/keys.js +15 -2
  83. package/dist/commands/keys.js.map +1 -1
  84. package/dist/commands/knowledge.d.ts +22 -6
  85. package/dist/commands/knowledge.js +121 -57
  86. package/dist/commands/knowledge.js.map +1 -1
  87. package/dist/commands/lens.d.ts +37 -1
  88. package/dist/commands/lens.js +194 -72
  89. package/dist/commands/lens.js.map +1 -1
  90. package/dist/commands/market-authoring.d.ts +29 -2
  91. package/dist/commands/market-authoring.js +348 -7
  92. package/dist/commands/market-authoring.js.map +1 -1
  93. package/dist/commands/market-official.d.ts +58 -0
  94. package/dist/commands/market-official.js +84 -0
  95. package/dist/commands/market-official.js.map +1 -0
  96. package/dist/commands/market.d.ts +400 -16
  97. package/dist/commands/market.js +1802 -146
  98. package/dist/commands/market.js.map +1 -1
  99. package/dist/commands/mcp.d.ts +60 -25
  100. package/dist/commands/mcp.js +251 -64
  101. package/dist/commands/mcp.js.map +1 -1
  102. package/dist/commands/observe.category.test.d.ts +1 -0
  103. package/dist/commands/observe.category.test.js +59 -0
  104. package/dist/commands/observe.category.test.js.map +1 -0
  105. package/dist/commands/observe.d.ts +3 -0
  106. package/dist/commands/observe.js +7 -5
  107. package/dist/commands/observe.js.map +1 -1
  108. package/dist/commands/observe.title.test.d.ts +1 -0
  109. package/dist/commands/observe.title.test.js +37 -0
  110. package/dist/commands/observe.title.test.js.map +1 -0
  111. package/dist/commands/open.d.ts +39 -9
  112. package/dist/commands/open.js +80 -25
  113. package/dist/commands/open.js.map +1 -1
  114. package/dist/commands/playbook.d.ts +42 -0
  115. package/dist/commands/playbook.js +171 -0
  116. package/dist/commands/playbook.js.map +1 -0
  117. package/dist/commands/pod.js +1 -1
  118. package/dist/commands/pod.js.map +1 -1
  119. package/dist/commands/pods.d.ts +11 -1
  120. package/dist/commands/pods.js +129 -131
  121. package/dist/commands/pods.js.map +1 -1
  122. package/dist/commands/proposals-actions.d.ts +62 -0
  123. package/dist/commands/proposals-actions.js +143 -8
  124. package/dist/commands/proposals-actions.js.map +1 -1
  125. package/dist/commands/proposals-await.d.ts +68 -0
  126. package/dist/commands/proposals-await.js +248 -0
  127. package/dist/commands/proposals-await.js.map +1 -0
  128. package/dist/commands/proton-setup.d.ts +73 -0
  129. package/dist/commands/proton-setup.js +579 -0
  130. package/dist/commands/proton-setup.js.map +1 -0
  131. package/dist/commands/providers.d.ts +23 -0
  132. package/dist/commands/providers.js +126 -1
  133. package/dist/commands/providers.js.map +1 -1
  134. package/dist/commands/raycast.js +6 -2
  135. package/dist/commands/raycast.js.map +1 -1
  136. package/dist/commands/rule.d.ts +150 -0
  137. package/dist/commands/rule.js +447 -0
  138. package/dist/commands/rule.js.map +1 -0
  139. package/dist/commands/session-feed-wait.d.ts +48 -0
  140. package/dist/commands/session-feed-wait.js +261 -0
  141. package/dist/commands/session-feed-wait.js.map +1 -0
  142. package/dist/commands/session-post.d.ts +40 -0
  143. package/dist/commands/session-post.js +96 -0
  144. package/dist/commands/session-post.js.map +1 -0
  145. package/dist/commands/session-wait.d.ts +116 -0
  146. package/dist/commands/session-wait.js +224 -0
  147. package/dist/commands/session-wait.js.map +1 -0
  148. package/dist/commands/sessions.d.ts +54 -0
  149. package/dist/commands/sessions.js +501 -58
  150. package/dist/commands/sessions.js.map +1 -1
  151. package/dist/commands/skill-manage.d.ts +21 -0
  152. package/dist/commands/skill-manage.js +136 -19
  153. package/dist/commands/skill-manage.js.map +1 -1
  154. package/dist/commands/skills-commands.d.ts +2 -0
  155. package/dist/commands/skills-commands.js +9 -1
  156. package/dist/commands/skills-commands.js.map +1 -1
  157. package/dist/commands/skills-commands.test.d.ts +1 -0
  158. package/dist/commands/skills-commands.test.js +57 -0
  159. package/dist/commands/skills-commands.test.js.map +1 -0
  160. package/dist/commands/skills.js +16 -0
  161. package/dist/commands/skills.js.map +1 -1
  162. package/dist/commands/status.d.ts +1 -4
  163. package/dist/commands/status.js +6 -35
  164. package/dist/commands/status.js.map +1 -1
  165. package/dist/commands/statusline.backoff.test.d.ts +13 -0
  166. package/dist/commands/statusline.backoff.test.js +100 -0
  167. package/dist/commands/statusline.backoff.test.js.map +1 -0
  168. package/dist/commands/statusline.js +16 -9
  169. package/dist/commands/statusline.js.map +1 -1
  170. package/dist/commands/sync-status.d.ts +12 -0
  171. package/dist/commands/sync-status.js +125 -0
  172. package/dist/commands/sync-status.js.map +1 -0
  173. package/dist/commands/templates-retire.d.ts +34 -0
  174. package/dist/commands/templates-retire.js +192 -0
  175. package/dist/commands/templates-retire.js.map +1 -0
  176. package/dist/commands/tools.js +6 -21
  177. package/dist/commands/tools.js.map +1 -1
  178. package/dist/commands/tracks.d.ts +112 -0
  179. package/dist/commands/tracks.js +543 -0
  180. package/dist/commands/tracks.js.map +1 -0
  181. package/dist/commands/update.d.ts +3 -11
  182. package/dist/commands/update.js +5 -134
  183. package/dist/commands/update.js.map +1 -1
  184. package/dist/commands/upload.d.ts +41 -3
  185. package/dist/commands/upload.js +120 -18
  186. package/dist/commands/upload.js.map +1 -1
  187. package/dist/commands/vendor.d.ts +28 -0
  188. package/dist/commands/vendor.js +110 -0
  189. package/dist/commands/vendor.js.map +1 -0
  190. package/dist/commands/versions.js +6 -0
  191. package/dist/commands/versions.js.map +1 -1
  192. package/dist/commands/view.js +10 -5
  193. package/dist/commands/view.js.map +1 -1
  194. package/dist/commands/whoami.d.ts +30 -3
  195. package/dist/commands/whoami.js +186 -23
  196. package/dist/commands/whoami.js.map +1 -1
  197. package/dist/commands/worker-accounts.d.ts +149 -0
  198. package/dist/commands/worker-accounts.js +339 -0
  199. package/dist/commands/worker-accounts.js.map +1 -0
  200. package/dist/commands/worker-accounts.test.d.ts +1 -0
  201. package/dist/commands/worker-accounts.test.js +224 -0
  202. package/dist/commands/worker-accounts.test.js.map +1 -0
  203. package/dist/commands/worker.d.ts +331 -0
  204. package/dist/commands/worker.js +1067 -0
  205. package/dist/commands/worker.js.map +1 -0
  206. package/dist/commands/worker.test.d.ts +1 -0
  207. package/dist/commands/worker.test.js +427 -0
  208. package/dist/commands/worker.test.js.map +1 -0
  209. package/dist/commands/workspace-ops.d.ts +35 -0
  210. package/dist/commands/workspace-ops.js +153 -0
  211. package/dist/commands/workspace-ops.js.map +1 -0
  212. package/dist/index.d.ts +6 -6
  213. package/dist/index.js +1043 -186
  214. package/dist/index.js.map +1 -1
  215. package/dist/lib/agent-connections.d.ts +31 -0
  216. package/dist/lib/agent-connections.js +33 -0
  217. package/dist/lib/agent-connections.js.map +1 -0
  218. package/dist/lib/agent-detect.d.ts +27 -0
  219. package/dist/lib/agent-detect.js +71 -0
  220. package/dist/lib/agent-detect.js.map +1 -0
  221. package/dist/lib/agent-door.d.ts +136 -0
  222. package/dist/lib/agent-door.js +429 -0
  223. package/dist/lib/agent-door.js.map +1 -0
  224. package/dist/lib/agents-config.d.ts +2 -0
  225. package/dist/lib/agents-config.js +4 -9
  226. package/dist/lib/agents-config.js.map +1 -1
  227. package/dist/lib/approval-poll.d.ts +31 -2
  228. package/dist/lib/approval-poll.js +20 -7
  229. package/dist/lib/approval-poll.js.map +1 -1
  230. package/dist/lib/atomic-file.d.ts +48 -0
  231. package/dist/lib/atomic-file.js +129 -0
  232. package/dist/lib/atomic-file.js.map +1 -0
  233. package/dist/lib/auth.d.ts +17 -11
  234. package/dist/lib/auth.js +47 -64
  235. package/dist/lib/auth.js.map +1 -1
  236. package/dist/lib/batch-approval.d.ts +85 -0
  237. package/dist/lib/batch-approval.js +170 -0
  238. package/dist/lib/batch-approval.js.map +1 -0
  239. package/dist/lib/box/egress.d.ts +82 -0
  240. package/dist/lib/box/egress.js +264 -0
  241. package/dist/lib/box/egress.js.map +1 -0
  242. package/dist/lib/box/evidence.d.ts +86 -0
  243. package/dist/lib/box/evidence.js +121 -0
  244. package/dist/lib/box/evidence.js.map +1 -0
  245. package/dist/lib/box/job-server.d.ts +72 -0
  246. package/dist/lib/box/job-server.js +246 -0
  247. package/dist/lib/box/job-server.js.map +1 -0
  248. package/dist/lib/box/provisioner.d.ts +83 -0
  249. package/dist/lib/box/provisioner.js +312 -0
  250. package/dist/lib/box/provisioner.js.map +1 -0
  251. package/dist/lib/box/proxmox.d.ts +112 -0
  252. package/dist/lib/box/proxmox.js +211 -0
  253. package/dist/lib/box/proxmox.js.map +1 -0
  254. package/dist/lib/box/wire.d.ts +98 -0
  255. package/dist/lib/box/wire.js +70 -0
  256. package/dist/lib/box/wire.js.map +1 -0
  257. package/dist/lib/capture-lane.d.ts +6 -5
  258. package/dist/lib/capture-lane.js +6 -5
  259. package/dist/lib/capture-lane.js.map +1 -1
  260. package/dist/lib/capture-structure.d.ts +230 -11
  261. package/dist/lib/capture-structure.js +251 -13
  262. package/dist/lib/capture-structure.js.map +1 -1
  263. package/dist/lib/catalog-templates.d.ts +59 -0
  264. package/dist/lib/catalog-templates.js +126 -0
  265. package/dist/lib/catalog-templates.js.map +1 -0
  266. package/dist/lib/cli-build.d.ts +19 -0
  267. package/dist/lib/cli-build.js +37 -0
  268. package/dist/lib/cli-build.js.map +1 -0
  269. package/dist/lib/client-header.test.d.ts +1 -0
  270. package/dist/lib/client-header.test.js +85 -0
  271. package/dist/lib/client-header.test.js.map +1 -0
  272. package/dist/lib/conversation-headers.d.ts +33 -0
  273. package/dist/lib/conversation-headers.js +69 -0
  274. package/dist/lib/conversation-headers.js.map +1 -0
  275. package/dist/lib/cp-packages.d.ts +167 -11
  276. package/dist/lib/cp-packages.js +253 -21
  277. package/dist/lib/cp-packages.js.map +1 -1
  278. package/dist/lib/credential-class.d.ts +144 -0
  279. package/dist/lib/credential-class.js +138 -0
  280. package/dist/lib/credential-class.js.map +1 -0
  281. package/dist/lib/describe-lens.d.ts +35 -0
  282. package/dist/lib/describe-lens.js +40 -0
  283. package/dist/lib/describe-lens.js.map +1 -1
  284. package/dist/lib/device-connect.d.ts +125 -0
  285. package/dist/lib/device-connect.js +256 -0
  286. package/dist/lib/device-connect.js.map +1 -0
  287. package/dist/lib/directory-lens.d.ts +99 -0
  288. package/dist/lib/directory-lens.js +187 -0
  289. package/dist/lib/directory-lens.js.map +1 -0
  290. package/dist/lib/exporter-coverage.d.ts +71 -0
  291. package/dist/lib/exporter-coverage.js +97 -0
  292. package/dist/lib/exporter-coverage.js.map +1 -0
  293. package/dist/lib/goal-words.d.ts +38 -0
  294. package/dist/lib/goal-words.js +112 -0
  295. package/dist/lib/goal-words.js.map +1 -0
  296. package/dist/lib/hub-client.d.ts +57 -15
  297. package/dist/lib/hub-client.js +396 -82
  298. package/dist/lib/hub-client.js.map +1 -1
  299. package/dist/lib/init-connect.d.ts +41 -0
  300. package/dist/lib/init-connect.js +75 -0
  301. package/dist/lib/init-connect.js.map +1 -0
  302. package/dist/lib/init-exit.d.ts +24 -0
  303. package/dist/lib/init-exit.js +41 -0
  304. package/dist/lib/init-exit.js.map +1 -0
  305. package/dist/lib/init-flow.d.ts +25 -0
  306. package/dist/lib/init-flow.js +40 -0
  307. package/dist/lib/init-flow.js.map +1 -0
  308. package/dist/lib/init-handoff.d.ts +24 -0
  309. package/dist/lib/init-handoff.js +26 -0
  310. package/dist/lib/init-handoff.js.map +1 -0
  311. package/dist/lib/init-output.d.ts +18 -0
  312. package/dist/lib/init-output.js +43 -0
  313. package/dist/lib/init-output.js.map +1 -0
  314. package/dist/lib/init-pod.d.ts +45 -0
  315. package/dist/lib/init-pod.js +36 -0
  316. package/dist/lib/init-pod.js.map +1 -0
  317. package/dist/lib/installed.d.ts +210 -11
  318. package/dist/lib/installed.js +234 -36
  319. package/dist/lib/installed.js.map +1 -1
  320. package/dist/lib/key-approval.d.ts +57 -0
  321. package/dist/lib/key-approval.js +117 -0
  322. package/dist/lib/key-approval.js.map +1 -0
  323. package/dist/lib/key-diagnosis.d.ts +56 -0
  324. package/dist/lib/key-diagnosis.js +227 -0
  325. package/dist/lib/key-diagnosis.js.map +1 -0
  326. package/dist/lib/key-doctor.d.ts +136 -0
  327. package/dist/lib/key-doctor.js +286 -0
  328. package/dist/lib/key-doctor.js.map +1 -0
  329. package/dist/lib/key-locations.d.ts +120 -0
  330. package/dist/lib/key-locations.js +326 -0
  331. package/dist/lib/key-locations.js.map +1 -0
  332. package/dist/lib/key-source.d.ts +68 -0
  333. package/dist/lib/key-source.js +109 -0
  334. package/dist/lib/key-source.js.map +1 -0
  335. package/dist/lib/key-state.d.ts +70 -0
  336. package/dist/lib/key-state.js +115 -0
  337. package/dist/lib/key-state.js.map +1 -0
  338. package/dist/lib/kind-package.d.ts +181 -0
  339. package/dist/lib/kind-package.js +679 -0
  340. package/dist/lib/kind-package.js.map +1 -0
  341. package/dist/lib/knowledge-title.d.ts +25 -0
  342. package/dist/lib/knowledge-title.js +47 -0
  343. package/dist/lib/knowledge-title.js.map +1 -0
  344. package/dist/lib/knowledge-title.test.d.ts +1 -0
  345. package/dist/lib/knowledge-title.test.js +34 -0
  346. package/dist/lib/knowledge-title.test.js.map +1 -0
  347. package/dist/lib/machine-instance.d.ts +64 -0
  348. package/dist/lib/machine-instance.js +142 -0
  349. package/dist/lib/machine-instance.js.map +1 -0
  350. package/dist/lib/mcp-bridge.d.ts +49 -0
  351. package/dist/lib/mcp-bridge.js +180 -0
  352. package/dist/lib/mcp-bridge.js.map +1 -0
  353. package/dist/lib/mcp-bridge.test.d.ts +1 -0
  354. package/dist/lib/mcp-bridge.test.js +118 -0
  355. package/dist/lib/mcp-bridge.test.js.map +1 -0
  356. package/dist/lib/mcp-call.d.ts +30 -0
  357. package/dist/lib/mcp-call.js +107 -0
  358. package/dist/lib/mcp-call.js.map +1 -0
  359. package/dist/lib/mcp-snippets.d.ts +53 -0
  360. package/dist/lib/mcp-snippets.js +82 -0
  361. package/dist/lib/mcp-snippets.js.map +1 -0
  362. package/dist/lib/next-steps.d.ts +8 -0
  363. package/dist/lib/next-steps.js +31 -1
  364. package/dist/lib/next-steps.js.map +1 -1
  365. package/dist/lib/openclaw.js +3 -2
  366. package/dist/lib/openclaw.js.map +1 -1
  367. package/dist/lib/package-wire.d.ts +179 -0
  368. package/dist/lib/package-wire.js +68 -0
  369. package/dist/lib/package-wire.js.map +1 -0
  370. package/dist/lib/pair-link.d.ts +38 -0
  371. package/dist/lib/pair-link.js +68 -0
  372. package/dist/lib/pair-link.js.map +1 -0
  373. package/dist/lib/pod-moved.d.ts +70 -0
  374. package/dist/lib/pod-moved.js +146 -0
  375. package/dist/lib/pod-moved.js.map +1 -0
  376. package/dist/lib/pod-reach.d.ts +39 -0
  377. package/dist/lib/pod-reach.js +117 -0
  378. package/dist/lib/pod-reach.js.map +1 -0
  379. package/dist/lib/pod-reach.test.d.ts +1 -0
  380. package/dist/lib/pod-reach.test.js +67 -0
  381. package/dist/lib/pod-reach.test.js.map +1 -0
  382. package/dist/lib/pod-wide.d.ts +40 -0
  383. package/dist/lib/pod-wide.js +46 -0
  384. package/dist/lib/pod-wide.js.map +1 -0
  385. package/dist/lib/pod.d.ts +74 -112
  386. package/dist/lib/pod.js +146 -415
  387. package/dist/lib/pod.js.map +1 -1
  388. package/dist/lib/project-ref.d.ts +9 -3
  389. package/dist/lib/project-ref.js +10 -4
  390. package/dist/lib/project-ref.js.map +1 -1
  391. package/dist/lib/register-claude-code-mcp.test.d.ts +1 -0
  392. package/dist/lib/register-claude-code-mcp.test.js +78 -0
  393. package/dist/lib/register-claude-code-mcp.test.js.map +1 -0
  394. package/dist/lib/review-credential.d.ts +119 -0
  395. package/dist/lib/review-credential.js +149 -0
  396. package/dist/lib/review-credential.js.map +1 -0
  397. package/dist/lib/safe-open.d.ts +49 -0
  398. package/dist/lib/safe-open.js +119 -0
  399. package/dist/lib/safe-open.js.map +1 -0
  400. package/dist/lib/session-feed.d.ts +185 -0
  401. package/dist/lib/session-feed.js +257 -0
  402. package/dist/lib/session-feed.js.map +1 -0
  403. package/dist/lib/session-lens.d.ts +40 -1
  404. package/dist/lib/session-lens.js +54 -4
  405. package/dist/lib/session-lens.js.map +1 -1
  406. package/dist/lib/superwhisper-import.d.ts +8 -0
  407. package/dist/lib/superwhisper-import.js +14 -7
  408. package/dist/lib/superwhisper-import.js.map +1 -1
  409. package/dist/lib/superwhisper-title.test.d.ts +1 -0
  410. package/dist/lib/superwhisper-title.test.js +18 -0
  411. package/dist/lib/superwhisper-title.test.js.map +1 -0
  412. package/dist/lib/targets.d.ts +152 -10
  413. package/dist/lib/targets.js +974 -325
  414. package/dist/lib/targets.js.map +1 -1
  415. package/dist/lib/template-file.d.ts +4 -0
  416. package/dist/lib/template-file.js.map +1 -1
  417. package/dist/lib/template-lifecycle.d.ts +217 -0
  418. package/dist/lib/template-lifecycle.js +433 -0
  419. package/dist/lib/template-lifecycle.js.map +1 -0
  420. package/dist/lib/template-steps.d.ts +88 -0
  421. package/dist/lib/template-steps.js +118 -0
  422. package/dist/lib/template-steps.js.map +1 -0
  423. package/dist/lib/time-ago.d.ts +2 -0
  424. package/dist/lib/time-ago.js +15 -0
  425. package/dist/lib/time-ago.js.map +1 -0
  426. package/dist/lib/wake-hook-settings.d.ts +57 -0
  427. package/dist/lib/wake-hook-settings.js +153 -0
  428. package/dist/lib/wake-hook-settings.js.map +1 -0
  429. package/dist/lib/wake-state.d.ts +65 -0
  430. package/dist/lib/wake-state.js +122 -0
  431. package/dist/lib/wake-state.js.map +1 -0
  432. package/dist/utils/logger.js +5 -2
  433. package/dist/utils/logger.js.map +1 -1
  434. package/package.json +26 -17
  435. package/skills/README.md +13 -11
  436. package/skills/agent-os/SKILL.md +214 -99
  437. package/skills/manifest.json +1 -1
  438. package/skills/onboard/SKILL.md +55 -26
  439. package/skills/synap/README.md +5 -8
  440. package/skills/synap/SKILL.md +1149 -289
  441. package/skills/synap/_order.txt +7 -0
  442. package/skills/synap/_skill.yaml +1 -1
  443. package/skills/synap/automations.md +15 -0
  444. package/skills/synap/capture.md +5 -0
  445. package/skills/synap/cli-operations.md +24 -18
  446. package/skills/synap/code-skills.md +70 -0
  447. package/skills/synap/concepts.md +36 -0
  448. package/skills/synap/content-os.md +10 -1
  449. package/skills/synap/creative-loop.md +15 -10
  450. package/skills/synap/crm.md +21 -52
  451. package/skills/synap/document-embeds.md +143 -47
  452. package/skills/synap/escalation-ladder.md +20 -10
  453. package/skills/synap/focus-sessions.md +56 -21
  454. package/skills/synap/from-intent.md +158 -0
  455. package/skills/synap/governance-rules.md +5 -5
  456. package/skills/synap/governance.md +10 -7
  457. package/skills/synap/hand-off.md +33 -0
  458. package/skills/synap/inline-patterns.md +15 -7
  459. package/skills/synap/lenses.md +34 -16
  460. package/skills/synap/linking.md +42 -41
  461. package/skills/synap/mcp-remote-servers.md +64 -0
  462. package/skills/synap/mental-model.md +17 -15
  463. package/skills/synap/operating-mode-rules.md +2 -2
  464. package/skills/synap/processes.md +118 -0
  465. package/skills/synap/quick-reference.md +7 -2
  466. package/skills/synap/reading.md +3 -1
  467. package/skills/synap/reflexes.md +12 -42
  468. package/skills/synap/viewframe-cells.md +38 -5
  469. package/skills/synap/work-flow.md +1 -1
  470. package/skills/synap/work-model.md +110 -0
  471. package/skills/synap/worked-examples.md +4 -5
  472. package/skills/synap/workspace-design.md +15 -11
  473. package/skills/synap/writes.md +105 -25
  474. package/skills/synap/writing-titles.md +63 -0
  475. package/skills/synap-app/SKILL.md +149 -67
  476. package/skills/synap-app/_skill.yaml +1 -1
  477. package/skills/synap-app/creating-the-base-app.md +12 -7
  478. package/skills/synap-app/governance.md +27 -16
  479. package/skills/synap-app/install-commands.md +18 -9
  480. package/skills/synap-app/overview.md +16 -9
  481. package/skills/synap-app/using-the-sdk.md +75 -25
  482. package/skills/synap-design/SKILL.md +474 -0
  483. package/skills/synap-design/_order.txt +4 -0
  484. package/skills/synap-design/_skill.yaml +21 -0
  485. package/skills/synap-design/loop.md +134 -0
  486. package/skills/synap-design/overview.md +44 -0
  487. package/skills/synap-design/principles.md +130 -0
  488. package/skills/synap-design/system.md +133 -0
  489. package/skills/synap-market/SKILL.md +306 -16
  490. package/skills/synap-market/_skill.yaml +1 -1
  491. package/skills/synap-market/author-and-publish.md +145 -8
  492. package/skills/synap-market/governance-and-catalog.md +7 -0
  493. package/skills/synap-market/install.md +79 -1
  494. package/skills/synap-market/overview.md +74 -6
  495. package/skills/synap-schema/SKILL.md +172 -10
  496. package/skills/synap-schema/_order.txt +2 -0
  497. package/skills/synap-schema/_skill.yaml +4 -2
  498. package/skills/synap-schema/common-mistakes.md +4 -2
  499. package/skills/synap-schema/discover-profiles.md +1 -1
  500. package/skills/synap-schema/entity-scope.md +15 -3
  501. package/skills/synap-schema/extend-first.md +29 -0
  502. package/skills/synap-schema/extend-vs-create.md +1 -1
  503. package/skills/synap-schema/intent-vocabulary-ssot.md +111 -0
  504. package/skills/synap-schema/property-types.md +1 -1
  505. package/skills/synap-schema/relations.md +1 -1
  506. package/skills/synap-ui/SKILL.md +19 -12
  507. package/skills/synap-ui/_skill.yaml +1 -1
  508. package/skills/synap-ui/ai-companion.md +1 -1
  509. package/skills/synap-ui/arranging-bento.md +2 -0
  510. package/skills/synap-ui/bento-layouts.md +10 -10
  511. package/skills/synap-ui/bento-recipes.md +313 -543
  512. package/skills/synap-ui/four-builds.md +5 -0
  513. package/skills/synap-ui/view-types.md +2 -2
  514. package/skills/synap-ui/widget-catalog.md +22 -278
  515. package/templates/automations/bookmark-enrichment.automation.json +290 -13
  516. package/templates/automations/url-bookmark-capture.automation.json +73 -11
  517. package/templates/box/runner.py +206 -0
  518. package/templates/skills/authoring-playbooks/SKILL.md +6 -3
  519. package/templates/skills/authoring-playbooks/reference/stages-contract.md +15 -0
  520. package/templates/skills/authoring-playbooks/reference/worked-example.md +3 -0
  521. package/dist/commands/infra.d.ts +0 -21
  522. package/dist/commands/infra.js +0 -262
  523. package/dist/commands/infra.js.map +0 -1
  524. package/node_modules/@synap/hub-rest-client/dist/index.cjs +0 -1261
  525. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +0 -1647
  526. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +0 -1647
  527. package/node_modules/@synap/hub-rest-client/dist/index.js +0 -1231
  528. package/node_modules/@synap/hub-rest-client/package.json +0 -45
  529. package/node_modules/@synap/hub-rest-client/src/client.ts +0 -1631
  530. package/node_modules/@synap/hub-rest-client/src/errors.ts +0 -30
  531. package/node_modules/@synap/hub-rest-client/src/index.ts +0 -153
  532. package/node_modules/@synap/hub-rest-client/src/setup.ts +0 -133
  533. 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");
@@ -31,41 +144,10 @@ function claudeDesktopBaseDir() {
31
144
  return CLAUDE_DESKTOP_LINUX;
32
145
  }
33
146
  }
34
- /** Raycast app-support root (macOS only — Raycast is macOS-only). */
35
- function raycastSupportDir() {
36
- return path.join(os.homedir(), "Library", "Application Support", "com.raycast.macos");
37
- }
38
- /**
39
- * Locate Raycast's EXISTING `mcp-config.json`. Raycast stores all non-dev MCP
40
- * servers in ONE file inside the "Manage MCP Servers" extension's support
41
- * directory — an opaque UUID folder under `…/com.raycast.macos/extensions/`.
42
- * The folder name is only known to Raycast and the file is created lazily the
43
- * first time the user opens "Manage MCP Servers".
44
- *
45
- * We therefore scan the support dirs for an EXISTING `mcp-config.json` and
46
- * return its path (so we can merge into it). Returns `null` when none exists —
47
- * the caller then prints the block for the user to paste via Raycast's
48
- * "Show Config File in Finder" action (the only reliable first-time path).
49
- */
50
- function raycastMcpConfigPath() {
51
- const extensionsDir = path.join(raycastSupportDir(), "extensions");
52
- try {
53
- for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
54
- if (!entry.isDirectory())
55
- continue;
56
- const candidate = path.join(extensionsDir, entry.name, "mcp-config.json");
57
- if (fs.existsSync(candidate))
58
- return candidate;
59
- }
60
- }
61
- catch {
62
- /* extensions dir missing — Raycast not installed or never opened */
63
- }
64
- return null;
65
- }
66
147
  export const TARGETS = {
67
148
  "claude-code": {
68
149
  name: "claude-code",
150
+ keyStore: { format: "claude-code", path: () => path.join(os.homedir(), ".claude", "settings.json") },
69
151
  label: "Claude Code",
70
152
  supports: { skills: true, mcp: true },
71
153
  skillsDir: () => path.join(os.homedir(), ".claude", "skills"),
@@ -73,6 +155,7 @@ export const TARGETS = {
73
155
  },
74
156
  "claude-desktop": {
75
157
  name: "claude-desktop",
158
+ keyStore: { format: "json", path: () => path.join(claudeDesktopBaseDir(), "claude_desktop_config.json") },
76
159
  label: "Claude Desktop",
77
160
  // Claude Desktop reads Agent Skills from its app-support skills/ dir
78
161
  // (alongside claude_desktop_config.json). The "upload via claude.ai" path
@@ -85,49 +168,67 @@ export const TARGETS = {
85
168
  },
86
169
  cursor: {
87
170
  name: "cursor",
171
+ keyStore: { format: "json", path: () => path.join(os.homedir(), ".cursor", "mcp.json") },
88
172
  label: "Cursor",
89
173
  supports: { skills: false, mcp: true },
90
174
  mcpConfigPath: () => path.join(os.homedir(), ".cursor", "mcp.json"),
91
175
  },
92
176
  raycast: {
93
177
  name: "raycast",
178
+ keyStore: {
179
+ none: "Raycast keeps its MCP sign-in in its own store; the commands' key is in ~/.synap/config.json",
180
+ },
94
181
  label: "Raycast",
95
- description: "Native MCP server (via mcp-remote bridge) — all Synap tools in Raycast AI",
182
+ description: "Synap extension commands + the pod's MCP server in Raycast AI (HTTP, OAuth)",
96
183
  supports: { skills: false, mcp: true },
97
184
  },
98
- openclaw: {
99
- name: "openclaw",
100
- label: "OpenClaw",
101
- supports: { skills: true, mcp: false },
102
- },
103
185
  openwebui: {
104
186
  name: "openwebui",
187
+ keyStore: { none: "registered in Open WebUI through its API — no local file" },
105
188
  label: "Open WebUI",
106
189
  description: "Register Synap as a model source and tool server in Open WebUI",
107
190
  supports: { skills: false, mcp: true },
108
191
  },
109
192
  codex: {
110
193
  name: "codex",
194
+ keyStore: { format: "toml", path: () => path.join(os.homedir(), ".codex", "config.toml") },
111
195
  label: "OpenAI Codex",
112
196
  description: "OpenAI Codex CLI — MCP server + instructions",
113
197
  supports: { skills: true, mcp: true },
114
- mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.yaml"),
198
+ mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.toml"),
115
199
  skillsDir: () => path.join(os.homedir(), ".codex"),
116
200
  },
201
+ chatgpt: {
202
+ name: "chatgpt",
203
+ keyStore: { none: "pasted into ChatGPT on the web — no local file" },
204
+ label: "ChatGPT (API key)",
205
+ description: "ChatGPT connector (web) — API-key auth to your pod's /mcp (provisions under generic surface)",
206
+ supports: { skills: false, mcp: true },
207
+ },
208
+ "chatgpt-oauth": {
209
+ name: "chatgpt-oauth",
210
+ keyStore: { none: "OAuth through the control plane — no key" },
211
+ label: "ChatGPT (OAuth, via Control Plane)",
212
+ description: "ChatGPT connector via Synap Control Plane OAuth — multi-pod, no key to paste",
213
+ supports: { skills: false, mcp: true },
214
+ },
117
215
  opencode: {
118
216
  name: "opencode",
217
+ keyStore: { format: "json", path: () => path.join(os.homedir(), ".config", "opencode", "opencode.json") },
119
218
  label: "opencode",
120
219
  description: "Synap IS as AI provider + Synap pod as MCP tools in ~/.config/opencode/opencode.json",
121
220
  supports: { skills: false, mcp: true },
122
221
  },
123
222
  aider: {
124
223
  name: "aider",
224
+ keyStore: { none: "aider gets the Intelligence Service provider, not a pod MCP key" },
125
225
  label: "aider",
126
226
  description: "Write Synap IS as the OpenAI-compatible provider in ~/.aider.conf.yml",
127
227
  supports: { skills: false, mcp: false },
128
228
  },
129
229
  windsurf: {
130
230
  name: "windsurf",
231
+ keyStore: { format: "json", path: () => path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json") },
131
232
  label: "Windsurf",
132
233
  description: "Codeium Windsurf editor — MCP config at ~/.codeium/windsurf/mcp_config.json",
133
234
  supports: { skills: false, mcp: true },
@@ -135,29 +236,57 @@ export const TARGETS = {
135
236
  },
136
237
  goose: {
137
238
  name: "goose",
239
+ keyStore: { format: "yaml-goose", path: () => path.join(os.homedir(), ".config", "goose", "config.yaml") },
138
240
  label: "Goose",
139
241
  description: "Block's AI agent — MCP extension in ~/.config/goose/config.yaml",
140
242
  supports: { skills: false, mcp: true },
141
243
  },
142
244
  zed: {
143
245
  name: "zed",
246
+ keyStore: { format: "jsonc", path: () => zedSettingsPath() },
144
247
  label: "Zed",
145
248
  description: "Zed editor — context_servers entry in Zed settings.json (via mcp-remote)",
146
249
  supports: { skills: false, mcp: true },
147
250
  },
148
251
  vscode: {
149
252
  name: "vscode",
253
+ keyStore: { format: "json", path: () => path.join(vsCodeUserDataDir(), "mcp.json") },
150
254
  label: "VS Code (Kilo Code / Cline / Continue / Copilot)",
151
255
  description: "VS Code native MCP — one config read by Kilo Code, Cline, Continue, and Copilot",
152
256
  supports: { skills: false, mcp: true },
153
257
  },
258
+ grok: {
259
+ name: "grok",
260
+ keyStore: { format: "toml", path: () => path.join(os.homedir(), ".grok", "config.toml") },
261
+ label: "Grok (Grok Build TUI)",
262
+ description: "xAI Grok Build — provisions an agent key and writes [mcp_servers.*] into ~/.grok/config.toml",
263
+ supports: { skills: false, mcp: true },
264
+ mcpConfigPath: () => path.join(os.homedir(), ".grok", "config.toml"),
265
+ },
154
266
  generic: {
155
267
  name: "generic",
268
+ keyStore: { none: "printed for you to paste — no file the CLI writes" },
156
269
  label: "Generic MCP Client",
157
270
  description: "Output MCP connection config for any MCP-compatible client",
158
271
  supports: { skills: false, mcp: true },
159
272
  },
160
273
  };
274
+ /**
275
+ * Every client file that may hold a key, for `synap doctor` — DERIVED from
276
+ * `TARGETS[*].keyStore`, never a second hand list. The surface is the CLI's
277
+ * key slot name (`agentKeysByPod.<surface>`), which is the target name.
278
+ */
279
+ export function clientKeyFiles() {
280
+ const out = [];
281
+ for (const t of Object.values(TARGETS)) {
282
+ if ("none" in t.keyStore)
283
+ continue;
284
+ const file = t.keyStore.path();
285
+ if (file)
286
+ out.push({ surface: t.name, tool: t.label, file, format: t.keyStore.format });
287
+ }
288
+ return out;
289
+ }
161
290
  export function isTargetName(value) {
162
291
  return value in TARGETS;
163
292
  }
@@ -181,13 +310,19 @@ export function buildMcpUrl(podUrl, workspaceId, projectId) {
181
310
  * Shared preamble for MCP surface installers: provision a dedicated agent key,
182
311
  * enroll the agent into the caller's workspaces, and resolve the scoped MCP URL.
183
312
  */
184
- async function prepareMcpSurface(cfg, agentType) {
185
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentType);
313
+ async function prepareMcpSurface(cfg, agentType, opts) {
314
+ // Idempotent by default: `synap connect` is re-runnable. Without it, every
315
+ // connect mints a NEW inactive key + a fresh approval request on the pod,
316
+ // even for a surface that is already connected — which is why the pod admin
317
+ // site keeps showing "validate this proposal" on every reconnect. The pod
318
+ // returns `alreadyValid` for an existing key; we recover the plaintext from
319
+ // local storage and reuse it, rotating only on positive invalidation.
320
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentType, { idempotent: opts?.idempotent ?? true, toolProfile: opts?.toolProfile });
186
321
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
187
322
  return { effectiveApiKey, agentUserId, mcpUrl: buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId) };
188
323
  }
189
324
  // ─── Agent context configuration ──────────────────────────────────────────────
190
- export async function configureAgentContext(podUrl, apiKey, agentType, agentUserId, info) {
325
+ export async function configureAgentContext(podUrl, apiKey, agentType, agentUserId, info, opts) {
191
326
  const { AGENT_TEMPLATES, getTemplate } = await import("./agent-templates.js");
192
327
  log.blank();
193
328
  log.heading("Configure your agent");
@@ -209,7 +344,7 @@ export async function configureAgentContext(podUrl, apiKey, agentType, agentUser
209
344
  // create`) still get a routing file — just with the MINIMAL default template,
210
345
  // since there's no TTY to prompt on.
211
346
  let templateId;
212
- if (process.stdin.isTTY) {
347
+ if (process.stdin.isTTY && opts?.prompt !== false) {
213
348
  ({ templateId } = await prompts({
214
349
  type: "select",
215
350
  name: "templateId",
@@ -235,17 +370,16 @@ export async function configureAgentContext(podUrl, apiKey, agentType, agentUser
235
370
  setAgentWorkspaceRouting({ memoryWorkspaceId: undefined, productWorkspaceIds: [] });
236
371
  // Always write to ~/.synap/contexts/<surface>.md
237
372
  const contextDir = path.join(os.homedir(), ".synap", "contexts");
238
- if (!fs.existsSync(contextDir))
239
- fs.mkdirSync(contextDir, { recursive: true });
373
+ ensureSecretDir(contextDir);
240
374
  const globalContextPath = path.join(contextDir, `${agentType}.md`);
241
- fs.writeFileSync(globalContextPath, content, { mode: 0o600 });
375
+ writeSecretFile(globalContextPath, content, { mode: 0o600 });
242
376
  // For skills-capable surfaces, also write alongside SKILL.md
243
377
  const skillsDir = info?.skillsDir?.();
244
378
  if (skillsDir) {
245
379
  const synapSkillDir = path.join(skillsDir, "synap");
246
380
  if (!fs.existsSync(synapSkillDir))
247
381
  fs.mkdirSync(synapSkillDir, { recursive: true });
248
- fs.writeFileSync(path.join(synapSkillDir, "CONTEXT.md"), content, { mode: 0o600 });
382
+ writeSecretFile(path.join(synapSkillDir, "CONTEXT.md"), content, { mode: 0o600 });
249
383
  log.success(`Agent context written to ${synapSkillDir}/CONTEXT.md`);
250
384
  }
251
385
  else {
@@ -273,7 +407,7 @@ export function listTargets() {
273
407
  * Run the install flow for a target. Returns true on success.
274
408
  * Throws only on unexpected errors — recoverable failures are logged + return false.
275
409
  */
276
- export async function installForTarget(target, cfg) {
410
+ export async function installForTarget(target, cfg, opts) {
277
411
  const info = TARGETS[target];
278
412
  if (!info) {
279
413
  log.error(`Unknown target: ${target}`);
@@ -294,15 +428,18 @@ export async function installForTarget(target, cfg) {
294
428
  case "raycast":
295
429
  result = await installRaycast(cfg);
296
430
  break;
297
- case "openclaw":
298
- result = await installOpenclaw(cfg);
299
- break;
300
431
  case "openwebui":
301
432
  result = await installOpenWebUI(cfg);
302
433
  break;
303
434
  case "codex":
304
435
  result = await installCodex(info, cfg);
305
436
  break;
437
+ case "chatgpt":
438
+ result = await installChatGpt(cfg);
439
+ break;
440
+ case "chatgpt-oauth":
441
+ result = await installChatGptOAuth(cfg);
442
+ break;
306
443
  case "opencode":
307
444
  result = await installOpencode(cfg);
308
445
  break;
@@ -321,22 +458,58 @@ export async function installForTarget(target, cfg) {
321
458
  case "vscode":
322
459
  result = await installVSCode(cfg);
323
460
  break;
461
+ case "grok":
462
+ result = await installGrok(info, cfg);
463
+ break;
324
464
  case "generic":
325
465
  result = await installGeneric(cfg);
326
466
  break;
327
467
  }
328
- // Post-install: configure agent workspace context for all successful MCP installs.
329
- if (result && info.supports.mcp && target !== "generic") {
468
+ // Post-install: agent context wizard for MCP targets. Skip Raycast — its AI
469
+ // reads the pod's MCP instructions; CONTEXT.md has no Raycast consumer.
470
+ if (result && info.supports.mcp && target !== "generic" && target !== "raycast") {
330
471
  const { getSurfaceAgentKey } = await import("./pod.js");
331
472
  const saved = getSurfaceAgentKey(target);
332
473
  const agentUserId = saved?.agentUserId ?? "";
333
474
  try {
334
- await configureAgentContext(cfg.podUrl, cfg.apiKey, target, agentUserId, info);
475
+ await configureAgentContext(cfg.podUrl, cfg.apiKey, target, agentUserId, info, {
476
+ prompt: !cfg.unattended,
477
+ });
335
478
  }
336
479
  catch (err) {
337
480
  log.warn(`Agent context wizard failed: ${err instanceof Error ? err.message : String(err)}`);
338
481
  }
339
482
  }
483
+ // ── Post-install verify (the missing gate) ────────────────────────────────
484
+ // The config is written and the agent key is stored — but until this point
485
+ // nothing has checked that the client will actually load Synap tools. A key
486
+ // scoped to a workspace the agent isn't enrolled in, a pod that is up but not
487
+ // serving MCP, or a config the client ignores all surface three steps later
488
+ // when the user reports "it says it works but it doesn't". Verify against the
489
+ // pod's /mcp with the AGENT key (the one the client will use), not the human
490
+ // profile key — those are different identities and only one is in the config.
491
+ if (result && info.supports.mcp && opts?.verify !== false) {
492
+ const { getSurfaceAgentKey } = await import("./pod.js");
493
+ const saved = getSurfaceAgentKey(target, cfg.podUrl);
494
+ const verifyKey = saved?.hubApiKey ?? cfg.apiKey;
495
+ const verifySpinner = ora(`Verifying ${info.label} can reach Synap…`).start();
496
+ try {
497
+ const v = await verifyMcpConnection(cfg.podUrl, verifyKey, { timeoutMs: 20_000 });
498
+ if (v.ok) {
499
+ verifySpinner.succeed(`${info.label} verified — ${v.toolCount} Synap tool(s) reachable at ${cfg.podUrl}/mcp`);
500
+ }
501
+ else {
502
+ verifySpinner.warn(`${info.label} install completed but verification FAILED.`);
503
+ log.warn(` ${v.error}`);
504
+ log.dim(` The client config was written, but the agent key couldn't reach Synap. ` +
505
+ `Re-run 'synap connect --target=${target}' after fixing it.`);
506
+ }
507
+ }
508
+ catch (err) {
509
+ verifySpinner.warn(`${info.label} verification errored.`);
510
+ log.warn(` ${err.message}`);
511
+ }
512
+ }
340
513
  return result;
341
514
  }
342
515
  // ─── Claude Code ─────────────────────────────────────────────────────────────
@@ -346,7 +519,7 @@ async function installClaudeCode(info, cfg) {
346
519
  // commands (capture, recall, search, create, etc.) — not via MCP tool calls.
347
520
  // Skills give it Synap knowledge; the agent key lets it call the CLI directly.
348
521
  // MCP is available as an opt-in for users who prefer the tool-call interface.
349
- const { mode } = await prompts({
522
+ const { mode } = cfg.unattended ? { mode: "both" } : await prompts({
350
523
  type: "select",
351
524
  name: "mode",
352
525
  message: "What do you want to install?",
@@ -489,74 +662,23 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
489
662
  const me = await meRes.json();
490
663
  if (!me.id)
491
664
  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),
665
+ // ONE door for the key: `provisionAgentKey` takes a key approved with this
666
+ // computer (device flow), resumes a request already filed from here, reuses
667
+ // the stored live key, or files a new request — with this computer's
668
+ // instance label — and WAITS for its approval. It never returns a pending
669
+ // key. (This function used to POST /setup/agent itself and wrote the still-
670
+ // pending key into every config — the 2026-10-12 incident.)
671
+ const provisioned = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-code", {
672
+ idempotent: true,
673
+ surfaceName: "claude-code",
674
+ fallbackCandidate: settings.env?.["SYNAP_HUB_API_KEY"],
497
675
  });
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
- }
676
+ effectiveApiKey = provisioned.hubApiKey;
677
+ const agentSetup = { agentUserId: provisioned.agentUserId, alreadyValid: provisioned.reused };
678
+ // The last gate before ANY file is written: the pod must not say this key is
679
+ // pending, revoked, expired or unknown. A refused key changes nothing here.
680
+ const { assertKeyWritable } = await import("./key-state.js");
681
+ await assertKeyWritable(cfg.podUrl, effectiveApiKey);
560
682
  // Persist the provisioned key to agentKeys["claude-code"] so SYNAP_AGENT=claude-code
561
683
  // works in CLI sessions outside of this env (e.g. terminal, not just inside Claude Code).
562
684
  const { setSurfaceAgentKey: saveSurfaceKey } = await import("./pod.js");
@@ -564,9 +686,16 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
564
686
  // Enroll the new agent user into the caller's workspaces using the HUMAN key
565
687
  // (before switching to the agent key). Scoped to one workspace when chosen.
566
688
  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);
689
+ // Set per-agent governance — read before write, so it never overwrites a
690
+ // choice. Unattended (`synap init`) writes NOTHING: a new agent follows the
691
+ // POD default ("reversible writes act", founder 2026-09-28). Interactive
692
+ // `synap connect` still offers the presets, and only for an agent nobody
693
+ // configured.
694
+ if (agentSetup.agentUserId && !cfg.unattended) {
695
+ await ensureAgentGovernance(cfg, agentSetup.agentUserId, undefined, {
696
+ onlyIfUnset: true,
697
+ knownAgent: !!agentSetup.alreadyValid,
698
+ });
570
699
  }
571
700
  const env = (settings.env ?? {});
572
701
  env["SYNAP_POD_URL"] = cfg.podUrl;
@@ -597,7 +726,7 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
597
726
  if (!fs.existsSync(settingsDir)) {
598
727
  fs.mkdirSync(settingsDir, { recursive: true });
599
728
  }
600
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
729
+ writeSecretFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
601
730
  // ── Register the MCP server where Claude Code actually reads it ───────────
602
731
  // The official `claude mcp add` writes to the user-scope registry that
603
732
  // `claude mcp list` + the runtime consult — unlike settings.json mcpServers,
@@ -605,31 +734,128 @@ export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
605
734
  // synap tools.
606
735
  if (writeMcp) {
607
736
  const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
608
- registerClaudeCodeMcp(mcpUrl, effectiveApiKey);
737
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
738
+ registerClaudeCodeMcp(mcpUrl, effectiveApiKey, serverName);
609
739
  }
610
740
  }
611
741
  /**
612
742
  * Register the Synap MCP server with Claude Code via the official CLI, into the
613
743
  * 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.
744
+ * entry first (legacy `"synap"` and the pod-derived name). Best-effort: if the
745
+ * `claude` binary isn't on PATH, prints the exact command to run by hand
746
+ * rather than failing the whole connect.
747
+ *
748
+ * Server name is pod-derived (`mcpServerNameFromPodUrl`) so multi-pod setups
749
+ * (perso + team) do not overwrite each other — same pattern as Grok/Cursor.
616
750
  */
617
- function registerClaudeCodeMcp(mcpUrl, apiKey) {
751
+ export function registerClaudeCodeMcp(mcpUrl, apiKey, serverName) {
618
752
  const header = `Authorization: Bearer ${apiKey}`;
753
+ const bridge = claudeCodeMcpTransport() === "bridge";
619
754
  try {
755
+ // Remove legacy single-name entry and any prior pod-derived name.
756
+ for (const name of new Set(["synap", serverName])) {
757
+ try {
758
+ execFileSync("claude", ["mcp", "remove", name], { stdio: "ignore" });
759
+ }
760
+ catch {
761
+ /* no prior entry — fine */
762
+ }
763
+ }
764
+ execFileSync("claude", bridge
765
+ ? // Stdio: Claude Code hands a stdio server its own session id, which
766
+ // the bridge forwards — see lib/mcp-bridge.ts. The key rides in the
767
+ // server's env, as the header does for http.
768
+ // `-e` is variadic in Claude Code's CLI: the server name must come
769
+ // BEFORE it, or `-e` swallows it as an env var.
770
+ ["mcp", "add", "--scope", "user", serverName, "-e", `SYNAP_MCP_API_KEY=${apiKey}`, "--", ...synapBridgeCommand(mcpUrl)]
771
+ : ["mcp", "add", "--transport", "http", serverName, mcpUrl, "--header", header, "--scope", "user"], { stdio: "ignore" });
772
+ 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.`);
773
+ if (serverName !== "synap") {
774
+ log.dim("Multi-pod: connect each profile separately — server names are derived from the pod host so they don't overwrite:");
775
+ log.dim(" synap connect --target=claude-code --name=perso");
776
+ log.dim(" synap connect --target=claude-code --name=team");
777
+ }
778
+ }
779
+ catch {
780
+ // Never print the key (terminal scrollback / shell history). Claude Code
781
+ // expands ${VAR} in an http header at launch, so the printed command names
782
+ // an env var; the key itself goes to a 0600 file the user sources.
783
+ const keyFile = path.join(os.homedir(), ".synap", "mcp-keys", `${serverName.replace(/[^A-Za-z0-9_-]/g, "_")}.env`);
620
784
  try {
621
- execFileSync("claude", ["mcp", "remove", "synap"], { stdio: "ignore" });
785
+ ensureSecretDir(path.dirname(keyFile));
786
+ writeSecretFile(keyFile, `export SYNAP_MCP_KEY='${apiKey}'\n`);
787
+ log.dim(`Could not run \`claude mcp add\` (is the claude CLI on PATH?). The '${serverName}' MCP server may now be UNREGISTERED in Claude Code. Register it by hand (the key is in a 0600 file, not on this screen):`);
788
+ log.dim(` source ${keyFile}`);
789
+ log.dim(bridge
790
+ ? ` claude mcp add --scope user ${serverName} -e SYNAP_MCP_API_KEY="$SYNAP_MCP_KEY" -- ${synapBridgeCommand(mcpUrl).map(shellQuote).join(" ")}`
791
+ : ` claude mcp add --transport http ${serverName} ${mcpUrl} --header 'Authorization: Bearer \${SYNAP_MCP_KEY}' --scope user`);
622
792
  }
623
793
  catch {
624
- /* no prior entry — fine */
794
+ 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
795
  }
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.");
796
+ }
797
+ }
798
+ function shellQuote(arg) {
799
+ return /^[A-Za-z0-9_\-./:=@]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
800
+ }
801
+ /**
802
+ * How Claude Code reaches the pod's MCP: `"http"` (direct) or `"bridge"` (the
803
+ * synap stdio bridge, which tells the pod which Claude Code conversation each
804
+ * call comes from, so work can be reopened in it). Stored in Claude Code's own
805
+ * settings env (`SYNAP_MCP_TRANSPORT`), which `writeClaudeCodeEnv` merges, so a
806
+ * re-connect or `synap pods use` keeps the choice. Default: http.
807
+ */
808
+ export function claudeCodeMcpTransport() {
809
+ try {
810
+ const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude", "settings.json"), "utf-8"));
811
+ return settings.env?.["SYNAP_MCP_TRANSPORT"] === "bridge" ? "bridge" : "http";
812
+ }
813
+ catch {
814
+ return "http";
815
+ }
816
+ }
817
+ /**
818
+ * Switch Claude Code's MCP transport and re-register the server with the key
819
+ * and pod `writeClaudeCodeEnv` already stored. Returns the server name, or
820
+ * null when Claude Code was never connected (nothing to switch).
821
+ */
822
+ export function setClaudeCodeMcpTransport(transport) {
823
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
824
+ let settings;
825
+ try {
826
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
628
827
  }
629
828
  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`);
829
+ return null;
632
830
  }
831
+ const env = (settings.env ?? {});
832
+ const podUrl = env["SYNAP_POD_URL"];
833
+ const apiKey = env["SYNAP_HUB_API_KEY"];
834
+ if (!podUrl || !apiKey)
835
+ return null;
836
+ if (transport === "bridge")
837
+ env["SYNAP_MCP_TRANSPORT"] = "bridge";
838
+ else
839
+ delete env["SYNAP_MCP_TRANSPORT"];
840
+ settings.env = env;
841
+ writeSecretFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
842
+ const serverName = mcpServerNameFromPodUrl(podUrl);
843
+ registerClaudeCodeMcp(buildMcpUrl(podUrl, env["SYNAP_WORKSPACE_ID"], env["SYNAP_PROJECT_ID"]), apiKey, serverName);
844
+ return serverName;
845
+ }
846
+ /**
847
+ * The command Claude Code spawns for the bridge: THIS CLI by absolute path (the
848
+ * one running `use-bridge` is the one known to have the bridge — a `synap` on
849
+ * PATH may be an older install without it), unless this run is a throwaway
850
+ * `npx` copy, whose cache path can vanish; then the `synap` on PATH.
851
+ */
852
+ function synapBridgeCommand(mcpUrl) {
853
+ const args = ["mcp", "bridge", "--url", mcpUrl];
854
+ const self = process.argv[1];
855
+ if (self && !self.includes(`${path.sep}_npx${path.sep}`)) {
856
+ return [process.execPath, fs.realpathSync(self), ...args];
857
+ }
858
+ return ["synap", ...args];
633
859
  }
634
860
  // ─── Agent key provisioning helper ──────────────────────────────────────────
635
861
  /**
@@ -649,40 +875,80 @@ function credentialError(agentType, status, body) {
649
875
  return (`Failed to provision agent key for ${agentType} (HTTP ${status}): ${body}\n` +
650
876
  `If this persists, re-authenticate with \`synap login --reconnect\`.`);
651
877
  }
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, () => { });
878
+ /** Open an http(s) URL in the browser — argv only, never a shell (`safe-open.ts`). */
879
+ export function openBrowserUrl(url) {
880
+ openExternalUrl(url);
658
881
  }
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
- });
882
+ /** The pod answered a key mint with something unusable (e.g. no agent id). */
883
+ export class AgentKeyMintError extends Error {
884
+ agentType;
885
+ constructor(agentType, message) {
886
+ super(message);
887
+ this.agentType = agentType;
888
+ this.name = "AgentKeyMintError";
889
+ }
672
890
  }
673
891
  /**
674
892
  * Provision an agent-owned API key for the given surface via POST /api/hub/setup/agent.
675
893
  * When requireApproval is true the key is created inactive; the user must approve in the
676
894
  * browser before this function returns.
677
895
  * Throws on failure — callers must not silently fall back to a human key.
896
+ *
897
+ * @param surfaceName The CLI surface name (e.g. "chatgpt", "codex") — used for
898
+ * idempotent key lookup in local storage. If omitted, `agentType`
899
+ * is used. This handles the case where the pod's agentType
900
+ * differs from the CLI surface (e.g. ChatGPT provisions under
901
+ * "generic" but the CLI surface is "chatgpt").
678
902
  */
679
- export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requireApproval = true, idempotent = false, } = {}) {
903
+ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requireApproval = true, idempotent = false, surfaceName, deferApproval = false, instanceId, fallbackCandidate, toolProfile, } = {}) {
904
+ // `synap init` already minted — and the person approved — this key in one
905
+ // step with the others (`approveAgentKeysAtOnce`): hand it over, mint nothing.
906
+ if (!deferApproval) {
907
+ const pre = takePreApprovedKey(agentType);
908
+ if (pre)
909
+ return { ...pre, reused: false };
910
+ }
911
+ const surface = surfaceName ?? agentType;
912
+ const instance = instanceId ?? machineInstanceId(surface);
913
+ const podBase = podUrl.replace(/\/$/, "");
914
+ const pod = await import("./pod.js");
915
+ // A request already filed from this computer is RESUMED, never filed again:
916
+ // one approval, one key (2026-10-12: three orphaned requests in one evening).
917
+ const recorded = pod.getPendingAgentKey(surface, podUrl);
918
+ if (recorded) {
919
+ const { classifyRecordedPending } = await import("./key-approval.js");
920
+ const now = await classifyRecordedPending(recorded);
921
+ if (now.kind === "active") {
922
+ pod.clearPendingAgentKey(surface, podUrl);
923
+ return { hubApiKey: recorded.hubApiKey, agentUserId: recorded.agentUserId, reused: true };
924
+ }
925
+ if (now.kind === "pending" || now.kind === "unknown") {
926
+ if (deferApproval) {
927
+ return {
928
+ hubApiKey: recorded.hubApiKey,
929
+ agentUserId: recorded.agentUserId,
930
+ reused: true,
931
+ pending: { pendingToken: recorded.keyId, reviewUrl: now.state.approvalUrl ?? recorded.approvalUrl },
932
+ };
933
+ }
934
+ await awaitApprovalOrThrow({
935
+ podUrl,
936
+ humanApiKey,
937
+ agentType,
938
+ surface,
939
+ hubApiKey: recorded.hubApiKey,
940
+ pendingToken: recorded.keyId,
941
+ approvalUrl: now.state.approvalUrl ?? recorded.approvalUrl,
942
+ });
943
+ return { hubApiKey: recorded.hubApiKey, agentUserId: recorded.agentUserId, reused: true };
944
+ }
945
+ pod.clearPendingAgentKey(surface, podUrl); // dead: file a fresh request below
946
+ }
680
947
  // `idempotent: true` reuses the pod's existing valid key for this agentType
681
948
  // instead of minting a fresh one + revoking the old — required by callers whose
682
949
  // running process holds a long-lived key (e.g. `bridge-setup`), so re-running
683
950
  // setup doesn't break a live bridge. Default false preserves the mint-fresh
684
951
  // behavior every other caller (connect / pods / init) already relied on.
685
- const podBase = podUrl.replace(/\/$/, "");
686
952
  let res;
687
953
  try {
688
954
  res = await fetch(`${podBase}/api/hub/setup/agent`, {
@@ -691,7 +957,16 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
691
957
  "Content-Type": "application/json",
692
958
  Authorization: `Bearer ${humanApiKey}`,
693
959
  },
694
- body: JSON.stringify({ agentType, idempotent, requireApproval }),
960
+ // instanceLabel: this computer's name, so the connection reads
961
+ // "Claude Code on <computer>" wherever the person decides about it.
962
+ body: JSON.stringify({
963
+ agentType,
964
+ idempotent,
965
+ requireApproval,
966
+ instanceId: instance,
967
+ instanceLabel: machineInstanceLabel(),
968
+ ...(toolProfile ? { toolProfile } : {}),
969
+ }),
695
970
  signal: AbortSignal.timeout(30000),
696
971
  });
697
972
  }
@@ -716,9 +991,11 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
716
991
  // no rotation, no redeploy. We mint fresh ONLY when no valid local key exists
717
992
  // (e.g. a brand-new machine). This generalizes the claude-code self-heal.
718
993
  if (!body.hubApiKey && body.alreadyValid && idempotent) {
719
- const { getSurfaceAgentKey } = await import("./pod.js");
720
- const stored = getSurfaceAgentKey(agentType);
721
- const candidate = stored?.hubApiKey;
994
+ // Use surfaceName for local storage lookup if provided; otherwise agentType.
995
+ // This handles cases where pod agentType ≠ CLI surface (e.g. ChatGPT uses
996
+ // "generic" but CLI surface is "chatgpt").
997
+ const stored = pod.getSurfaceAgentKey(surface);
998
+ const candidate = stored?.hubApiKey ?? fallbackCandidate;
722
999
  const expectedAgentUserId = body.agentUserId ?? stored?.agentUserId;
723
1000
  if (candidate && expectedAgentUserId) {
724
1001
  // auth/status returns the KEY's owner userId (the agent's, for agent keys).
@@ -757,6 +1034,10 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
757
1034
  return provisionAgentKey(podUrl, humanApiKey, agentType, {
758
1035
  requireApproval,
759
1036
  idempotent: false,
1037
+ surfaceName,
1038
+ deferApproval,
1039
+ instanceId: instance,
1040
+ toolProfile,
760
1041
  });
761
1042
  }
762
1043
  if (!body.hubApiKey) {
@@ -764,22 +1045,75 @@ export async function provisionAgentKey(podUrl, humanApiKey, agentType, { requir
764
1045
  `This is a server-side bug — check the pod logs.`);
765
1046
  }
766
1047
  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);
1048
+ // The pre-approved key is handed to an installer that enrolls and scopes
1049
+ // by this id: an empty one would connect an agent the pod cannot name.
1050
+ if (!body.agentUserId) {
1051
+ throw new AgentKeyMintError(agentType, `The pod minted a ${agentType} key but named no agent for it.`);
774
1052
  }
775
- finally {
776
- clearInterval(ticker);
777
- process.stdout.write("\n");
1053
+ // Recorded BEFORE any wait: an interrupted or timed-out wait resumes this
1054
+ // same request next time instead of filing another.
1055
+ pod.setPendingAgentKey(surface, {
1056
+ hubApiKey: body.hubApiKey,
1057
+ agentUserId: body.agentUserId,
1058
+ podUrl,
1059
+ keyId: body.pendingToken,
1060
+ approvalUrl: body.reviewUrl,
1061
+ instanceId: instance,
1062
+ createdAt: new Date().toISOString(),
1063
+ });
1064
+ if (deferApproval) {
1065
+ return {
1066
+ hubApiKey: body.hubApiKey,
1067
+ agentUserId: body.agentUserId,
1068
+ reused: false,
1069
+ pending: { pendingToken: body.pendingToken, reviewUrl: body.reviewUrl },
1070
+ };
778
1071
  }
1072
+ await awaitApprovalOrThrow({
1073
+ podUrl,
1074
+ humanApiKey,
1075
+ agentType,
1076
+ surface,
1077
+ hubApiKey: body.hubApiKey,
1078
+ pendingToken: body.pendingToken,
1079
+ approvalUrl: body.reviewUrl,
1080
+ });
779
1081
  log.success(` Approved! Writing ${agentType} agent key.`);
1082
+ return { hubApiKey: body.hubApiKey, agentUserId: body.agentUserId, reused: false };
1083
+ }
1084
+ // Minted live (no approval asked). Still never hand out a key the pod says
1085
+ // is unusable — callers write what this returns into configs.
1086
+ if (!deferApproval) {
1087
+ const { assertKeyWritable } = await import("./key-state.js");
1088
+ await assertKeyWritable(podUrl, body.hubApiKey);
780
1089
  }
781
1090
  return { hubApiKey: body.hubApiKey, agentUserId: body.agentUserId ?? "", reused: false };
782
1091
  }
1092
+ /**
1093
+ * Open the approval page and wait for THIS pending key to go live. Approved →
1094
+ * the recorded request is cleared. Rejected → cleared, and a plain error.
1095
+ * Timed out → the request stays recorded and `AgentKeyAwaitingApprovalError`
1096
+ * names the link and the resume command: nothing is written anywhere.
1097
+ */
1098
+ async function awaitApprovalOrThrow(args) {
1099
+ const { waitForKeyLive, openAndWait, AgentKeyAwaitingApprovalError } = await import("./key-approval.js");
1100
+ const pod = await import("./pod.js");
1101
+ const outcome = await openAndWait(args.agentType, args.approvalUrl, args.podUrl, () => waitForKeyLive({
1102
+ podUrl: args.podUrl,
1103
+ pendingKey: args.hubApiKey,
1104
+ pendingToken: args.pendingToken,
1105
+ humanApiKey: args.humanApiKey,
1106
+ }), openBrowserUrl);
1107
+ if (outcome === "approved") {
1108
+ pod.clearPendingAgentKey(args.surface, args.podUrl);
1109
+ return;
1110
+ }
1111
+ if (outcome === "rejected") {
1112
+ pod.clearPendingAgentKey(args.surface, args.podUrl);
1113
+ throw new Error(`The ${args.agentType} connection was declined on the pod. Run connect again to ask again.`);
1114
+ }
1115
+ throw new AgentKeyAwaitingApprovalError(args.agentType, args.approvalUrl);
1116
+ }
783
1117
  /**
784
1118
  * Enroll an agent user into the caller's workspaces via POST /api/hub/workspaces/enroll-agent.
785
1119
  * Non-fatal — logs on failure so connect flow is never blocked by enrollment errors.
@@ -837,15 +1171,23 @@ async function installClaudeDesktop(info, cfg) {
837
1171
  // stdio MCP messages into HTTPS calls against the pod's /mcp endpoint.
838
1172
  //
839
1173
  // Reference: https://www.npmjs.com/package/mcp-remote
840
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-desktop");
1174
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-desktop", { idempotent: true });
841
1175
  const { setSurfaceAgentKey: saveDesktopKey } = await import("./pod.js");
842
1176
  saveDesktopKey("claude-desktop", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
843
1177
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
844
1178
  const desktopMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
845
- writeMcpServerEntry(mcpPath, "synap", {
1179
+ // Pod-derived name so connecting a SECOND pod adds an entry instead of
1180
+ // overwriting the first (same rule as Claude Code / Cursor / Grok).
1181
+ const desktopServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1182
+ writeMcpServerEntry(mcpPath, desktopServerName, {
846
1183
  command: "npx",
847
- args: ["-y", "mcp-remote", desktopMcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
1184
+ // The header value rides in env (this file is 0600), never in the
1185
+ // mcp-remote process's argv where any local `ps` can read it.
1186
+ args: ["-y", "mcp-remote", desktopMcpUrl, "--header", "Authorization:${SYNAP_AUTH}"],
1187
+ env: { SYNAP_AUTH: `Bearer ${effectiveApiKey}` },
848
1188
  });
1189
+ if (desktopServerName !== "synap")
1190
+ removeMcpServerEntry(mcpPath, "synap");
849
1191
  log.blank();
850
1192
  log.success(`MCP server 'synap' added to ${path.relative(os.homedir(), mcpPath)}`);
851
1193
  log.dim("The entry uses 'npx mcp-remote' as a stdio bridge — Claude Desktop spawns it.");
@@ -880,19 +1222,32 @@ async function installCursor(info, cfg) {
880
1222
  const mcpPath = info.mcpConfigPath?.();
881
1223
  if (!mcpPath)
882
1224
  return false;
883
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "cursor");
1225
+ const mint = TARGET_KEY_MINT.cursor;
1226
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, mint.agentType, { idempotent: mint.idempotent });
884
1227
  const { setSurfaceAgentKey: saveCursorKey } = await import("./pod.js");
885
1228
  saveCursorKey("cursor", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
886
1229
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
887
1230
  const cursorMcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
888
- writeMcpServerEntry(mcpPath, "synap", {
1231
+ // Pod-derived name so multi-pod connects don't overwrite each other (peer of Grok).
1232
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1233
+ writeMcpServerEntry(mcpPath, serverName, {
889
1234
  url: cursorMcpUrl,
890
1235
  headers: { Authorization: `Bearer ${effectiveApiKey}` },
891
1236
  });
1237
+ // Drop legacy single-name entry when we wrote a pod-qualified name, so reconnects
1238
+ // don't leave a stale "synap" next to "synap-thearch".
1239
+ if (serverName !== "synap") {
1240
+ removeMcpServerEntry(mcpPath, "synap");
1241
+ }
892
1242
  log.blank();
893
- log.success(`MCP server 'synap' added to ${path.relative(os.homedir(), mcpPath)}`);
1243
+ log.success(`MCP server '${serverName}' added to ${path.relative(os.homedir(), mcpPath)}`);
894
1244
  if (cfg.workspaceId)
895
1245
  log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
1246
+ if (serverName !== "synap") {
1247
+ log.dim("Multi-pod: connect each profile separately — server names are derived from the pod host so they don't overwrite:");
1248
+ log.dim(" synap connect --target=cursor --name=perso");
1249
+ log.dim(" synap connect --target=cursor --name=team");
1250
+ }
896
1251
  log.dim("Restart Cursor. The synap tool set will appear in agent mode.");
897
1252
  log.blank();
898
1253
  log.info("Cursor reads Claude-format skills from ~/.claude/skills/ too.");
@@ -900,10 +1255,20 @@ async function installCursor(info, cfg) {
900
1255
  return true;
901
1256
  }
902
1257
  // ─── Raycast ─────────────────────────────────────────────────────────────────
1258
+ /**
1259
+ * Raycast has two halves (decision B1, 2026-10-21):
1260
+ * - the Synap extension's native COMMANDS (search, capture, review…) call Hub
1261
+ * REST with a dedicated `raycast` agent key, read from ~/.synap/config.json;
1262
+ * - Raycast AI talks to the pod's own MCP server over HTTP (Streamable HTTP),
1263
+ * signed in with OAuth, or with that same agent key in a header as the
1264
+ * fallback. The extension ships no AI tools of its own any more.
1265
+ *
1266
+ * Raycast keeps its MCP servers in its own store, filled through its "Install
1267
+ * MCP Server" form; its file format for an HTTP server is not documented, so we
1268
+ * print the form's fields rather than write a guessed shape into Raycast's
1269
+ * config. The key is never printed: the fallback header lives in a 0600 file.
1270
+ */
903
1271
  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.
907
1272
  const { setActiveWorkspaceId, clearActiveWorkspaceId } = await import("./pod.js");
908
1273
  if (cfg.workspaceId) {
909
1274
  setActiveWorkspaceId(cfg.workspaceId);
@@ -911,94 +1276,29 @@ async function installRaycast(cfg) {
911
1276
  else {
912
1277
  clearActiveWorkspaceId();
913
1278
  }
914
- // Provision a dedicated "raycast" agent identity + resolve the scoped MCP URL.
915
- const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "raycast");
1279
+ // A key minted here lists the 9-tool starter set on /mcp and loads more on
1280
+ // demand (`entry`); an existing live key is reused as it is.
1281
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "raycast", {
1282
+ toolProfile: "entry",
1283
+ });
916
1284
  const { setSurfaceAgentKey } = await import("./pod.js");
917
1285
  setSurfaceAgentKey("raycast", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
918
- // ── Write the Raycast MCP server config (merge, never clobber) ─────────────
919
- // Raycast's native MCP is stdio-only — bridge the HTTP /mcp endpoint with the
920
- // `mcp-remote` npm package (npx spawns it). Same bridge as Claude Desktop/Zed.
921
- const synapServerEntry = {
922
- command: "npx",
923
- args: [
924
- "-y",
925
- "mcp-remote",
926
- mcpUrl,
927
- "--header",
928
- `Authorization: Bearer ${effectiveApiKey}`,
929
- ],
930
- };
931
- // The mcp-config.json lives in an opaque UUID folder Raycast creates the first
932
- // time "Manage MCP Servers" is opened. If it already exists we merge into it;
933
- // otherwise we can't know the folder, so we print the block to paste.
934
- const existingPath = raycastMcpConfigPath();
935
- let wrote = false;
936
- if (existingPath) {
937
- try {
938
- let mcpConfig = {};
939
- try {
940
- mcpConfig = JSON.parse(fs.readFileSync(existingPath, "utf-8"));
941
- }
942
- catch {
943
- try {
944
- fs.copyFileSync(existingPath, `${existingPath}.bak`);
945
- }
946
- catch { /* best-effort */ }
947
- }
948
- mcpConfig.mcpServers = mcpConfig.mcpServers ?? {};
949
- mcpConfig.mcpServers["synap"] = synapServerEntry;
950
- fs.writeFileSync(existingPath, JSON.stringify(mcpConfig, null, 2) + "\n", { mode: 0o600 });
951
- wrote = true;
952
- log.success(`Synap MCP server added to Raycast: ${existingPath}`);
953
- log.dim("Reopen Raycast → 'Manage MCP Servers' → 'synap' is connected with all tools.");
954
- log.dim("First call spawns 'npx mcp-remote' (needs Node on PATH).");
955
- }
956
- catch (err) {
957
- log.warn(`Could not write Raycast MCP config: ${err.message}`);
958
- }
959
- }
960
- if (!wrote) {
961
- log.info("To add Synap to Raycast as an MCP server:");
962
- log.dim(" 1. Open Raycast → run 'Manage MCP Servers'");
963
- 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:");
965
- log.blank();
966
- log.dim(JSON.stringify({ mcpServers: { synap: synapServerEntry } }, null, 2));
967
- log.blank();
968
- log.dim(" (Re-running `synap connect --target=raycast` after step 2 writes it for you.)");
969
- }
970
- log.blank();
1286
+ const { writeMcpSnippetFiles } = await import("./mcp-snippets.js");
1287
+ const files = writeMcpSnippetFiles(mcpUrl, effectiveApiKey, agentUserId);
1288
+ log.success("Raycast agent key stored for the Synap extension's commands.");
971
1289
  log.dim(` Pod: ${cfg.podUrl}`);
972
1290
  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
1291
  log.blank();
998
- log.info("Install the skills:");
999
- for (const name of cfg.skills ?? getDeliverableSkills()) {
1000
- log.dim(` openclaw skills install ${name}`);
1001
- }
1292
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1293
+ log.info("Add Synap to Raycast AI (one time, Raycast Pro):");
1294
+ log.dim(" 1. In Raycast, run “Install MCP Server”.");
1295
+ log.dim(` 2. Name: ${serverName} Transport: HTTP URL: ${mcpUrl}`);
1296
+ log.dim(" 3. Authentication: OAuth → Dynamic → Sign In, then approve on your pod's consent page.");
1297
+ log.dim(" Fallback, if Sign In fails: no OAuth; add the HTTP header");
1298
+ log.dim(` Authorization: Bearer ${redactKey(effectiveApiKey)} (full value: ${files.jsonFile}, 0600)`);
1299
+ log.dim(" 4. Press “Install MCP Server” (⌘↵). In Raycast AI, mention the server and ask anything.");
1300
+ log.blank();
1301
+ log.dim("Raycast AI starts with the 9 essential Synap tools; it loads more when a task needs them.");
1002
1302
  return true;
1003
1303
  }
1004
1304
  // ─── Open WebUI ──────────────────────────────────────────────────────────────
@@ -1013,12 +1313,10 @@ async function installOpenWebUI(cfg) {
1013
1313
  log.info("2. MCP tool server:");
1014
1314
  log.dim(" Admin → Tools → Add Tool Server");
1015
1315
  log.dim(` URL: ${mcpUrl}`);
1016
- log.dim(` Header: Authorization: Bearer ${cfg.apiKey}`);
1316
+ log.dim(` Header: Authorization: Bearer <key — saved in ~/.synap/openwebui-mcp.json (0600)>`);
1017
1317
  log.blank();
1018
1318
  const synapDir = path.join(os.homedir(), ".synap");
1019
- if (!fs.existsSync(synapDir)) {
1020
- fs.mkdirSync(synapDir, { recursive: true });
1021
- }
1319
+ ensureSecretDir(synapDir);
1022
1320
  const refFile = path.join(synapDir, "openwebui-mcp.json");
1023
1321
  const refData = {
1024
1322
  modelSource: { baseUrl: `http://${podHost}/v1` },
@@ -1027,15 +1325,139 @@ async function installOpenWebUI(cfg) {
1027
1325
  headers: { Authorization: `Bearer ${cfg.apiKey}` },
1028
1326
  },
1029
1327
  };
1030
- fs.writeFileSync(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1328
+ writeSecretFile(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1031
1329
  log.success(`Connection details saved to ~/.synap/openwebui-mcp.json`);
1032
1330
  return true;
1033
1331
  }
1332
+ // ─── ChatGPT (web) ───────────────────────────────────────────────────────────
1333
+ /**
1334
+ * ChatGPT connects to Synap in TWO ways. This target wires the API-key path
1335
+ * (the one the CLI can automate) and documents the OAuth path.
1336
+ *
1337
+ * API key: ChatGPT's "Add custom connector" accepts a URL + an
1338
+ * `Authorization: Bearer <key>` header. That is the pod's standard MCP form,
1339
+ * so the connection is the same one every other target uses — just pointed at
1340
+ * the pod directly.
1341
+ *
1342
+ * OAuth: ChatGPT also supports OAuth (no header field). The pod's /mcp is
1343
+ * Bearer-only, so OAuth for a SINGLE pod isn't available — it lives on the
1344
+ * Control Plane (`https://api.synap.live/mcp`), which is an OAuth MCP server
1345
+ * exposing `list_pods` / `connect_pod` / 14 proxied `pod__*` tools. That path
1346
+ * is multi-pod and managed; `synap mcp connect-claude` prints it for
1347
+ * claude.ai, and the same URL works for ChatGPT's OAuth connector.
1348
+ *
1349
+ * Agent type: the pod only mints keys for known surface agent types
1350
+ * (`SURFACE_AGENT_TYPE_REQUIRED`), so a ChatGpt agent provisions under
1351
+ * "generic" — a dedicated identity, never the human profile key.
1352
+ */
1353
+ async function installChatGpt(cfg) {
1354
+ // ChatGPT provisions under agentType "generic" (not a known surface type),
1355
+ // so we store under surface name "chatgpt" for the user-facing CLI.
1356
+ // The pod's SURFACE_AGENT_TYPE_REQUIRED guard allows "generic" but not "chatgpt".
1357
+ // For idempotent reuse: pass surfaceName="chatgpt" so the local lookup finds
1358
+ // the stored key, while the pod call uses agentType="generic".
1359
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, agentTypeOfSurface("chatgpt"), { idempotent: true, surfaceName: "chatgpt" });
1360
+ const { setSurfaceAgentKey } = await import("./pod.js");
1361
+ setSurfaceAgentKey("chatgpt", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1362
+ await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1363
+ const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1364
+ const synapDir = path.join(os.homedir(), ".synap");
1365
+ ensureSecretDir(synapDir);
1366
+ const refFile = path.join(synapDir, "chatgpt-mcp.json");
1367
+ const refData = {
1368
+ mcpToolServer: {
1369
+ url: mcpUrl,
1370
+ headers: { Authorization: `Bearer ${effectiveApiKey}` },
1371
+ },
1372
+ oauthAlternative: {
1373
+ url: "https://api.synap.live/mcp",
1374
+ note: "ChatGPT OAuth connector (no header field). Multi-pod: list_pods, " +
1375
+ "connect_pod, then proxied pod__* tools. Same URL as claude.ai.",
1376
+ },
1377
+ };
1378
+ writeSecretFile(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1379
+ log.heading("Connecting ChatGPT → " + cfg.podUrl);
1380
+ log.blank();
1381
+ log.info("Path 1 — API key (this CLI can wire it):");
1382
+ log.blank();
1383
+ log.info(" ChatGPT → Settings → Connectors → Add custom connector");
1384
+ log.info(` Name: Synap`);
1385
+ log.info(` URL: ${mcpUrl}`);
1386
+ log.info(` Auth: API key`);
1387
+ log.info(` API key: ${redactKey(effectiveApiKey)} (full key: ~/.synap/chatgpt-mcp.json, 0600)`);
1388
+ log.blank();
1389
+ log.dim(" ChatGPT shows the key field inline — paste it there. If your build only " +
1390
+ "shows URL + a header, set Authorization: Bearer <key>.");
1391
+ log.blank();
1392
+ log.info("Path 2 — OAuth (multi-pod, no key to paste):");
1393
+ log.blank();
1394
+ log.info(" ChatGPT → Settings → Connectors → Add custom connector");
1395
+ log.info(" URL: https://api.synap.live/mcp");
1396
+ log.info(" Auth: OAuth (leave API key blank)");
1397
+ log.blank();
1398
+ log.dim(" After OAuth, ask ChatGPT to 'list my Synap pods' (list_pods) and connect " +
1399
+ "one (connect_pod). Same flow as claude.ai — see `synap mcp connect-claude`.");
1400
+ log.blank();
1401
+ log.success(`Connection details saved to ~/.synap/chatgpt-mcp.json`);
1402
+ log.dim("ChatGPT caches connectors per workspace — remove and re-add if you switch pods.");
1403
+ return true;
1404
+ }
1405
+ // ─── ChatGPT (web) — OAuth via Control Plane ──────────────────────────────────
1406
+ /**
1407
+ * ChatGPT OAuth via the Control Plane — same URL as claude.ai.
1408
+ *
1409
+ * The CP's `/mcp` endpoint is OAuth-only (ES256 access tokens, RFC 9728
1410
+ * `WWW-Authenticate` discovery). This path is MULTI-POD: after OAuth, the
1411
+ * user sees `list_pods` → `connect_pod` to pick a pod, then gets 14 proxied
1412
+ * `pod__*` tools. No API key is pasted; the CP stores an encrypted grant.
1413
+ *
1414
+ * Use this when:
1415
+ * - You have multiple pods and want to switch between them in ChatGPT
1416
+ * - You prefer OAuth over API-key auth
1417
+ * - You want the managed CP proxy (revoke in one place, no pod restarts)
1418
+ */
1419
+ async function installChatGptOAuth(cfg) {
1420
+ // Use the CP origin from the pod (or managed fallback)
1421
+ const { deriveCpOrigin } = await import("../commands/mcp.js");
1422
+ const { cpOrigin } = deriveCpOrigin(cfg.podUrl);
1423
+ const url = `${cpOrigin.replace(/\/$/, "")}/mcp`;
1424
+ log.heading("Connecting ChatGPT (OAuth) via Control Plane");
1425
+ log.blank();
1426
+ log.info("This uses the SAME OAuth URL as claude.ai — the CP is the single broker.");
1427
+ log.blank();
1428
+ log.info("ChatGPT → Settings → Connectors → Add custom connector");
1429
+ log.info(` Name: Synap (Control Plane)`);
1430
+ log.info(` URL: ${url}`);
1431
+ log.info(` Auth: OAuth (leave API key blank)`);
1432
+ log.blank();
1433
+ log.info("After connecting, ask ChatGPT to:");
1434
+ log.info(" 1. 'list my Synap pods' (list_pods)");
1435
+ log.info(" 2. 'connect pod <your-pod>' (connect_pod)");
1436
+ log.blank();
1437
+ log.dim("The CP stores an encrypted grant. Revoke in pod-admin → My Connections.");
1438
+ log.blank();
1439
+ log.dim("See `synap mcp connect-claude` for the identical claude.ai flow.");
1440
+ // Save a reference file for the user
1441
+ const synapDir = path.join(os.homedir(), ".synap");
1442
+ ensureSecretDir(synapDir);
1443
+ const refFile = path.join(synapDir, "chatgpt-oauth.json");
1444
+ const refData = {
1445
+ oauthUrl: url,
1446
+ note: "ChatGPT OAuth connector via Synap Control Plane. Multi-pod: list_pods, connect_pod, then proxied pod__* tools.",
1447
+ };
1448
+ writeSecretFile(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
1449
+ log.success(`Connection details saved to ~/.synap/chatgpt-oauth.json`);
1450
+ return true;
1451
+ }
1034
1452
  // ─── Windsurf ────────────────────────────────────────────────────────────────
1035
1453
  async function installWindsurf(info, cfg) {
1036
1454
  const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
1037
1455
  const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "windsurf");
1038
- writeMcpServerEntry(configPath, "synap", { url: mcpUrl, headers: { Authorization: `Bearer ${effectiveApiKey}` } });
1456
+ // Pod-derived name — a second pod must not overwrite the first.
1457
+ const windsurfServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1458
+ writeMcpServerEntry(configPath, windsurfServerName, { url: mcpUrl, headers: { Authorization: `Bearer ${effectiveApiKey}` } });
1459
+ if (windsurfServerName !== "synap")
1460
+ removeMcpServerEntry(configPath, "synap");
1039
1461
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1040
1462
  save("windsurf", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1041
1463
  log.success(`Windsurf MCP config updated: ${configPath}`);
@@ -1077,7 +1499,7 @@ async function installGoose(cfg) {
1077
1499
  else {
1078
1500
  updated = (existing ? existing.trimEnd() + "\n\n" : "") + "extensions:\n" + block + "\n";
1079
1501
  }
1080
- fs.writeFileSync(configPath, updated, { mode: 0o600 });
1502
+ writeSecretFile(configPath, updated, { mode: 0o600 });
1081
1503
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1082
1504
  save("goose", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1083
1505
  log.success(`Goose config updated: ${configPath}`);
@@ -1112,16 +1534,21 @@ async function installZed(cfg) {
1112
1534
  catch { /* malformed — start from scratch */ }
1113
1535
  }
1114
1536
  const contextServers = (settings.context_servers ?? {});
1115
- contextServers["synap"] = {
1537
+ // Pod-derived name — a second pod must not overwrite the first.
1538
+ const zedServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1539
+ if (zedServerName !== "synap")
1540
+ delete contextServers["synap"];
1541
+ contextServers[zedServerName] = {
1116
1542
  source: "custom",
1117
1543
  command: {
1118
1544
  path: "npx",
1119
- args: ["-y", "mcp-remote@latest", mcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
1545
+ args: ["-y", "mcp-remote@latest", mcpUrl, "--header", "Authorization:${SYNAP_AUTH}"],
1546
+ env: { SYNAP_AUTH: `Bearer ${effectiveApiKey}` },
1120
1547
  },
1121
1548
  settings: {},
1122
1549
  };
1123
1550
  settings.context_servers = contextServers;
1124
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
1551
+ writeSecretFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
1125
1552
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1126
1553
  save("zed", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1127
1554
  log.success(`Zed settings updated: ${settingsPath}`);
@@ -1152,12 +1579,16 @@ async function installVSCode(cfg) {
1152
1579
  catch { /* ignore */ }
1153
1580
  }
1154
1581
  existing.servers = existing.servers ?? {};
1155
- existing.servers["synap"] = {
1582
+ // Pod-derived name — a second pod must not overwrite the first.
1583
+ const vscodeServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1584
+ if (vscodeServerName !== "synap")
1585
+ delete existing.servers["synap"];
1586
+ existing.servers[vscodeServerName] = {
1156
1587
  type: "http",
1157
1588
  url: mcpUrl,
1158
1589
  headers: { Authorization: `Bearer ${effectiveApiKey}` },
1159
1590
  };
1160
- fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
1591
+ writeSecretFile(mcpJsonPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
1161
1592
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1162
1593
  save("vscode", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1163
1594
  log.success(`VS Code MCP config updated: ${mcpJsonPath}`);
@@ -1165,39 +1596,149 @@ async function installVSCode(cfg) {
1165
1596
  log.dim("Reload VS Code (or open the MCP panel) to activate.");
1166
1597
  return true;
1167
1598
  }
1599
+ /**
1600
+ * Derive a stable MCP server name from the pod URL so multi-pod setups
1601
+ * (perso + team) don't overwrite each other. e.g. pod.thearch.synap.live → synap-thearch
1602
+ */
1603
+ function mcpServerNameFromPodUrl(podUrl, fallback = "synap") {
1604
+ try {
1605
+ const host = new URL(podUrl).hostname; // pod.thearch.synap.live
1606
+ const parts = host.split(".").filter(Boolean);
1607
+ if (parts[0] === "pod" && parts[1])
1608
+ return `synap-${parts[1]}`;
1609
+ if (parts[0])
1610
+ return `synap-${parts[0]}`;
1611
+ }
1612
+ catch {
1613
+ /* ignore */
1614
+ }
1615
+ return fallback;
1616
+ }
1617
+ /** Merge/replace [mcp_servers.<name>] in a Grok config.toml without a TOML dep. */
1618
+ function writeGrokTomlMcpServer(configPath, serverName, opts) {
1619
+ const dir = path.dirname(configPath);
1620
+ if (!fs.existsSync(dir)) {
1621
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1622
+ }
1623
+ let existing = "";
1624
+ if (fs.existsSync(configPath)) {
1625
+ try {
1626
+ existing = fs.readFileSync(configPath, "utf-8");
1627
+ }
1628
+ catch {
1629
+ /* start fresh */
1630
+ }
1631
+ }
1632
+ // Strip any previous section for this server (header through next top-level section)
1633
+ const sectionHeader = `[mcp_servers.${serverName}]`;
1634
+ const sectionRe = new RegExp(
1635
+ // eslint-disable-next-line no-useless-escape
1636
+ `\\n?\\[mcp_servers\\.${serverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
1637
+ let next = existing.replace(sectionRe, "").replace(/\n{3,}/g, "\n\n").trimEnd();
1638
+ const block = [
1639
+ "",
1640
+ sectionHeader,
1641
+ `url = "${opts.url}"`,
1642
+ `headers = { Authorization = "Bearer ${opts.bearer}" }`,
1643
+ `enabled = true`,
1644
+ `startup_timeout_sec = 30`,
1645
+ "",
1646
+ ].join("\n");
1647
+ next = (next ? next + "\n" : "") + block;
1648
+ writeSecretFile(configPath, next.endsWith("\n") ? next : next + "\n", {
1649
+ mode: 0o600,
1650
+ });
1651
+ }
1652
+ // ─── Grok (Grok Build TUI) ───────────────────────────────────────────────────
1653
+ async function installGrok(info, cfg) {
1654
+ const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".grok", "config.toml");
1655
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1656
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "grok");
1657
+ writeGrokTomlMcpServer(configPath, serverName, {
1658
+ url: mcpUrl,
1659
+ bearer: effectiveApiKey,
1660
+ });
1661
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1662
+ save("grok", {
1663
+ hubApiKey: effectiveApiKey,
1664
+ agentUserId,
1665
+ podUrl: cfg.podUrl,
1666
+ });
1667
+ log.success(`Grok MCP config updated: ${configPath}`);
1668
+ log.info(` Server table: [mcp_servers.${serverName}]`);
1669
+ log.info(` URL: ${mcpUrl}`);
1670
+ log.dim(" Agent key provisioned (not the human profile key).");
1671
+ log.blank();
1672
+ if (serverName !== "synap") {
1673
+ log.dim("Multi-pod: connect each profile separately — server names are derived from the pod host so they don't overwrite:");
1674
+ log.dim(" synap connect --target=grok --name=perso");
1675
+ log.dim(" synap connect --target=grok --name=team");
1676
+ }
1677
+ log.dim("Restart Grok (or open a new session) and run: grok mcp doctor");
1678
+ return true;
1679
+ }
1168
1680
  async function installGeneric(cfg) {
1169
- const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1681
+ // Provision a real agent key (same as other MCP surfaces) — never paste the
1682
+ // human profile key into MCP configs (agents can't approve; human keys on MCP
1683
+ // write path are rejected by the pod).
1684
+ const { effectiveApiKey, agentUserId, mcpUrl } = await prepareMcpSurface(cfg, "generic");
1685
+ const serverName = mcpServerNameFromPodUrl(cfg.podUrl);
1170
1686
  const httpConfig = {
1171
- synap: {
1687
+ [serverName]: {
1172
1688
  url: mcpUrl,
1173
1689
  transport: "http",
1174
- headers: { Authorization: `Bearer ${cfg.apiKey}` },
1690
+ headers: { Authorization: `Bearer ${effectiveApiKey}` },
1175
1691
  },
1176
1692
  };
1177
- log.info("Universal MCP connection config:");
1693
+ log.info("Universal MCP connection config (agent key):");
1178
1694
  log.blank();
1179
1695
  log.info("HTTP direct:");
1180
- log.dim(JSON.stringify(httpConfig, null, 2));
1696
+ // Redacted on screen; the real key is in ~/.synap/mcp-config.json (0600).
1697
+ const shown = redactKey(effectiveApiKey);
1698
+ log.dim(JSON.stringify({ [serverName]: { ...httpConfig[serverName], headers: { Authorization: `Bearer ${shown}` } } }, null, 2));
1181
1699
  log.blank();
1182
- log.info("stdio bridge (mcp-remote):");
1183
- log.dim(` command: npx -y mcp-remote ${mcpUrl} --header "Authorization: Bearer ${cfg.apiKey}"`);
1700
+ log.info("Grok Build (~/.grok/config.toml):");
1701
+ log.dim(`[mcp_servers.${serverName}]`);
1702
+ log.dim(`url = "${mcpUrl}"`);
1703
+ log.dim(`headers = { Authorization = "Bearer ${shown}" }`);
1184
1704
  log.blank();
1185
- log.info("Environment variable form:");
1186
- log.dim(` MCP_SERVER_URL=${mcpUrl}`);
1187
- log.dim(` MCP_API_KEY=${cfg.apiKey}`);
1705
+ log.info("stdio bridge (mcp-remote) — key via env, not argv:");
1706
+ log.dim(` command: npx -y mcp-remote ${mcpUrl} --header "Authorization:\${SYNAP_AUTH}"`);
1707
+ log.dim(` env: SYNAP_AUTH="Bearer ${shown}"`);
1708
+ log.dim(" Real key: ~/.synap/mcp-config.json (0600).");
1188
1709
  log.blank();
1189
1710
  const synapDir = path.join(os.homedir(), ".synap");
1190
- if (!fs.existsSync(synapDir)) {
1191
- fs.mkdirSync(synapDir, { recursive: true });
1192
- }
1711
+ ensureSecretDir(synapDir);
1193
1712
  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`);
1713
+ // Merge with existing so dual-pod generic installs don't clobber each other
1714
+ let existing = {};
1715
+ if (fs.existsSync(configFile)) {
1716
+ try {
1717
+ existing = JSON.parse(fs.readFileSync(configFile, "utf-8"));
1718
+ }
1719
+ catch {
1720
+ /* replace */
1721
+ }
1722
+ }
1723
+ const merged = { ...existing, ...httpConfig };
1724
+ writeSecretFile(configFile, JSON.stringify(merged, null, 2) + "\n", {
1725
+ mode: 0o600,
1726
+ });
1727
+ const { setSurfaceAgentKey: save } = await import("./pod.js");
1728
+ save("generic", {
1729
+ hubApiKey: effectiveApiKey,
1730
+ agentUserId,
1731
+ podUrl: cfg.podUrl,
1732
+ });
1733
+ log.success(`MCP config written to ~/.synap/mcp-config.json (server: ${serverName})`);
1734
+ log.dim("Prefer: synap connect --target=grok to auto-write ~/.grok/config.toml");
1196
1735
  return true;
1197
1736
  }
1198
1737
  // ─── Codex ───────────────────────────────────────────────────────────────────
1199
1738
  async function installCodex(info, cfg) {
1200
- const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codex", "config.yaml");
1739
+ // Codex reads MCP servers from ~/.codex/config.toml (not config.yaml).
1740
+ // HTTP MCP format: [mcp_servers.synap] with url, headers, type="http"
1741
+ const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codex", "config.toml");
1201
1742
  const configDir = path.dirname(configPath);
1202
1743
  if (!fs.existsSync(configDir)) {
1203
1744
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -1211,27 +1752,36 @@ async function installCodex(info, cfg) {
1211
1752
  catch { /* ignore */ }
1212
1753
  }
1213
1754
  // Build MCP server entry
1214
- const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "codex");
1755
+ const mint = TARGET_KEY_MINT.codex;
1756
+ const { hubApiKey: effectiveApiKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, mint.agentType, { idempotent: mint.idempotent });
1757
+ const { setSurfaceAgentKey } = await import("./pod.js");
1758
+ setSurfaceAgentKey("codex", { hubApiKey: effectiveApiKey, agentUserId, podUrl: cfg.podUrl });
1215
1759
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1216
1760
  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)
1761
+ // Build TOML block for HTTP MCP server
1219
1762
  const mcpBlock = [
1220
- "mcpServers:",
1221
- ` - name: synap`,
1222
- ` url: "${mcpUrl}"`,
1223
- ` headers:`,
1224
- ` Authorization: "Bearer ${effectiveApiKey}"`,
1763
+ "",
1764
+ "[mcp_servers.synap]",
1765
+ `url = "${mcpUrl}"`,
1766
+ `http_headers = { Authorization = "Bearer ${effectiveApiKey}" }`,
1767
+ `startup_timeout_sec = 30`,
1768
+ `enabled = true`,
1769
+ "",
1225
1770
  ].join("\n");
1771
+ // Replace existing synap entry or append
1226
1772
  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);
1773
+ const sectionHeader = "[mcp_servers.synap]";
1774
+ const sectionRe = new RegExp(`\\n?\\[mcp_servers\\.synap\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
1775
+ if (existing.includes(sectionHeader)) {
1776
+ updated = existing.replace(sectionRe, "").replace(/\n{3,}/g, "\n\n").trimEnd();
1777
+ updated = (updated ? updated + "\n" : "") + mcpBlock;
1230
1778
  }
1231
1779
  else {
1232
- updated = existing ? `${existing.trimEnd()}\n\n${mcpBlock}\n` : `${mcpBlock}\n`;
1780
+ updated = existing ? `${existing.trimEnd()}\n${mcpBlock}\n` : mcpBlock;
1233
1781
  }
1234
- fs.writeFileSync(configPath, updated, { mode: 0o600 });
1782
+ if (!updated.endsWith("\n"))
1783
+ updated += "\n";
1784
+ writeSecretFile(configPath, updated, { mode: 0o600 });
1235
1785
  // Skills → append synap context to ~/.codex/instructions.md
1236
1786
  const instructionsPath = path.join(configDir, "instructions.md");
1237
1787
  const marker = "<!-- synap-skill -->";
@@ -1254,7 +1804,7 @@ async function installCodex(info, cfg) {
1254
1804
  "Always call orient (or list workspaces) first to discover workspace IDs.",
1255
1805
  "",
1256
1806
  ].join("\n");
1257
- fs.writeFileSync(instructionsPath, (instructions.trimEnd() + snippet).trimStart() + "\n", { mode: 0o600 });
1807
+ writeSecretFile(instructionsPath, (instructions.trimEnd() + snippet).trimStart() + "\n", { mode: 0o600 });
1258
1808
  }
1259
1809
  log.success(`Codex config updated: ${configPath}`);
1260
1810
  if (cfg.workspaceId) {
@@ -1333,7 +1883,7 @@ export async function installOpencode(cfg) {
1333
1883
  existing = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1334
1884
  }
1335
1885
  catch {
1336
- fs.copyFileSync(configPath, `${configPath}.bak`);
1886
+ copySecretBackup(configPath, `${configPath}.bak`);
1337
1887
  log.warn("Existing opencode.json unreadable — backed up to opencode.json.bak");
1338
1888
  }
1339
1889
  }
@@ -1345,12 +1895,16 @@ export async function installOpencode(cfg) {
1345
1895
  models,
1346
1896
  };
1347
1897
  // 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");
1898
+ const { hubApiKey: agentKey, agentUserId } = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "opencode", { idempotent: true });
1349
1899
  await enrollAgentIfNeeded(cfg.podUrl, cfg.apiKey, agentUserId, cfg.workspaceId);
1350
1900
  const podBase = cfg.podUrl.replace(/\/$/, "");
1351
1901
  const mcpUrl = buildMcpUrl(cfg.podUrl, cfg.workspaceId, cfg.projectId);
1352
1902
  const mcpBlock = (existing.mcp ?? {});
1353
- mcpBlock["synap"] = { type: "remote", url: mcpUrl, headers: { Authorization: `Bearer ${agentKey}` } };
1903
+ // Pod-derived name — a second pod must not overwrite the first.
1904
+ const opencodeServerName = mcpServerNameFromPodUrl(cfg.podUrl);
1905
+ if (opencodeServerName !== "synap")
1906
+ delete mcpBlock["synap"];
1907
+ mcpBlock[opencodeServerName] = { type: "remote", url: mcpUrl, headers: { Authorization: `Bearer ${agentKey}` } };
1354
1908
  const updated = {
1355
1909
  $schema: "https://opencode.ai/config.json",
1356
1910
  ...existing,
@@ -1358,7 +1912,7 @@ export async function installOpencode(cfg) {
1358
1912
  mcp: mcpBlock,
1359
1913
  ...(existing.model ? {} : { model: defaultModel }),
1360
1914
  };
1361
- fs.writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n", { mode: 0o600 });
1915
+ writeSecretFile(configPath, JSON.stringify(updated, null, 2) + "\n", { mode: 0o600 });
1362
1916
  const { setSurfaceAgentKey: save } = await import("./pod.js");
1363
1917
  save("opencode", { hubApiKey: agentKey, agentUserId, podUrl: cfg.podUrl });
1364
1918
  log.success(`opencode configured: ~/.config/opencode/opencode.json`);
@@ -1393,7 +1947,7 @@ export async function installAider(cfg) {
1393
1947
  const updated = existing.includes(marker)
1394
1948
  ? existing.replace(/# synap-provider[\s\S]*?(?=\n[a-zA-Z#]|\s*$)/, block.trimEnd())
1395
1949
  : existing ? `${existing.trimEnd()}\n\n${block}` : block;
1396
- fs.writeFileSync(confPath, updated.trimEnd() + "\n", { mode: 0o600 });
1950
+ writeSecretFile(confPath, updated.trimEnd() + "\n", { mode: 0o600 });
1397
1951
  log.success(`${provider.name} configured in ~/.aider.conf.yml`);
1398
1952
  log.dim(` Base URL: ${provider.baseUrl}`);
1399
1953
  log.dim(` Model: openai/${defaultModel}`);
@@ -1415,16 +1969,55 @@ export function writeMcpServerEntry(configPath, serverName, server) {
1415
1969
  catch {
1416
1970
  // corrupt or unreadable — back up and start fresh
1417
1971
  const backup = `${configPath}.bak-${Date.now()}`;
1418
- fs.copyFileSync(configPath, backup);
1972
+ copySecretBackup(configPath, backup);
1419
1973
  log.warn(`Existing config unreadable; backed up to ${path.basename(backup)}`);
1420
1974
  }
1421
1975
  }
1422
1976
  config.mcpServers = config.mcpServers ?? {};
1423
1977
  config.mcpServers[serverName] = server;
1424
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", {
1978
+ writeSecretFile(configPath, JSON.stringify(config, null, 2) + "\n", {
1425
1979
  mode: 0o600,
1426
1980
  });
1427
1981
  }
1982
+ /** Best-effort remove of a named MCP server entry (e.g. legacy `"synap"`). */
1983
+ function removeMcpServerEntry(configPath, serverName) {
1984
+ if (!fs.existsSync(configPath))
1985
+ return;
1986
+ try {
1987
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1988
+ if (!config.mcpServers?.[serverName])
1989
+ return;
1990
+ delete config.mcpServers[serverName];
1991
+ if (Object.keys(config.mcpServers).length === 0)
1992
+ delete config.mcpServers;
1993
+ writeSecretFile(configPath, JSON.stringify(config, null, 2) + "\n", {
1994
+ mode: 0o600,
1995
+ });
1996
+ }
1997
+ catch {
1998
+ /* leave config alone if unreadable */
1999
+ }
2000
+ }
2001
+ /** The prompt's first choice: write nothing, follow the pod default. */
2002
+ const POD_DEFAULT_CHOICE = "pod-default";
2003
+ /** The pod posture name each named preset maps to. */
2004
+ const PRESET_POSTURE = {
2005
+ creates: "create-with-undo",
2006
+ };
2007
+ /**
2008
+ * Read an agent's governance, or `null` when the pod has no such read door
2009
+ * (a pod older than it: 404/405) — "unknown", never "unset".
2010
+ */
2011
+ async function readAgentGovernance(agentUserId, hubCfg) {
2012
+ try {
2013
+ return (await hubGet(`/agent-users/${agentUserId}/governance`, {}, hubCfg));
2014
+ }
2015
+ catch (err) {
2016
+ if (err instanceof HubError && (err.status === 404 || err.status === 405))
2017
+ return null;
2018
+ throw err;
2019
+ }
2020
+ }
1428
2021
  // Governance presets — MUST stay in sync with GOVERNANCE_MODES in the backend
1429
2022
  // `@synap/governance-policy`. (Single-source TODO: have the backend resolve a
1430
2023
  // mode NAME so the CLI never re-states the autoApproveFor list — until then,
@@ -1465,11 +2058,37 @@ const GOVERNANCE_PRESETS = {
1465
2058
  writesRequireProposal: false,
1466
2059
  },
1467
2060
  };
1468
- export async function ensureAgentGovernance(cfg, agentUserId, presetMode) {
2061
+ export async function ensureAgentGovernance(cfg, agentUserId, presetMode, opts = {}) {
1469
2062
  const userId = await resolveUserId(cfg);
1470
2063
  const hubCfg = { ...cfg, userId };
1471
- // Non-interactive when a preset is passed (e.g. `--governance normal`);
1472
- // otherwise prompt (Normal pre-selected, Enter = <1s).
2064
+ let current = null;
2065
+ // A failed read (vs a pod that has no read door) is its own fact: the
2066
+ // strict fallback below is the same, but the message must not claim the
2067
+ // pod is old.
2068
+ let readFailed = false;
2069
+ try {
2070
+ current = await readAgentGovernance(agentUserId, hubCfg);
2071
+ }
2072
+ catch (err) {
2073
+ readFailed = true;
2074
+ log.warn(`Could not read agent approval: ${err.message}`);
2075
+ if (opts.onlyIfUnset)
2076
+ return; // unknown ⇒ never overwrite
2077
+ }
2078
+ if (opts.onlyIfUnset && current === null && opts.knownAgent) {
2079
+ log.dim("Kept this agent's approval settings.");
2080
+ return;
2081
+ }
2082
+ if (opts.onlyIfUnset && current?.configured) {
2083
+ log.dim(current.posture === "create-with-undo"
2084
+ ? 'Agent approval: "creates" — creating happens at once with Undo; changes and deletes wait for you.'
2085
+ : "Kept this agent's approval settings.");
2086
+ return;
2087
+ }
2088
+ // Non-interactive when a preset is passed (e.g. `--governance creates`);
2089
+ // otherwise prompt. "Follow the pod default" is pre-selected and writes
2090
+ // nothing: a new agent's trust is the pod's rule, a preset is only ever a
2091
+ // stricter (or looser) explicit choice.
1473
2092
  let mode = presetMode;
1474
2093
  if (!mode) {
1475
2094
  const res = await prompts({
@@ -1477,13 +2096,23 @@ export async function ensureAgentGovernance(cfg, agentUserId, presetMode) {
1477
2096
  name: "mode",
1478
2097
  message: "Governance — how should this agent's actions be gated?",
1479
2098
  choices: [
2099
+ {
2100
+ title: "Follow the pod default (recommended)",
2101
+ description: "Uses your pod's trust setting (Settings › Approvals); change it there anytime.",
2102
+ value: POD_DEFAULT_CHOICE,
2103
+ },
1480
2104
  {
1481
2105
  title: "Safe — every change requires your approval in Synap Studio",
1482
2106
  description: "Creates, updates, and deletes all go through proposals. Maximum control.",
1483
2107
  value: "safe",
1484
2108
  },
1485
2109
  {
1486
- title: "Normal — creating & editing are instant, deletes need approval (recommended)",
2110
+ title: "Creates — creating is instant with Undo; changes and deletes need approval",
2111
+ description: "Agents add new things at once and you can undo each one; edits, new fields, automations and deletes go through proposals.",
2112
+ value: "creates",
2113
+ },
2114
+ {
2115
+ title: "Normal — creating & editing are instant, deletes need approval",
1487
2116
  description: "Agents create and edit data immediately (edits merge, never wipe fields); deletes and full-content rewrites go through proposals.",
1488
2117
  value: "normal",
1489
2118
  },
@@ -1493,19 +2122,39 @@ export async function ensureAgentGovernance(cfg, agentUserId, presetMode) {
1493
2122
  value: "crazy",
1494
2123
  },
1495
2124
  ],
1496
- initial: 1,
2125
+ initial: 0,
1497
2126
  });
1498
2127
  if (!res.mode)
1499
2128
  return;
2129
+ if (res.mode === POD_DEFAULT_CHOICE) {
2130
+ log.dim("Agent approval: follows the pod default.");
2131
+ return;
2132
+ }
1500
2133
  mode = res.mode;
1501
2134
  }
1502
- const profile = GOVERNANCE_PRESETS[mode] ?? GOVERNANCE_PRESETS.normal;
2135
+ const posture = PRESET_POSTURE[mode];
2136
+ // A pod without the read door (`current === null` after a clean read) also
2137
+ // predates named postures: it would ignore `posture` and loosen the agent.
2138
+ // Fall back to the strictest legacy list rather than guess.
2139
+ const podKnowsPostures = current !== null;
2140
+ const legacyMode = mode === "creates" ? "safe" : mode;
2141
+ const profile = GOVERNANCE_PRESETS[legacyMode] ?? GOVERNANCE_PRESETS.safe;
1503
2142
  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}"`);
2143
+ await hubPatch(`/agent-users/${agentUserId}/governance`, posture && podKnowsPostures
2144
+ ? { posture }
2145
+ : {
2146
+ autoApproveFor: profile.autoApproveFor,
2147
+ writesRequireProposal: profile.writesRequireProposal,
2148
+ }, hubCfg);
2149
+ log.success(posture && podKnowsPostures
2150
+ ? 'Agent approval: "creates". Creating happens at once with Undo; changes, new fields, automations and deletes wait for you.'
2151
+ : mode === "creates"
2152
+ ? readFailed
2153
+ ? 'Agent approval: "safe" — could not read this pod\'s approval settings, so every change waits for you.'
2154
+ : 'Agent approval: "safe" — this pod predates "creates", so every change waits for you.'
2155
+ : legacyMode === "normal"
2156
+ ? 'Agent approval: "normal". Creating and editing (including profile and property changes and new automations) happen at once; deletes wait for you.'
2157
+ : `Agent approval: "${legacyMode}".`);
1509
2158
  }
1510
2159
  catch (err) {
1511
2160
  log.warn(`Could not apply agent governance: ${err.message}`);