heyspex 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +21 -0
  2. package/bin/hs.mjs +931 -98
  3. package/bin/spexcode.mjs +262 -0
  4. package/dev-factory/engine/worktree.mjs +740 -0
  5. package/dev-factory/lib/code-repo-root.mjs +67 -0
  6. package/dev-factory/lib/context-budget.mjs +84 -0
  7. package/dev-factory/lib/epic-detect.mjs +190 -0
  8. package/dev-factory/lib/fleet-queue-file.mjs +846 -0
  9. package/dev-factory/lib/fleet-queue.mjs +426 -0
  10. package/dev-factory/lib/fleet-state.mjs +14 -18
  11. package/dev-factory/lib/model-catalog.mjs +338 -0
  12. package/dev-factory/lib/seat-context.mjs +199 -0
  13. package/dev-factory/lib/spec-io.mjs +93 -0
  14. package/dev-factory/lib/sufficiency-score.mjs +8 -2
  15. package/dev-factory/lib/worktree-allocator.mjs +744 -0
  16. package/dev-factory/model-catalog.json +222 -0
  17. package/dist/cli/hs-check.mjs +1085 -103
  18. package/dist/cli/hs-claim.mjs +936 -63
  19. package/dist/cli/hs-connect.mjs +9786 -1910
  20. package/dist/cli/hs-defer.mjs +5267 -3053
  21. package/dist/cli/hs-dev.mjs +564 -0
  22. package/dist/cli/hs-env.mjs +385 -299
  23. package/dist/cli/hs-index-rebuild.mjs +7129 -0
  24. package/dist/cli/hs-index-reconcile.mjs +7852 -0
  25. package/dist/cli/hs-init.mjs +777 -138
  26. package/dist/cli/hs-install.mjs +6583 -485
  27. package/dist/cli/hs-join.mjs +10137 -2368
  28. package/dist/cli/hs-vault-pull.mjs +4659 -5882
  29. package/dist/cli/hs-vault.mjs +3793 -1537
  30. package/dist/cli/hs-workspace.mjs +2550 -524
  31. package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
  32. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
  33. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
  34. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
  35. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
  36. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
  38. package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
  42. package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
  43. package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
  44. package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
  45. package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
  46. package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
  47. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
  48. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
  49. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
  50. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
  51. package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
  52. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
  53. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
  54. package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
  55. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
  56. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
  57. package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
  58. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
  59. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
  60. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
  61. package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
  62. package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
  63. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
  64. package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
  65. package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
  66. package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
  67. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
  68. package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
  69. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
  70. package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
  71. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
  72. package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
  73. package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
  74. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
  75. package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
  76. package/docs/mcp-client-config.md +259 -6
  77. package/package.json +231 -124
  78. package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
  79. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
  80. package/plugins/heyspex/.mcp.json +12 -0
  81. package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
  82. package/plugins/heyspex/bin/run-hook.mjs +62 -0
  83. package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
  84. package/plugins/heyspex/hooks/hooks.json +361 -0
  85. package/plugins/heyspex/mcp.json +13 -0
  86. package/scripts/check-mcp-freshness.mjs +17 -9
  87. package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
  88. package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
  89. package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
  90. package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
  91. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
  92. package/scripts/claude-hooks/_run.mjs +120 -0
  93. package/scripts/claude-hooks/_transcript-text.mjs +60 -0
  94. package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
  95. package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
  96. package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
  97. package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
  98. package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
  99. package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
  100. package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
  101. package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
  102. package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
  103. package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
  104. package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
  105. package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
  106. package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
  107. package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
  108. package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
  109. package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
  110. package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
  111. package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
  112. package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
  113. package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
  114. package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
  115. package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
  116. package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
  117. package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
  118. package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
  119. package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
  120. package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
  121. package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
  122. package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
  123. package/scripts/claude-hooks/log-session-activity.mjs +341 -0
  124. package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
  125. package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
  126. package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
  127. package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
  128. package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
  129. package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
  130. package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
  131. package/scripts/claude-hooks/research-method-guard.mjs +398 -0
  132. package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
  133. package/scripts/claude-hooks/session-end.mjs +449 -0
  134. package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
  135. package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
  136. package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
  137. package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
  138. package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
  139. package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
  140. package/scripts/cursor-hooks/manifest.mjs +235 -0
  141. package/scripts/dev-server-status.mjs +34 -13
  142. package/scripts/dev-server-stop.mjs +313 -0
  143. package/scripts/heyspex-mode.mjs +268 -0
  144. package/scripts/hs-auto-resolve.mjs +240 -0
  145. package/scripts/hs-auto-source.mjs +398 -0
  146. package/scripts/hs-auto.mjs +134 -0
  147. package/scripts/hs-capture-cli.mjs +326 -0
  148. package/scripts/hs-classifier.mjs +136 -0
  149. package/scripts/hs-companion.mjs +88 -0
  150. package/scripts/hs-gateway.mjs +115 -0
  151. package/scripts/hs-mode.mjs +125 -0
  152. package/scripts/hs-next.mjs +441 -0
  153. package/scripts/hs-settings.mjs +338 -0
  154. package/scripts/hs-view.mjs +333 -0
  155. package/scripts/hs-worktree.mjs +201 -0
  156. package/scripts/install-claude-code-hooks.mjs +270 -0
  157. package/scripts/install-cursor-hooks.mjs +471 -0
  158. package/scripts/lib/agent-env-markers.mjs +38 -0
  159. package/scripts/lib/console-url-resolver.mjs +197 -60
  160. package/scripts/lib/customer-kit.mjs +886 -0
  161. package/scripts/lib/dev-mode.mjs +244 -28
  162. package/scripts/lib/heyspex-home.mjs +72 -0
  163. package/scripts/lib/hs-command.mjs +277 -0
  164. package/scripts/lib/hs-companion-settings.mjs +472 -0
  165. package/scripts/lib/hs-source-commit.mjs +59 -5
  166. package/scripts/lib/hs-source-identity.mjs +526 -0
  167. package/scripts/lib/hs-source-log.mjs +11 -21
  168. package/scripts/lib/hs-source-push.mjs +107 -58
  169. package/scripts/lib/hs-worktree-args.mjs +115 -0
  170. package/scripts/lib/shared-path-helpers.mjs +134 -0
  171. package/scripts/lib/standalone-doctor.mjs +540 -3
  172. package/scripts/lib/subagent-git-lock.mjs +109 -0
  173. package/scripts/lib/template-pin.mjs +34 -0
  174. package/scripts/provenance-lib.mjs +91 -0
  175. package/scripts/stamp-provenance-on-edit.mjs +222 -0
  176. package/scripts/wip-ledger-health.mjs +653 -0
  177. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
  178. package/spexharness/src/adapters/audit.mjs +197 -0
  179. package/spexharness/src/adapters/claude-code.mjs +441 -0
  180. package/spexharness/src/chat/tui/ansi.mjs +448 -0
  181. package/spexharness/src/chat/tui/palette.mjs +22 -0
  182. package/spexharness/src/chat/tui/select.mjs +208 -0
  183. package/spexharness/src/config-root.mjs +54 -0
  184. package/spexharness/src/context-budget.mjs +141 -0
  185. package/spexharness/src/dev-server-control.mjs +389 -0
  186. package/spexharness/src/doctrine/behaviour.mjs +371 -0
  187. package/spexharness/src/doctrine/corpus.mjs +733 -0
  188. package/spexharness/src/doctrine/inject.mjs +338 -0
  189. package/spexharness/src/doctrine/merge.mjs +184 -0
  190. package/spexharness/src/doctrine/sources.mjs +252 -0
  191. package/spexharness/src/doctrine/synthesize.mjs +401 -0
  192. package/spexharness/src/doctrine/vault-source.mjs +574 -0
  193. package/spexharness/src/doctrine/writer.mjs +176 -0
  194. package/spexharness/src/events.mjs +84 -0
  195. package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
  196. package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
  197. package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
  198. package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
  199. package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
  200. package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
  201. package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
  202. package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
  203. package/spexharness/src/gates/git-identity-guard.mjs +61 -0
  204. package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
  205. package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
  206. package/spexharness/src/gates/git-stash-gate.mjs +159 -0
  207. package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
  208. package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
  209. package/spexharness/src/gates/inject-vault-context.mjs +239 -0
  210. package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
  211. package/spexharness/src/gates/plan-review-gate.mjs +233 -0
  212. package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
  213. package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
  214. package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
  215. package/spexharness/src/gates/settings-write-gate.mjs +104 -0
  216. package/spexharness/src/git-reflog-audit.mjs +294 -0
  217. package/spexharness/src/intent-classifier.mjs +194 -0
  218. package/spexharness/src/intent-directive.mjs +541 -0
  219. package/spexharness/src/profile/learn.mjs +551 -0
  220. package/spexharness/src/profile/proposals.mjs +357 -0
  221. package/spexharness/src/profile/vault-inclusion.mjs +386 -0
  222. package/spexharness/src/redact.mjs +151 -0
  223. package/spexharness/src/rule-table.mjs +819 -0
  224. package/spexharness/src/seat-context.mjs +122 -0
  225. package/spexharness/src/session-bootstrap.mjs +512 -0
  226. package/spexharness/src/session-record.mjs +815 -0
  227. package/spexharness/src/token-inventory.mjs +207 -0
  228. package/vendor/heyspex-mcp/account-switch.mjs +95 -0
  229. package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
  230. package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
  231. package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
  232. package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
  233. package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
  234. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
  235. package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
  236. package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
  237. package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
  238. package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
  239. package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
  240. package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
  241. package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
  242. package/vendor/heyspex-mcp/cli.mjs +201 -15524
  243. package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
  244. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
  245. package/vendor/heyspex-mcp/config-seed.yaml +15 -0
  246. package/vendor/heyspex-mcp/config-settings.mjs +290 -7
  247. package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
  248. package/vendor/heyspex-mcp/console-transport.mjs +166 -0
  249. package/vendor/heyspex-mcp/credentials.mjs +584 -0
  250. package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
  251. package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
  252. package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
  253. package/vendor/heyspex-mcp/design-tools.mjs +4 -1
  254. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
  255. package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
  256. package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
  257. package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
  258. package/vendor/heyspex-mcp/events-tools.mjs +207 -0
  259. package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
  260. package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
  261. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
  262. package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
  263. package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
  264. package/vendor/heyspex-mcp/http-transport.mjs +300 -0
  265. package/vendor/heyspex-mcp/list-tools.mjs +257 -0
  266. package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
  267. package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
  268. package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
  269. package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
  270. package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
  271. package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
  272. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
  273. package/vendor/heyspex-mcp/package-root.mjs +32 -0
  274. package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
  275. package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
  276. package/vendor/heyspex-mcp/plan-push.mjs +906 -21
  277. package/vendor/heyspex-mcp/plan-review.mjs +124 -30
  278. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  279. package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
  280. package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
  281. package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
  282. package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
  283. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
  284. package/vendor/heyspex-mcp/promote-research.mjs +660 -53
  285. package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
  286. package/vendor/heyspex-mcp/prompts.mjs +1460 -0
  287. package/vendor/heyspex-mcp/read-path.mjs +29 -9
  288. package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
  289. package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
  290. package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
  291. package/vendor/heyspex-mcp/register-spec.mjs +266 -54
  292. package/vendor/heyspex-mcp/request-context.mjs +59 -0
  293. package/vendor/heyspex-mcp/research-focus.mjs +207 -4
  294. package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
  295. package/vendor/heyspex-mcp/research-tools.mjs +4 -4
  296. package/vendor/heyspex-mcp/response-budget.mjs +348 -0
  297. package/vendor/heyspex-mcp/review-focus.mjs +25 -1
  298. package/vendor/heyspex-mcp/search-specs.mjs +280 -0
  299. package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
  300. package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
  301. package/vendor/heyspex-mcp/session-owner.mjs +108 -0
  302. package/vendor/heyspex-mcp/session-tools.mjs +152 -0
  303. package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
  304. package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
  305. package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
  306. package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
  307. package/vendor/heyspex-mcp/split-research.mjs +301 -56
  308. package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
  309. package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
  310. package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
  311. package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
  312. package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
  313. package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
  314. package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
  315. package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
  316. package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
  317. package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
  318. package/vendor/heyspex-mcp/vault-io.mjs +724 -0
  319. package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
  320. package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
  321. package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
  322. package/vendor/heyspex-mcp/whoami.mjs +91 -0
