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
29 changes: 9 additions & 20 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -604,20 +604,6 @@ async function _getAISessionTitle(sessionId, projectPath) {
}
}

/**
* Resolve the user's globally installed Claude CLI, honouring the path
* override configured in AI Settings. Kept as a local function so the SDK
* path below reads the same as it always has; the search itself now lives
* in cli-locator.js, shared with the other CLIs the panel can drive.
* Pass `{ force: true }` to invalidate the cache after a spawn failure.
* @return {Promise<string|null>} absolute path, or null when not found
*/
function findGlobalClaudeCli(opts) {
return CliLocator.locateCli("claude", opts).then(function (result) {
return result.path;
});
}

// Brand names for the messages below. Not translatable and never shown
// raw — the browser maps errorCode to a localized string; these only reach
// logs and metrics.
Expand Down Expand Up @@ -826,7 +812,7 @@ exports.getCliSpawnProfile = async function (params) {
if (!result.available) {
return Object.assign({}, result, { command: null, args: [] });
}
const profile = CliLocator.getSpawnProfile(result.path);
const profile = CliLocator.getSpawnProfile(result.path, result.source);
return Object.assign({}, result, profile);
};

Expand Down Expand Up @@ -2118,7 +2104,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
// It also carries the live preview nudge as a fallback for
// Claude CLI versions that predate PostToolBatch: the batch
// hook below is the primary path, but we run the user's
// global CLI (findGlobalClaudeCli) so we can't assume it.
// whichever CLI the locator picked, so we can't assume it.
// Whichever fires first takes the hint; the other sees a
// cleared counter.
hooks: [
Expand Down Expand Up @@ -2214,10 +2200,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
};
}

// Set Claude CLI path if found
const claudePath = await findGlobalClaudeCli();
if (claudePath) {
queryOptions.pathToClaudeCodeExecutable = claudePath;
// Set Claude CLI path if found. The env the binary's origin calls for
// rides along: the SDK spawns the same executable the CLI pane does, so
// the bundled copy must not try to auto-update here either.
const located = await CliLocator.locateCli("claude");
if (located.path) {
queryOptions.pathToClaudeCodeExecutable = located.path;
Object.assign(queryOptions.env, CliLocator.getSourceEnv(located.source));
}

if (model) {
Expand Down
29 changes: 26 additions & 3 deletions src-node/cli-locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,26 @@ function findDownloader() {
return null;
}

/**
* Env a CLI needs on top of PATH, decided by where the binary came from.
*
* The bundled Claude Code ships inside Phoenix and is replaced when Phoenix
* updates, so its own auto-updater has nothing useful to do — and it cannot
* do it anyway: it resolves the npm prefix of whatever install it thinks it
* is and tries to write there, which is a directory Phoenix neither owns nor
* should touch. It fails every launch and says so in red across the CLI pane
* ("Auto-update failed: no write permission to npm prefix"). Turning the
* updater off is the supported way out (Anthropic set the same var for their
* own managed runs). A CLI the *user* installed keeps its updater: that copy
* is theirs to keep current, and it is how their version eventually
* overtakes the bundled one.
* @param {?string} source - `source` from locateCli(): "bundled" when ours
* @return {Object}
*/
function _cliSourceEnv(source) {
return source === "bundled" ? { DISABLE_AUTOUPDATER: "1" } : {};
}

/**
* How to hand a resolved binary to node-pty.
*
Expand All @@ -789,12 +809,14 @@ function findDownloader() {
* to spawn, so every terminal caller must resolve through here rather than
* using the raw path.
* `env` carries the same PATH used for probes so child tools such as npm
* remain available in the terminal. Callers must forward it to the PTY.
* remain available in the terminal, plus whatever `source` implies. Callers
* must forward it to the PTY.
* @param {string} cliPath - Resolved CLI path
* @param {?string} [source] - `source` from locateCli(), for the env above
* @return {{command: string, args: Array<string>, env: Object}}
*/
function getSpawnProfile(cliPath) {
const env = _cliPathEnv(cliPath, process.env);
function getSpawnProfile(cliPath, source) {
const env = Object.assign(_cliPathEnv(cliPath, process.env), _cliSourceEnv(source));
if (isWindows && /\.(cmd|bat)$/i.test(cliPath || "")) {
return { command: process.env.COMSPEC || "cmd.exe", args: ["/c", cliPath], env };
}
Expand All @@ -809,6 +831,7 @@ exports.locateCli = locateCli;
exports.validateCliPath = validateCliPath;
exports.findDownloader = findDownloader;
exports.getSpawnProfile = getSpawnProfile;
exports.getSourceEnv = _cliSourceEnv;
exports.compareVersions = compareVersions;
exports.setBundledRoot = setBundledRoot;
exports.setOverrides = setOverrides;
Expand Down
6 changes: 3 additions & 3 deletions src-node/test/test-cli-locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ function _runProcess(command, args, env) {

/**
* Return a launch profile and the input environment after the locator runs.
* @param {Object} params - Simulated platform, env, and cliPath
* @param {Object} params - Simulated platform, env, cliPath, and locate source
* @return {Promise<Object>} Profile and environment for Jasmine assertions
*/
async function getSpawnProfile({platform, env, cliPath}) {
async function getSpawnProfile({platform, env, cliPath, source}) {
const locator = _loadLocator(platform, env);
return { profile: locator.getSpawnProfile(cliPath), env };
return { profile: locator.getSpawnProfile(cliPath, source), env };
}

/**
Expand Down
17 changes: 17 additions & 0 deletions test/spec/CLILocator-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ define(function (require, exports, module) {
});
});

it("should silence the auto-updater only for the bundled Claude Code", async function () {
const args = {
platform: "linux", env: { PATH: "/usr/bin" }, cliPath: "/opt/node/bin/claude"
};
// Ours: it cannot write to the npm prefix it thinks it owns, and
// says so in red on every launch. Phoenix updates this copy.
const bundled = await nodeConnector.execPeer("getSpawnProfile",
Object.assign({ source: "bundled" }, args));
expect(bundled.profile.env.DISABLE_AUTOUPDATER).toBe("1");
// Theirs: left alone, so their own install can still catch up.
for (const source of ["native", "fallback", "override", null]) {
const own = await nodeConnector.execPeer("getSpawnProfile",
Object.assign({ source }, args));
expect(Object.keys(own.profile.env)).withContext(source).toEqual(["PATH"]);
}
});

it("macOS: should make Homebrew bin available to the updater", async function () {
const result = await nodeConnector.execPeer("getSpawnProfile", {
platform: "darwin",
Expand Down
Loading