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