anyray-connect 0.11.74 → 0.11.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/cli.js +628 -306
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/desktop.js +6 -1
  4. package/dist/commands/desktop.js.map +1 -1
  5. package/dist/commands/doctor.js +90 -22
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/hook.js +8 -0
  8. package/dist/commands/hook.js.map +1 -1
  9. package/dist/commands/migrate.js +17 -4
  10. package/dist/commands/migrate.js.map +1 -1
  11. package/dist/commands/projectApply.js +267 -0
  12. package/dist/commands/projectApply.js.map +1 -0
  13. package/dist/commands/quitWatch.js +124 -0
  14. package/dist/commands/quitWatch.js.map +1 -0
  15. package/dist/commands/refreshProxy.js +258 -33
  16. package/dist/commands/refreshProxy.js.map +1 -1
  17. package/dist/commands/status.js +11 -0
  18. package/dist/commands/status.js.map +1 -1
  19. package/dist/target.js +1 -1
  20. package/dist/target.js.map +1 -1
  21. package/dist/tools/claudeCode.js +23 -7
  22. package/dist/tools/claudeCode.js.map +1 -1
  23. package/dist/tools/claudeDesktop.js +232 -1
  24. package/dist/tools/claudeDesktop.js.map +1 -1
  25. package/dist/tools/claudeDesktopNative.js +9 -1
  26. package/dist/tools/claudeDesktopNative.js.map +1 -1
  27. package/dist/tools/claudeDesktopProfileNotice.js +28 -19
  28. package/dist/tools/claudeDesktopProfileNotice.js.map +1 -1
  29. package/dist/tools/codex.js +17 -6
  30. package/dist/tools/codex.js.map +1 -1
  31. package/dist/tools/copilotChat.js +22 -24
  32. package/dist/tools/copilotChat.js.map +1 -1
  33. package/dist/tools/copilotCli.js +149 -85
  34. package/dist/tools/copilotCli.js.map +1 -1
  35. package/dist/tools/cursor.js +126 -38
  36. package/dist/tools/cursor.js.map +1 -1
  37. package/dist/tools/opencode.js +354 -65
  38. package/dist/tools/opencode.js.map +1 -1
  39. package/dist/tools/sharedHooks.js +38 -0
  40. package/dist/tools/sharedHooks.js.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/dist/util/appQuit.js +331 -0
  43. package/dist/util/appQuit.js.map +1 -0
  44. package/dist/util/codexConfig.js +22 -0
  45. package/dist/util/codexConfig.js.map +1 -1
  46. package/dist/util/desktopProfileMigrate.js +39 -4
  47. package/dist/util/desktopProfileMigrate.js.map +1 -1
  48. package/dist/util/devRefresh.js +128 -7
  49. package/dist/util/devRefresh.js.map +1 -1
  50. package/dist/util/devVerify.js +5 -1
  51. package/dist/util/devVerify.js.map +1 -1
  52. package/dist/util/historyStore.js +0 -0
  53. package/dist/util/historyStore.js.map +1 -1
  54. package/dist/util/pendingOps.js +416 -0
  55. package/dist/util/pendingOps.js.map +1 -0
  56. package/dist/util/persist.js +15 -3
  57. package/dist/util/persist.js.map +1 -1
  58. package/dist/util/proxyService.js +59 -2
  59. package/dist/util/proxyService.js.map +1 -1
  60. package/dist/util/refreshScheduler.js +25 -0
  61. package/dist/util/refreshScheduler.js.map +1 -1
  62. package/dist/util/remoteContext.js +55 -0
  63. package/dist/util/remoteContext.js.map +1 -0
  64. package/dist/util/seatState.js +28 -2
  65. package/dist/util/seatState.js.map +1 -1
  66. package/dist/util/serviceArtifacts.js +9 -0
  67. package/dist/util/serviceArtifacts.js.map +1 -1
  68. package/dist/util/verify.js +38 -7
  69. package/dist/util/verify.js.map +1 -1
  70. package/dist/util/vscodeProcess.js +119 -0
  71. package/dist/util/vscodeProcess.js.map +1 -0
  72. package/dist/util/vscodeState.js +6 -0
  73. package/dist/util/vscodeState.js.map +1 -1
  74. package/dist/version.js +1 -1
  75. package/package.json +2 -1
  76. package/dist/commands/cursorWatch.js +0 -95
  77. package/dist/commands/cursorWatch.js.map +0 -1
  78. package/dist/util/cursorPending.js +0 -188
  79. package/dist/util/cursorPending.js.map +0 -1
package/dist/cli.js CHANGED
@@ -10,13 +10,15 @@
10
10
  * personal gateway authentication, and content-free attribution.
11
11
  */
12
12
  import { isPersonalClientKey, isInsecureRemoteOrigin, normalizeOrigin, resolveOrigin, resolveTarget, withLocalProxy, } from "./target.js";
13
- import { pickLoopbackPort, installProxyService, uninstallProxyService, probeHealth, } from "./util/proxyService.js";
13
+ import { pickLoopbackPort, installProxyService, uninstallProxyService, probeHealth, DEFAULT_LOOPBACK_PORT, } from "./util/proxyService.js";
14
14
  import { installRefreshScheduler, uninstallRefreshScheduler, } from "./util/refreshScheduler.js";
15
+ import { detectRemoteDevBackend, isContainerBackend, } from "./util/remoteContext.js";
15
16
  import { runRefreshProxy } from "./commands/refreshProxy.js";
16
17
  import { loopbackEngaged } from "./util/loopback.js";
17
- import { runCursorWatch } from "./commands/cursorWatch.js";
18
+ import { runQuitWatch } from "./commands/quitWatch.js";
18
19
  import { runManaged } from "./commands/managed.js";
19
20
  import { runPrintKey } from "./commands/printKey.js";
21
+ import { runProjectApply } from "./commands/projectApply.js";
20
22
  import { runClaudeDesktopCredential, } from "./commands/claudeDesktopCredential.js";
21
23
  import { runDesktop } from "./commands/desktop.js";
22
24
  import { runRecallCommand, runRetrieveCommand } from "./commands/retrieval.js";
@@ -33,11 +35,13 @@ import { runSelfUpdate } from "./util/selfUpdate.js";
33
35
  import { REGISTRY, byId } from "./tools/index.js";
34
36
  import { staleShellExportsForTargets } from "./tools/shellEnv.js";
35
37
  import { effectiveSubscription, metadataHeaderValue } from "./types.js";
38
+ import { quitTargetKey, quitTargetLabel, relaunchResolvable, quitTargetSupported, relaunchApp, requestGracefulQuit, waitForQuit, } from "./util/appQuit.js";
36
39
  import { bold, cyan, dim, fail, heading, info, note, ok, warn, } from "./util/log.js";
37
40
  import { clearDevKey, clearEnrollment, hasEnrollment, loadProfile, recordManagedTools, saveProfile, saveDevKey, loadDevKey, stateDir, ensureStateDir, purgeRetiredFallbackState, } from "./util/persist.js";
38
41
  import { confirm, isInteractive, prompt, resolveDisplayName, resolveTeam, } from "./commands/shared.js";
39
42
  import { verifyConnection } from "./util/verify.js";
40
43
  import { detectSeatState } from "./util/seatState.js";
44
+ import { isRemoteDevContext } from "./tools/copilotChat.js";
41
45
  import { runStatus } from "./commands/status.js";
42
46
  import { runDoctor } from "./commands/doctor.js";
43
47
  import { runSync } from "./commands/sync.js";
@@ -56,6 +60,7 @@ import { runAcpAgent } from "./commands/acpAgent.js";
56
60
  import { deriveMachineId } from "./util/machineId.js";
57
61
  import { relaunchFromDurableRuntime } from "./util/durableRuntime.js";
58
62
  import { CLAUDE_DESKTOP_CREDENTIAL_ARG, } from "./tools/claudeDesktopCredentialProtocol.js";
