diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 574ec26af9c..dd578ff1571 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1607,6 +1607,7 @@ "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", + "claude-desktop-mode-explanation.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" }, "migrated": [ diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 92efabad4d0..238e6bb03e8 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -18,6 +18,7 @@ import { import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { applyDesktopFirstParty, + isClaudeDesktopMode, recordClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopApplyMode, @@ -196,6 +197,44 @@ export function parseDesktopApplyArgs( return { target: { kind, mode: parsedMode.mode } }; } +/** + * Why a gateway apply happened when the help text calls first-party the default. + * + * `resolveClaudeDesktopMode` keeps an existing install where it is: an explicit + * `claudeCode.desktopMode` wins, and a stored gateway apply marker keeps gateway. Both rules are + * right — a working Desktop install must not flip underneath its user because a default moved. + * Together they mean an existing gateway user never arrives at first-party without discovering + * `--first-party` unaided, while `ocx claude desktop --help` tells them first-party is "(default)". + * + * The fix is not to change the resolution. It is to say, at the moment of the apply, that the + * other mode exists and what selects it. Returns null when the user asked for gateway explicitly, + * because they already know, and when first-party is simply unavailable here — a connected client + * or a disabled intercept cannot run it, so offering it would be advice that fails. + */ +export function gatewayModeExplanation(input: { + requestedExplicitly: boolean; + config: Pick; + connection?: ClientConnectionState; +}): string[] { + if (input.requestedExplicitly) return []; + const connection = input.connection ?? readClientConnectionState(); + if (connection.kind === "connected") return []; + // Only a stored preference is worth explaining. Without one, gateway was chosen because + // first-party cannot run here, and naming an unavailable alternative is advice that fails. + const savedMode = input.config.claudeCode?.desktopMode; + const hasSavedGateway = isClaudeDesktopMode(savedMode) && savedMode === "gateway"; + const hasApplyMarker = input.config.claudeCode?.desktopProfile?.appliedFingerprint !== undefined; + if (!hasSavedGateway && !hasApplyMarker) return []; + const reason = hasSavedGateway + ? "this machine has claudeCode.desktopMode saved as gateway" + : "this machine carries a previous gateway apply"; + return [ + `Applied the gateway profile because ${reason}; an existing install is never switched for you.`, + "First-party keeps Desktop on your claude.ai account and routes only the Code tab through the local proxy:", + " ocx claude desktop apply --first-party", + ]; +} + /** * First-party apply: settings.json env only. The intercept pair the env points at runs inside * the hub process, so this is a local-hub operation — a connected client machine cannot reach @@ -361,6 +400,12 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); } else { console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); + for (const line of gatewayModeExplanation({ + requestedExplicitly: applyFlags.some(flag => flag !== "--first-party"), + config: loadConfig(), + })) { + console.log(line); + } } // The write landed; only the bookkeeping marker did not. Saying nothing // would leave the saved-vs-applied display wrong with no explanation. diff --git a/tests/claude-integration/claude-desktop-mode-explanation.test.ts b/tests/claude-integration/claude-desktop-mode-explanation.test.ts new file mode 100644 index 00000000000..a2b6e80529e --- /dev/null +++ b/tests/claude-integration/claude-desktop-mode-explanation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { gatewayModeExplanation } from "../../src/cli/claude-desktop"; +import type { ClientConnectionState } from "../../src/client/state"; + +const disconnected = { kind: "disconnected" } as ClientConnectionState; +const connected = { kind: "connected" } as unknown as ClientConnectionState; + +describe("gateway apply explains the first-party alternative", () => { + test("a stored gateway marker is named as the reason, with the command that switches", () => { + const lines = gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }, + connection: disconnected, + }); + + expect(lines.join("\n")).toContain("previous gateway apply"); + expect(lines.join("\n")).toContain("ocx claude desktop apply --first-party"); + }); + + test("an explicit saved desktopMode is named as itself, not as a leftover marker", () => { + const lines = gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { desktopMode: "gateway" } }, + connection: disconnected, + }); + + expect(lines.join("\n")).toContain("desktopMode saved as gateway"); + }); + + test("asking for gateway explicitly says nothing, because the user already chose", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: true, + config: { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }, + connection: disconnected, + })).toEqual([]); + }); + + test("a connected client says nothing, because first-party cannot run there", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }, + connection: connected, + })).toEqual([]); + }); + + test("a fresh machine that fell back to gateway is not told to switch to something unavailable", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: false, + config: {}, + connection: disconnected, + })).toEqual([]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6a492dc56d1..e1e266abf4c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1439,5 +1439,6 @@ "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", + "claude-desktop-mode-explanation.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" }