Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/renderer/actions/agentLoginActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 35 additions & 9 deletions src/renderer/actions/agentLoginActions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -210,20 +212,44 @@ function quotePosixShellArg(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}

function shellEnvPrefix(env: Record<string, string> | 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<string, string>): 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<string, string> | 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 {
Expand Down