63
+ import { platform } from "node:os";
59
64
  const HELP = `${bold("anyray-connect")} — point your local coding tools at the Anyray gateway
60
65
 
61
66
  ${bold("Usage")}
@@ -70,9 +75,11 @@ ${bold("Subcommands")}
70
75
  doctor Diagnose gateway reachability + tool config (incl. each tool's
71
76
  retrieval MCP-server registration); exits non-zero on problems.
72
77
  --json prints a shareable content-free report (send it to support);
73
- --verify also runs the auth smoke probe and a retrieval-loop
74
- probe (two tiny requests; the loop probe never latches
75
- model retrieve capability);
78
+ the auth smoke probe runs by DEFAULT (one tiny request) so an
79
+ all-clear means a request actually worked, not just that the
80
+ config looks right; --no-verify skips it for offline runs;
81
+ --verify additionally runs a retrieval-loop probe (which
82
+ never latches model retrieve capability);
76
83
  --repair re-applies managed tools whose routing drifted
77
84
  (only tools a previous apply recorded — a reverted tool
78
85
  is never resurrected) and deletes the retired fallback
@@ -173,6 +180,12 @@ ${bold("Options")}
173
180
  LiteLLM virtual key). Stored locally (mode 600) and in the
174
181
  tool config, never sent to Anyray's control plane.
175
182
  --tools <ids> Comma-separated subset: ${REGISTRY.map((t) => t.id).join(", ")}
183
+ --project Write committable, credential-free routing config into the
184
+ CURRENT repo (.claude/settings.json + .codex/config.toml)
185
+ instead of this machine's user config — so devcontainers,
186
+ Codespaces, SSH checkouts, and CI route without a per-machine
187
+ apply. Org API lane only; each machine still enrolls once.
188
+ Combine with --revert to remove it.
176
189
  --dry-run Show what would change without writing anything
177
190
  --yes, -y Don't prompt for confirmation (for scripted rollout)
178
191
  --force Skip the gateway reachability check before writing
@@ -183,6 +196,15 @@ ${bold("Options")}
183
196
  daemon fills a freshly re-minted key on each request so a
184
197
  long session never 401s on an expired key mid-flight; this
185
198
  forces the legacy lane that bakes the key once at launch.
199
+ --quit-apps Close the apps this run reconfigures — and reopen them when
200
+ it finishes — without asking. Only apps that are open AND
201
+ whose configuration actually changes; the quit is always the
202
+ polite one the app may refuse, never a force kill. An
203
+ interactive run is asked once and defaults to NO; --yes, a
204
+ non-TTY, --dry-run and the managed/MDM payloads close
205
+ nothing unless this flag is passed explicitly.
206
+ --no-quit-apps Never close anything, overriding --quit-apps. Apps that were
207
+ open keep their old routing until you restart them yourself.
186
208
  --revert Undo a previous run: unpick every tool config we wrote and
187
209
  clear the stored enrollment. Reconnecting needs --sso (or a
188
210
  fresh --enroll link). Cannot be combined with --sso or
@@ -275,6 +297,9 @@ export const parseArgs = (argv) => {
275
297
  .map((s) => s.trim())
276
298
  .filter(Boolean);
277
299
  break;
300
+ case "--project":
301
+ args.project = true;
302
+ break;
278
303
  case "--dry-run":
279
304
  args.dryRun = true;
280
305
  break;
@@ -295,6 +320,12 @@ export const parseArgs = (argv) => {
295
320
  case "--no-key-refresh":
296
321
  args.noKeyRefresh = true;
297
322
  break;
323
+ case "--quit-apps":
324
+ args.quitApps = true;
325
+ break;
326
+ case "--no-quit-apps":
327
+ args.noQuitApps = true;
328
+ break;
298
329
  case "--revert":
299
330
  args.revert = true;
300
331
  break;
@@ -304,6 +335,9 @@ export const parseArgs = (argv) => {
304
335
  case "--verify":
305
336
  args.verify = true;
306
337
  break;
338
+ case "--no-verify":
339
+ args.noVerify = true;
340
+ break;
307
341
  case "--repair":
308
342
  args.repair = true;
309
343
  break;
@@ -482,6 +516,10 @@ explicitIds) => {
482
516
  : await adapter.apply(target, {
483
517
  ...opts,
484
518
  explicit: explicitIds?.has(adapter.id) === true,
519
+ // This loop prints every message below, so an adapter may take a
520
+ // side action on the developer's own files here. The background
521
+ // lanes call `apply` directly and never set this.
522
+ reportsToUser: true,
485
523
  });
486
524
  for (const m of result.messages)
487
525
  info(` ${m}`);
@@ -530,6 +568,153 @@ export const manualStepsNotice = (titles) => {
530
568
  "Then restart or reload the app as instructed; use `npx anyray-connect status` for the available route checks.",
531
569
  ];
532
570
  };
571
+ /**
572
+ * The plan pass: which open apps would this apply actually affect?
573
+ *
574
+ * An editor caches its environment at launch and Cursor / Claude Desktop cannot
575
+ * have their stores rewritten while they are open, so a config written now does
576
+ * not take effect until the app restarts. Rather than printing "restart X" and
577
+ * hoping, Connect offers to do it — but only for an adapter that is
578
+ * installed, declares how its app is named to each platform
579
+ * (`quitGated.quitTarget`, read off the REGISTRY so this file never names an
580
+ * app), is running, and is NOT already pointed at this gateway.
581
+ *
582
+ * That last clause is what keeps a no-op re-run silent: nothing changes, so
583
+ * nobody's apps get closed.
584
+ *
585
+ * KNOWN BLIND SPOT, deliberately not guessed at: `pointsAt` reads the tool's
586
+ * CONFIG on disk, so it cannot see "configured, but the running process still
587
+ * holds the environment it cached at launch" — that app reads as correctly
588
+ * configured here and is left alone. `doctor` owns that case; inferring it
589
+ * would mean offering to close apps on every single re-run.
590
+ *
591
+ * Read-only and best-effort throughout: an adapter probe that throws simply
592
+ * drops out of the plan.
593
+ */
594
+ export const planAppRestarts = async (adapters, origin,
595
+ // Explicit so a test can assert a platform deliberately. Reading the host's
596
+ // real platform made three of these tests pass on macOS and fail on Linux CI,
597
+ // because the fixture named no Linux spelling and the skip was invisible.
598
+ runtimePlatform = platform()) => {
599
+ const candidates = [];
600
+ // One APP, not one adapter. VS Code carries both Copilot adapters, so without
601
+ // this the developer is asked twice about the same window and it is closed
602
+ // twice — the second quit landing on an app that is already gone.
603
+ const claimedApps = new Set();
604
+ for (const adapter of adapters) {
605
+ const seam = adapter.quitGated;
606
+ const quitTarget = seam?.quitTarget;
607
+ if (!seam || !quitTarget)
608
+ continue;
609
+ // A target that doesn't name this platform can't be honoured here, and an
610
+ // offer we can't honour reads as a broken promise.
611
+ if (!quitTargetSupported(quitTarget, runtimePlatform))
612
+ continue;
613
+ // Never close what we cannot reopen (Flatpak/AppImage editors on Linux).
614
+ if (!relaunchResolvable(quitTarget, runtimePlatform))
615
+ continue;
616
+ const app = quitTargetKey(quitTarget, runtimePlatform);
617
+ if (claimedApps.has(app))
618
+ continue;
619
+ try {
620
+ const detected = await adapter.detect();
621
+ if (!detected.installed)
622
+ continue;
623
+ if (!seam.isRunning())
624
+ continue;
625
+ if ((await adapter.pointsAt?.(origin)) === true)
626
+ continue;
627
+ }
628
+ catch {
629
+ // Never let a probe failure turn into an offer to close someone's editor.
630
+ continue;
631
+ }
632
+ claimedApps.add(app);
633
+ candidates.push({
634
+ id: adapter.id,
635
+ // The APP's name, not the adapter's — see `quitTargetLabel`.
636
+ title: quitTargetLabel(quitTarget),
637
+ quitTarget,
638
+ isRunning: () => seam.isRunning(),
639
+ });
640
+ }
641
+ return candidates;
642
+ };
643
+ /**
644
+ * May this invocation close apps at all? Consent has to be present on the
645
+ * command line or at a prompt:
646
+ * - `--no-quit-apps` refuses, and wins over everything else.
647
+ * - `--dry-run` writes nothing, so it closes nothing (it reports the offer).
648
+ * - `--quit-apps` is an operator asking for it explicitly — the only way a
649
+ * scripted run gets it.
650
+ * - otherwise: an interactive TTY that was not handed `--yes` may ASK (the
651
+ * prompt itself defaults to no).
652
+ *
653
+ * `--yes`, a non-TTY and the managed/MDM payloads therefore close NOTHING by
654
+ * default. That is the hard rule: a buyer rolling Connect out to 500 seats must
655
+ * never have it close an app on someone's machine.
656
+ */
657
+ export const quitAppsAllowed = (args, interactive) => {
658
+ if (args.noQuitApps === true)
659
+ return false;
660
+ if (args.dryRun)
661
+ return false;
662
+ if (args.quitApps === true)
663
+ return true;
664
+ return !args.yes && interactive;
665
+ };
666
+ /** The one-line offer. Returned as a string so it's testable without a TTY. */
667
+ export const appRestartOffer = (titles) => {
668
+ const which = titles.length === 1
669
+ ? `${titles[0]} is open and won't pick this up until it restarts. Quit it now?`
670
+ : `${titles.slice(0, -1).join(", ")} and ${titles[titles.length - 1]} are open and won't pick this up until they restart. Quit them now?`;
671
+ return `\n${which}`;
672
+ };
673
+ /**
674
+ * Ask each accepted app to quit and confirm it actually went away. Returns the
675
+ * ones Connect really closed — the exact set the relaunch is allowed to reopen.
676
+ *
677
+ * An app that refuses (an unsaved buffer, a modal) is a legitimate answer: say
678
+ * so plainly and carry on with the apply, which leaves the adapter's own
679
+ * queue-on-quit path to finish the job whenever the developer closes it. Never
680
+ * escalate to a force kill, never retry — these apps write state on exit.
681
+ */
682
+ export const quitAppsNow = async (candidates, deps = {}) => {
683
+ const requestQuit = deps.requestQuit ?? ((target) => requestGracefulQuit(target));
684
+ const waitFor = deps.waitFor ?? ((isRunning) => waitForQuit(isRunning));
685
+ const closed = [];
686
+ for (const candidate of candidates) {
687
+ const delivered = requestQuit(candidate.quitTarget);
688
+ const gone = delivered && (await waitFor(candidate.isRunning));
689
+ if (gone) {
690
+ closed.push(candidate);
691
+ ok(` Closed ${candidate.title}.`);
692
+ continue;
693
+ }
694
+ warn(` ${candidate.title} is still open — leaving it alone.`);
695
+ note(` Its configuration is written either way; ${candidate.title} picks it up the next time you close it.`);
696
+ }
697
+ return closed;
698
+ };
699
+ /**
700
+ * Reopen exactly what we closed, under the consent that closed it — never a
701
+ * second prompt, and never an app the developer closed themselves. Leaving
702
+ * someone's IDE shut is a worse outcome than never having offered.
703
+ */
704
+ export const relaunchApps = (closed, deps = {}) => {
705
+ const relaunch = deps.relaunch ?? ((target) => relaunchApp(target));
706
+ const reopened = [];
707
+ for (const candidate of closed) {
708
+ if (relaunch(candidate.quitTarget)) {
709
+ reopened.push(candidate.title);
710
+ ok(` Reopened ${candidate.title}.`);
711
+ }
712
+ else {
713
+ warn(` Could not reopen ${candidate.title} — start it when you're ready.`);
714
+ }
715
+ }
716
+ return reopened;
717
+ };
533
718
  /**
534
719
  * May the wizard put this tool in its prefilled selection?
535
720
  *
@@ -623,6 +808,46 @@ const runVerify = async (target, toolIds) => {
623
808
  for (const line of verdict.remedy)
624
809
  info(dim(` ${line}`));
625
810
  };
811
+ /** Per-probe timeout for the reconcile retries — generous, a wedged daemon fails them all. */
812
+ const RECONCILE_RETRY_PROBE_TIMEOUT_MS = 5_000;
813
+ const RECONCILE_PROBE_GAP_MS = 500;
814
+ const RECONCILE_PROBE_ATTEMPTS = 3;
815
+ // Scheduler-tick self-heal: EDR kills/quarantines the daemon service; a silent
816
+ // port with key refresh enabled reinstalls it (the probe-first check is what
817
+ // leaves a live daemon alone — including a healthy one on an older build).
818
+ export const reconcileProxyService = async (deps = {}) => {
819
+ try {
820
+ const profile = await (deps.load ?? loadProfile)();
821
+ if (profile.keyRefreshEnabled !== true || !profile.gateway)
822
+ return;
823
+ const port = profile.fallbackPort ?? DEFAULT_LOOPBACK_PORT;
824
+ // Patience before the bounce: a 1.5s probe blip on a busy-but-alive daemon
825
+ // must not trigger a reinstall (greptile P1 on #1389). A quarantined daemon
826
+ // fails every probe instantly (connection refused), so healing stays prompt;
827
+ // a wedged one fails them all slowly and still heals this tick.
828
+ const probe = deps.probe ?? probeHealth;
829
+ const probeGapMs = deps.probeGapMs ?? RECONCILE_PROBE_GAP_MS;
830
+ for (let attempt = 0; attempt < RECONCILE_PROBE_ATTEMPTS; attempt++) {
831
+ const timeoutMs = attempt === 0 ? undefined : RECONCILE_RETRY_PROBE_TIMEOUT_MS;
832
+ if ((await probe(port, timeoutMs)) !== null)
833
+ return;
834
+ if (attempt < RECONCILE_PROBE_ATTEMPTS - 1 && probeGapMs > 0)
835
+ await new Promise((r) => setTimeout(r, probeGapMs));
836
+ }
837
+ const svc = await (deps.install ?? installProxyService)({
838
+ port,
839
+ remoteOrigin: new URL(profile.gateway).origin,
840
+ });
841
+ // Lands in the scheduler's log file — a permanent install failure must not
842
+ // retry silently twice an hour forever.
843
+ if (!svc.ok)
844
+ for (const m of svc.messages)
845
+ console.error(m);
846
+ }
847
+ catch {
848
+ // Malformed profile/gateway URL or install failure — next tick retries.
849
+ }
850
+ };
626
851
  /**
627
852
  * Install + health-check the loopback daemon, then return the target the tools
628
853
  * should be pointed at. On health-check success the tools point at the loopback
@@ -635,7 +860,7 @@ export const setupLoopbackProxy = async (target, port, deps = {}) => {
635
860
  const probe = deps.probe ?? probeHealth;
636
861
  const pollDelayMs = deps.pollDelayMs ?? 300;
637
862
  heading("Seamless key refresh");
638
- const svc = await install();
863
+ const svc = await install({ port, remoteOrigin: target.origin });
639
864
  for (const m of svc.messages)
640
865
  info(` ${m}`);
641
866
  // A freshly-installed supervisor may take a moment to bind, so poll. But
@@ -660,9 +885,13 @@ export const setupLoopbackProxy = async (target, port, deps = {}) => {
660
885
  ? // Install succeeded but the port never bound — a transient miss the
661
886
  // next run retries against the same supervisor.
662
887
  " It will retry on the next run; check the service logs if it persists."
663
- : // No supervised daemon on this platform (svc.messages said why); the
664
- // scheduled ~30-min re-mint still keeps the seat's key fresh.
665
- " Your key still refreshes on a schedule no daemon needed here."));
888
+ : isContainerBackend(detectRemoteDevBackend())
889
+ ? // Container: the OS schedule is skipped too (no service manager), so
890
+ // don't promise one the helper + re-runs are what refresh here.
891
+ " In a container the key refreshes via Claude Code's helper and on each `anyray-connect` run instead."
892
+ : // No supervised daemon on this platform (svc.messages said why); the
893
+ // scheduled ~30-min re-mint still keeps the seat's key fresh.
894
+ " Your key still refreshes on a schedule — no daemon needed here."));
666
895
  return target;
667
896
  };
668
897
  /**
@@ -810,6 +1039,7 @@ export const run = async (argv) => {
810
1039
  // it just runs the gated refresh and exits 0.
811
1040
  if (argv[0] === "__anyray-refresh") {
812
1041
  await refreshIfDue();
1042
+ await reconcileProxyService();
813
1043
  // Same tick keeps the binary itself current (util/selfUpdate.ts). Runs
814
1044
  // AFTER the key re-mint — that's the credential-critical half — and never
815
1045
  // throws, so a GitHub outage can't cost a seat its key.
@@ -826,12 +1056,14 @@ export const run = async (argv) => {
826
1056
  if (argv[0] === "__anyray-fallback-proxy") {
827
1057
  return runRefreshProxy();
828
1058
  }
829
- // Hidden entrypoint: the Cursor adapter spawns this DETACHED when Cursor was
830
- // running at apply/revert time. It waits for Cursor to quit, completes the
1059
+ // Hidden entrypoint: a quit-gated adapter spawns this DETACHED when its app
1060
+ // was running at apply time. It waits for that app to quit, completes the
831
1061
  // queued operation through the normal adapter path, and exits. Silent +
832
- // best-effort (never throws, never prints).
833
- if (argv[0] === "__anyray-cursor-watch") {
834
- return runCursorWatch();
1062
+ // best-effort (never throws, never prints). Takes the adapter id, so it
1063
+ // serves Cursor's exact-row write and Claude Desktop's profile migration
1064
+ // through one mechanism.
1065
+ if (argv[0] === "__anyray-quit-watch") {
1066
+ return runQuitWatch(argv.slice(1));
835
1067
  }
836
1068
  // Hidden credential-helper protocol for Claude Desktop subscription mode.
837
1069
  // Dispatch before durable-runtime relaunch and all human-facing logging:
@@ -928,6 +1160,7 @@ export const run = async (argv) => {
928
1160
  gateway: parsed.gateway,
929
1161
  json: parsed.json,
930
1162
  verify: parsed.verify,
1163
+ noVerify: parsed.noVerify,
931
1164
  repair: parsed.repair,
932
1165
  securityReview: parsed.securityReview,
933
1166
  });
@@ -960,6 +1193,22 @@ const runApplyCommand = async (argv, forceWizard) => {
960
1193
  fail("--revert cannot be combined with --sso or --enroll. It removes Anyray configuration; re-run with --sso <link> --yes (without --revert) to configure a new CLI.");
961
1194
  return 1;
962
1195
  }
1196
+ if (args.project) {
1197
+ // Project scope writes a machine-independent artifact: enrollment is
1198
+ // per-machine (--sso/--enroll) and seat/BYO lanes are per-dev credentials —
1199
+ // none of them can ride a committed file.
1200
+ if (args.sso || args.enroll || args.subscription || args.upstream) {
1201
+ fail("--project cannot be combined with --sso/--enroll (per-machine enrollment) or --subscription/--upstream (per-dev credential lanes). It writes an org-lane, credential-free repo config.");
1202
+ return 1;
1203
+ }
1204
+ return runProjectApply({
1205
+ gateway: args.gateway,
1206
+ tools: args.tools,
1207
+ revert: args.revert,
1208
+ dryRun: args.dryRun,
1209
+ yes: args.yes,
1210
+ });
1211
+ }
963
1212
  if (Boolean(args.upstream) !== Boolean(args.upstreamKey)) {
964
1213
  fail("--upstream and --upstream-key go together: the URL says where your personal key is valid, the key is what the gateway forwards there.");
965
1214
  return 1;
@@ -1307,6 +1556,17 @@ const runApplyCommand = async (argv, forceWizard) => {
1307
1556
  info(` Codex (ChatGPT): ${seat.openai
1308
1557
  ? cyan("signed in → your subscription")
1309
1558
  : dim("not signed in → org key")}`);
1559
+ // Seat probes are machine-scoped. On a remote box with nothing signed in,
1560
+ // "falls back to the org key" is a silent trap: the developer's sign-ins are
1561
+ // on their laptop, and a subscription-only deployment has no working org key
1562
+ // for that lane to use — so every tool we configure here would 401. Say it
1563
+ // BEFORE the adapters write, so the fix is obvious rather than archaeology.
1564
+ if (!seat.anthropic && !seat.openai && !seat.copilot && isRemoteDevContext()) {
1565
+ warn(" No subscription sign-ins found on this host. Sign-ins do not cross the");
1566
+ warn(" Remote-SSH hop — your laptop's are invisible here, and these tools run");
1567
+ warn(" HERE. Sign in on this machine (`copilot` → /login, Claude Code, Codex)");
1568
+ warn(" and re-run, or everything below routes on the org key instead.");
1569
+ }
1310
1570
  }
1311
1571
  const target = resolveTarget({
1312
1572
  origin,
@@ -1386,317 +1646,379 @@ const runApplyCommand = async (argv, forceWizard) => {
1386
1646
  }
1387
1647
  ok(`Gateway reachable (${probe.detail}).`);
1388
1648
  }
1389
- // Adapters now keep durable helper/journal files below ~/.anyray. Existing
1390
- // global-install users may still have the historical plain-file profile at
1391
- // that path, so migrate it once before any apply/revert mutation. Dry-run
1392
- // stays strictly read-only.
1393
- if (!args.dryRun) {
1394
- try {
1395
- await ensureStateDir();
1396
- }
1397
- catch {
1398
- fail("Could not prepare the owner-only Anyray state directory.");
1399
- return 1;
1400
- }
1401
- }
1402
- // Seamless key refresh: ON by default for subscription/passthrough Claude Code
1403
- // + Codex the only tools whose traffic actually traverses the loopback. They
1404
- // bake a short-TTL client key as a STATIC header read once at launch, so
1405
- // without the daemon a long session 401s the moment the key lapses mid-flight,
1406
- // curable only by a re-run + a tool relaunch. The daemon's surface is
1407
- // deliberately tiny (fill the empty key slot, forward the bytes unchanged) —
1408
- // that is what lets it default on where the retired provider-direct lane could
1409
- // not. --no-key-refresh forces the legacy direct static-key lane. Other tools
1410
- // (native Cursor, the Copilot extension, ACP launchers) rebuild their own
1411
- // target and bypass the loopback, so they are unaffected either way — see the
1412
- // scope boundary in commands/refreshProxy.ts.
1413
- const keyRefreshSeatSelected = (adapters.some((a) => a.id === "claude-code") &&
1414
- effectiveSubscription(target, "anthropic")) ||
1415
- (adapters.some((a) => a.id === "codex") &&
1416
- effectiveSubscription(target, "openai"));
1417
- const wantKeyRefresh = !args.revert && args.noKeyRefresh !== true && keyRefreshSeatSelected;
1418
- // A scoped apply (`--tools x`) that doesn't itself engage the loopback must
1419
- // not tear down the DEFAULT-ON key-refresh lane the tools it did NOT name
1420
- // still depend on — the apply-side mirror of the scoped-revert credential rule
1421
- // below. Without this, `anyray-connect --tools cursor` after a full connect
1422
- // would uninstall the refresh daemon and drop Claude Code / Codex back to the
1423
- // 401-prone static-key lane. An explicit --no-key-refresh still wins, and a
1424
- // full, unscoped apply falls through to the normal want-driven teardown below.
1425
- const scopedApply = !args.revert && (args.tools?.length ?? 0) > 0;
1426
- const preserveKeyRefresh = scopedApply &&
1427
- args.noKeyRefresh !== true &&
1428
- Boolean(profile.keyRefreshEnabled);
1429
- // The refresh SCHEDULER is the universal trigger: a per-user launchd/systemd/
1430
- // Task Scheduler task that runs `__anyray-refresh` every ~30 min to re-mint
1431
- // the short-TTL ark_ key for EVERY configured tool. The loopback daemon above
1432
- // only covers
1433
- // CC/Codex (their traffic traverses it); Cursor/Copilot/Claude-Desktop/
1434
- // shell-env read a static key from their own config with no daemon or hook to
1435
- // renew it, so they 401 mid-session when it lapses — the exact bug this fixes.
1436
- // Installed for every ENROLLED seat (an env-key seat has no cert to refresh),
1437
- // gated by the same --no-key-refresh opt-out the daemon lane uses. It coexists
1438
- // with the daemon: the shared refresh-state throttle stops the two triggers
1439
- // double-minting, and if the daemon is ever down the scheduler's refresh
1440
- // degrades those seats' tools to gateway-direct with a fresh key.
1441
- const usingEnvironmentKey = Boolean(envClientKey && target.clientKey === envClientKey && !enrolledCert);
1442
- const hasRefreshableCert = !usingEnvironmentKey && Boolean(enrolledCert ?? profile.enrolledCert);
1443
- const wantRefreshScheduler = !args.revert && args.noKeyRefresh !== true && hasRefreshableCert;
1444
- let applyTarget = target;
1445
- const resolvedKeyRefreshEnabled = wantKeyRefresh || preserveKeyRefresh;
1446
- // Hold the port when the loopback is wanted now, or preserved for the tools
1447
- // this scoped apply didn't name; otherwise clear it so the persisted profile
1448
- // and the teardown below agree the daemon is gone.
1449
- let resolvedFallbackPort = resolvedKeyRefreshEnabled
1450
- ? profile.fallbackPort
1451
- : undefined;
1452
- if (wantKeyRefresh && !args.dryRun) {
1453
- resolvedFallbackPort = profile.fallbackPort ?? (await pickLoopbackPort());
1454
- }
1455
- // ACP agents and Claude Desktop's credential helper intentionally persist no
1456
- // static key: they load this owner-only profile when the app launches them.
1457
- // Commit the complete target + enrollment pair before any adapter or loopback
1458
- // service can register such a launcher. A persistence failure is fatal here,
1459
- // while no tool configuration has changed yet.
1460
- if (!args.revert && !args.dryRun) {
1461
- // An explicit pre-minted environment key is a complete, non-refreshable
1462
- // credential source (see `usingEnvironmentKey` above). Never retain a
1463
- // possibly unrelated enrollment cert or expiry beside it: a later helper
1464
- // refresh could otherwise replace the operator-supplied identity with the
1465
- // old certificate's identity.
1466
- const nextProfile = {
1467
- ...profile,
1468
- gateway: target.origin,
1469
- name: resolvedUser,
1470
- team: resolvedTeam,
1471
- clientKey: target.clientKey ?? profile.clientKey,
1472
- enrolledCert: usingEnvironmentKey
1473
- ? undefined
1474
- : (enrolledCert ?? profile.enrolledCert),
1475
- enrolledCertExpiry: usingEnvironmentKey
1476
- ? undefined
1477
- : (enrolledCertExpiry ?? profile.enrolledCertExpiry),
1478
- enrolledTokenHash: usingEnvironmentKey
1479
- ? undefined
1480
- : (enrolledTokenHash ?? profile.enrolledTokenHash),
1481
- ssoLink: usingEnvironmentKey
1482
- ? undefined
1483
- : ssoEnrollment
1484
- ? enrolledSsoLink
1485
- : enrolledCert
1486
- ? undefined
1487
- : profile.ssoLink,
1488
- clientKeyExpiresAt: usingEnvironmentKey
1489
- ? undefined
1490
- : (clientKeyExpiresAt ??
1491
- (target.clientKey === profile.clientKey?.trim()
1492
- ? profile.clientKeyExpiresAt
1493
- : undefined)),
1494
- authMode: target.authMode,
1495
- byoUpstream: target.byoUpstream,
1496
- keyRefreshEnabled: resolvedKeyRefreshEnabled,
1497
- refreshSchedulerEnabled: wantRefreshScheduler,
1498
- fallbackPort: resolvedFallbackPort,
1499
- };
1500
- try {
1501
- await persistApplyProfile(profile, nextProfile, enrolledPrivateKeyPem, undefined, usingEnvironmentKey);
1502
- }
1503
- catch {
1504
- fail("Could not save the owner-only Anyray runtime profile. No tool configuration was changed.");
1505
- return 1;
1649
+ // Quit-aware apply, planned BEFORE anything is written and after the preflight
1650
+ // (no point closing an editor for a gateway that turned out to be down). The
1651
+ // plan only contains apps that are open and that this run actually changes —
1652
+ // see `planAppRestarts` for the "already pointed at us" filter and for what it
1653
+ // deliberately cannot see. Revert is out of scope: the filter is apply-shaped
1654
+ // (a revert has work precisely when the tool IS pointed at us), and the
1655
+ // adapters' queue-on-quit path already covers a revert that finds an app open.
1656
+ const quitPlan = args.revert
1657
+ ? []
1658
+ : await planAppRestarts(adapters, target.origin);
1659
+ let closedByConnect = [];
1660
+ /**
1661
+ * Give back every app we took away — from ANY exit after the quit, not just
1662
+ * the happy one. Two failure paths below return before the apply finishes,
1663
+ * and each of them used to leave the developer's editors closed with nothing
1664
+ * ever reopening them. Closing someone's editor is only defensible if
1665
+ * reopening it is unconditional.
1666
+ */
1667
+ const reopenClosed = () => {
1668
+ if (closedByConnect.length === 0)
1669
+ return;
1670
+ heading("Reopening");
1671
+ relaunchApps(closedByConnect);
1672
+ closedByConnect = [];
1673
+ };
1674
+ if (quitPlan.length > 0) {
1675
+ const titles = quitPlan.map((candidate) => candidate.title);
1676
+ const plural = titles.length === 1 ? "it" : "them";
1677
+ heading("Open apps");
1678
+ // One gate decides whether anything closes, so a path that must not act
1679
+ // cannot drift out of step with the one that may.
1680
+ if (!quitAppsAllowed(args, isInteractive())) {
1681
+ // Headless-complete: report, never act. Dry-run says what it would offer.
1682
+ const hint = args.noQuitApps === true ? "" : " (or pass --quit-apps)";
1683
+ note(args.dryRun
1684
+ ? ` Would offer to quit ${titles.join(", ")} dry run, nothing was closed.`
1685
+ : ` ${titles.join(", ")} ${titles.length === 1 ? "is" : "are"} open; restart ${plural} to pick this up${hint}.`);
1506
1686
  }
1507
- // This run minted a key (--enroll / --sso). Share that with the
1508
- // auto-refresher's bookkeeping: with no successful mint on record, the next
1509
- // detached refresh sees a credential "not rotated in hours", re-mints
1510
- // immediately, and rotates the key out from under the session just set up.
1511
- if (clientKeyExpiresAt && !usingEnvironmentKey) {
1512
- await recordMintSuccess({ expiresAt: clientKeyExpiresAt });
1687
+ else {
1688
+ // `--quit-apps` is consent already given; otherwise ask once, default NO.
1689
+ const agreed = args.quitApps === true || (await confirm(appRestartOffer(titles)));
1690
+ if (agreed)
1691
+ closedByConnect = await quitAppsNow(quitPlan);
1692
+ else
1693
+ note(` Leaving ${plural} open — restart when convenient.`);
1513
1694
  }
1514
1695
  }
1515
- if (wantKeyRefresh && !args.dryRun) {
1516
- applyTarget = await setupLoopbackProxy(target, resolvedFallbackPort);
1517
- }
1518
- // Drop the enrollment credential FIRST, before a single config is unpicked —
1519
- // see util/persist `clearEnrollment` for why a revert that leaves it behind
1520
- // undoes itself. Order matters: `refreshIfDue` re-reads the profile on every
1521
- // run, so clearing up front means a hook that fires *during* the revert finds
1522
- // nothing to mint and leaves our work alone. Clearing last would leave a
1523
- // window config already removed, cert still live where that hook restores
1524
- // the configs and we then strip the cert they now depend on, stranding the
1525
- // tools on a key that can never refresh.
1526
- //
1527
- // A partial revert (`--tools`) is scoped to the tools it names, so it must
1528
- // leave the credential alone: the tools it did NOT touch still need to
1529
- // refresh their key.
1530
- const fullRevert = args.revert && (args.tools?.length ?? 0) === 0;
1531
- if (fullRevert) {
1532
- if (args.dryRun) {
1533
- if (await hasEnrollment()) {
1534
- heading("Enrollment");
1535
- info(" would remove the stored gateway credential");
1696
+ // Everything from here on runs with the developer's editors CLOSED, so
1697
+ // the reopen has to be in a `finally`. Guarding only the explicit early
1698
+ // returns was not enough: any throw in between — a filesystem error, an
1699
+ // adapter probe, the loopback setup would unwind past it and leave
1700
+ // them shut. Closing someone's editor is only defensible if reopening
1701
+ // it happens on every path out.
1702
+ try {
1703
+ // Adapters now keep durable helper/journal files below ~/.anyray. Existing
1704
+ // global-install users may still have the historical plain-file profile at
1705
+ // that path, so migrate it once before any apply/revert mutation. Dry-run
1706
+ // stays strictly read-only.
1707
+ if (!args.dryRun) {
1708
+ try {
1709
+ await ensureStateDir();
1710
+ }
1711
+ catch {
1712
+ fail("Could not prepare the owner-only Anyray state directory.");
1713
+ return 1;
1536
1714
  }
1537
1715
  }
1538
- else {
1716
+ // Seamless key refresh: ON by default for subscription/passthrough Claude Code
1717
+ // + Codex — the only tools whose traffic actually traverses the loopback. They
1718
+ // bake a short-TTL client key as a STATIC header read once at launch, so
1719
+ // without the daemon a long session 401s the moment the key lapses mid-flight,
1720
+ // curable only by a re-run + a tool relaunch. The daemon's surface is
1721
+ // deliberately tiny (fill the empty key slot, forward the bytes unchanged) —
1722
+ // that is what lets it default on where the retired provider-direct lane could
1723
+ // not. --no-key-refresh forces the legacy direct static-key lane. Other tools
1724
+ // (native Cursor, the Copilot extension, ACP launchers) rebuild their own
1725
+ // target and bypass the loopback, so they are unaffected either way — see the
1726
+ // scope boundary in commands/refreshProxy.ts.
1727
+ const keyRefreshSeatSelected = (adapters.some((a) => a.id === "claude-code") &&
1728
+ effectiveSubscription(target, "anthropic")) ||
1729
+ (adapters.some((a) => a.id === "codex") &&
1730
+ effectiveSubscription(target, "openai"));
1731
+ const wantKeyRefresh = !args.revert && args.noKeyRefresh !== true && keyRefreshSeatSelected;
1732
+ // A scoped apply (`--tools x`) that doesn't itself engage the loopback must
1733
+ // not tear down the DEFAULT-ON key-refresh lane the tools it did NOT name
1734
+ // still depend on — the apply-side mirror of the scoped-revert credential rule
1735
+ // below. Without this, `anyray-connect --tools cursor` after a full connect
1736
+ // would uninstall the refresh daemon and drop Claude Code / Codex back to the
1737
+ // 401-prone static-key lane. An explicit --no-key-refresh still wins, and a
1738
+ // full, unscoped apply falls through to the normal want-driven teardown below.
1739
+ const scopedApply = !args.revert && (args.tools?.length ?? 0) > 0;
1740
+ const preserveKeyRefresh = scopedApply &&
1741
+ args.noKeyRefresh !== true &&
1742
+ Boolean(profile.keyRefreshEnabled);
1743
+ // The refresh SCHEDULER is the universal trigger: a per-user launchd/systemd/
1744
+ // Task Scheduler task that runs `__anyray-refresh` every ~30 min to re-mint
1745
+ // the short-TTL ark_ key for EVERY configured tool. The loopback daemon above
1746
+ // only covers
1747
+ // CC/Codex (their traffic traverses it); Cursor/Copilot/Claude-Desktop/
1748
+ // shell-env read a static key from their own config with no daemon or hook to
1749
+ // renew it, so they 401 mid-session when it lapses — the exact bug this fixes.
1750
+ // Installed for every ENROLLED seat (an env-key seat has no cert to refresh),
1751
+ // gated by the same --no-key-refresh opt-out the daemon lane uses. It coexists
1752
+ // with the daemon: the shared refresh-state throttle stops the two triggers
1753
+ // double-minting, and if the daemon is ever down the scheduler's refresh
1754
+ // degrades those seats' tools to gateway-direct with a fresh key.
1755
+ const usingEnvironmentKey = Boolean(envClientKey && target.clientKey === envClientKey && !enrolledCert);
1756
+ const hasRefreshableCert = !usingEnvironmentKey && Boolean(enrolledCert ?? profile.enrolledCert);
1757
+ const wantRefreshScheduler = !args.revert && args.noKeyRefresh !== true && hasRefreshableCert;
1758
+ let applyTarget = target;
1759
+ const resolvedKeyRefreshEnabled = wantKeyRefresh || preserveKeyRefresh;
1760
+ // Hold the port when the loopback is wanted now, or preserved for the tools
1761
+ // this scoped apply didn't name; otherwise clear it so the persisted profile
1762
+ // and the teardown below agree the daemon is gone.
1763
+ let resolvedFallbackPort = resolvedKeyRefreshEnabled
1764
+ ? profile.fallbackPort
1765
+ : undefined;
1766
+ if (wantKeyRefresh && !args.dryRun) {
1767
+ resolvedFallbackPort = profile.fallbackPort ?? (await pickLoopbackPort());
1768
+ }
1769
+ // ACP agents and Claude Desktop's credential helper intentionally persist no
1770
+ // static key: they load this owner-only profile when the app launches them.
1771
+ // Commit the complete target + enrollment pair before any adapter or loopback
1772
+ // service can register such a launcher. A persistence failure is fatal here,
1773
+ // while no tool configuration has changed yet.
1774
+ if (!args.revert && !args.dryRun) {
1775
+ // An explicit pre-minted environment key is a complete, non-refreshable
1776
+ // credential source (see `usingEnvironmentKey` above). Never retain a
1777
+ // possibly unrelated enrollment cert or expiry beside it: a later helper
1778
+ // refresh could otherwise replace the operator-supplied identity with the
1779
+ // old certificate's identity.
1780
+ const nextProfile = {
1781
+ ...profile,
1782
+ gateway: target.origin,
1783
+ name: resolvedUser,
1784
+ team: resolvedTeam,
1785
+ clientKey: target.clientKey ?? profile.clientKey,
1786
+ enrolledCert: usingEnvironmentKey
1787
+ ? undefined
1788
+ : (enrolledCert ?? profile.enrolledCert),
1789
+ enrolledCertExpiry: usingEnvironmentKey
1790
+ ? undefined
1791
+ : (enrolledCertExpiry ?? profile.enrolledCertExpiry),
1792
+ enrolledTokenHash: usingEnvironmentKey
1793
+ ? undefined
1794
+ : (enrolledTokenHash ?? profile.enrolledTokenHash),
1795
+ ssoLink: usingEnvironmentKey
1796
+ ? undefined
1797
+ : ssoEnrollment
1798
+ ? enrolledSsoLink
1799
+ : enrolledCert
1800
+ ? undefined
1801
+ : profile.ssoLink,
1802
+ clientKeyExpiresAt: usingEnvironmentKey
1803
+ ? undefined
1804
+ : (clientKeyExpiresAt ??
1805
+ (target.clientKey === profile.clientKey?.trim()
1806
+ ? profile.clientKeyExpiresAt
1807
+ : undefined)),
1808
+ authMode: target.authMode,
1809
+ byoUpstream: target.byoUpstream,
1810
+ keyRefreshEnabled: resolvedKeyRefreshEnabled,
1811
+ refreshSchedulerEnabled: wantRefreshScheduler,
1812
+ fallbackPort: resolvedFallbackPort,
1813
+ };
1539
1814
  try {
1540
- if (await clearEnrollment()) {
1541
- heading("Enrollment");
1542
- ok(" Removed the stored gateway credential.");
1543
- note(" Reconnect any time with `npx anyray-connect@latest --sso`.");
1544
- }
1815
+ await persistApplyProfile(profile, nextProfile, enrolledPrivateKeyPem, undefined, usingEnvironmentKey);
1545
1816
  }
1546
- catch (error) {
1547
- // Local disk failure say so plainly and keep going: the tool configs
1548
- // below still come out, and a half-reverted install is worth more than
1549
- // a stack trace. Never echo the error value (it can quote a path).
1550
- heading("Enrollment");
1551
- warn(" Could not remove the stored gateway credential.");
1552
- warn(` Delete ${stateDir()} by hand, or the key-refresh hook may restore your tool config.`);
1553
- void error;
1817
+ catch {
1818
+ fail("Could not save the owner-only Anyray runtime profile. No tool configuration was changed.");
1819
+ return 1;
1820
+ }
1821
+ // This run minted a key (--enroll / --sso). Share that with the
1822
+ // auto-refresher's bookkeeping: with no successful mint on record, the next
1823
+ // detached refresh sees a credential "not rotated in hours", re-mints
1824
+ // immediately, and rotates the key out from under the session just set up.
1825
+ if (clientKeyExpiresAt && !usingEnvironmentKey) {
1826
+ await recordMintSuccess({ expiresAt: clientKeyExpiresAt });
1554
1827
  }
1555
1828
  }
1556
- }
1557
- else if (args.revert && !args.dryRun) {
1558
- note("\nKeeping the stored enrollment — the tools you did not name still need it.");
1559
- note("Run --revert with no --tools to remove it.");
1560
- }
1561
- // A named `--tools` list (the wizard writes its checklist here too) is the
1562
- // developer choosing these adapters by hand; the default sweep names none.
1563
- const { changes, manual, results } = await runApply(adapters, applyTarget, opts, args.revert, new Set(args.tools ?? []));
1564
- // Keep the managed-tool record in step: apply records landed tools, revert
1565
- // removes them, a full revert clears the record. Drift repair (`doctor
1566
- // --repair`, the detached refresh) only ever re-applies recorded ids, so
1567
- // this is what makes an intentional revert stick (#1031).
1568
- if (!args.dryRun) {
1569
- await recordManagedTools(results, args.revert, fullRevert).catch(() => undefined);
1570
- }
1571
- // Tear down the loopback daemon whenever it isn't wanted but a previous run
1572
- // left it installed — covers an explicit --revert, --no-key-refresh, or a
1573
- // subscription→api-key switch that no longer needs the refresh lane. Runs
1574
- // AFTER the adapters above re-pointed tools straight at the gateway, so
1575
- // uninstalling the loopback service strands nothing.
1576
- if (!resolvedKeyRefreshEnabled &&
1577
- !args.dryRun &&
1578
- (loopbackEngaged(profile) || profile.fallbackPort)) {
1579
- heading("Local loopback proxy");
1580
- const svc = await uninstallProxyService(profile.fallbackPort);
1581
- for (const m of svc.messages)
1582
- info(` ${m}`);
1583
- // Re-read: on a --revert `clearEnrollment` already stripped the credential
1584
- // from disk, so spreading the pre-run `profile` snapshot would write it
1585
- // straight back. loadProfile() reflects what is actually persisted now.
1586
- await saveProfile({
1587
- ...(await loadProfile()),
1588
- keyRefreshEnabled: false,
1589
- fallbackPort: undefined,
1590
- });
1591
- ok(" Removed the local loopback proxy.");
1592
- }
1593
- // Reconcile the universal refresh scheduler: (re)install it for an enrolled
1594
- // seat, or tear down one a prior run left when the seat no longer wants it (a
1595
- // full revert dropped the enrollment, or --no-key-refresh opted out). A SCOPED
1596
- // revert (`--tools x --revert`) leaves it — the tools it did not name still
1597
- // depend on the refresh. Independent of the daemon teardown above: a
1598
- // Cursor-only seat has the scheduler but never had a daemon.
1599
- await reconcileRefreshScheduler({
1600
- want: wantRefreshScheduler,
1601
- announcePrimary: !wantKeyRefresh,
1602
- scopedRevert: Boolean(args.revert) && !fullRevert,
1603
- dryRun: Boolean(args.dryRun),
1604
- wasEnabled: Boolean(profile.refreshSchedulerEnabled),
1605
- });
1606
- heading("Summary");
1607
- if (args.dryRun) {
1608
- note("Dry run complete — re-run without --dry-run to apply.");
1609
- }
1610
- else if (args.revert) {
1611
- ok(`Reverted (${changes} file(s) touched).`);
1612
- // Editors read our hooks at startup and keep them in memory, so one that was
1613
- // already open goes on running them until it restarts. Say so — otherwise
1614
- // the leftover hook lines read as a revert that missed something.
1615
- note("Restart any editor or agent that was running — it loaded our hooks at startup.");
1616
- // The same "gone from disk, still live in memory" trap, but for the shell
1617
- // exports — and this one doesn't just look wrong, it BREAKS the dev's tools
1618
- // (see tools/shellEnv `staleShellExports`). We can prove it: the vars are in
1619
- // our own env. Say it loudly and hand over the exact command.
1620
- const priorTargets = [
1621
- target,
1622
- ...(profile.fallbackPort
1623
- ? [withLocalProxy(target, profile.fallbackPort)]
1624
- : []),
1625
- ];
1626
- const stale = staleShellExportsForTargets(priorTargets);
1627
- if (stale.length > 0) {
1628
- heading("This terminal still points at the gateway");
1629
- warn(` Still exported here: ${stale.join(", ")}`);
1630
- warn(" Removing the block from your shell profile cannot reach a shell");
1631
- warn(" that already ran it. Until these are gone, your tools keep");
1632
- warn(" reaching the gateway and now fail with 401.");
1633
- info("\n Fix this terminal:");
1634
- info(dim(` unset ${stale.join(" ")}`));
1635
- info("\n Every other open terminal needs the same — or just open a new one.");
1636
- info(" VS Code injects a copy it captured when IT launched, so its");
1637
- info(" terminals stay stale even if you open a new one: quit the app");
1638
- info(" entirely (Cmd+Q). Reloading the window keeps the old copy.");
1829
+ if (wantKeyRefresh && !args.dryRun) {
1830
+ applyTarget = await setupLoopbackProxy(target, resolvedFallbackPort);
1639
1831
  }
1640
- }
1641
- else {
1642
- ok(`Done (${changes} file(s) written).`);
1643
- // Tools whose config we can't script (Claude desktop, JetBrains, Windsurf)
1644
- // are the easiest thing to overlook the loop printed their steps, but a
1645
- // dev skimming for the green "Done" can miss that no file was actually
1646
- // written for them. Re-surface them loudly, and on an interactive run make
1647
- // the dev explicitly acknowledge before we move on. Scripted runs
1648
- // (--yes / non-TTY) just get the reminder — never a hanging prompt.
1649
- if (manual.length > 0) {
1650
- heading("Action required manual steps");
1651
- for (const line of manualStepsNotice(manual))
1652
- warn(line);
1653
- if (isInteractive() && !args.yes) {
1654
- const acknowledged = await confirm("Have you applied the manual settings shown above?");
1655
- if (acknowledged) {
1656
- ok("Acknowledged.");
1832
+ // Drop the enrollment credential FIRST, before a single config is unpicked —
1833
+ // see util/persist `clearEnrollment` for why a revert that leaves it behind
1834
+ // undoes itself. Order matters: `refreshIfDue` re-reads the profile on every
1835
+ // run, so clearing up front means a hook that fires *during* the revert finds
1836
+ // nothing to mint and leaves our work alone. Clearing last would leave a
1837
+ // window config already removed, cert still live where that hook restores
1838
+ // the configs and we then strip the cert they now depend on, stranding the
1839
+ // tools on a key that can never refresh.
1840
+ //
1841
+ // A partial revert (`--tools`) is scoped to the tools it names, so it must
1842
+ // leave the credential alone: the tools it did NOT touch still need to
1843
+ // refresh their key.
1844
+ const fullRevert = args.revert && (args.tools?.length ?? 0) === 0;
1845
+ if (fullRevert) {
1846
+ if (args.dryRun) {
1847
+ if (await hasEnrollment()) {
1848
+ heading("Enrollment");
1849
+ info(" would remove the stored gateway credential");
1657
1850
  }
1658
- else {
1659
- warn("Apply them before using the tool, or it will keep sending traffic straight to the provider (unattributed).");
1851
+ }
1852
+ else {
1853
+ try {
1854
+ if (await clearEnrollment()) {
1855
+ heading("Enrollment");
1856
+ ok(" Removed the stored gateway credential.");
1857
+ note(" Reconnect any time with `npx anyray-connect@latest --sso`.");
1858
+ }
1859
+ }
1860
+ catch (error) {
1861
+ // Local disk failure — say so plainly and keep going: the tool configs
1862
+ // below still come out, and a half-reverted install is worth more than
1863
+ // a stack trace. Never echo the error value (it can quote a path).
1864
+ heading("Enrollment");
1865
+ warn(" Could not remove the stored gateway credential.");
1866
+ warn(` Delete ${stateDir()} by hand, or the key-refresh hook may restore your tool config.`);
1867
+ void error;
1660
1868
  }
1661
1869
  }
1662
1870
  }
1663
- // Smoke-test the connection so a routing/sign-in problem surfaces NOW with a
1664
- // concrete fix, not as a cryptic 4xx on the dev's first real request.
1665
- if (args.skipVerify) {
1666
- note("\nSkipped the connection smoke test (--skip-verify).");
1871
+ else if (args.revert && !args.dryRun) {
1872
+ note("\nKeeping the stored enrollment the tools you did not name still need it.");
1873
+ note("Run --revert with no --tools to remove it.");
1667
1874
  }
1668
- else {
1669
- // MUST probe `target` (the remote gateway), NOT `applyTarget` (the local
1670
- // key-refresh daemon the tools were pointed at). The verify classifiers
1671
- // sniff response bodies, and a daemon-synthesized 502 ("gateway
1672
- // unreachable") would confuse them into reporting a routing fault the
1673
- // gateway itself never returned. The daemon hop is health-checked
1674
- // separately at install time (and by `doctor`).
1675
- await runVerify(target, adapters.map((a) => a.id));
1875
+ // A named `--tools` list (the wizard writes its checklist here too) is the
1876
+ // developer choosing these adapters by hand; the default sweep names none.
1877
+ const { changes, manual, results } = await runApply(adapters, applyTarget, opts, args.revert, new Set(args.tools ?? []));
1878
+ // Keep the managed-tool record in step: apply records landed tools, revert
1879
+ // removes them, a full revert clears the record. Drift repair (`doctor
1880
+ // --repair`, the detached refresh) only ever re-applies recorded ids, so
1881
+ // this is what makes an intentional revert stick (#1031).
1882
+ if (!args.dryRun) {
1883
+ await recordManagedTools(results, args.revert, fullRevert).catch(() => undefined);
1884
+ }
1885
+ // Tear down the loopback daemon whenever it isn't wanted but a previous run
1886
+ // left it installed — covers an explicit --revert, --no-key-refresh, or a
1887
+ // subscription→api-key switch that no longer needs the refresh lane. Runs
1888
+ // AFTER the adapters above re-pointed tools straight at the gateway, so
1889
+ // uninstalling the loopback service strands nothing.
1890
+ if (!resolvedKeyRefreshEnabled &&
1891
+ !args.dryRun &&
1892
+ (loopbackEngaged(profile) || profile.fallbackPort)) {
1893
+ heading("Local loopback proxy");
1894
+ const svc = await uninstallProxyService(profile.fallbackPort);
1895
+ for (const m of svc.messages)
1896
+ info(` ${m}`);
1897
+ // Re-read: on a --revert `clearEnrollment` already stripped the credential
1898
+ // from disk, so spreading the pre-run `profile` snapshot would write it
1899
+ // straight back. loadProfile() reflects what is actually persisted now.
1900
+ await saveProfile({
1901
+ ...(await loadProfile()),
1902
+ keyRefreshEnabled: false,
1903
+ fallbackPort: undefined,
1904
+ });
1905
+ ok(" Removed the local loopback proxy.");
1906
+ }
1907
+ // Reconcile the universal refresh scheduler: (re)install it for an enrolled
1908
+ // seat, or tear down one a prior run left when the seat no longer wants it (a
1909
+ // full revert dropped the enrollment, or --no-key-refresh opted out). A SCOPED
1910
+ // revert (`--tools x --revert`) leaves it — the tools it did not name still
1911
+ // depend on the refresh. Independent of the daemon teardown above: a
1912
+ // Cursor-only seat has the scheduler but never had a daemon.
1913
+ await reconcileRefreshScheduler({
1914
+ want: wantRefreshScheduler,
1915
+ announcePrimary: !wantKeyRefresh,
1916
+ scopedRevert: Boolean(args.revert) && !fullRevert,
1917
+ dryRun: Boolean(args.dryRun),
1918
+ wasEnabled: Boolean(profile.refreshSchedulerEnabled),
1919
+ });
1920
+ // Reopen here on the happy path so it reads in the right order, before the
1921
+ // summary. `reopenClosed` clears its list, so the `finally` below is a
1922
+ // no-op once this has run — it exists for the paths that never reach here.
1923
+ reopenClosed();
1924
+ heading("Summary");
1925
+ if (args.dryRun) {
1926
+ note("Dry run complete — re-run without --dry-run to apply.");
1676
1927
  }
1677
- info("\nRe-test by hand any time:");
1678
- const nativeCursorOnly = target.authMode !== "placeholder" &&
1679
- adapters.length === 1 &&
1680
- adapters[0]?.id === "cursor";
1681
- if (nativeCursorOnly) {
1682
- info(dim(` curl -sS ${target.origin}/connect/savings \\\n` +
1683
- ` -H "x-anyray-api-key: $(anyray-connect print-key)"`));
1684
- info(dim(" Validate Opus itself inside Cursor: keep the Team login, select a native Claude Opus model, and run an Agent prompt."));
1928
+ else if (args.revert) {
1929
+ ok(`Reverted (${changes} file(s) touched).`);
1930
+ // Editors read our hooks at startup and keep them in memory, so one that was
1931
+ // already open goes on running them until it restarts. Say so — otherwise
1932
+ // the leftover hook lines read as a revert that missed something.
1933
+ note("Restart any editor or agent that was running — it loaded our hooks at startup.");
1934
+ // The same "gone from disk, still live in memory" trap, but for the shell
1935
+ // exports and this one doesn't just look wrong, it BREAKS the dev's tools
1936
+ // (see tools/shellEnv `staleShellExports`). We can prove it: the vars are in
1937
+ // our own env. Say it loudly and hand over the exact command.
1938
+ const priorTargets = [
1939
+ target,
1940
+ ...(profile.fallbackPort
1941
+ ? [withLocalProxy(target, profile.fallbackPort)]
1942
+ : []),
1943
+ ];
1944
+ const stale = staleShellExportsForTargets(priorTargets);
1945
+ if (stale.length > 0) {
1946
+ heading("This terminal still points at the gateway");
1947
+ warn(` Still exported here: ${stale.join(", ")}`);
1948
+ warn(" Removing the block from your shell profile cannot reach a shell");
1949
+ warn(" that already ran it. Until these are gone, your tools keep");
1950
+ warn(" reaching the gateway and now fail with 401.");
1951
+ info("\n Fix this terminal:");
1952
+ info(dim(` unset ${stale.join(" ")}`));
1953
+ info("\n Every other open terminal needs the same — or just open a new one.");
1954
+ info(" VS Code injects a copy it captured when IT launched, so its");
1955
+ info(" terminals stay stale even if you open a new one: quit the app");
1956
+ info(" entirely (Cmd+Q). Reloading the window keeps the old copy.");
1957
+ }
1685
1958
  }
1686
1959
  else {
1687
- // A minimal chat completion is the one request every API-routed gateway
1688
- // serves. `anyray-default` lets gateway policy choose model/provider.
1689
- info(dim(
1690
- // Never echo a personal key point at the tool config it landed in.
1691
- ` curl -sS ${target.openaiBaseUrl}/chat/completions \\\n` +
1692
- ` -H "Content-Type: application/json" \\\n` +
1693
- ` -H "Authorization: Bearer ${target.clientKey
1694
- ? `<your personal key (${maskKey(target.clientKey)}) see your tool config>`
1695
- : "<enrollment required>"}" \\\n` +
1696
- ` -d '{"model":"anyray-default","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'`));
1960
+ ok(`Done (${changes} file(s) written).`);
1961
+ // Tools whose config we can't script (Claude desktop, JetBrains, Windsurf)
1962
+ // are the easiest thing to overlook — the loop printed their steps, but a
1963
+ // dev skimming for the green "Done" can miss that no file was actually
1964
+ // written for them. Re-surface them loudly, and on an interactive run make
1965
+ // the dev explicitly acknowledge before we move on. Scripted runs
1966
+ // (--yes / non-TTY) just get the reminder — never a hanging prompt.
1967
+ if (manual.length > 0) {
1968
+ heading("Action required manual steps");
1969
+ for (const line of manualStepsNotice(manual))
1970
+ warn(line);
1971
+ if (isInteractive() && !args.yes) {
1972
+ const acknowledged = await confirm("Have you applied the manual settings shown above?");
1973
+ if (acknowledged) {
1974
+ ok("Acknowledged.");
1975
+ }
1976
+ else {
1977
+ warn("Apply them before using the tool, or it will keep sending traffic straight to the provider (unattributed).");
1978
+ }
1979
+ }
1980
+ }
1981
+ // Smoke-test the connection so a routing/sign-in problem surfaces NOW with a
1982
+ // concrete fix, not as a cryptic 4xx on the dev's first real request.
1983
+ if (args.skipVerify) {
1984
+ note("\nSkipped the connection smoke test (--skip-verify).");
1985
+ }
1986
+ else {
1987
+ // MUST probe `target` (the remote gateway), NOT `applyTarget` (the local
1988
+ // key-refresh daemon the tools were pointed at). The verify classifiers
1989
+ // sniff response bodies, and a daemon-synthesized 502 ("gateway
1990
+ // unreachable") would confuse them into reporting a routing fault the
1991
+ // gateway itself never returned. The daemon hop is health-checked
1992
+ // separately at install time (and by `doctor`).
1993
+ await runVerify(target, adapters.map((a) => a.id));
1994
+ }
1995
+ info("\nRe-test by hand any time:");
1996
+ const nativeCursorOnly = target.authMode !== "placeholder" &&
1997
+ adapters.length === 1 &&
1998
+ adapters[0]?.id === "cursor";
1999
+ if (nativeCursorOnly) {
2000
+ info(dim(` curl -sS ${target.origin}/connect/savings \\\n` +
2001
+ ` -H "x-anyray-api-key: $(anyray-connect print-key)"`));
2002
+ info(dim(" Validate Opus itself inside Cursor: keep the Team login, select a native Claude Opus model, and run an Agent prompt."));
2003
+ }
2004
+ else {
2005
+ // A minimal chat completion is the one request every API-routed gateway
2006
+ // serves. `anyray-default` lets gateway policy choose model/provider.
2007
+ info(dim(
2008
+ // Never echo a personal key — point at the tool config it landed in.
2009
+ ` curl -sS ${target.openaiBaseUrl}/chat/completions \\\n` +
2010
+ ` -H "Content-Type: application/json" \\\n` +
2011
+ ` -H "Authorization: Bearer ${target.clientKey
2012
+ ? `<your personal key (${maskKey(target.clientKey)}) — see your tool config>`
2013
+ : "<enrollment required>"}" \\\n` +
2014
+ ` -d '{"model":"anyray-default","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'`));
2015
+ }
2016
+ info(dim(" Revert: npx anyray-connect --revert"));
1697
2017
  }
1698
- info(dim(" Revert: npx anyray-connect --revert"));
2018
+ return 0;
2019
+ }
2020
+ finally {
2021
+ reopenClosed();
1699
2022
  }
1700
- return 0;
1701
2023
  };
1702
2024
  //# sourceMappingURL=cli.js.map