diff --git a/llp/0242-fresh-installs-attach-base-url.issue.md b/llp/0242-fresh-installs-attach-base-url.issue.md index 137ff93c..6a82af29 100644 --- a/llp/0242-fresh-installs-attach-base-url.issue.md +++ b/llp/0242-fresh-installs-attach-base-url.issue.md @@ -5,7 +5,7 @@ **Systems:** Config, Plugins, Gateway **Author:** Phil / Claude **Date:** 2026-08-17 -**Related:** LLP 0231, LLP 0232, LLP 0233, LLP 0174 +**Related:** LLP 0231, LLP 0232, LLP 0233, LLP 0174, LLP 0251 **Extended-by:** LLP 0262 (accepted 2026-08-17; the resolution below is itself migrated: the `claude` client's attach target becomes the OTEL mode, so proxy mode stops being what fresh installs compose for it) diff --git a/llp/0243-picker-composes-proxy-mode.decision.md b/llp/0243-picker-composes-proxy-mode.decision.md index c0e5967a..6ebc1e00 100644 --- a/llp/0243-picker-composes-proxy-mode.decision.md +++ b/llp/0243-picker-composes-proxy-mode.decision.md @@ -5,7 +5,7 @@ **Systems:** Config, Plugins, Gateway **Author:** Phil / Claude **Date:** 2026-08-17 -**Related:** LLP 0130, LLP 0135, LLP 0213, LLP 0232, LLP 0242 +**Related:** LLP 0130, LLP 0135, LLP 0213, LLP 0232, LLP 0242, LLP 0251 **Extends:** LLP 0233 (#proxy-mode-is-explicit: the key is still the only switch and is still explicit in the file; what changes is who writes it) **Extended-by:** LLP 0262 (accepted 2026-08-17; the Claude row stops diff --git a/llp/0244-attach-migrates-to-proxy-mode.decision.md b/llp/0244-attach-migrates-to-proxy-mode.decision.md index 4af4c5fb..c59d4188 100644 --- a/llp/0244-attach-migrates-to-proxy-mode.decision.md +++ b/llp/0244-attach-migrates-to-proxy-mode.decision.md @@ -6,7 +6,7 @@ **Author:** Phil / Claude **Date:** 2026-08-17 **Related:** LLP 0031, LLP 0174, LLP 0181, LLP 0232, LLP 0233, LLP 0242, -LLP 0243 +LLP 0243, LLP 0251 **Extended-by:** LLP 0262 (accepted 2026-08-17; the migration machinery here is retargeted, not reverted: `hyp attach claude` migrates a proxy-mode install to the OTEL mode, unwinding the launchd environment and offering diff --git a/llp/0249-proxy-mode-default-attach.plan.md b/llp/0249-proxy-mode-default-attach.plan.md new file mode 100644 index 00000000..72e54c7c --- /dev/null +++ b/llp/0249-proxy-mode-default-attach.plan.md @@ -0,0 +1,193 @@ +# LLP 0249: Proxy mode by default and the attach migration, implementation plan + +**Type:** plan +**Status:** Active +**Related:** LLP 0251, LLP 0242 +**Generated-by:** neutral + +> [LLP 0251](./0251-proxy-mode-default-attach.design.md) is the technical +> design closing LLP 0242. This plan's central finding, verified against the +> tree rather than assumed from the design's prose: the design is already +> realized on `master` by commit `04330abb` (#794), exactly as its section 0 +> states. Every file, symbol, test, `@ref` annotation, and smoke edit the +> design names exists and the section 5 gate passes (32/32 across the five +> named test files on this branch). This plan therefore schedules no rebuild +> of working code. It schedules the two things a symbol-by-symbol conformance +> pass actually found outstanding: one missing test pin (the `--json` attach +> shape the design claims is proven but no test exercises) and the corpus +> cross-links binding the design of record to the docs it covers. + +## What is already built on master (verified, not planned) + +Each design section was checked against the tree at `04330abb`, which is an +ancestor of this integration branch. Nothing below is a task; it is the +record of why it is not one. + +**Section 2, fresh installs (LLP 0243), fully built:** + +- `hypaware-core/plugins-workspace/claude/hypaware.plugin.json` line 45 + declares `"gateway_proxy_mode": true` in the claude picker row's `compose` + block. The codex manifest declares nothing, so a Codex-only install mints + no CA, as designed. +- `composePickerConfig` in `src/core/cli/walkthrough.js` folds the flag + (line 973) and writes `proxy_mode: true` onto the composed + `@hypaware/ai-gateway` entry (line 1014), `@ref`'d to + LLP 0243#composed-default. No `listen` is written. +- The `hyp init claude` literal preset + (`hypaware-core/plugins-workspace/claude/src/index.js` line 413) writes + the key literally, `@ref`'d to the same anchor. +- The carry-forward merge lets a prior gateway entry own the key entirely, + including its absence (`walkthrough.js` line 1276, `@ref` LLP + 0243#user-key-wins), pinned by `test/core/compose-picker-config.test.js` + lines 514 and 534. +- The finale waits on `waitForLocalCa` (imported from `src/core/tls/ca.js`, + wait seam at `walkthrough.js` line 1537) before a proxy-mode attach, + pinned by `test/core/walkthrough-finale-ca-wait.test.js`. + +**Section 3, existing installs (LLP 0244), fully built:** + +- `maybeOfferProxyModeMigration` (`src/core/commands/clients.js` line 821) + runs before endpoint resolution (line 308), never throws into the attach + (the caller downgrades to a warning), and gates exactly as designed: + dry-run silent; `attach all`, `--json`, and non-TTY emit the one pointer + line (line 886); a central-layer gateway reports fleet management instead + of prompting; the offer is keyed on the config, not the CA. +- `enableGatewayProxyMode` (`src/core/config/gateway_proxy_enable.js`) sets + the key on the existing local entry with the LLP 0031 guarded write, + refuses `no_gateway` and `central_managed` (line 154), restarts, waits for + bind and CA through injectable seams (`waitForCaFn` defaulting to + `waitForLocalCa`, lines 80 and 251), and reports every step. +- `@ref` annotations to LLP 0243/0244 anchors are present in + `walkthrough.js`, `clients.js`, `gateway_proxy_enable.js`, the claude + preset, and the tests. All six anchors the design and code cite + (`#composed-default`, `#user-key-wins`, `#attach-offers`, + `#non-interactive`, `#central-managed`, `#enable-write`) resolve in + LLP 0243/0244. LLP 0233 already carries its `Extended-by: LLP 0244` + forward ref (landed in #794). + +**Section 5, the gate, built and green on this branch:** + +`test/core/init-proxy-mode-default.test.js`, +`test/core/attach-proxy-migration.test.js`, +`test/core/gateway-proxy-enable.test.js`, +`test/core/walkthrough-finale-ca-wait.test.js`, and +`test/core/walkthrough-attach-lane.test.js` all exist and pass (32/32 on +this branch). Both named smokes were updated by #794: +`walkthrough_picker_to_first_query.js` asserts `proxy_mode: true` in the +golden composed config, and `claude_attach_detach.js` asserts the non-TTY +pointer note is the attach's only stderr line. + +### The consent and refusal pins, enumerated + +The consented-migration path is the delicate part of this design, so this +plan records exactly which behaviours `test/core/attach-proxy-migration.test.js` +already pins, one test each: + +1. Decline: the question is asked once, default no, nothing is written, the + attach still lands, and the re-run pointer is printed. +2. Accept: `proxy_mode: true` lands in the local config; with no daemon + service installed the output names the daemon start as the remaining step. +3. Idempotence: with the key already set, no question and no note. +4. A client whose picker row does not declare `compose.gateway_proxy_mode` + is never asked. +5. Central-managed gateway: no question even on a TTY, the fleet-managed + note instead, nothing written, even beside a local entry. +6. Non-TTY: no question, exactly the one pointer line, attach unchanged + (also pinned from the enablement side in + `test/core/attach-enablement-state.test.js` line 286 and end-to-end in + the `claude_attach_detach` smoke). +7. `--dry-run`: no question, no note, no write. +8. `hyp attach all`: never asks mid-run, points at the interactive command. +9. A failed accepted migration warns and the attach still succeeds in + base-URL mode. + +**The one drift found:** design section 5 claims the tests prove the +"non-TTY, `--json` and `attach all` pointer lines", but no test anywhere +passes `json: true`. The code gates on `parsed.json` correctly +(`clients.js` line 886), and the test harness already plumbs an unused +`json` opt (`attach-proxy-migration.test.js` line 76), so only the pin is +missing: a `--json` run on a TTY is the one shape whose no-prompt guarantee +nothing enforces against regression. T1 closes this. + +## What remains: the task rationale + +- **T1** adds the missing `--json` pin. Small and mechanical because the + harness support already exists; it matters because `--json` on a TTY is + the only LLP 0244 #non-interactive shape a regression could silently + re-prompt, and a prompt inside a `--json` run would both hang scripted + callers and corrupt the JSON contract. +- **T2** lands the corpus cross-links: LLP 0242, 0243, and 0244 predate the + design of record and none of their `Related:` lines names LLP 0251. + Adding the forward link is a trivial editorial fix, which the repo's LLP + conventions explicitly permit on Accepted docs. T2 also re-verifies the + six anchors and runs `/ref-check` over the files this change set touches, + so the corpus lands coherent. + +The two tasks touch disjoint files (one test file; three llp docs) and run +fully in parallel. + +Any further drift a task worker finds between LLP 0251's prose and the tree +must become a new issue LLP, not an edit: LLP 0251 is Active and settled, +and this plan's verification already establishes the tree matches it +everywhere checked. + +## External blockers (not expressible as `deps`) + +- **Merge order: `integration/proxy-mode-capture` must land first.** The + design declares `Depends-on: proxy-mode-capture` and its section 0 cites + LLP 0245, which exists only on that branch; `llp/0245-*.design.md` is + absent from this branch. The *code* dependency is already satisfied + (master carries the proxy-mode capture stack via #782 and #792), so + neither task here is blocked; only the doc reference dangles until that + branch merges. Merging this branch first would leave LLP 0251 citing a + document the tree does not yet have. +- **Pre-existing unrelated failures.** The full `npm test` run on this + branch shows a failing cluster confined to parquet/iceberg NULL semantics, + pushdown conversion, and report rendering, none of it touched by this + change set; #794's own commit message records the same cluster as + pre-existing on a clean tree. Task workers should gate on the section 5 + test files plus the files they touch, not on that cluster turning green. + +## Assumption stated for the record + +This change set's design was written retrospectively against work that +merged to master as #794 before the design doc itself landed. This plan +takes the neutral pipeline's requirement (no plan means nothing can be +implemented) at face value and supplies the plan as the record binding the +change set together: verification of the as-built tree, plus the two +genuinely outstanding items as tasks. If a human would rather close the +change set with no tasks at all, T2 should still land somewhere; the +corpus cross-links otherwise never get written. + +## Notes for implementers + +- JavaScript, no semicolons; no em dashes anywhere, including in test names + and llp doc edits. +- T1's new tests ride the existing harness in + `test/core/attach-proxy-migration.test.js` (its `runAttach` opts already + accept `json`); no new seams are needed. Everything runs with the + `security` / `launchctl` seams refused or shimmed, per the LLP 0244 + consequence the existing tests in that file already honor. +- T2's edits are one line per doc on the `Related:` metadata line; do not + touch any settled section body. Run `/ref-check llp/` (or the repo's + ref-check skill against the touched files) before the PR. +- Neither task flips any LLP status. LLP 0242/0243/0244 stay Accepted, + LLP 0251 stays Active. + +## References + +- [LLP 0251](./0251-proxy-mode-default-attach.design.md): the design this + plan schedules, section by section +- [LLP 0242](./0242-fresh-installs-attach-base-url.issue.md): the request; + [LLP 0243](./0243-picker-composes-proxy-mode.decision.md) and + [LLP 0244](./0244-attach-migrates-to-proxy-mode.decision.md): its + resolution decisions, both fully realized by `04330abb` (#794) +- LLP 0245 (`integration/proxy-mode-capture`): the predecessor design; + see External blockers for the merge-order constraint +- `llp/0173-openclaw-two-lane-capture.plan.md`: format precedent for the + task-graph and complexity-rating structure + +## Tasks + +- id: T1 branch: task/proxy-mode-default-attach/T1 deps: [] complexity: 2 -- test/core/attach-proxy-migration.test.js: add the missing LLP 0244 #non-interactive pin for the `--json` attach shape. Using the file's existing harness (its opts already plumb `json`, line 76, currently unused), add a test running `hyp attach --client claude --json` on a TTY against a proxy-capable descriptor and a key-less local gateway config, asserting: no prompt is issued (the askYesNo seam is never reached), stderr carries exactly the one pointer line `note: this install attaches claude by base URL; run 'hyp attach claude' in an interactive terminal to switch it to proxy mode`, no config write occurs (`proxy_mode` stays absent from the file on disk), and stdout remains the attach's valid JSON payload with nothing interleaved. Add a companion case proving `--json` combined with non-TTY still emits the pointer exactly once, not twice. Annotate with `// @ref LLP 0244#non-interactive [tests]: --json never prompts even on a TTY and emits exactly the pointer`. Gate: this file plus test/core/attach-enablement-state.test.js pass. No production code change is expected; if the assertion fails against `src/core/commands/clients.js` line 886's `parsed.json` gate, that is a bug to report, not a test to soften. +- id: T2 branch: task/proxy-mode-default-attach/T2 deps: [] complexity: 1 -- Corpus cross-links for the design of record: append `LLP 0251` to the `Related:` metadata line of llp/0242-fresh-installs-attach-base-url.issue.md, llp/0243-picker-composes-proxy-mode.decision.md, and llp/0244-attach-migrates-to-proxy-mode.decision.md (trivial editorial forward-refs, permitted on Accepted docs; touch only the metadata line, never a settled section body). Re-verify that the six anchors LLP 0251 and the code cite resolve: #composed-default and #user-key-wins in 0243, #attach-offers, #enable-write, #central-managed, and #non-interactive in 0244. Run the repo's ref-check skill over llp/ and the files carrying `@ref LLP 0243` / `@ref LLP 0244` annotations (src/core/cli/walkthrough.js, src/core/commands/clients.js, src/core/config/gateway_proxy_enable.js, hypaware-core/plugins-workspace/claude/src/index.js, the test files) and confirm zero broken references. Gate: ref-check clean; no test changes. diff --git a/llp/0251-proxy-mode-default-attach.design.md b/llp/0251-proxy-mode-default-attach.design.md new file mode 100644 index 00000000..1c126217 --- /dev/null +++ b/llp/0251-proxy-mode-default-attach.design.md @@ -0,0 +1,168 @@ +# LLP 0251: Proxy mode by default and the attach migration, technical design + +**Type:** design +**Status:** Active +**Systems:** Config, Plugins, Gateway, Daemon +**Generated-by:** neutral +**Depends-on:** proxy-mode-capture +**Related:** LLP 0242, LLP 0243, LLP 0244, LLP 0231, LLP 0232, LLP 0233, +LLP 0031, LLP 0174, LLP 0181, LLP 0183 + +> Technical design for closing LLP 0242: every config-writing install path +> composes `proxy_mode: true` when a picked client attaches by proxy, and +> `hyp attach claude` becomes the consented migration verb for existing +> base-URL installs. Named files, the compose and write seams, consent and +> back-compat rules, failure modes, and what the tests prove. + +Coverage anchor: + +`@ref LLP 0242: fresh installs still attached Claude by base URL and old installs had no path to proxy mode; the picker-fold gateway_proxy_mode composition, the hyp init claude literal key, and the attach-time consented migration through enableGatewayProxyMode this document designs are LLP 0242's fix` + +## 0. Scope and code status {#scope} + +LLP 0242 is the accepted issue; LLP 0243 (composition) and LLP 0244 +(migration) are its resolution decisions. This document is the +implementation design binding them to the tree; it builds directly on the +proxy-mode capture stack designed in LLP 0245 (the `proxy-mode-capture` +change set), reusing its `waitForLocalCa` seam and the LLP 0232 attach +preflight semantics. + +The design is realized on `master` by commit `04330abb` (#794). Paths and +names below are verified against that tree; the tests in section 5 exist +and gate it. This document is the request-level design of record, with the +request (LLP 0242) `@ref`'d above. + +## 1. Why nothing turned the key on {#why} + +LLP 0233 made `proxy_mode` explicit: only the literal config key enables +interception, never inference, upgrade, or adapter installation. The +gateway mints the CA only when the key is on, and the LLP 0232 attach +preflight picks proxy mode only when that CA exists on disk. Correct at +every link, and no link ever started the chain: neither the picker +composer, the express path, nor the `hyp init claude` preset wrote the +key, so every install landed on base-URL attach. The fix therefore has +exactly two halves: who writes the key for a fresh config (LLP 0243), and +who writes it into a config that already exists (LLP 0244). + +## 2. Fresh installs: composition writes the key {#composition} + +**Manifest declaration.** A picker row whose client attaches by proxy +declares it in its plugin manifest, beside the upstream it already +contributes: `hypaware-core/plugins-workspace/claude/hypaware.plugin.json` +carries `"gateway_proxy_mode": true` in its picker row's `compose` block. +The Codex row does not declare it, so a Codex-only install mints no CA it +will never use (LLP 0243 #composed-default). + +**The fold.** `composePickerConfig` in `src/core/cli/walkthrough.js` +accumulates `gatewayProxyMode` across picked descriptors +(`if (compose.gateway_proxy_mode === true) gatewayProxyMode = true`) the +same way it accumulates `requires_gateway` and upstream contributions. +When set, the composed `@hypaware/ai-gateway` entry carries the explicit +`proxy_mode: true` key in the written file, preserving LLP 0233's +key-is-the-only-switch invariant: composition writes whole configs on the +user's instruction, which is not inference. Every path that rides the fold +(interactive picker, express, `hyp init --yes`) produces a proxy-mode +config by default. No `listen` is written, keeping the LLP 0114 +default-only EADDRINUSE fallback. + +**The literal preset.** `hyp init claude` +(`hypaware-core/plugins-workspace/claude/src/index.js`) writes its config +literally rather than through the fold, so it writes +`proxy_mode: true` literally on its gateway entry. + +**The user's key wins on reconfigure.** The carry-forward merge (LLP 0183) +lets a prior gateway entry own the key entirely on the interactive +reconfigure lane, the only lane that supplies `args.existing`: a +hand-written `proxy_mode: false` survives, and so does the key's +*absence*, because a reconfigure is a picker run, not the migration verb. +Non-interactive re-init (`--yes`, presets, `--from-file`) composes from +scratch by design; it only overwrites behind an explicit `--force`, which +is the whole-file consent, and so re-applies the composed default like a +fresh install (LLP 0243 #user-key-wins). + +**Finale ordering.** The walkthrough's attach lane runs against the daemon +it just started, so before attaching a proxy-mode client the finale waits +on `waitForLocalCa` (imported from `src/core/tls/ca.js` into +`walkthrough.js`); otherwise the LLP 0232 preflight would race the +gateway's first CA mint and silently produce a base-URL attach. + +## 3. Existing installs: attach is the migration verb {#migration} + +**The offer.** `maybeOfferProxyModeMigration({ name, ctx, parsed })` in +`src/core/commands/clients.js` runs before the gateway endpoint is +resolved, so an accepted migration restarts the daemon first and the +endpoint resolution then discovers the fresh one. It offers only when the +client's picker descriptor declares `compose.gateway_proxy_mode: true` +and the effective gateway block lacks `proxy_mode: true`. The offer is +keyed on the *config*, not the CA: a stale CA with the key off means an +earlier install was half-unwound, and the config write is still the +repair. One yes/no question, default no, names the three consequences: the +config write, the daemon restart, and the coming macOS trust dialog. +Consent here covers the config write only; the CA trust grant keeps its +own dialog (LLP 0237) and can be refused independently (LLP 0244 +#attach-offers). + +**Consent boundaries.** A dry run says nothing. Non-TTY, `--json`, and +`hyp attach all` never prompt and never migrate; each attaches exactly as +today and emits the one stderr line naming the interactive command that +migrates (LLP 0244 #non-interactive). When the gateway block comes from +the central layer, a local write would be dropped as a collision +(LLP 0031 merge), so attach reports that proxy mode is fleet-managed and +where to enable it, in every attach shape (LLP 0244 #central-managed). +The helper never throws into the attach: the caller downgrades any escape +to a warning, because base-URL attach is what the install already does and +remains the working fallback. + +**The write.** `enableGatewayProxyMode(args)` in +`src/core/config/gateway_proxy_enable.js` reuses the LLP 0174 enable +machinery with one new write shape: it sets `proxy_mode: true` on the +*existing* local `@hypaware/ai-gateway` entry (guarded local write with +the LLP 0031 backup) rather than appending a plugin, and refuses with +outcome `no_gateway` when no layer provides one, because inventing a +gateway entry is not this verb's job. It then restarts the daemon, waits +for the bind, and waits for the CA file via the injectable `waitForCaFn` +(defaulting to `waitForLocalCa`), because attaching before the gateway +has minted the CA would silently produce another base-URL attach +(LLP 0244 #enable-write). Every step is reported; the seams +(`daemonStatus`, `restartDaemon`, `waitForBind`, `waitForCaFn`, `sleep`, +`now`) are injectable so tests never drive the real service manager +(LLP 0181). When no daemon service is installed, the write still lands +and the output says the daemon start is the remaining step. + +**Idempotence.** Once the key is set the offer never appears again; the +attach proceeds straight into the LLP 0245 proxy attach. + +## 4. Failure modes {#failure-modes} + +- **Migration accepted but the daemon restart or bind fails**: reported + per step; the attach continues in base-URL mode with a warning naming + what failed. The config key is written, so the next daemon start and the + next attach complete the switch. +- **CA never appears within `caTimeoutMs`**: same downgrade; the gateway's + own status (`proxy_mode_error`) says why interception did not come up. +- **No gateway entry in any layer**: `no_gateway` refusal; a config with + no gateway anywhere has a bigger problem than proxy mode. +- **Fleet-managed gateway**: no local write ever; report-only, so the + local CLI never fights the central layer. +- **Declined default**: a hand-written `proxy_mode: false` (or its + absence, on the interactive reconfigure lane) is permanent until the + user acts; no path re-adds the key by inference. + +## 5. What the tests prove {#tests} + +Traditional (root `test/`): `test/core/init-proxy-mode-default.test.js` +(every fold-riding install path and the literal preset write the key; the +Codex-only pick does not; the carry-forward lets a prior entry own the +key), `test/core/attach-proxy-migration.test.js` (the offer appears +exactly for a proxy-capable client on a key-less config; default no; +dry-run silence; non-TTY, `--json` and `attach all` pointer lines; +central-managed report; failure downgraded to warning), +`test/core/gateway-proxy-enable.test.js` (guarded write on the existing +entry, `no_gateway` refusal, restart/bind/CA waits through injected +seams), `test/core/walkthrough-finale-ca-wait.test.js` (the finale waits +for the CA before attaching), and `test/core/walkthrough-attach-lane.test.js` +(attach lane ordering). Per the LLP 0244 consequence, everything above +runs with the `security` / `launchctl` seams refused or shimmed; hermetic +smokes (`walkthrough_picker_to_first_query`, `claude_attach_detach`) boot +gateways that mint a CA only inside the temp `HYP_HOME` and must never +reach the host keychain or launchd table. diff --git a/test/core/attach-proxy-migration.test.js b/test/core/attach-proxy-migration.test.js index cdd5fd57..ec7ff822 100644 --- a/test/core/attach-proxy-migration.test.js +++ b/test/core/attach-proxy-migration.test.js @@ -73,7 +73,7 @@ function writeGatewayConfig(home, opts) { * enable prompt never fires and the migration offer is the only question in * play. `answer` (when stdin is a TTY) is pre-buffered for it. * - * @param {{ home: string, answer?: string, tty?: boolean, json?: boolean }} opts + * @param {{ home: string, answer?: string, tty?: boolean }} opts */ function makeCtx({ home, answer, tty = true }) { const registered = ['claude', 'codex'] @@ -86,12 +86,24 @@ function makeCtx({ home, answer, tty = true }) { if (!registered.includes(name)) return undefined return { name, - /** @param {{ endpoint: string, dryRun?: boolean }} args */ + /** @param {{ endpoint: string, dryRun?: boolean, json?: boolean, stdout: { write(chunk: unknown): boolean } }} args */ async attach(args) { writeFileSync( path.join(home, `${name}-attached.json`), JSON.stringify({ endpoint: args.endpoint, dryRun: args.dryRun === true }) ) + // Mirror a real adapter's --json contract: under json, stdout carries + // exactly the one-line machine payload and nothing else, so the + // --json pins below can assert it stays clean of any migration note. + // The flag is read off `args`, the way a real adapter reads it, so + // the pins also fail if the command stops propagating `--json` into + // `client.attach()` and every adapter starts printing prose into a + // machine-readable run. + if (args.json === true) { + args.stdout.write( + JSON.stringify({ status: 'ok', action: 'attach', client: name, dry_run: args.dryRun === true }) + '\n' + ) + } }, } }, @@ -229,6 +241,68 @@ test('non-TTY: no question, one pointer note, attach unchanged', async () => { }) }) +// @ref LLP 0244#non-interactive [tests]: --json never prompts even on a TTY and emits exactly the pointer +test('--json on a TTY: no prompt, exactly one pointer note, no write, stdout stays the attach JSON payload', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + const before = readFileSync(localConfigPath(home), 'utf8') + // A `y` is queued even though nothing may consume it: were the askYesNo + // seam ever reached, an unanswerable prompt would park on an empty stdin + // and the run would never settle, cancelling the rest of this file + // instead of naming the regression. With the answer buffered, a reached + // seam instead accepts the migration and trips the question, stderr, and + // config-unchanged assertions below, loudly and in place. + const { ctx, stdout, stderr } = makeCtx({ home, tty: true, answer: 'y' }) + const code = await runAttach(['--client', 'claude', '--json'], ctx) + assert.equal(code, 0, stderr.text()) + // The askYesNo seam is never reached: its question never reaches stderr. + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) + // Exactly the one pointer line, nothing else on stderr. + assert.equal( + stderr.text(), + "note: this install attaches claude by base URL; run 'hyp attach claude' in an " + + 'interactive terminal to switch it to proxy mode\n' + ) + // No config write: proxy_mode stays absent from the file on disk. + const after = readFileSync(localConfigPath(home), 'utf8') + assert.equal(after, before) + assert.doesNotMatch(after, /proxy_mode/) + // stdout stays the attach's valid JSON payload, nothing interleaved. + const stdoutText = stdout.text() + assert.equal(stdoutText.split('\n').filter((line) => line.length > 0).length, 1) + const payload = JSON.parse(stdoutText) + assert.equal(payload.status, 'ok') + assert.equal(payload.client, 'claude') + }) +}) + +// @ref LLP 0244#non-interactive [tests]: --json never prompts even on a TTY and emits exactly the pointer +test('--json combined with non-TTY still emits the pointer exactly once, not twice', async () => { + await withTempHome(async (home) => { + writeGatewayConfig(home) + const before = readFileSync(localConfigPath(home), 'utf8') + const { ctx, stdout, stderr } = makeCtx({ home, tty: false, answer: 'y' }) + const code = await runAttach(['--client', 'claude', '--json'], ctx) + assert.equal(code, 0, stderr.text()) + assert.ok(!stderr.text().includes(MIGRATION_QUESTION)) + // Both conditions (json and non-TTY) independently qualify for the + // pointer; it must still land exactly once, never doubled. + assert.equal( + stderr.text(), + "note: this install attaches claude by base URL; run 'hyp attach claude' in an " + + 'interactive terminal to switch it to proxy mode\n' + ) + const after = readFileSync(localConfigPath(home), 'utf8') + assert.equal(after, before) + assert.doesNotMatch(after, /proxy_mode/) + const stdoutText = stdout.text() + assert.equal(stdoutText.split('\n').filter((line) => line.length > 0).length, 1) + const payload = JSON.parse(stdoutText) + assert.equal(payload.status, 'ok') + assert.equal(payload.client, 'claude') + }) +}) + test('--dry-run: no question, no note, no write', async () => { await withTempHome(async (home) => { writeGatewayConfig(home)