diff --git a/src/renderer/actions/agentLoginActions.test.ts b/src/renderer/actions/agentLoginActions.test.ts index 9e9d6776..8ad22fee 100644 --- a/src/renderer/actions/agentLoginActions.test.ts +++ b/src/renderer/actions/agentLoginActions.test.ts @@ -177,6 +177,36 @@ describe("runAgentLoginCommand", () => { expect(bridge.openExternalNative).toHaveBeenCalledWith(url); }); + it("sets profile env via PowerShell assignments on native Windows, not a POSIX prefix", () => { + runAgentLoginCommand({ + label: "Claude Code", + command: "claude auth login", + env: { CLAUDE_CONFIG_DIR: "C:\\Users\\sdsle\\.lightcode\\claude-profiles\\home" }, + project: windowsProject, + }); + + const script = writeScriptToShellMock.mock.calls[0]?.[1] ?? ""; + // PowerShell can't run `KEY=value command`; it must assign $env: first. + expect(script).toContain( + "Clear-Host; $env:CLAUDE_CONFIG_DIR = 'C:\\Users\\sdsle\\.lightcode\\claude-profiles\\home'; claude auth login", + ); + expect(script).not.toContain("CLAUDE_CONFIG_DIR=C:"); + }); + + it("sets profile env via an inline POSIX prefix on WSL", () => { + runAgentLoginCommand({ + label: "Claude Code", + command: "claude auth login", + env: { CLAUDE_CONFIG_DIR: "/home/demo/.claude-profiles/home" }, + project: wslProject, + }); + + const script = writeScriptToShellMock.mock.calls[0]?.[1] ?? ""; + expect(script).toContain( + "clear; BROWSER='/bin/true' CLAUDE_CONFIG_DIR='/home/demo/.claude-profiles/home' claude auth login", + ); + }); + it("does not intercept login URLs on native Windows so the CLI's own browser opener wins", () => { runAgentLoginCommand({ label: "Grok", diff --git a/src/renderer/actions/agentLoginActions.ts b/src/renderer/actions/agentLoginActions.ts index 1c19f9cd..2f0a552d 100644 --- a/src/renderer/actions/agentLoginActions.ts +++ b/src/renderer/actions/agentLoginActions.ts @@ -1,5 +1,5 @@ import { toast } from "@heroui/react"; -import type { Project } from "@/shared/contracts"; +import type { Project, ProjectLocation } from "@/shared/contracts"; import { stripAnsi } from "@/shared/ansi"; import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; @@ -71,6 +71,7 @@ export function runAgentLoginCommand(input: { const loginCommand = buildTerminalCommand({ command: input.command, env: suppressWslBrowser ? { BROWSER: "/bin/true", ...(input.env ?? {}) } : input.env, + locationKind: project.location.kind, }); const command = project.location.kind === "windows" ? `Clear-Host; ${loginCommand}` : `clear; ${loginCommand}`; @@ -160,6 +161,7 @@ export function runAgentInstallCommand(input: { const command = buildTerminalCommand({ command: typeof input.command === "function" ? input.command(project) : input.command, env: input.env, + locationKind: project.location.kind, }); const completionToken = createCompletionToken(); const script = appendCompletionSignal(command, project, completionToken); @@ -210,20 +212,44 @@ function quotePosixShellArg(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -function shellEnvPrefix(env: Record | undefined): string { - if (!env) return ""; - return Object.entries(env) - .filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) - .map(([key, value]) => `${key}=${quotePosixShellArg(value)}`) - .join(" "); +// PowerShell single-quoted literals take backslashes verbatim (so Windows paths +// like C:\Users\... need no escaping) and escape an embedded single quote by +// doubling it. +function quotePowerShellArg(value: string): string { + return `'${value.replaceAll("'", "''")}'`; } +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +function validEnvEntries(env: Record): Array<[string, string]> { + return Object.entries(env).filter(([key]) => ENV_KEY_PATTERN.test(key)); +} + +/** + * Prefix `command` with the env vars in a shell-correct way for the location. + * + * POSIX / WSL shells accept an inline `KEY='value' command` prefix. PowerShell + * (native Windows) does NOT — it parses `KEY='value'` as a command name and + * fails with "not recognized as a name of a cmdlet". There we emit `$env:KEY = + * 'value'` assignment statements separated from the command by `;`, which set + * the vars in the session the command then inherits. + */ function buildTerminalCommand(input: { command: string; env: Record | undefined; + locationKind: ProjectLocation["kind"]; }): string { - const envPrefix = shellEnvPrefix(input.env); - return envPrefix ? `${envPrefix} ${input.command}` : input.command; + if (!input.env) return input.command; + const entries = validEnvEntries(input.env); + if (entries.length === 0) return input.command; + if (input.locationKind === "windows") { + const assignments = entries + .map(([key, value]) => `$env:${key} = ${quotePowerShellArg(value)}`) + .join("; "); + return `${assignments}; ${input.command}`; + } + const prefix = entries.map(([key, value]) => `${key}=${quotePosixShellArg(value)}`).join(" "); + return `${prefix} ${input.command}`; } function isGeminiLoginCommand(input: { label: string; command: string }): boolean {