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