@@ -0,0 +1,2076 @@
1
+ // The argv verbs: node cli.mjs validate | register | call | export | pair | login.
2
+ // Each returns an exit code. Extracted from cli.mjs under
3
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
4
+ //
5
+ // Freshness handshake (HS-G263-DIAG-2F0X): this process is long-running, so a
6
+ // vendor/heyspex-mcp/*.mjs edit made after startup does NOT reach it — the
7
+ // in-memory tool schemas silently drift from code on disk. Stamping a build
8
+ // fingerprint at startup lets scripts/check-mcp-freshness.mjs (and its
9
+ // SessionStart hook) prove staleness instead of guessing. See
10
+ // [[mcp-server-restart-after-vendor-updates]].
11
+ //
12
+ // Browser hand-off primitives for `hs login` (RFC 8252 loopback), epic
13
+ // [[HS-IFQM-IZQ7-EPIC-hs-login-browser-hand-off-for-cli-and]].
14
+
15
+ import { DEFAULT_LOGIN_TIMEOUT_MS, buildConnectUrl, describeCallbackFailure, describeRedeemFailure, generateLoginState, openBrowser as openBrowserDefault, startCallbackListener as startCallbackListenerDefault } from './login-handoff.mjs';
16
+ import { getConfig, getConsoleUrl } from './mcp-config.mjs';
17
+ import { homedir, hostname } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { handleToolsCall, handleToolsList } from './tool-dispatch.mjs';
21
+ import { writeBuildRecord } from '../../scripts/check-mcp-freshness.mjs';
22
+ import { registerSpec } from './register-spec.mjs';
23
+ import { validateLinkage } from './validate-linkage.mjs';
24
+ import { execFileSync } from 'node:child_process';
25
+ import {
26
+ LEGACY_ACCOUNT_ID,
27
+ assertNotRealHomeUnderTest,
28
+ describeBearerSource,
29
+ findStoredAccount,
30
+ migrateLegacyCredentials,
31
+ readBearer,
32
+ readCredentialsStore,
33
+ writeAccountCredentials,
34
+ } from './credentials.mjs';
35
+ import { followCurrentAccountInRegistry, signOutAccount, switchCurrentAccount } from './account-switch.mjs';
36
+ import { resolveSessionOwnerUncached } from './session-owner.mjs';
37
+ import { isLoopbackConsoleUrl } from '../../scripts/lib/console-url-resolver.mjs';
38
+ import { formatIdentity, probeCredentialIdentity, readStoredBearer } from './whoami.mjs';
39
+ import {
40
+ EXIT_ACCOUNT_MISMATCH,
41
+ EXIT_RECEIPT_DISAGREE,
42
+ buildSidesReceipt,
43
+ formatSidesReceipt,
44
+ unreadSide,
45
+ } from './receipt-shape.mjs';
46
+
47
+ // Basenames of `.md` files staged under specs/ — the scope for --staged-only.
48
+ export function stagedSpecBasenames(vaultRoot) {
49
+ let out = '';
50
+ try {
51
+ out = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM'], {
52
+ cwd: vaultRoot,
53
+ encoding: 'utf-8',
54
+ });
55
+ } catch {
56
+ return [];
57
+ }
58
+ return out
59
+ .split(/\r?\n/)
60
+ .map((p) => p.trim())
61
+ .filter((p) => p.startsWith('specs/') && p.endsWith('.md'))
62
+ .map((p) => p.replace(/.*\//, '').replace(/\.md$/, ''));
63
+ }
64
+
65
+ // Frozen-by-protocol mismatches are informational, never defects — a closed /
66
+ // superseded spec's roster is historical record and editing it is forbidden
67
+ // ([[closed-trackable-specs-are-historical-record-not-living-docs]]). Printed on
68
+ // BOTH the OK and the failing path so they are always visible, never swallowed.
69
+ function renderFrozenMismatches(result) {
70
+ const frozen = result.frozen_parent_child_mismatches ?? [];
71
+ if (!frozen.length) return;
72
+ process.stdout.write(
73
+ ` ${frozen.length} frozen parent/child mismatch(es) — excluded: a closed/superseded spec's ` +
74
+ `roster is historical record and must not be edited. No action required.\n`,
75
+ );
76
+ for (const m of frozen) {
77
+ process.stdout.write(` [FROZEN — not a defect] ${m.parent} ⇄ ${m.child} (${m.frozen_side} frozen): ${m.issue}\n`);
78
+ }
79
+ }
80
+
81
+ export function renderLinkageText(result, { stagedOnly } = {}) {
82
+ const s = result.summary;
83
+ if (result.valid) {
84
+ process.stdout.write(
85
+ `validate-linkage: OK (${s.total_specs_checked} subject spec(s) checked${stagedOnly ? ', staged-only' : ''})\n`,
86
+ );
87
+ if (result.warnings.length) {
88
+ process.stdout.write(` (${result.warnings.length} warning(s) — run with --json for details)\n`);
89
+ }
90
+ renderFrozenMismatches(result);
91
+ return;
92
+ }
93
+ process.stdout.write(
94
+ `validate-linkage: ${s.orphans} orphan(s), ${s.broken_links} broken link(s), ` +
95
+ `${s.missing_refs} missing manifest ref(s), ${s.mismatches} parent/child mismatch(es)\n\n`,
96
+ );
97
+ for (const o of result.orphan_specs) {
98
+ process.stdout.write(`[ORPHAN ${o.type}] ${o.spec_id}\n → ${o.file_path}\n ${o.reason}\n fix: ${o.suggestion}\n`);
99
+ }
100
+ for (const b of result.broken_wikilinks) {
101
+ process.stdout.write(`[BROKEN LINK] ${b.link} at ${b.file}:${b.line}\n`);
102
+ }
103
+ for (const m of result.missing_manifest_refs) {
104
+ process.stdout.write(`[MISSING REF] ${m.manifest} → ${m.ref} (${m.error})\n`);
105
+ }
106
+ for (const m of result.parent_child_mismatches) {
107
+ process.stdout.write(`[MISMATCH] ${m.parent} ⇄ ${m.child}: ${m.issue}\n`);
108
+ }
109
+ renderFrozenMismatches(result);
110
+ }
111
+
112
+ export async function runValidateCli(argv) {
113
+ const flags = { json: false, preCommit: false, stagedOnly: false, noWarnings: false, spec: null };
114
+ for (let i = 0; i < argv.length; i++) {
115
+ const a = argv[i];
116
+ if (a === '--json') flags.json = true;
117
+ else if (a === '--pre-commit') flags.preCommit = true;
118
+ else if (a === '--staged-only') flags.stagedOnly = true;
119
+ else if (a === '--no-warnings') flags.noWarnings = true;
120
+ else if (a === '--spec') flags.spec = argv[++i];
121
+ else if (a.startsWith('--spec=')) flags.spec = a.slice('--spec='.length);
122
+ else {
123
+ process.stderr.write(`[heyspex-mcp validate] unknown argument: ${a}\n`);
124
+ return 2;
125
+ }
126
+ }
127
+ const { vaultRoot } = getConfig();
128
+ if (!existsSync(vaultRoot)) {
129
+ process.stderr.write(`[heyspex-mcp validate] vault root not found: ${vaultRoot}\n`);
130
+ return 2;
131
+ }
132
+ let specIds = flags.spec ? [flags.spec] : null;
133
+ if (flags.stagedOnly) {
134
+ specIds = stagedSpecBasenames(vaultRoot);
135
+ if (specIds.length === 0) return 0; // nothing staged under specs/ → nothing to validate
136
+ }
137
+ const result = validateLinkage(vaultRoot, { spec_ids: specIds, includeWarnings: !flags.noWarnings });
138
+ if (flags.json) {
139
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
140
+ } else {
141
+ renderLinkageText(result, flags);
142
+ }
143
+ return result.valid ? 0 : 1;
144
+ }
145
+
146
+ // `register` CLI verb — the terminal / disaster-recovery face of
147
+ // heyspex_register_spec ([[HS-TASK-5K13]], [[capability-three-faces-no-per-capability-slash]]).
148
+ // node vendor/heyspex-mcp/cli.mjs register HS-TASK-5K13 --parent HS-EPIC-0037 [--json] [--dry-run]
149
+ export async function runRegisterCli(argv) {
150
+ const flags = { json: false, dryRun: false, specId: null, parent: null, roadmap: null, position: null };
151
+ const positionals = [];
152
+ for (let i = 0; i < argv.length; i++) {
153
+ const a = argv[i];
154
+ if (a === '--json') flags.json = true;
155
+ else if (a === '--dry-run') flags.dryRun = true;
156
+ else if (a === '--parent') flags.parent = argv[++i];
157
+ else if (a.startsWith('--parent=')) flags.parent = a.slice('--parent='.length);
158
+ else if (a === '--roadmap') flags.roadmap = argv[++i];
159
+ else if (a.startsWith('--roadmap=')) flags.roadmap = a.slice('--roadmap='.length);
160
+ else if (a === '--position') flags.position = argv[++i];
161
+ else if (a.startsWith('--position=')) flags.position = a.slice('--position='.length);
162
+ else if (a.startsWith('--')) {
163
+ process.stderr.write(`[heyspex-mcp register] unknown argument: ${a}\n`);
164
+ return 2;
165
+ } else positionals.push(a);
166
+ }
167
+ flags.specId = positionals[0] || null;
168
+ if (!flags.specId) {
169
+ process.stderr.write('[heyspex-mcp register] usage: register <spec-id> [--parent <id>] [--roadmap <id>] [--position <p>] [--json] [--dry-run]\n');
170
+ return 2;
171
+ }
172
+ const { vaultRoot } = getConfig();
173
+ if (!existsSync(vaultRoot)) {
174
+ process.stderr.write(`[heyspex-mcp register] vault root not found: ${vaultRoot}\n`);
175
+ return 2;
176
+ }
177
+ const result = registerSpec(vaultRoot, {
178
+ spec_id: flags.specId,
179
+ parent_id: flags.parent,
180
+ roadmap_id: flags.roadmap || undefined,
181
+ position: flags.position || undefined,
182
+ dryRun: flags.dryRun,
183
+ });
184
+ if (flags.json) {
185
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
186
+ } else if (!result.success) {
187
+ process.stdout.write(`register-spec: ${result.error} — ${result.message}\n`);
188
+ if (result.suggestions?.length) process.stdout.write(` did you mean: ${result.suggestions.join(', ')}\n`);
189
+ } else if (result.already_registered) {
190
+ process.stdout.write(`register-spec: ${result.spec_id} already linked — no change.\n`);
191
+ } else {
192
+ process.stdout.write(`register-spec: ${result.dry_run ? '[dry-run] would apply' : 'linked'} ${result.spec_id}\n`);
193
+ for (const c of result.changes) process.stdout.write(` • ${c.action} (${c.file})\n`);
194
+ if (result.validation) {
195
+ process.stdout.write(` validation: ${result.validation.valid ? 'OK' : `${result.validation.orphan_specs.length} orphan(s), ${result.validation.parent_child_mismatches.length} mismatch(es) remaining`}\n`);
196
+ }
197
+ }
198
+ return result.success ? 0 : 1;
199
+ }
200
+
201
+ /**
202
+ * Coerce an `--arg key=value` value: JSON literals when parseable, else raw string.
203
+ * @param {string} raw
204
+ */
205
+ export function coerceCallCliArgValue(raw) {
206
+ try {
207
+ return JSON.parse(raw);
208
+ } catch {
209
+ return raw;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * @param {string} flag e.g. `--arg spec_id=HS-TASK-NCH5`
215
+ * @returns {{ key: string, value: unknown } | { error: { code: number, message: string } }}
216
+ */
217
+ export function parseCallCliArgFlag(flag) {
218
+ const body = flag.startsWith('--arg=') ? flag.slice('--arg='.length) : null;
219
+ const eq = body !== null ? body.indexOf('=') : -1;
220
+ if (body !== null && eq > 0) {
221
+ const key = body.slice(0, eq);
222
+ return { key, value: coerceCallCliArgValue(body.slice(eq + 1)) };
223
+ }
224
+ const bare = flag.startsWith('--arg') ? flag : null;
225
+ if (bare === null) {
226
+ return { error: { code: 2, message: `invalid --arg flag: ${flag}` } };
227
+ }
228
+ return { error: { code: 2, message: '--arg requires key=value (e.g. --arg spec_id=HS-TASK-NCH5)' } };
229
+ }
230
+
231
+ /**
232
+ * Parse argv for the `call` CLI verb — one-shot MCP tool invocation without
233
+ * starting the stdio JSON-RPC loop ([[HS-TASK-ISK7]] parity for all tools).
234
+ *
235
+ * Prefer repeated `--arg key=value` over `--args '<json>'` on Windows/PowerShell —
236
+ * shell JSON quoting is unreliable there.
237
+ *
238
+ * @param {string[]} argv tokens after `call`
239
+ * @returns {{ help?: true, error?: { code: number, message: string }, tool?: string, args?: object, json?: boolean }}
240
+ */
241
+ export function parseCallCliArgs(argv) {
242
+ let tool = null;
243
+ let json = false;
244
+ let argsJson = null;
245
+ let argsFromFile = false;
246
+ /** @type {Record<string, unknown>} */
247
+ const argPairs = {};
248
+ for (let i = 0; i < argv.length; i += 1) {
249
+ const a = argv[i];
250
+ if (a === '--help' || a === '-h') return { help: true };
251
+ if (a === '--json') {
252
+ json = true;
253
+ continue;
254
+ }
255
+ if (a === '--args') {
256
+ const v = argv[++i];
257
+ if (v === undefined) {
258
+ return { error: { code: 2, message: '--args requires a JSON object string' } };
259
+ }
260
+ argsJson = v;
261
+ continue;
262
+ }
263
+ if (a.startsWith('--args=')) {
264
+ argsJson = a.slice('--args='.length);
265
+ continue;
266
+ }
267
+ if (a === '--args-file') {
268
+ const v = argv[++i];
269
+ if (v === undefined) {
270
+ return { error: { code: 2, message: '--args-file requires a path' } };
271
+ }
272
+ argsJson = v;
273
+ argsFromFile = true;
274
+ continue;
275
+ }
276
+ if (a.startsWith('--args-file=')) {
277
+ argsJson = a.slice('--args-file='.length);
278
+ argsFromFile = true;
279
+ continue;
280
+ }
281
+ if (a === '--arg') {
282
+ const v = argv[++i];
283
+ if (v === undefined || !v.includes('=')) {
284
+ return { error: { code: 2, message: '--arg requires key=value (e.g. --arg spec_id=HS-TASK-NCH5)' } };
285
+ }
286
+ const eq = v.indexOf('=');
287
+ const key = v.slice(0, eq);
288
+ if (!key) {
289
+ return { error: { code: 2, message: '--arg requires key=value (e.g. --arg spec_id=HS-TASK-NCH5)' } };
290
+ }
291
+ argPairs[key] = coerceCallCliArgValue(v.slice(eq + 1));
292
+ continue;
293
+ }
294
+ if (a.startsWith('--arg=')) {
295
+ const parsed = parseCallCliArgFlag(a);
296
+ if (parsed.error) return parsed;
297
+ argPairs[parsed.key] = parsed.value;
298
+ continue;
299
+ }
300
+ if (a.startsWith('-')) {
301
+ return { error: { code: 2, message: `unknown argument: ${a}` } };
302
+ }
303
+ if (!tool) {
304
+ tool = a;
305
+ continue;
306
+ }
307
+ return { error: { code: 2, message: `unexpected argument: ${a}` } };
308
+ }
309
+ if (!tool) {
310
+ return { error: { code: 2, message: 'tool name required (e.g. heyspex_claim_spec)' } };
311
+ }
312
+ /** @type {Record<string, unknown>} */
313
+ let args = {};
314
+ if (argsJson !== null) {
315
+ let rawJson = argsJson;
316
+ if (argsFromFile) {
317
+ if (!existsSync(rawJson)) {
318
+ return { error: { code: 2, message: `--args-file not found: ${rawJson}` } };
319
+ }
320
+ try {
321
+ rawJson = readFileSync(rawJson, 'utf8');
322
+ } catch (err) {
323
+ return {
324
+ error: {
325
+ code: 2,
326
+ message: `--args-file could not read ${argsJson}: ${err?.message ?? err}`,
327
+ },
328
+ };
329
+ }
330
+ }
331
+ try {
332
+ const parsed = JSON.parse(rawJson);
333
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
334
+ return { error: { code: 2, message: '--args/--args-file must be a JSON object' } };
335
+ }
336
+ args = parsed;
337
+ } catch {
338
+ return { error: { code: 2, message: '--args/--args-file must be valid JSON' } };
339
+ }
340
+ }
341
+ Object.assign(args, argPairs);
342
+ return { tool, args, json };
343
+ }
344
+
345
+ export const PAIR_CLI_HELP = `heyspex-mcp pair — exchange a console pairing code for a persistent MCP bearer
346
+
347
+ Usage:
348
+ node vendor/heyspex-mcp/cli.mjs pair <code> [--console-url <url>] [--label <text>] [--json]
349
+ hs mcp pair <code> [--console-url <url>]
350
+
351
+ Mint a pairing code from the signed-in console (POST /api/mcp/pairing-codes,
352
+ Clerk-gated), then redeem it here. The console is then asked who the new
353
+ \`hsmcp_\` bearer belongs to (GET /api/mcp/session); only then is the bearer
354
+ AND the console URL stored in ~/.heyspex/credentials.yaml, under THAT account
355
+ (created with restrictive permissions if absent) — NEVER in the repo — and that
356
+ account becomes current. Other accounts already stored stay signed in. If the
357
+ console cannot confirm the account, nothing is stored and the verb exits 1.
358
+ Every subsequent MCP call to a cloud console picks the current account's token
359
+ up automatically (env HEYSPEX_MCP_TOKEN still wins if set).
360
+
361
+ [[HS-GD45-MCPC-TASK-mcp-cloud-auth-shared-console-resolver]].
362
+ `;
363
+
364
+ /**
365
+ * Upsert top-level \`key: value\` lines in a file under \`~/.heyspex/\`,
366
+ * preserving every other line untouched (no full YAML parse/dump — these
367
+ * files carry hand-maintained blocks and comments that a naive round-trip
368
+ * could reformat). Creates the directory + file (with the given file mode)
369
+ * if absent. Never writes into the repo — always the caller's HOME.
370
+ *
371
+ * Shared by {@link upsertCredentialsYamlKeys} (`credentials.yaml`, secret
372
+ * bearer material, 0o600) and {@link upsertConfigYamlKeys} (`config.yaml`,
373
+ * non-secret per-machine settings like \`account_id:\`/\`owner:\`).
374
+ */
375
+ function upsertHeyspexHomeYamlKeys(homeDir, fileName, updates, { fileMode = 0o600 } = {}) {
376
+ const dir = join(homeDir, '.heyspex');
377
+ const filePath = join(dir, fileName);
378
+ // Never the real ~/.heyspex under a test runner (HS-UA9S, same guard as registry-file.ts).
379
+ assertNotRealHomeUnderTest(filePath);
380
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
381
+ let content = '';
382
+ try {
383
+ content = readFileSync(filePath, 'utf8');
384
+ } catch {
385
+ content = '';
386
+ }
387
+ const lines = content.length > 0 ? content.split(/\r?\n/) : [];
388
+ for (const [key, value] of Object.entries(updates)) {
389
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
390
+ const pattern = new RegExp(`^${escapedKey}:.*$`);
391
+ const serialized = `${key}: ${value}`;
392
+ const idx = lines.findIndex((line) => pattern.test(line));
393
+ if (idx >= 0) lines[idx] = serialized;
394
+ else lines.push(serialized);
395
+ }
396
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
397
+ writeFileSync(filePath, `${lines.join('\n')}\n`, { mode: fileMode });
398
+ return filePath;
399
+ }
400
+
401
+ /**
402
+ * Upsert top-level \`key: value\` lines in \`~/.heyspex/credentials.yaml\`,
403
+ * preserving every other line untouched (no full YAML parse/dump — this file
404
+ * carries hand-maintained blocks like \`clerk:\` that a naive round-trip
405
+ * could reformat). Creates the directory + file with restrictive
406
+ * permissions if absent. Never writes into the repo — always the caller's
407
+ * HOME.
408
+ */
409
+ export function upsertCredentialsYamlKeys(homeDir, updates) {
410
+ return upsertHeyspexHomeYamlKeys(homeDir, 'credentials.yaml', updates, { fileMode: 0o600 });
411
+ }
412
+
413
+ /**
414
+ * Upsert top-level \`key: value\` lines in \`~/.heyspex/config.yaml\` (the
415
+ * user's HOME config, NOT the vault-tracked \`<repo>/.heyspex/config.yaml\`
416
+ * that \`config-settings.mjs\`/\`vault.ts\`'s \`configYamlPath()\` read/write).
417
+ * This is where the ONE account reader (\`session-owner.mjs\`, which
418
+ * \`resolveConfiguredAccountId\` in
419
+ * \`spex-console/src/lib/cli/vault-workspace-shared.ts\` delegates to) reads
420
+ * \`account_id:\` from — nothing wrote that key before this, so the old
421
+ * non-strict resolver always fell through to the \`pwher\` sentinel
422
+ * ([[HS-4GFR-GO2H-DIAG-cli-cloud-writes-land-on-the-pwher]]); that fallback is
423
+ * gone ([[HS-630L-WW9R-TASK-one-strict-cli-account-reader-hs-login]]).
424
+ *
425
+ * Same comment/unknown-key-preserving upsert as
426
+ * {@link upsertCredentialsYamlKeys}, but non-secret content, so a normal
427
+ * (non-0600) file mode — this file also carries a hand-maintained \`owner:\`
428
+ * key and may carry user comments, all preserved untouched.
429
+ */
430
+ export function upsertConfigYamlKeys(homeDir, updates) {
431
+ return upsertHeyspexHomeYamlKeys(homeDir, 'config.yaml', updates, { fileMode: 0o644 });
432
+ }
433
+
434
+ export function parsePairCliArgs(argv) {
435
+ if (argv.includes('--help') || argv.includes('-h')) return { help: true };
436
+ const positional = [];
437
+ let consoleUrlOverride;
438
+ let label;
439
+ let json = false;
440
+ for (let i = 0; i < argv.length; i += 1) {
441
+ const arg = argv[i];
442
+ if (arg === '--console-url') { consoleUrlOverride = argv[i + 1]; i += 1; continue; }
443
+ if (arg === '--label') { label = argv[i + 1]; i += 1; continue; }
444
+ if (arg === '--json') { json = true; continue; }
445
+ positional.push(arg);
446
+ }
447
+ const code = positional[0];
448
+ if (!code) {
449
+ return {
450
+ error: { message: 'Usage: hs mcp pair <code> [--console-url <url>] [--label <text>]', code: 2 },
451
+ };
452
+ }
453
+ return { code, consoleUrlOverride, label, json };
454
+ }
455
+
456
+ /**
457
+ * THE redeem + credential-write path, shared by `hs mcp pair <code>` (code
458
+ * typed by hand) and `hs login` (code delivered by the browser hand-off).
459
+ * There is exactly one of these on purpose: both verbs must store the bearer
460
+ * the same way, through the per-account store (`credentials.mjs`
461
+ * `writeAccountCredentials`), which preserves the hand-maintained blocks in
462
+ * ~/.heyspex/credentials.yaml.
463
+ *
464
+ * Prints NOTHING (callers own their own voice) and never returns, logs, or
465
+ * echoes token material — the summary carries ids only, exactly like the
466
+ * pair verb has always printed.
467
+ *
468
+ * In order ([[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]], after
469
+ * [[HS-630L-WW9R-TASK-one-strict-cli-account-reader-hs-login]]):
470
+ * 1. redeem the code (the bearer is held in memory only);
471
+ * 2. ask the console who that bearer belongs to (`probe` =
472
+ * `probeCredentialIdentity`, GET /api/mcp/session — the same probe
473
+ * `hs whoami` and the install/connect identity gate use). The pair body's
474
+ * `userId` is never trusted: it is what the console minted the code for,
475
+ * not a verdict on the token. A failed probe stores NOTHING (no token
476
+ * under a guessed id) and returns `{ ok: false, half: 'account', message }`;
477
+ * 3. store the bearer + console URL under the CONFIRMED account id, make it
478
+ * current, and move registry.json's `current_account_id` with it
479
+ * (`account-switch.mjs`). Other stored accounts are untouched. An
480
+ * existing v1 (single-token) file is re-keyed in the same write: its
481
+ * token goes under the account the console confirms for IT, or stays
482
+ * under `legacy` when the console cannot say. Nothing is written to
483
+ * ~/.heyspex/config.yaml any more (one account-id store, invariant 3).
484
+ *
485
+ * Returns { ok: true, summary } | { ok: false, half: 'bearer' | 'account', message }.
486
+ */
487
+ export async function redeemAndStorePairingCode({
488
+ consoleUrl,
489
+ code,
490
+ label,
491
+ probe = probeCredentialIdentity,
492
+ homeDir = homedir(),
493
+ requireAccount = null,
494
+ }) {
495
+ let response;
496
+ try {
497
+ response = await fetch(`${consoleUrl}/api/mcp/pair`, {
498
+ method: 'POST',
499
+ headers: { 'content-type': 'application/json' },
500
+ body: JSON.stringify({
501
+ code,
502
+ label: label ?? hostname(),
503
+ }),
504
+ });
505
+ } catch (err) {
506
+ return {
507
+ ok: false,
508
+ half: 'bearer',
509
+ message: `could not reach ${consoleUrl}: ${err?.message ?? err}`,
510
+ };
511
+ }
512
+
513
+ let body = null;
514
+ try {
515
+ body = await response.json();
516
+ } catch {
517
+ body = null;
518
+ }
519
+
520
+ if (!response.ok || !body?.token) {
521
+ return {
522
+ ok: false,
523
+ half: 'bearer',
524
+ message: `pairing failed: ${describeRedeemFailure(response.status, body?.error)}`,
525
+ };
526
+ }
527
+
528
+ const resolvedConsoleUrl =
529
+ typeof body.consoleUrl === 'string' && body.consoleUrl.trim() ? body.consoleUrl.trim() : consoleUrl;
530
+
531
+ // The account: ask the console who the redeemed bearer belongs to BEFORE
532
+ // anything is stored. Never `body.userId` ([[HS-630L-WW9R-TASK-one-strict-cli-account-reader-hs-login]] AC3).
533
+ const identity = await probe({ consoleUrl: resolvedConsoleUrl, token: body.token });
534
+ if (identity?.kind !== 'identified') {
535
+ return {
536
+ ok: false,
537
+ half: 'account',
538
+ message:
539
+ `the console did not confirm which account the new sign-in belongs to ` +
540
+ `(${describeProbeFailure(identity)}). Nothing was stored — the credentials on this ` +
541
+ 'machine are unchanged. Check the console with `hs whoami`, then run `hs login` again.',
542
+ };
543
+ }
544
+
545
+ // `hs login --account <id>`: the sign-in must be THAT account. Anyone else
546
+ // is refused before anything is stored, so the current account never moves.
547
+ if (requireAccount && !identityMatches(identity, requireAccount)) {
548
+ return {
549
+ ok: false,
550
+ half: 'account',
551
+ mismatch: { requested: requireAccount, accountId: identity.accountId, email: identity.email ?? null },
552
+ message:
553
+ `you asked to sign in as ${requireAccount}, but the console confirmed ${formatIdentity(identity)}. ` +
554
+ `Nothing was stored -- this machine's current account is unchanged. Sign in as ${requireAccount} ` +
555
+ `in the browser, then run \`hs login --account ${requireAccount}\` again.`,
556
+ };
557
+ }
558
+
559
+ // A v1 (single-token) file: learn whose its token is before re-keying it.
560
+ const existing = readCredentialsStore({ homeDir });
561
+ let rekeyLegacyAs = null;
562
+ const legacy = existing.schemaVersion === 1 ? existing.accounts[LEGACY_ACCOUNT_ID] : null;
563
+ if (legacy?.mcp_token) {
564
+ if (legacy.mcp_token === body.token) {
565
+ rekeyLegacyAs = identity.accountId;
566
+ } else {
567
+ const legacyIdentity = await probe({
568
+ consoleUrl: legacy.console_url ?? resolvedConsoleUrl,
569
+ token: legacy.mcp_token,
570
+ });
571
+ if (legacyIdentity?.kind === 'identified') rekeyLegacyAs = legacyIdentity.accountId;
572
+ }
573
+ }
574
+
575
+ let credentialsPath;
576
+ try {
577
+ credentialsPath = writeAccountCredentials({
578
+ homeDir,
579
+ accountId: identity.accountId,
580
+ mcpToken: body.token,
581
+ consoleUrl: resolvedConsoleUrl,
582
+ email: identity.email,
583
+ makeCurrent: true,
584
+ rekeyLegacyAs,
585
+ });
586
+ } catch (err) {
587
+ return { ok: false, half: 'bearer', message: `could not store the credentials: ${err?.message ?? err}` };
588
+ }
589
+ const registry = followCurrentAccountInRegistry({ homeDir, accountId: identity.accountId });
590
+ const stored = readCredentialsStore({ homeDir });
591
+
592
+ return {
593
+ ok: true,
594
+ summary: {
595
+ paired: true,
596
+ sessionId: body.sessionId,
597
+ // The account the CONSOLE confirmed for the stored bearer — not the pair
598
+ // body's claim. Both normally agree; when they do not, the probe wins and
599
+ // `pairBodyUserId` lets `--json` consumers see the disagreement.
600
+ userId: identity.accountId,
601
+ email: identity.email,
602
+ pairBodyUserId: typeof body.userId === 'string' ? body.userId : null,
603
+ consoleUrl: resolvedConsoleUrl,
604
+ credentialsPath,
605
+ currentAccountId: stored.currentAccountId,
606
+ storedAccounts: Object.keys(stored.accounts),
607
+ registryCurrentAccount: registry.status,
608
+ accountSource: 'session-probe',
609
+ },
610
+ };
611
+ }
612
+
613
+ /** The human lines every successful pairing prints (pair + login share them). */
614
+ function renderPairedLines(summary) {
615
+ const others = summary.storedAccounts.filter((id) => id !== summary.userId);
616
+ return (
617
+ ` console: ${summary.consoleUrl}\n` +
618
+ ` session: ${summary.sessionId}\n` +
619
+ ` user: ${summary.email ? `${summary.email} (${summary.userId})` : summary.userId}\n` +
620
+ ` bearer stored under account ${summary.userId} in ${summary.credentialsPath} (never the repo); it is now current.\n` +
621
+ (others.length > 0 ? ` also signed in on this machine: ${others.join(', ')} (hs login --switch-account <id>)\n` : '')
622
+ );
623
+ }
624
+
625
+ /** One line for the human about why GET /api/mcp/session did not identify the stored bearer. */
626
+ function describeProbeFailure(identity) {
627
+ switch (identity?.kind) {
628
+ case 'rejected':
629
+ return `the console rejected the stored token with HTTP ${identity.status}`;
630
+ case 'unsupported':
631
+ return `the console has no /api/mcp/session route yet (HTTP ${identity.status}) — it needs the deploy`;
632
+ case 'unreachable':
633
+ return `the session probe failed: ${identity.message}`;
634
+ case 'no_credential':
635
+ return 'no bearer was available to probe with';
636
+ default:
637
+ return 'the session probe returned no identity';
638
+ }
639
+ }
640
+
641
+ /** True when `wanted` names this identity: its account id, or its email (any case). */
642
+ function identityMatches(identity, wanted) {
643
+ const w = String(wanted ?? '').trim();
644
+ if (!w) return true;
645
+ return w === identity.accountId || Boolean(identity.email && identity.email.toLowerCase() === w.toLowerCase());
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // The `hs login` receipt ([[HS-CRNE-WW9R-TASK-give-hs-install-and-hs-login-the]]):
650
+ // what this machine stored (credentials.yaml) next to what the console says
651
+ // that stored token belongs to (GET /api/mcp/session), in the shared receipt
652
+ // envelope (./receipt-shape.mjs). The account must agree.
653
+ // ---------------------------------------------------------------------------
654
+
655
+ /** The retry a login receipt names when the two sides name different accounts. */
656
+ export const LOGIN_RECEIPT_RETRY = 'hs login --switch-account';
657
+
658
+ /** The rows of the login receipt: the account must agree; the rest is shown. */
659
+ export const LOGIN_RECEIPT_ROWS = Object.freeze([
660
+ { label: 'account', local: 'account_id', cloud: 'userId', compare: true },
661
+ { label: 'email', local: 'email', cloud: 'email' },
662
+ { label: 'console', local: 'console_url' },
663
+ { label: 'token from', local: 'source' },
664
+ { label: 'mode', cloud: 'mode' },
665
+ ]);
666
+
667
+ /** The current account as stored on this machine, or null (none, or only a v1 `legacy` token). */
668
+ function currentStoredAccount(homeDir) {
669
+ const store = readCredentialsStore({ homeDir });
670
+ const id = store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID ? store.currentAccountId : null;
671
+ return { store, id, entry: id ? (store.accounts[id] ?? null) : null };
672
+ }
673
+
674
+ /** Local side: the current account in credentials.yaml, its console, and where the bearer resolves from. */
675
+ export function readLoginLocalSide({ homeDir = homedir(), env = process.env } = {}) {
676
+ try {
677
+ const { id, entry } = currentStoredAccount(homeDir);
678
+ return {
679
+ read: true,
680
+ account_id: id,
681
+ email: entry?.email ?? null,
682
+ console_url: entry?.console_url ?? null,
683
+ source: readBearer({ env, homeDir }).source ?? null,
684
+ };
685
+ } catch (err) {
686
+ return unreadSide(`credentials.yaml could not be read: ${err?.message ?? err}`);
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Cloud side: GET /api/mcp/session with the token stored for the current
692
+ * account, at that account's console (else `fallbackConsoleUrl`). Never
693
+ * includes token material.
694
+ */
695
+ export async function readLoginCloudSide({ homeDir = homedir(), probe = probeCredentialIdentity, fallbackConsoleUrl } = {}) {
696
+ const { entry } = currentStoredAccount(homeDir);
697
+ if (!entry?.mcp_token) return unreadSide('no stored sign-in to ask the console about');
698
+ const identity = await probe({ consoleUrl: entry.console_url ?? fallbackConsoleUrl, token: entry.mcp_token });
699
+ if (identity?.kind !== 'identified') {
700
+ return unreadSide(`GET /api/mcp/session: ${describeProbeFailure(identity)}`);
701
+ }
702
+ return {
703
+ read: true,
704
+ userId: identity.accountId,
705
+ email: identity.email ?? null,
706
+ mode: typeof identity.mode === 'string' ? identity.mode : null,
707
+ };
708
+ }
709
+
710
+ /** Build the login receipt; a disagreement names {@link LOGIN_RECEIPT_RETRY}. */
711
+ export function buildLoginReceipt({ local, cloud, before }) {
712
+ const receipt = buildSidesReceipt({ local, cloud, rows: LOGIN_RECEIPT_ROWS, before: before ?? {} });
713
+ if (receipt.verified && !receipt.agree) receipt.retry = LOGIN_RECEIPT_RETRY;
714
+ return receipt;
715
+ }
716
+
717
+ /** Exit for a finished login: 21 when both sides were read and disagree, else `ok`. */
718
+ function loginReceiptExit(receipt, ok = 0) {
719
+ return receipt.verified && !receipt.agree ? EXIT_RECEIPT_DISAGREE : ok;
720
+ }
721
+
722
+ /** Print a finished login: JSON with `receipt`, or the text lines then the receipt table. */
723
+ function emitLogin({ json, stdout, summary, text, receipt }) {
724
+ if (json) stdout.write(`${JSON.stringify({ ...summary, receipt }, null, 2)}\n`);
725
+ else stdout.write(`${text}${formatSidesReceipt(receipt, LOGIN_RECEIPT_ROWS)}\n`);
726
+ }
727
+
728
+ /** Same console, ignoring case and trailing slashes. */
729
+ function sameConsoleUrl(a, b) {
730
+ const norm = (u) => String(u ?? '').trim().replace(/\/+$/, '').toLowerCase();
731
+ return Boolean(a) && norm(a) === norm(b);
732
+ }
733
+
734
+ export async function runPairCli(argv) {
735
+ const parsed = parsePairCliArgs(argv);
736
+ if (parsed.help) {
737
+ process.stdout.write(`${PAIR_CLI_HELP}\n`);
738
+ return 0;
739
+ }
740
+ if (parsed.error) {
741
+ process.stderr.write(`[heyspex-mcp pair] ${parsed.error.message}\n`);
742
+ return parsed.error.code;
743
+ }
744
+
745
+ const consoleUrl = parsed.consoleUrlOverride
746
+ ? parsed.consoleUrlOverride.trim().replace(/\/+$/, '')
747
+ : getConsoleUrl();
748
+
749
+ const redeemed = await redeemAndStorePairingCode({
750
+ consoleUrl,
751
+ code: parsed.code,
752
+ label: parsed.label,
753
+ });
754
+ if (!redeemed.ok) {
755
+ const half = redeemed.half === 'account' ? 'account half failed: ' : '';
756
+ process.stderr.write(`[heyspex-mcp pair] ${half}${redeemed.message}\n`);
757
+ return 1;
758
+ }
759
+
760
+ const summary = redeemed.summary;
761
+ if (parsed.json) {
762
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
763
+ } else {
764
+ process.stdout.write(`Paired.\n${renderPairedLines(summary)}`);
765
+ }
766
+ return 0;
767
+ }
768
+
769
+ export const LOGIN_CLI_HELP = `heyspex-mcp login — pair this machine through your browser (nothing to type)
770
+
771
+ Usage:
772
+ node vendor/heyspex-mcp/cli.mjs login [--console-url <url>] [--label <text>] [--timeout <seconds>] [--no-browser] [--json]
773
+ hs login [--switch-account [<id|email>] | --create-account] [--console-url <url>] [--label <text>]
774
+ hs login --account <id|email> [--json]
775
+ hs login --migrate [--dry-run] [--json]
776
+
777
+ Opens your default browser at <console>/companion/connect. If you are already
778
+ signed in there, the console mints the single-use pairing code itself and hands
779
+ it straight back to a one-shot listener on 127.0.0.1 — no code to read, copy or
780
+ paste. The console is then asked who the new \`hsmcp_\` bearer belongs to
781
+ (GET /api/mcp/session, the same probe \`hs whoami\` uses), and the bearer AND the
782
+ console URL are stored in ~/.heyspex/credentials.yaml (never in the repo) under
783
+ THAT account, which becomes current — exactly like \`hs mcp pair\`. Accounts
784
+ already stored on this machine stay signed in, each with its own token and
785
+ console. If the console cannot confirm the account, nothing is stored and the
786
+ command exits non-zero naming \`hs whoami\`.
787
+
788
+ Already signed in? When the current account's stored token is confirmed by the
789
+ same console, \`hs login\` changes nothing, opens no browser and says so (use
790
+ --switch-account to sign in as someone else). Every run ends with a receipt:
791
+ the account stored here next to the account GET /api/mcp/session names for that
792
+ token. If they differ the command exits ${EXIT_RECEIPT_DISAGREE} and names
793
+ \`${LOGIN_RECEIPT_RETRY}\`.
794
+
795
+ The listener binds 127.0.0.1 only, serves exactly one exchange, verifies the
796
+ state nonce, and closes. It gives up after ${Math.round(DEFAULT_LOGIN_TIMEOUT_MS / 1000)} seconds.
797
+
798
+ Flags:
799
+ --console-url <url> Override the resolved console (default: shared resolver)
800
+ --label <text> Name this machine in the console session list (default: hostname)
801
+ --timeout <seconds> How long to wait for the browser (default: ${Math.round(DEFAULT_LOGIN_TIMEOUT_MS / 1000)})
802
+ --no-browser Print the URL and wait; don't launch anything (headless/SSH)
803
+ --json Emit the same summary shape as \`hs mcp pair --json\`
804
+ --switch-account [<id|email>]
805
+ With an account that already holds a token here: make it
806
+ current (no browser, no pairing); the registry's current
807
+ account moves with it. Otherwise (or with no value): the
808
+ console shows "Signed in as X — continue / use a different
809
+ account / create a new account" first, then pairs
810
+ --create-account Same chooser, opened on "create a new account"
811
+ --account <id|email> Be signed in as exactly this account. Already holding a
812
+ token for it: make it current (no browser). Otherwise
813
+ sign in through the account chooser; if the console
814
+ confirms a DIFFERENT account, nothing is stored, the
815
+ current account is unchanged and the command exits ${EXIT_ACCOUNT_MISMATCH}
816
+ --migrate Re-key an existing single-account credentials file under
817
+ the account the console confirms for its token (no pairing)
818
+ --dry-run With --migrate: print the plan, write nothing
819
+
820
+ Check which account this machine is signed in as: \`hs whoami\`. Sign out: \`hs logout\`.
821
+
822
+ Pairing a machine from a browser on a DIFFERENT computer? That case cannot use
823
+ a loopback redirect — use the typed-code fallback: \`hs mcp pair <code>\`.
824
+
825
+ [[HS-IFQM-IZQ7-EPIC-hs-login-browser-hand-off-for-cli-and]] ·
826
+ [[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]].
827
+ `;
828
+
829
+ export function parseLoginCliArgs(argv) {
830
+ if (argv.includes('--help') || argv.includes('-h')) return { help: true };
831
+ let consoleUrlOverride;
832
+ let label;
833
+ let json = false;
834
+ let openBrowser = true;
835
+ let timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS;
836
+ let prompt;
837
+ let switchTarget;
838
+ let migrate = false;
839
+ let dryRun = false;
840
+ let account;
841
+ for (let i = 0; i < argv.length; i += 1) {
842
+ const arg = argv[i];
843
+ if (arg === '--console-url') { consoleUrlOverride = argv[i + 1]; i += 1; continue; }
844
+ // `hs login --account <id|email>` (the repair HS-VWUD's account_token_missing
845
+ // refusal names): a required value, never a following flag.
846
+ if (arg === '--account') {
847
+ const value = argv[i + 1];
848
+ if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) {
849
+ return { error: { message: '--account expects an account id or email', code: 2 } };
850
+ }
851
+ account = value.trim();
852
+ i += 1;
853
+ continue;
854
+ }
855
+ if (arg === '--label') { label = argv[i + 1]; i += 1; continue; }
856
+ if (arg === '--json') { json = true; continue; }
857
+ if (arg === '--no-browser') { openBrowser = false; continue; }
858
+ if (arg === '--migrate') { migrate = true; continue; }
859
+ if (arg === '--dry-run') { dryRun = true; continue; }
860
+ if (arg === '--switch-account' || arg === '--create-account') {
861
+ const next = arg === '--switch-account' ? 'select_account' : 'create';
862
+ if (prompt && prompt !== next) {
863
+ return { error: { message: '--switch-account and --create-account are mutually exclusive', code: 2 } };
864
+ }
865
+ prompt = next;
866
+ // `--switch-account <id|email>`: an optional value (anything not a flag).
867
+ const value = argv[i + 1];
868
+ if (arg === '--switch-account' && typeof value === 'string' && value && !value.startsWith('-')) {
869
+ switchTarget = value.trim();
870
+ i += 1;
871
+ }
872
+ continue;
873
+ }
874
+ if (arg === '--timeout') {
875
+ const seconds = Number(argv[i + 1]);
876
+ i += 1;
877
+ if (!Number.isFinite(seconds) || seconds <= 0) {
878
+ return { error: { message: '--timeout expects a positive number of seconds', code: 2 } };
879
+ }
880
+ timeoutMs = Math.round(seconds * 1000);
881
+ continue;
882
+ }
883
+ return {
884
+ error: {
885
+ message:
886
+ `unknown argument \`${arg}\`\n` +
887
+ ' Usage: hs login [--switch-account [<id|email>] | --create-account] [--console-url <url>] [--label <text>] [--timeout <seconds>] [--no-browser] [--json]\n' +
888
+ ' hs login --account <id|email> [--json]\n' +
889
+ ' hs login --migrate [--dry-run] [--json]\n' +
890
+ ' (a pairing code you typed by hand goes to `hs mcp pair <code>`)',
891
+ code: 2,
892
+ },
893
+ };
894
+ }
895
+ if (dryRun && !migrate) {
896
+ return { error: { message: '--dry-run only applies to --migrate', code: 2 } };
897
+ }
898
+ if (migrate && prompt) {
899
+ return { error: { message: '--migrate does not pair; drop --switch-account / --create-account', code: 2 } };
900
+ }
901
+ if (account && (prompt || migrate)) {
902
+ return {
903
+ error: { message: '--account names the one account to be signed in as; drop --switch-account / --create-account / --migrate', code: 2 },
904
+ };
905
+ }
906
+ return { consoleUrlOverride, label, json, openBrowser, timeoutMs, prompt, switchTarget, migrate, dryRun, account };
907
+ }
908
+
909
+ /**
910
+ * `hs login --switch-account <id|email>` when that account already holds a
911
+ * token here: make it current in the credentials store AND registry.json
912
+ * (`account-switch.mjs`), with no browser and no pairing. Returns `null` when
913
+ * the account is not stored (the caller then pairs).
914
+ */
915
+ function switchToStoredAccount({ target, homeDir, env, json, stdout }) {
916
+ const store = readCredentialsStore({ homeDir });
917
+ const match = findStoredAccount(store, target);
918
+ if (!match?.mcp_token || match.accountId === LEGACY_ACCOUNT_ID) return null;
919
+ const before = readLoginLocalSide({ homeDir, env });
920
+ const result = switchCurrentAccount({ homeDir, accountId: match.accountId });
921
+ // A stored-account switch makes no network call, so the receipt's cloud
922
+ // side is honestly "not read" (`hs whoami` asks the console).
923
+ const receipt = buildLoginReceipt({
924
+ local: readLoginLocalSide({ homeDir, env }),
925
+ cloud: unreadSide('a stored-account switch makes no network call -- confirm with `hs whoami`'),
926
+ before: { local: before },
927
+ });
928
+ const already = before.read && before.account_id === match.accountId;
929
+ const summary = {
930
+ switched: !already,
931
+ method: 'switch',
932
+ accountId: match.accountId,
933
+ email: match.email,
934
+ consoleUrl: match.console_url,
935
+ credentialsPath: result.credentialsPath,
936
+ registryCurrentAccount: result.registry.status,
937
+ };
938
+ const who = match.email ? `${match.email} (${match.accountId})` : match.accountId;
939
+ emitLogin({
940
+ json,
941
+ stdout,
942
+ summary,
943
+ receipt,
944
+ text:
945
+ (already
946
+ ? `Already signed in as ${who} — nothing to change.\n`
947
+ : `Switched to ${who} — no new sign-in needed.\n`) +
948
+ ` console: ${match.console_url ?? '(resolved by the shared console resolver)'}\n` +
949
+ ` current account set in ${result.credentialsPath}` +
950
+ (result.registry.status === 'updated' ? ' and ~/.heyspex/registry.json' : '') +
951
+ '.\n Confirm with: hs whoami\n',
952
+ });
953
+ return 0;
954
+ }
955
+
956
+ /**
957
+ * `hs login --migrate [--dry-run]`: the explicit v1 → v2 re-key of an existing
958
+ * single-account credentials file. The flat token is probed (GET
959
+ * /api/mcp/session) and moved under the account the console confirms for it;
960
+ * a failed probe writes nothing.
961
+ */
962
+ async function migrateStoredCredentials({ parsed, homeDir, probe, stdout, stderr, resolveConsoleUrl }) {
963
+ const store = readCredentialsStore({ homeDir });
964
+ const emit = (payload, line) => {
965
+ stdout.write(parsed.json ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
966
+ return 0;
967
+ };
968
+ if (store.schemaVersion === 2) {
969
+ return emit(
970
+ { action: 'already-v2', currentAccountId: store.currentAccountId },
971
+ 'Credentials are already stored per account; nothing to migrate.',
972
+ );
973
+ }
974
+ const legacy = store.accounts[LEGACY_ACCOUNT_ID];
975
+ if (!legacy?.mcp_token) {
976
+ return emit({ action: 'nothing-to-migrate' }, 'No stored sign-in to migrate. Run: hs login');
977
+ }
978
+ const consoleUrl = (parsed.consoleUrlOverride ?? legacy.console_url ?? resolveConsoleUrl())
979
+ .trim()
980
+ .replace(/\/+$/, '');
981
+ const identity = await probe({ consoleUrl, token: legacy.mcp_token });
982
+ if (identity?.kind !== 'identified') {
983
+ stderr.write(
984
+ `[heyspex-mcp login] --migrate: the console did not confirm which account the stored token belongs to ` +
985
+ `(${describeProbeFailure(identity)}). Nothing was changed. Run \`hs whoami\`, or \`hs login\` to sign in again.\n`,
986
+ );
987
+ return 1;
988
+ }
989
+ const plan = migrateLegacyCredentials({
990
+ homeDir,
991
+ confirmedAccountId: identity.accountId,
992
+ email: identity.email,
993
+ apply: !parsed.dryRun,
994
+ });
995
+ const registry = parsed.dryRun
996
+ ? { status: 'dry-run' }
997
+ : followCurrentAccountInRegistry({ homeDir, accountId: identity.accountId });
998
+ const configNote =
999
+ store.legacyAccountId && store.legacyAccountId !== identity.accountId
1000
+ ? ` (~/.heyspex/config.yaml said ${store.legacyAccountId}; the console's answer wins)`
1001
+ : '';
1002
+ return emit(
1003
+ {
1004
+ action: plan.action,
1005
+ dryRun: parsed.dryRun,
1006
+ accountId: identity.accountId,
1007
+ email: identity.email,
1008
+ path: plan.path,
1009
+ registryCurrentAccount: registry.status,
1010
+ },
1011
+ `${parsed.dryRun ? 'Would re-key' : 'Re-keyed'} the stored sign-in under ${formatIdentity(identity)}${configNote}.\n` +
1012
+ ` file: ${plan.path}${parsed.dryRun ? '\n Nothing was written (--dry-run).' : ''}`,
1013
+ );
1014
+ }
1015
+
1016
+ /**
1017
+ * `hs login` — the browser hand-off (RFC 8252 loopback), replacing the
1018
+ * mint-a-code-and-paste-it ceremony with one click. Shares its redemption and
1019
+ * its credential write with `hs mcp pair`; only the code's carrier differs.
1020
+ *
1021
+ * `deps` exists for tests: it stubs the browser launch, the listener and the
1022
+ * output streams so the flow can be exercised without opening a real browser,
1023
+ * reaching a console, or monkeypatching process.stdout (which would swallow
1024
+ * the test runner's own reporter output).
1025
+ */
1026
+ export async function runLoginCli(argv, deps = {}) {
1027
+ const {
1028
+ openBrowser: openBrowserFn = openBrowserDefault,
1029
+ startListener = startCallbackListenerDefault,
1030
+ redeem = redeemAndStorePairingCode,
1031
+ generateState = generateLoginState,
1032
+ probe = probeCredentialIdentity,
1033
+ stdout = process.stdout,
1034
+ stderr = process.stderr,
1035
+ homeDir = homedir(),
1036
+ resolveConsoleUrl = getConsoleUrl,
1037
+ env = process.env,
1038
+ } = deps;
1039
+
1040
+ const parsed = parseLoginCliArgs(argv);
1041
+ if (parsed.help) {
1042
+ stdout.write(`${LOGIN_CLI_HELP}\n`);
1043
+ return 0;
1044
+ }
1045
+ if (parsed.error) {
1046
+ stderr.write(`[heyspex-mcp login] ${parsed.error.message}\n`);
1047
+ return parsed.error.code;
1048
+ }
1049
+
1050
+ if (parsed.migrate) {
1051
+ return migrateStoredCredentials({ parsed, homeDir, probe, stdout, stderr, resolveConsoleUrl });
1052
+ }
1053
+
1054
+ // `--switch-account <id|email>` / `--account <id|email>` naming an account
1055
+ // that already holds a token here: switch without pairing. Otherwise fall
1056
+ // through to the browser.
1057
+ const storedTarget = parsed.account ?? parsed.switchTarget;
1058
+ if (storedTarget) {
1059
+ const switched = switchToStoredAccount({ target: storedTarget, homeDir, env, json: parsed.json, stdout });
1060
+ if (switched !== null) return switched;
1061
+ }
1062
+
1063
+ const consoleUrl = parsed.consoleUrlOverride
1064
+ ? parsed.consoleUrlOverride.trim().replace(/\/+$/, '')
1065
+ : resolveConsoleUrl();
1066
+
1067
+ // The receipt's "before" read of this machine (HS-CRNE): its `changed` is
1068
+ // what this run moved.
1069
+ const before = readLoginLocalSide({ homeDir, env });
1070
+
1071
+ // A plain re-run while signed in is a no-op (invariant 6): the current
1072
+ // account's stored token, on this same console, is asked who it belongs to.
1073
+ // Confirmed -> nothing to do, no browser, no pairing write. A disagreement
1074
+ // is the receipt's (exit 21, retry `hs login --switch-account`). A token
1075
+ // the console no longer accepts (or cannot answer for) signs in again.
1076
+ if (!parsed.prompt && !parsed.switchTarget && !parsed.account) {
1077
+ const { store, id, entry } = currentStoredAccount(homeDir);
1078
+ if (store.schemaVersion === 2 && entry?.mcp_token && sameConsoleUrl(entry.console_url, consoleUrl)) {
1079
+ const cloud = await readLoginCloudSide({ homeDir, probe, fallbackConsoleUrl: consoleUrl });
1080
+ if (cloud.read) {
1081
+ const local = readLoginLocalSide({ homeDir, env });
1082
+ const receipt = buildLoginReceipt({ local, cloud, before: { local: before } });
1083
+ const who = formatIdentity({ accountId: id, email: entry.email ?? null });
1084
+ emitLogin({
1085
+ json: parsed.json,
1086
+ stdout,
1087
+ receipt,
1088
+ summary: {
1089
+ paired: false,
1090
+ method: 'already-signed-in',
1091
+ userId: id,
1092
+ email: entry.email ?? null,
1093
+ consoleUrl: entry.console_url,
1094
+ currentAccountId: id,
1095
+ storedAccounts: Object.keys(store.accounts),
1096
+ },
1097
+ text: receipt.agree
1098
+ ? `Already signed in as ${who} on ${entry.console_url} — nothing to change.\n` +
1099
+ ' Sign in as someone else: hs login --switch-account\n'
1100
+ : `Signed in here as ${who}, but the console says that token belongs to another account.\n`,
1101
+ });
1102
+ return loginReceiptExit(receipt);
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ const state = generateState();
1108
+
1109
+ let listener;
1110
+ try {
1111
+ listener = await startListener({ expectedState: state, timeoutMs: parsed.timeoutMs });
1112
+ } catch (err) {
1113
+ stderr.write(
1114
+ `[heyspex-mcp login] could not open a local listener on 127.0.0.1: ${err?.message ?? err}\n` +
1115
+ ' Fall back to the typed code: hs mcp pair <code>\n',
1116
+ );
1117
+ return 1;
1118
+ }
1119
+
1120
+ const connectUrl = buildConnectUrl({
1121
+ consoleUrl,
1122
+ port: listener.port,
1123
+ state,
1124
+ client: 'cli',
1125
+ label: parsed.label ?? hostname(),
1126
+ // `--account <id>` that is not stored here opens the account chooser, so
1127
+ // the browser's current session is not silently reused for it.
1128
+ ...(parsed.prompt || parsed.account ? { prompt: parsed.prompt ?? 'select_account' } : {}),
1129
+ });
1130
+
1131
+ // In --json mode stdout stays pure JSON, so progress goes to stderr — a
1132
+ // headless box still sees the URL it needs to paste.
1133
+ const progress = parsed.json ? stderr : stdout;
1134
+ if (parsed.openBrowser) {
1135
+ const launched = openBrowserFn(connectUrl);
1136
+ progress.write(
1137
+ launched
1138
+ ? 'Opening your browser to finish sign-in...\n'
1139
+ : 'Could not launch a browser on this machine.\n',
1140
+ );
1141
+ }
1142
+ progress.write(
1143
+ ` If it doesn't open, paste this into a signed-in browser:\n ${connectUrl}\n` +
1144
+ ` Waiting up to ${Math.round(parsed.timeoutMs / 1000)}s (listening on 127.0.0.1:${listener.port})...\n`,
1145
+ );
1146
+
1147
+ const outcome = await listener.waitForCallback();
1148
+
1149
+ if (!outcome.ok) {
1150
+ const detail = describeCallbackFailure(outcome.reason);
1151
+ stderr.write(
1152
+ `[heyspex-mcp login] sign-in did not complete: ${detail}\n` +
1153
+ ' Nothing was stored. Retry with `hs login`, or use the typed-code fallback: `hs mcp pair <code>`\n',
1154
+ );
1155
+ return 1;
1156
+ }
1157
+
1158
+ // From here the code is treated exactly like a hand-typed one — same redeem,
1159
+ // same credential write, same session probe for the account half. The code
1160
+ // is NEVER printed.
1161
+ const redeemed = await redeem({
1162
+ consoleUrl,
1163
+ code: outcome.code,
1164
+ label: parsed.label,
1165
+ probe,
1166
+ homeDir,
1167
+ ...(parsed.account ? { requireAccount: parsed.account } : {}),
1168
+ });
1169
+ if (!redeemed.ok && redeemed.mismatch) {
1170
+ // `--account <id>` and the console confirmed someone else: nothing stored,
1171
+ // the account-mismatch exit (20), both accounts named.
1172
+ const { requested, accountId, email } = redeemed.mismatch;
1173
+ if (parsed.json) {
1174
+ stdout.write(
1175
+ `${JSON.stringify(
1176
+ {
1177
+ ok: false,
1178
+ error: 'ACCOUNT_MISMATCH',
1179
+ message: redeemed.message,
1180
+ requested_account: requested,
1181
+ signed_in_account: accountId,
1182
+ signed_in_email: email,
1183
+ stored: false,
1184
+ retry: `hs login --account ${requested}`,
1185
+ },
1186
+ null,
1187
+ 2,
1188
+ )}\n`,
1189
+ );
1190
+ } else {
1191
+ stderr.write(`[heyspex-mcp login] ${redeemed.message}\n`);
1192
+ }
1193
+ return EXIT_ACCOUNT_MISMATCH;
1194
+ }
1195
+ if (!redeemed.ok) {
1196
+ const half = redeemed.half === 'account' ? 'account half failed: ' : '';
1197
+ stderr.write(`[heyspex-mcp login] ${half}${redeemed.message}\n`);
1198
+ return 1;
1199
+ }
1200
+
1201
+ const summary = { ...redeemed.summary, method: 'browser' };
1202
+ // The receipt (HS-CRNE): read back what was stored, then ask the console
1203
+ // again -- with the token read back from disk -- who it belongs to.
1204
+ const receipt = buildLoginReceipt({
1205
+ local: readLoginLocalSide({ homeDir, env }),
1206
+ cloud: await readLoginCloudSide({ homeDir, probe, fallbackConsoleUrl: consoleUrl }),
1207
+ before: { local: before },
1208
+ });
1209
+ // `--switch-account <x>` that had to pair: say so when the browser signed in
1210
+ // as someone else (the new account is stored and current either way).
1211
+ const target = parsed.switchTarget;
1212
+ const switchMissed =
1213
+ target &&
1214
+ target !== summary.userId &&
1215
+ !(summary.email && summary.email.toLowerCase() === target.toLowerCase());
1216
+ emitLogin({
1217
+ json: parsed.json,
1218
+ stdout,
1219
+ receipt,
1220
+ summary: { ...summary, ...(target ? { requestedAccount: target } : {}) },
1221
+ text: `Signed in.\n${renderPairedLines(summary)}`,
1222
+ });
1223
+ if (switchMissed) {
1224
+ stderr.write(
1225
+ `[heyspex-mcp login] you asked to switch to ${target}, but the browser signed in as ` +
1226
+ `${summary.email ? `${summary.email} (${summary.userId})` : summary.userId}. That account is stored and current; ` +
1227
+ `run \`hs login --switch-account ${target}\` again and pick ${target} in the browser.\n`,
1228
+ );
1229
+ return 1;
1230
+ }
1231
+ return loginReceiptExit(receipt);
1232
+ }
1233
+
1234
+ export const WHOAMI_CLI_HELP = `heyspex-mcp whoami — which account is this machine signed in as?
1235
+
1236
+ Usage:
1237
+ hs whoami [--account <id|email>] [--console-url <url>] [--json]
1238
+
1239
+ Asks the console who a stored credential (~/.heyspex/credentials.yaml, written
1240
+ by \`hs login\` / \`hs mcp pair\`) belongs to, and prints the account id, email,
1241
+ token source (env | credentials-file), console URL and whether that account is
1242
+ the machine's current one. Without --account it is the CURRENT account — the
1243
+ one \`hs connect\` / \`hs install\` would attach a repo to; with --account it is
1244
+ that stored account (its own token and console). Prints no token material.
1245
+
1246
+ Not the right account? \`hs login --switch-account <id|email>\` (or \`--create-account\`).
1247
+
1248
+ Exit codes: 0 signed in · 16 not signed in / credential rejected / no such stored
1249
+ account · 17 the console names a different account than the one the token is
1250
+ stored under · 12 console unreachable
1251
+
1252
+ [[HS-HAQI-KODY-DIAG-hs-install-and-hs-connect-bind-a-new]] ·
1253
+ [[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]].
1254
+ `;
1255
+
1256
+ export function parseWhoamiCliArgs(argv) {
1257
+ if (argv.includes('--help') || argv.includes('-h')) return { help: true };
1258
+ let consoleUrlOverride;
1259
+ let accountId;
1260
+ let json = false;
1261
+ for (let i = 0; i < argv.length; i += 1) {
1262
+ const arg = argv[i];
1263
+ if (arg === '--console-url') {
1264
+ consoleUrlOverride = argv[i + 1];
1265
+ i += 1;
1266
+ if (!consoleUrlOverride || consoleUrlOverride.startsWith('-')) {
1267
+ return { error: { message: '--console-url requires a URL', code: 2 } };
1268
+ }
1269
+ continue;
1270
+ }
1271
+ if (arg === '--account') {
1272
+ accountId = argv[i + 1];
1273
+ i += 1;
1274
+ if (!accountId || accountId.startsWith('-')) {
1275
+ return { error: { message: '--account requires an account id or email', code: 2 } };
1276
+ }
1277
+ continue;
1278
+ }
1279
+ if (arg === '--json') { json = true; continue; }
1280
+ return {
1281
+ error: {
1282
+ message: `unknown argument \`${arg}\`\n Usage: hs whoami [--account <id|email>] [--console-url <url>] [--json]`,
1283
+ code: 2,
1284
+ },
1285
+ };
1286
+ }
1287
+ return accountId === undefined ? { consoleUrlOverride, json } : { consoleUrlOverride, json, accountId };
1288
+ }
1289
+
1290
+ /**
1291
+ * Which stored account a `hs whoami` / `hs logout` invocation is about, from
1292
+ * the credentials store: the named one (`--account <id|email>`), else the
1293
+ * current one. On a v1 file the single flat token is the account the
1294
+ * `config.yaml` `account_id` names (read-compatible, no migration needed).
1295
+ * Pure over the store read. `found: false` only when an account was NAMED and
1296
+ * is not stored.
1297
+ */
1298
+ function selectStoredAccount(store, named) {
1299
+ const v1Id = store.schemaVersion === 1 && store.accounts[LEGACY_ACCOUNT_ID] ? store.legacyAccountId : null;
1300
+ const currentId = store.schemaVersion === 2 ? store.currentAccountId : v1Id ?? (store.accounts[LEGACY_ACCOUNT_ID] ? LEGACY_ACCOUNT_ID : null);
1301
+ if (!named) {
1302
+ const entry = currentId ? store.accounts[currentId] ?? store.accounts[LEGACY_ACCOUNT_ID] : null;
1303
+ return { found: true, accountId: currentId, entry: entry ?? null, currentId };
1304
+ }
1305
+ const match = findStoredAccount(store, named) ?? (v1Id && named === v1Id ? { accountId: v1Id, ...store.accounts[LEGACY_ACCOUNT_ID] } : null);
1306
+ if (!match) return { found: false, accountId: null, entry: null, currentId };
1307
+ const { accountId, ...entry } = match;
1308
+ return { found: true, accountId, entry, currentId };
1309
+ }
1310
+
1311
+ /**
1312
+ * `hs whoami` — print the account a stored credential belongs to. Shares its
1313
+ * probe with the install/connect identity gate (`whoami.mjs`), so the answer
1314
+ * here is the account those verbs would bind.
1315
+ */
1316
+ export async function runWhoamiCli(argv, deps = {}) {
1317
+ const {
1318
+ probe = probeCredentialIdentity,
1319
+ readBearer: readBearerDep,
1320
+ env = process.env,
1321
+ homeDir = homedir(),
1322
+ stdout = process.stdout,
1323
+ stderr = process.stderr,
1324
+ resolveConsoleUrl = getConsoleUrl,
1325
+ } = deps;
1326
+ const parsed = parseWhoamiCliArgs(argv);
1327
+ if (parsed.help) {
1328
+ stdout.write(`${WHOAMI_CLI_HELP}\n`);
1329
+ return 0;
1330
+ }
1331
+ if (parsed.error) {
1332
+ stderr.write(`[heyspex-mcp whoami] ${parsed.error.message}\n`);
1333
+ return parsed.error.code;
1334
+ }
1335
+ const emit = (payload, line, code) => {
1336
+ if (parsed.json) stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1337
+ else (code === 0 ? stdout : stderr).write(`${line}\n`);
1338
+ return code;
1339
+ };
1340
+
1341
+ const store = readCredentialsStore({ homeDir });
1342
+ const selected = selectStoredAccount(store, parsed.accountId);
1343
+ if (!selected.found) {
1344
+ const stored = Object.keys(store.accounts).filter((id) => id !== LEGACY_ACCOUNT_ID);
1345
+ return emit(
1346
+ { signedIn: false, reason: 'no_such_account', account: parsed.accountId, storedAccounts: stored },
1347
+ `No stored sign-in for ${parsed.accountId} on this machine` +
1348
+ (stored.length > 0 ? ` (stored: ${stored.join(', ')})` : '') +
1349
+ `.\n Run: hs login --switch-account ${parsed.accountId}`,
1350
+ 16,
1351
+ );
1352
+ }
1353
+
1354
+ const consoleUrl = (
1355
+ parsed.consoleUrlOverride ??
1356
+ (parsed.accountId ? selected.entry?.console_url : null) ??
1357
+ resolveConsoleUrl()
1358
+ )
1359
+ .trim()
1360
+ .replace(/\/+$/, '');
1361
+ // The ONE reader's verdict (env HEYSPEX_MCP_TOKEN first, then the store's
1362
+ // named-or-current account) — token AND source, so the receipt says which
1363
+ // bearer was confirmed ([[HS-6V9X-WW9R-TASK-make-hs-install-remote-confirm-the]]
1364
+ // AC1/AC3). A `readBearer` dep that returns a bare token (older tests) has no source.
1365
+ const bearer = readBearerDep
1366
+ ? normalizeBearerVerdict(readBearerDep())
1367
+ : readBearer({ env, homeDir, ...(parsed.accountId ? { accountId: selected.accountId } : {}) });
1368
+ const result = await probe({ consoleUrl, token: bearer.token });
1369
+ // What the store says this token is filed under (null for an env token, or a
1370
+ // v1 file whose config.yaml names no account).
1371
+ const storedAccountId =
1372
+ bearer.source === 'credentials-file' && selected.accountId !== LEGACY_ACCOUNT_ID ? selected.accountId : null;
1373
+ switch (result.kind) {
1374
+ case 'identified': {
1375
+ const current =
1376
+ bearer.source === 'env'
1377
+ ? result.accountId === selected.currentId
1378
+ : !parsed.accountId || selected.accountId === selected.currentId;
1379
+ const mismatch = storedAccountId !== null && storedAccountId !== result.accountId;
1380
+ return emit(
1381
+ {
1382
+ signedIn: true,
1383
+ accountId: result.accountId,
1384
+ email: result.email,
1385
+ displayName: result.displayName,
1386
+ consoleUrl,
1387
+ tokenSource: bearer.source,
1388
+ storedAccountId,
1389
+ current,
1390
+ ...(mismatch ? { mismatch: true } : {}),
1391
+ },
1392
+ mismatch
1393
+ ? `The token stored under ${storedAccountId} belongs to ${formatIdentity(result)} on ${consoleUrl}.\n` +
1394
+ ` Sign that account in again: hs login --switch-account ${storedAccountId}`
1395
+ : `Signed in as ${formatIdentity(result)}\n console: ${consoleUrl}\n` +
1396
+ ` token: ${describeBearerSource(bearer.source)}\n` +
1397
+ ` current: ${current}\n` +
1398
+ ' Not you? hs login --switch-account <id|email> (or --create-account)',
1399
+ mismatch ? 17 : 0,
1400
+ );
1401
+ }
1402
+ case 'no_credential':
1403
+ return emit(
1404
+ { signedIn: false, reason: 'no_credential', consoleUrl },
1405
+ 'Not signed in on this machine (no credential in ~/.heyspex/credentials.yaml).\n Run: hs login',
1406
+ 16,
1407
+ );
1408
+ case 'rejected':
1409
+ return emit(
1410
+ { signedIn: false, reason: 'rejected', status: result.status, consoleUrl },
1411
+ `The stored credential was rejected by ${consoleUrl} (HTTP ${result.status}) — revoked or from another console.\n Run: hs login`,
1412
+ 16,
1413
+ );
1414
+ case 'unsupported':
1415
+ return emit(
1416
+ { signedIn: null, reason: 'unsupported', status: result.status, consoleUrl },
1417
+ `${consoleUrl} does not answer whoami yet (HTTP ${result.status}); it needs the console deploy that adds GET /api/mcp/session.`,
1418
+ 12,
1419
+ );
1420
+ default:
1421
+ return emit(
1422
+ { signedIn: null, reason: 'unreachable', message: result.message, consoleUrl },
1423
+ `Could not reach ${consoleUrl}: ${result.message}`,
1424
+ 12,
1425
+ );
1426
+ }
1427
+ }
1428
+
1429
+ export const LOGOUT_CLI_HELP = `heyspex-mcp logout — sign an account out of this machine
1430
+
1431
+ Usage:
1432
+ hs logout [--account <id|email>] [--json]
1433
+
1434
+ Removes ONE account's stored token and console URL from
1435
+ ~/.heyspex/credentials.yaml: the named account, or (with no flag) the current
1436
+ account. Every other stored account stays signed in. Signing the current
1437
+ account out clears the machine's current account in credentials.yaml AND
1438
+ ~/.heyspex/registry.json; pick the next one with \`hs login --switch-account <id>\`.
1439
+ Nothing is sent to the console (the token is not revoked there). Prints no
1440
+ token material. A HEYSPEX_MCP_TOKEN env var still outranks the file.
1441
+
1442
+ Exit codes: 0 signed out (or nothing to sign out) · 16 no such stored account
1443
+
1444
+ [[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]].
1445
+ `;
1446
+
1447
+ export function parseLogoutCliArgs(argv) {
1448
+ if (argv.includes('--help') || argv.includes('-h')) return { help: true };
1449
+ let accountId;
1450
+ let json = false;
1451
+ for (let i = 0; i < argv.length; i += 1) {
1452
+ const arg = argv[i];
1453
+ if (arg === '--account') {
1454
+ accountId = argv[i + 1];
1455
+ i += 1;
1456
+ if (!accountId || accountId.startsWith('-')) {
1457
+ return { error: { message: '--account requires an account id or email', code: 2 } };
1458
+ }
1459
+ continue;
1460
+ }
1461
+ if (arg === '--json') { json = true; continue; }
1462
+ return {
1463
+ error: { message: `unknown argument \`${arg}\`\n Usage: hs logout [--account <id|email>] [--json]`, code: 2 },
1464
+ };
1465
+ }
1466
+ return { accountId, json };
1467
+ }
1468
+
1469
+ /** `hs logout [--account <id|email>]` — remove one stored account (default: the current one). */
1470
+ export async function runLogoutCli(argv, deps = {}) {
1471
+ const { homeDir = homedir(), env = process.env, stdout = process.stdout, stderr = process.stderr } = deps;
1472
+ const parsed = parseLogoutCliArgs(argv);
1473
+ if (parsed.help) {
1474
+ stdout.write(`${LOGOUT_CLI_HELP}\n`);
1475
+ return 0;
1476
+ }
1477
+ if (parsed.error) {
1478
+ stderr.write(`[heyspex-mcp logout] ${parsed.error.message}\n`);
1479
+ return parsed.error.code;
1480
+ }
1481
+ const emit = (payload, line, code) => {
1482
+ if (parsed.json) stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1483
+ else (code === 0 ? stdout : stderr).write(`${line}\n`);
1484
+ return code;
1485
+ };
1486
+ const store = readCredentialsStore({ homeDir });
1487
+ let target = null;
1488
+ if (parsed.accountId) {
1489
+ const match =
1490
+ findStoredAccount(store, parsed.accountId) ??
1491
+ (store.schemaVersion === 1 && store.legacyAccountId === parsed.accountId && store.accounts[LEGACY_ACCOUNT_ID]
1492
+ ? { accountId: LEGACY_ACCOUNT_ID }
1493
+ : null);
1494
+ if (!match) {
1495
+ return emit(
1496
+ { signedOut: false, reason: 'no_such_account', account: parsed.accountId },
1497
+ `No stored sign-in for ${parsed.accountId} on this machine; nothing was removed.`,
1498
+ 16,
1499
+ );
1500
+ }
1501
+ target = match.accountId;
1502
+ }
1503
+ const result = signOutAccount({ homeDir, accountId: target });
1504
+ const envNote =
1505
+ typeof env?.HEYSPEX_MCP_TOKEN === 'string' && env.HEYSPEX_MCP_TOKEN.trim()
1506
+ ? '\n Note: HEYSPEX_MCP_TOKEN is set in this shell and still signs requests in; unset it too.'
1507
+ : '';
1508
+ if (!result.removed) {
1509
+ return emit(
1510
+ { signedOut: false, reason: 'not_signed_in', currentAccountId: result.currentAccountId },
1511
+ `Not signed in on this machine; nothing was removed.${envNote}`,
1512
+ 0,
1513
+ );
1514
+ }
1515
+ const removedLabel = result.removed === LEGACY_ACCOUNT_ID ? store.legacyAccountId ?? 'the stored sign-in' : result.removed;
1516
+ const remaining = Object.keys(readCredentialsStore({ homeDir }).accounts);
1517
+ return emit(
1518
+ {
1519
+ signedOut: true,
1520
+ accountId: result.removed === LEGACY_ACCOUNT_ID ? store.legacyAccountId : result.removed,
1521
+ wasCurrent: result.wasCurrent,
1522
+ currentAccountId: result.currentAccountId,
1523
+ remainingAccounts: remaining,
1524
+ registryCurrentAccount: result.registry.status,
1525
+ },
1526
+ `Signed out ${removedLabel}.` +
1527
+ (result.wasCurrent
1528
+ ? remaining.length > 0
1529
+ ? `\n No account is current now. Still signed in: ${remaining.join(', ')} — pick one with hs login --switch-account <id>.`
1530
+ : '\n No account is signed in on this machine now. Run: hs login'
1531
+ : `\n The current account (${result.currentAccountId}) is unchanged.`) +
1532
+ envNote,
1533
+ 0,
1534
+ );
1535
+ }
1536
+
1537
+ export const CALL_CLI_HELP = `heyspex-mcp call — invoke one MCP tool and exit (no stdio loop)
1538
+
1539
+ Usage:
1540
+ node vendor/heyspex-mcp/cli.mjs call <tool> [--arg key=value ...] [--args '<json>'] [--args-file <path>] [--json]
1541
+ hs mcp call <tool> [--arg key=value ...] [--args '<json>'] [--args-file <path>] [--json]
1542
+
1543
+ Runs a single tools/call handler and exits. Use this when the MCP host does not
1544
+ expose heyspex_* tools — never pipe JSON-RPC into the long-lived stdio server.
1545
+
1546
+ Prefer repeated --arg key=value over --args JSON (especially on Windows/PowerShell,
1547
+ where inline JSON quoting is unreliable).
1548
+
1549
+ Examples:
1550
+ hs mcp call heyspex_claim_spec --arg spec_id=HS-TASK-NCH5 --json
1551
+ hs mcp call heyspex_next_on_deck --arg summary=true --arg spec_id=HS-EPIC-YQ5A --json
1552
+ hs mcp call heyspex_validate_linkage --args '{"spec_id":"HS-EPIC-YQ5A"}' --json
1553
+ hs mcp call heyspex_defer_spec --args-file .cursor/defer-args.json --json
1554
+ `;
1555
+
1556
+ export async function runCallCli(argv) {
1557
+ const parsed = parseCallCliArgs(argv);
1558
+ if (parsed.help) {
1559
+ process.stdout.write(`${CALL_CLI_HELP}\n`);
1560
+ return 0;
1561
+ }
1562
+ if (parsed.error) {
1563
+ process.stderr.write(`[heyspex-mcp call] ${parsed.error.message}\n`);
1564
+ return parsed.error.code;
1565
+ }
1566
+ const result = await handleToolsCall({ name: parsed.tool, arguments: parsed.args });
1567
+ const text = result?.content?.[0]?.text;
1568
+ if (parsed.json) {
1569
+ try {
1570
+ process.stdout.write(`${JSON.stringify(JSON.parse(text), null, 2)}\n`);
1571
+ } catch {
1572
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1573
+ }
1574
+ } else if (text) {
1575
+ process.stdout.write(`${text}\n`);
1576
+ } else {
1577
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1578
+ }
1579
+ return result?.isError ? 1 : 0;
1580
+ }
1581
+
1582
+ export const EXPORT_CLI_HELP = `heyspex-mcp export — introspection for the stdio subprocess proxy
1583
+
1584
+ Usage:
1585
+ node vendor/heyspex-mcp/cli.mjs export tools-list [--json]
1586
+
1587
+ Emits JSON for tools/list from a fresh Node process (used by the long-lived MCP
1588
+ stdio parent so tool schemas stay current without reconnecting Cursor).
1589
+ `;
1590
+
1591
+ /**
1592
+ * Freshness handshake (HS-G263-DIAG-2F0X): stamp what build this process IS,
1593
+ * right now — so later drift is provable instead of silent. Never fatal: a
1594
+ * write failure here must not block whatever caller invoked us.
1595
+ *
1596
+ * IMPORTANT: this must be called from every code path that determines what
1597
+ * the MCP client believes "the running server" is currently serving — NOT
1598
+ * just the literal stdio-bootstrap block. Under MCP_SUBPROCESS_TOOLS (the
1599
+ * default; see mcp-subprocess.mjs), the long-lived stdio parent is spawned
1600
+ * once per agent session and then kept alive; it never re-runs its own
1601
+ * bootstrap block again. Every `tools/list` (and `tools/call`) the client
1602
+ * makes after that is actually served by a FRESH short-lived
1603
+ * `node cli.mjs export tools-list` / `call` child spawned per-request — that
1604
+ * child sets `RUNNING_AS_CLI = true`, which always skipped the old
1605
+ * bootstrap-only call site below. That is why a schema change (e.g.
1606
+ * force_promote / heyspex_claim_file becoming reachable) was visible to the
1607
+ * client with NO corresponding build-record write: the code path the client
1608
+ * actually observed (`export tools-list`) never touched
1609
+ * .heyspex/mcp-server-build.json. Calling it here, at the moment the fresh
1610
+ * subprocess reports the tool catalog, is what "every server start"
1611
+ * concretely means in this architecture — it fires on the actual signal the
1612
+ * owner used as proof of freshness (new tools reachable).
1613
+ */
1614
+ export function stampMcpBuildRecord() {
1615
+ try {
1616
+ const { vaultRoot: fingerprintVaultRoot } = getConfig();
1617
+ writeBuildRecord({
1618
+ vaultRoot: fingerprintVaultRoot,
1619
+ pid: process.pid,
1620
+ startedAt: new Date().toISOString(),
1621
+ });
1622
+ } catch (err) {
1623
+ process.stderr.write?.(`[heyspex-mcp] build fingerprint skipped: ${err?.message ?? err}\n`);
1624
+ }
1625
+ }
1626
+
1627
+ export async function runExportCli(argv) {
1628
+ if (argv.includes('--help') || argv.includes('-h')) {
1629
+ process.stdout.write(`${EXPORT_CLI_HELP}\n`);
1630
+ return 0;
1631
+ }
1632
+ const sub = argv.find((a) => !a.startsWith('-'));
1633
+ const json = argv.includes('--json');
1634
+ if (sub === 'tools-list') {
1635
+ // Stamp BEFORE emitting: the payload we're about to hand back IS "the
1636
+ // running server's schema" from the client's perspective, so the build
1637
+ // record must reflect this exact fingerprint, not a stale parent-startup
1638
+ // snapshot. See stampMcpBuildRecord() doc above.
1639
+ stampMcpBuildRecord();
1640
+ const payload = handleToolsList();
1641
+ process.stdout.write(`${JSON.stringify(payload, null, json ? 2 : 0)}\n`);
1642
+ return 0;
1643
+ }
1644
+ process.stderr.write('[heyspex-mcp export] usage: export tools-list [--json]\n');
1645
+ return 2;
1646
+ }
1647
+
1648
+ // ─── `hs mcp install --remote` ────────────────────────────────────────────────
1649
+ // Writes (or prints) the Claude Code project `.mcp.json` that points at the
1650
+ // REMOTE MCP endpoint — `POST <console>/api/mcp`, the Streamable-HTTP form —
1651
+ // instead of spawning this stdio server. The bearer minted by `hs login` /
1652
+ // `hs mcp pair` is referenced BY ENV NAME, never inlined: the file is
1653
+ // routinely committed, and a literal `hsmcp_` token in it would be a leaked
1654
+ // credential. Same two artifacts `hs install --remote` produces
1655
+ // (spex-console/src/lib/cli/install.ts `renderRemoteClaudeMcpJson` /
1656
+ // `buildClaudeMcpAddRemoteCommandPowerShell`) — keep the shapes in step; that
1657
+ // module is the one under unit test and this verb is its no-TypeScript face
1658
+ // for a machine that only has the packed MCP server.
1659
+
1660
+ /** Env var the generated config references for the `hsmcp_` bearer. */
1661
+ export const REMOTE_MCP_TOKEN_ENV = 'HEYSPEX_MCP_TOKEN';
1662
+
1663
+ export const MCP_INSTALL_CLI_HELP = `heyspex-mcp install — wire a client to the HeySpex MCP server
1664
+
1665
+ Usage:
1666
+ node vendor/heyspex-mcp/cli.mjs install --remote [--console-url <url>] [--write] [--json]
1667
+ hs mcp install --remote [--console-url <url>] [--write] [--json]
1668
+
1669
+ --remote is currently required: this verb exists for the CLOUD endpoint
1670
+ (\`POST <console>/api/mcp\`). The local stdio wiring is \`hs install\`, which
1671
+ also writes specs/, heyspex.yaml and the agent contract.
1672
+
1673
+ Prints the \`.mcp.json\` block and the equivalent \`claude mcp add --transport
1674
+ http …\` command. Nothing is written unless --write is passed.
1675
+
1676
+ --write reads the existing .mcp.json first. When a stdio \`heyspex\` entry
1677
+ carrying env.HEYSPEX_VAULT_ROOT (the local vault pin — same key \`hs install\`
1678
+ and the satellite attachment write) is present, that entry is preserved
1679
+ byte-for-byte under its existing key and the cloud form is written under a
1680
+ second key (\`heyspex-cloud\`) instead of overwriting it — the local pin and
1681
+ the cloud endpoint live side by side. With no pin present, the write still
1682
+ lands under the single \`heyspex\` key, matching today's output.
1683
+
1684
+ --console-url <url> Console origin (default: the resolved console URL).
1685
+ --write Write .mcp.json into the resolved vault root,
1686
+ preserving any stdio vault pin under a second key.
1687
+ Confirms FIRST (GET /api/mcp/session) that the bearer
1688
+ the manifest will use belongs to the account this repo
1689
+ is recorded under (heyspex.yaml account:, else this
1690
+ machine's configured account); a mismatch refuses
1691
+ (exit 17) naming both accounts and
1692
+ \`hs login --switch-account\`, and writes nothing.
1693
+ --json Emit { url, mcp_json, claude_command, wrote, bearer,
1694
+ account, token_source } instead (never the token).
1695
+
1696
+ Auth: pair the machine first (\`hs login\`, or \`hs mcp pair <code>\`). Once
1697
+ paired, an already-paired machine resolves the bearer automatically from
1698
+ ~/.heyspex/credentials.yaml's \`mcp_token\` (same precedence as every other
1699
+ cloud call: ${REMOTE_MCP_TOKEN_ENV} env first, credentials-store fallback) —
1700
+ no unconditional export is required. The generated \`.mcp.json\` still stores
1701
+ \${${REMOTE_MCP_TOKEN_ENV}} as an env REFERENCE (never the literal token, and
1702
+ never into a committed file); export ${REMOTE_MCP_TOKEN_ENV} in the shell
1703
+ that launches the client only if that client does not itself read
1704
+ ~/.heyspex/credentials.yaml.`;
1705
+
1706
+ /** Endpoint URL for a console origin (idempotent if it already ends in /api/mcp). */
1707
+ export function remoteMcpEndpoint(consoleUrl) {
1708
+ const base = String(consoleUrl ?? '').trim().replace(/\/+$/, '');
1709
+ if (!base) return '';
1710
+ return /\/api\/mcp$/.test(base) ? base : `${base}/api/mcp`;
1711
+ }
1712
+
1713
+ /** The `.mcp.json` object for the remote endpoint (env-reference auth). */
1714
+ export function renderRemoteMcpJson(url) {
1715
+ return {
1716
+ mcpServers: {
1717
+ heyspex: {
1718
+ type: 'http',
1719
+ url,
1720
+ headers: { Authorization: `Bearer \${${REMOTE_MCP_TOKEN_ENV}}` },
1721
+ },
1722
+ },
1723
+ };
1724
+ }
1725
+
1726
+ /**
1727
+ * The equivalent one-liner. The header is SINGLE-quoted so PowerShell does
1728
+ * not expand the reference — the literal `${HEYSPEX_MCP_TOKEN}` is what gets
1729
+ * stored, exactly as in the JSON form.
1730
+ */
1731
+ export function buildRemoteClaudeMcpAddCommand(url) {
1732
+ return (
1733
+ `claude mcp add --transport http --scope project heyspex '${String(url).replace(/'/g, "''")}' `
1734
+ + `--header 'Authorization: Bearer \${${REMOTE_MCP_TOKEN_ENV}}'`
1735
+ );
1736
+ }
1737
+
1738
+ /**
1739
+ * Detect a stdio `heyspex` entry carrying `env.HEYSPEX_VAULT_ROOT` — the
1740
+ * local vault pin (an ordinary `hs install` or an `--attach-vault` satellite
1741
+ * write both put it there, see `spex-console/src/lib/cli/install.ts`
1742
+ * `renderClaudeMcpJson`) — in a parsed `.mcp.json` object. Returns the entry
1743
+ * object, or `null` when no such pin is present (missing file, missing key,
1744
+ * a non-stdio transport, or no vault-root env). Pure and never throws.
1745
+ */
1746
+ export function findStdioVaultPin(mcpJson) {
1747
+ const entry = mcpJson?.mcpServers?.heyspex;
1748
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
1749
+ if (entry.type !== 'stdio') return null;
1750
+ const env = entry.env;
1751
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return null;
1752
+ const pin = env.HEYSPEX_VAULT_ROOT;
1753
+ return typeof pin === 'string' && pin.trim() ? entry : null;
1754
+ }
1755
+
1756
+ /** Read + JSON.parse an existing `.mcp.json`, or `null` if absent/unparseable. Never throws. */
1757
+ function readExistingMcpJson(target) {
1758
+ try {
1759
+ return JSON.parse(readFileSync(target, 'utf8'));
1760
+ } catch {
1761
+ return null;
1762
+ }
1763
+ }
1764
+
1765
+ /**
1766
+ * Merge the remote cloud entry into an existing `.mcp.json`. When a stdio
1767
+ * vault pin ({@link findStdioVaultPin}) is present under `heyspex`, that
1768
+ * entry is left untouched (same object, so its serialization is
1769
+ * byte-for-byte identical) and the cloud form is written under a second key,
1770
+ * `heyspex-cloud` — `--remote --write` must not detach a project from its
1771
+ * pinned vault ([[HS-D1QU-B0IT-EPIC-cloud-mcp-alongside-the-satellite-pin]]).
1772
+ * With no pin, keeps today's single-key `heyspex` shape. Any other top-level
1773
+ * keys in the existing file (and any other existing `mcpServers` entries)
1774
+ * are preserved untouched. Pure.
1775
+ */
1776
+ export function mergeRemoteMcpJson(existingJson, cloudEntry) {
1777
+ const base =
1778
+ existingJson && typeof existingJson === 'object' && !Array.isArray(existingJson) ? existingJson : {};
1779
+ const existingServers =
1780
+ base.mcpServers && typeof base.mcpServers === 'object' && !Array.isArray(base.mcpServers)
1781
+ ? base.mcpServers
1782
+ : {};
1783
+ const pin = findStdioVaultPin(base);
1784
+ const mergedServers = { ...existingServers };
1785
+ if (pin) {
1786
+ mergedServers['heyspex-cloud'] = cloudEntry;
1787
+ } else {
1788
+ mergedServers.heyspex = cloudEntry;
1789
+ }
1790
+ return { ...base, mcpServers: mergedServers };
1791
+ }
1792
+
1793
+ /**
1794
+ * Resolve whether an `Authorization` bearer is available for this machine,
1795
+ * and where it would come from — same precedence as console-fetch.mjs's
1796
+ * {@link getAuthHeader} (env `HEYSPEX_MCP_TOKEN` first, then
1797
+ * `~/.heyspex/credentials.yaml`'s `mcp_token`, written by `hs login` / `hs
1798
+ * mcp pair`). NEVER returns the literal token — only a resolved/source
1799
+ * verdict — so callers can steer printed guidance and JSON output without
1800
+ * ever risking the value landing in a committed file
1801
+ * ([[HS-D1QU-B0IT-EPIC-cloud-mcp-alongside-the-satellite-pin]] AC4).
1802
+ */
1803
+ export function resolveInstallBearer({ env = process.env, homeDir = homedir() } = {}) {
1804
+ // The ONE shared reader (./credentials.mjs, HS-HL0D) — the same precedence
1805
+ // the MCP runtime, `hs whoami` and the CLI gate use; only its SOURCE leaves
1806
+ // this function. `credentials` keeps this verb's pinned --json vocabulary.
1807
+ const { source } = readBearer({ env, homeDir });
1808
+ if (source === 'env') return { resolved: true, source: 'env' };
1809
+ if (source === 'credentials-file') return { resolved: true, source: 'credentials' };
1810
+ return { resolved: false, source: null };
1811
+ }
1812
+
1813
+ /**
1814
+ * Accept either a bare token (string | null — what an older `readBearer` dep
1815
+ * or `readStoredBearer` returns) or the shared reader's `{ token, source }`.
1816
+ * @returns {{ token: string | null, source: 'env' | 'credentials-file' | null }}
1817
+ */
1818
+ export function normalizeBearerVerdict(value) {
1819
+ if (value && typeof value === 'object') {
1820
+ const token = typeof value.token === 'string' && value.token.trim() ? value.token.trim() : null;
1821
+ const source = token && (value.source === 'env' || value.source === 'credentials-file') ? value.source : null;
1822
+ return { token, source };
1823
+ }
1824
+ const token = typeof value === 'string' && value.trim() ? value.trim() : null;
1825
+ return { token, source: null };
1826
+ }
1827
+
1828
+ // Anchored to a TOP-LEVEL `account:` line of heyspex.yaml — the same shape
1829
+ // spex-console/src/lib/cli/install-identity.ts `parseMarkerAccount` reads.
1830
+ const MARKER_ACCOUNT_LINE_RE = /^account:[ \t]*["']?([^"'\r\n#]+)/m;
1831
+
1832
+ /**
1833
+ * The account a repo is recorded under: `heyspex.yaml` `account:` in
1834
+ * `vaultRoot` when the marker names one (written once
1835
+ * [[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]] lands), else this
1836
+ * machine's configured account (`HS_ACCOUNT_ID`, then config.yaml — the ONE
1837
+ * account reader, ./session-owner.mjs), else `null` ("unrecorded"). Never
1838
+ * throws.
1839
+ * @param {{ vaultRoot: string, env?: object, homeDir?: string }} opts
1840
+ * @returns {{ accountId: string, source: 'heyspex.yaml' | 'config.yaml' } | null}
1841
+ */
1842
+ export function readRecordedRepoAccount({ vaultRoot, env = process.env, homeDir = homedir() }) {
1843
+ let marker = null;
1844
+ try {
1845
+ marker = readFileSync(join(vaultRoot, 'heyspex.yaml'), 'utf8');
1846
+ } catch {
1847
+ marker = null;
1848
+ }
1849
+ const fromMarker = typeof marker === 'string' ? marker.match(MARKER_ACCOUNT_LINE_RE)?.[1]?.trim() : '';
1850
+ if (fromMarker) return { accountId: fromMarker, source: 'heyspex.yaml' };
1851
+ const configured = resolveSessionOwnerUncached({ env, homeDir });
1852
+ return configured ? { accountId: configured, source: 'config.yaml' } : null;
1853
+ }
1854
+
1855
+ /**
1856
+ * The pure decision behind `hs mcp install --remote --write`'s confirmation
1857
+ * ([[HS-6V9X-WW9R-TASK-make-hs-install-remote-confirm-the]] AC2/AC3): given
1858
+ * the GET /api/mcp/session verdict for the bearer this manifest will use, the
1859
+ * repo's recorded account and the bearer's source, either proceed with an
1860
+ * `hs whoami`-style receipt or refuse (exit 20 `ACCOUNT_MISMATCH` when the
1861
+ * repo's heyspex.yaml records another account, 17 `IDENTITY_UNCONFIRMED` when
1862
+ * only this machine's configured account differs -- both naming both accounts
1863
+ * and the repair command; 16 when not
1864
+ * signed in / rejected; 12 when the console cannot answer). Never includes
1865
+ * token material.
1866
+ * @param {{ probe: object, recorded: { accountId: string, source: string } | null, consoleUrl: string, tokenSource: string | null }} input
1867
+ */
1868
+ export function decideRemoteInstallAccount({ probe, recorded, consoleUrl, tokenSource }) {
1869
+ const refuse = (code, error, message, extra = {}) => ({
1870
+ ok: false,
1871
+ code,
1872
+ message: `[heyspex-mcp install] refusing -- ${message} Nothing was written.`,
1873
+ payload: { ok: false, error, message, ...extra },
1874
+ });
1875
+ if (probe.kind === 'no_credential') {
1876
+ return refuse(16, 'sign_in_required', 'this machine is not signed in to HeySpex. Run `hs login` first.');
1877
+ }
1878
+ if (probe.kind === 'rejected') {
1879
+ return refuse(
1880
+ 16,
1881
+ 'sign_in_required',
1882
+ `the stored sign-in was rejected by ${consoleUrl} (HTTP ${probe.status}) -- revoked, or from another console. Run \`hs login\` first.`,
1883
+ );
1884
+ }
1885
+ if (probe.kind === 'unsupported') {
1886
+ return refuse(
1887
+ 12,
1888
+ 'console_unsupported',
1889
+ `${consoleUrl} cannot confirm whose sign-in this is yet (HTTP ${probe.status}); it needs the console deploy that adds GET /api/mcp/session.`,
1890
+ );
1891
+ }
1892
+ if (probe.kind !== 'identified') {
1893
+ return refuse(
1894
+ 12,
1895
+ 'console_unreachable',
1896
+ `could not reach ${consoleUrl} to confirm which account this token belongs to (${probe.message}).`,
1897
+ );
1898
+ }
1899
+ const identity = { accountId: probe.accountId, email: probe.email };
1900
+ const shown = `${formatIdentity(identity)} [token: ${describeBearerSource(tokenSource)}]`;
1901
+ if (recorded && recorded.accountId !== identity.accountId) {
1902
+ // The repo's COMMITTED marker names the account: an account mismatch,
1903
+ // the same exit (20 `ACCOUNT_MISMATCH`) and repair `hs connect` / `hs
1904
+ // install --remote` give ([[HS-VWUD-WW9R-TASK-record-the-connecting-account-in]],
1905
+ // install-identity.ts). Compared only with this machine's configured
1906
+ // account (no `account:` in the marker) it stays unconfirmed (17).
1907
+ const fromMarker = recorded.source === 'heyspex.yaml';
1908
+ const repair = fromMarker ? `hs login --account ${recorded.accountId}` : 'hs login --switch-account';
1909
+ return refuse(
1910
+ fromMarker ? EXIT_ACCOUNT_MISMATCH : 17,
1911
+ fromMarker ? 'ACCOUNT_MISMATCH' : 'identity_unconfirmed',
1912
+ `this repo is recorded under account ${recorded.accountId} (${recorded.source}), but the token this install would wire belongs to ${shown}. ` +
1913
+ `Sign in as the repo's account with \`${repair}\`, then re-run (or pass --account ${recorded.accountId} to \`hs install --remote\`).`,
1914
+ {
1915
+ repo_account: recorded.accountId,
1916
+ repo_account_source: recorded.source,
1917
+ signed_in_account: identity.accountId,
1918
+ signed_in_email: identity.email,
1919
+ token_source: tokenSource,
1920
+ repair,
1921
+ },
1922
+ );
1923
+ }
1924
+ const repoLine = !recorded
1925
+ ? ' repo: unrecorded (heyspex.yaml has no account:, no account_id in ~/.heyspex/config.yaml) -- using the signed-in account'
1926
+ : recorded.source === 'heyspex.yaml'
1927
+ ? ` repo: ${recorded.accountId} (heyspex.yaml) -- matches the signed-in account`
1928
+ : ` repo: unrecorded in heyspex.yaml -- compared with this machine's configured account ${recorded.accountId} (~/.heyspex/config.yaml), which matches`;
1929
+ return {
1930
+ ok: true,
1931
+ receipt: [`Signed in as ${formatIdentity(identity)}`, ` token: ${describeBearerSource(tokenSource)}`, repoLine],
1932
+ account: {
1933
+ account_id: identity.accountId,
1934
+ email: identity.email,
1935
+ repo_account: recorded?.accountId ?? null,
1936
+ repo_account_source: recorded?.source ?? 'unrecorded',
1937
+ },
1938
+ };
1939
+ }
1940
+
1941
+ /**
1942
+ * `deps` exists for tests (mirrors {@link runLoginCli}): it injects the
1943
+ * resolved vault root, the env/homeDir bearer resolution reads, and the
1944
+ * output streams, so the read-before-write merge and the bearer-resolution
1945
+ * guidance can be exercised without touching this machine's real
1946
+ * `~/.heyspex/` or the checkout's own `.mcp.json`.
1947
+ */
1948
+ export async function runMcpInstallCli(argv, deps = {}) {
1949
+ const {
1950
+ env = process.env,
1951
+ homeDir = homedir(),
1952
+ stdout = process.stdout,
1953
+ stderr = process.stderr,
1954
+ vaultRoot: vaultRootOverride,
1955
+ probe = probeCredentialIdentity,
1956
+ } = deps;
1957
+
1958
+ if (argv.includes('--help') || argv.includes('-h')) {
1959
+ stdout.write(`${MCP_INSTALL_CLI_HELP}\n`);
1960
+ return 0;
1961
+ }
1962
+ if (!argv.includes('--remote')) {
1963
+ stderr.write('[heyspex-mcp install] --remote is required (local stdio wiring is `hs install`)\n');
1964
+ return 2;
1965
+ }
1966
+ const json = argv.includes('--json');
1967
+ const write = argv.includes('--write');
1968
+ const overrideIdx = argv.indexOf('--console-url');
1969
+ const override = overrideIdx >= 0 ? argv[overrideIdx + 1] : undefined;
1970
+ const consoleOrigin = String(override ?? getConsoleUrl()).trim().replace(/\/+$/, '').replace(/\/api\/mcp$/, '');
1971
+ const url = remoteMcpEndpoint(override ?? getConsoleUrl());
1972
+ if (!url) {
1973
+ stderr.write('[heyspex-mcp install] could not resolve a console URL\n');
1974
+ return 1;
1975
+ }
1976
+
1977
+ const mcpJson = renderRemoteMcpJson(url);
1978
+ const cloudEntry = mcpJson.mcpServers.heyspex;
1979
+ const command = buildRemoteClaudeMcpAddCommand(url);
1980
+ const bearer = resolveInstallBearer({ env, homeDir });
1981
+ // The ONE reader's full verdict — the token is used ONLY to ask the console
1982
+ // whose it is; it never reaches stdout, stderr or a written file.
1983
+ const bearerVerdict = readBearer({ env, homeDir });
1984
+
1985
+ let vaultRoot = null;
1986
+ if (write) {
1987
+ try {
1988
+ vaultRoot = vaultRootOverride ?? getConfig().vaultRoot;
1989
+ } catch (err) {
1990
+ stderr.write(`[heyspex-mcp install] could not resolve the vault root: ${err?.message ?? err}\n`);
1991
+ return 1;
1992
+ }
1993
+ }
1994
+
1995
+ // --write confirms, BEFORE writing, that the bearer this manifest will use
1996
+ // belongs to the account this repo is recorded under: GET /api/mcp/session
1997
+ // names the token's account; heyspex.yaml `account:` (else this machine's
1998
+ // configured account) names the repo's. A loopback console binds no cloud
1999
+ // account and is not gated — the same rule as the `hs install` gate
2000
+ // ([[HS-6V9X-WW9R-TASK-make-hs-install-remote-confirm-the]] AC2).
2001
+ let confirmation = null;
2002
+ if (write && !isLoopbackConsoleUrl(consoleOrigin)) {
2003
+ const probed = await probe({ consoleUrl: consoleOrigin, token: bearerVerdict.token });
2004
+ const verdict = decideRemoteInstallAccount({
2005
+ probe: probed,
2006
+ recorded: readRecordedRepoAccount({ vaultRoot, env, homeDir }),
2007
+ consoleUrl: consoleOrigin,
2008
+ tokenSource: bearerVerdict.source,
2009
+ });
2010
+ if (!verdict.ok) {
2011
+ if (json) stdout.write(`${JSON.stringify({ ...verdict.payload, wrote: null, token_source: bearerVerdict.source }, null, 2)}\n`);
2012
+ else stderr.write(`${verdict.message}\n`);
2013
+ return verdict.code;
2014
+ }
2015
+ confirmation = verdict;
2016
+ }
2017
+
2018
+ let wrote = null;
2019
+ let outputJson = mcpJson;
2020
+ if (write) {
2021
+ try {
2022
+ const target = join(vaultRoot, '.mcp.json');
2023
+ const existing = readExistingMcpJson(target);
2024
+ const merged = mergeRemoteMcpJson(existing, cloudEntry);
2025
+ writeFileSync(target, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
2026
+ wrote = target;
2027
+ outputJson = merged;
2028
+ } catch (err) {
2029
+ stderr.write(`[heyspex-mcp install] could not write .mcp.json: ${err?.message ?? err}\n`);
2030
+ return 1;
2031
+ }
2032
+ }
2033
+
2034
+ const body = `${JSON.stringify(outputJson, null, 2)}\n`;
2035
+
2036
+ if (json) {
2037
+ stdout.write(
2038
+ `${JSON.stringify(
2039
+ {
2040
+ url,
2041
+ mcp_json: outputJson,
2042
+ claude_command: command,
2043
+ wrote,
2044
+ bearer,
2045
+ // The receipt: which account the bearer was confirmed as (null when
2046
+ // this run did not confirm one -- print-only, or a loopback console)
2047
+ // and where the bearer came from. Never the token itself.
2048
+ account: confirmation?.account ?? null,
2049
+ token_source: bearerVerdict.source,
2050
+ },
2051
+ null,
2052
+ 2,
2053
+ )}\n`,
2054
+ );
2055
+ return 0;
2056
+ }
2057
+
2058
+ const confirmationBlock = confirmation ? `${confirmation.receipt.join('\n')}\n\n` : '';
2059
+
2060
+ const bearerGuidance = bearer.resolved
2061
+ ? bearer.source === 'env'
2062
+ ? `Then launch the client — ${REMOTE_MCP_TOKEN_ENV} is already set in this shell.\n`
2063
+ : `Then launch the client — this machine is already paired, so the bearer resolves from ` +
2064
+ `~/.heyspex/credentials.yaml (mcp_token). Export ${REMOTE_MCP_TOKEN_ENV} in the shell that ` +
2065
+ `launches the client if it does not read that file itself.\n`
2066
+ : `Then pair the machine (\`hs login\`) and export ${REMOTE_MCP_TOKEN_ENV} before launching the client.\n`;
2067
+
2068
+ stdout.write(
2069
+ `HeySpex remote MCP → ${url}\n\n`
2070
+ + confirmationBlock
2071
+ + `.mcp.json${wrote ? ` (written to ${wrote})` : ' (not written — pass --write)'}:\n${body}\n`
2072
+ + `Equivalent command:\n ${command}\n\n`
2073
+ + bearerGuidance,
2074
+ );
2075
+ return 0;
2076
+ }