heyspex 0.1.0 → 0.2.1

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