Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c7861ea
fix(terminal): open OSC 8 hyperlinks in the default browser
abose Sep 23, 2026
8f01b8d
feat(ai): fall back to the Claude Code bundled with the SDK
abose Sep 23, 2026
b4cad06
feat(ai): strings and styles for Set up on the start screen
abose Sep 23, 2026
6031698
build: update pro deps
abose Sep 23, 2026
04b9a84
fix(ai): notice a cached Claude Code that has gone, and look again
abose Sep 23, 2026
c4b763d
style(ai): remove unused Codex placeholder icon styles
abose Sep 23, 2026
d82be77
feat(ai): add translatable CLI provider attribution
abose Sep 23, 2026
945cee8
feat(ai): clarify visual chat and update Claude SDK
abose Sep 23, 2026
a967890
feat(terminal): simplify project-switch notice with dismiss button
abose Sep 23, 2026
a0ab5f2
style(ai): show the CLI tools side by side on the start screen
abose Sep 23, 2026
cca982f
feat(ai): add a hover-revealed Back button in CLI modes
abose Sep 23, 2026
d84dcc8
feat(ai): style the panel's home view and live session dots
abose Sep 23, 2026
e52646a
build: update pro deps
abose Sep 23, 2026
a50162f
fix(project): keep a newer project's preferences when an older load f…
abose Sep 23, 2026
e3f0a8c
style(ai): show a checkerboard behind transparent chat images
abose Sep 24, 2026
1743189
build: update pro deps
abose Sep 24, 2026
f11f0f7
fix(live-preview): keep CM edits from being undone by stale md viewer…
abose Sep 25, 2026
1e7bda2
test(live-preview): fix flaky markdown and multibrowser live preview …
abose Sep 25, 2026
293634a
build: update pro deps
abose Sep 25, 2026
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
165 changes: 145 additions & 20 deletions src-node/cli-locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ const VERSION_PROBE_TIMEOUT_MS = 3000;

// Negative results expire so a fresh install completed during a session is
// detected on the next lookup (the install-poll flow depends on this).
// Positive results are cached indefinitely — the self-heal in
// checkAvailability handles the mid-session-uninstall case by forcing.
// Positive results are kept for as long as the binary is still there (see
// _cacheHit): an updater that moves the install, or an uninstall, must be
// noticed by the next lookup, or a prompt goes to a path that is gone.
const NULL_CACHE_TTL_MS = 15000;

// Characters that must never reach a user-supplied override path. Windows
Expand Down Expand Up @@ -160,6 +161,15 @@ const CLI_REGISTRY = {

const CLI_IDS = Object.keys(CLI_REGISTRY);

// The Claude Code the Claude Agent SDK ships with, in a package for this OS
// and CPU beside the SDK. It pairs with the SDK version Phoenix was built
// against, and is what runs when the system has no Claude Code, or one older
// than it. A configured path still wins outright.
const BUNDLED_CLAUDE_SCOPE = "@anthropic-ai";
const BUNDLED_CLAUDE_PACKAGE = "claude-agent-sdk";
let _bundledRoot = (typeof __dirname === "string") ? __dirname : null;
let _bundledClaude = null; // Promise<?{path, version}>: looked up once per process

// cliId -> { path, at, overrideSig, source, version, errorCode, override, searched }
const _cache = new Map();
// cliId -> in-flight discovery promise, so concurrent callers share one walk
Expand Down Expand Up @@ -492,11 +502,98 @@ function setOverrides(paths) {
return applied;
}

/** Whether this Linux runs on musl (Alpine), where the SDK's glibc build cannot load. */
function _isMusl() {
if (process.platform !== "linux") { return false; }
const report = (process.report && typeof process.report.getReport === "function")
? process.report.getReport() : null;
return !!report && !!report.header && report.header.glibcVersionRuntime === undefined;
}

/**
* Where the SDK's platform package puts Claude Code, likeliest first. The
* SDK resolves it the same way.
* @return {Array<string>}
*/
function _bundledClaudeCandidates() {
if (!_bundledRoot) { return []; }
const platform = process.platform;
const prefix = BUNDLED_CLAUDE_PACKAGE + "-" + platform + "-" + (process.arch || "x64");
let packages = [prefix];
if (platform === "linux") {
packages = _isMusl() ? [prefix + "-musl", prefix] : [prefix, prefix + "-musl"];
}
const bin = platform === "win32" ? "claude.exe" : "claude";
return packages.map(function (pkg) {
return path.join(_bundledRoot, "node_modules", BUNDLED_CLAUDE_SCOPE, pkg, bin);
});
}

/**
* The bundled Claude Code, proved by `--version`. Looked up once: it does
* not change while Phoenix runs.
* @return {Promise<?{path: string, version: string}>}
*/
function _locateBundledClaude() {
if (!_bundledClaude) {
_bundledClaude = (async function () {
const cli = CLI_REGISTRY.claude;
for (const p of _bundledClaudeCandidates()) {
if (!canAccess(p)) { continue; }
const probe = await _probeVersion(cli, p);
if (probe.ok) {
console.log("[Phoenix AI] Bundled claude CLI at:", p, probe.version);
return { path: p, version: probe.version };
}
console.log("[Phoenix AI] Bundled claude CLI unusable:", p, probe.errorCode);
}
return null;
}());
}
return _bundledClaude;
}

/**
* Compare two versions part by part: "2.1.141 (Claude Code)" reads as
* 2.1.141, and a missing part counts as 0.
* @return {number} negative when `a` is older than `b`, positive when newer, 0 when the same
*/
function compareVersions(a, b) {
const parse = function (v) {
const match = /\d+(?:\.\d+)*/.exec(String(v || ""));
return match ? match[0].split(".").map(Number) : [];
};
const pa = parse(a);
const pb = parse(b);
const count = Math.max(pa.length, pb.length);
for (let i = 0; i < count; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff) { return diff; }
}
return 0;
}

/**
* Where the bundled Claude Code is looked for. Set by tests; the default is
* this module's own directory, which is where the SDK is installed.
* @param {?string} dir
*/
function setBundledRoot(dir) {
_bundledRoot = dir || null;
_bundledClaude = null;
_cache.delete("claude");
}

function _cacheHit(cliId, overrideSig) {
const entry = _cache.get(cliId);
if (!entry || entry.overrideSig !== overrideSig) {
return null;
}
if (entry.path !== null && !canAccess(entry.path)) {
console.log("[Phoenix AI] " + cliId + " CLI is no longer at", entry.path);
_cache.delete(cliId);
return null;
}
const fresh = entry.path !== null || (Date.now() - entry.at) < NULL_CACHE_TTL_MS;
return fresh ? entry : null;
}
Expand Down Expand Up @@ -569,29 +666,55 @@ function locateCli(cliId, opts) {

const { native, fallback } = _candidates(cli);
entry.searched = [...native, ...fallback];
// Only claude ships with Phoenix. With a bundled copy to weigh a
// system install against, the system install's version has to be
// known, so the native tier is probed too.
const bundled = cliId === "claude" ? await _locateBundledClaude() : null;
if (bundled) {
entry.searched.push(bundled.path);
}
let found = null; // {path, source, version}
for (const p of native) {
if (canAccess(p)) {
console.log("[Phoenix AI] Found native " + cli.bin + " CLI at:", p);
entry.path = p;
entry.source = "native";
entry.at = Date.now();
_cache.set(cliId, entry);
return _toResult(cliId, entry);
if (!canAccess(p)) { continue; }
let version = null;
if (bundled) {
const probe = await _probeVersion(cli, p);
if (!probe.ok) {
console.log("[Phoenix AI] Native " + cli.bin + " CLI unusable:", p, probe.errorCode);
continue;
}
version = probe.version;
}
console.log("[Phoenix AI] Found native " + cli.bin + " CLI at:", p);
found = { path: p, source: "native", version: version };
break;
}
for (const p of fallback) {
if (!canAccess(p)) { continue; }
const probe = await _probeVersion(cli, p);
if (probe.ok) {
console.log("[Phoenix AI] Validated " + cli.bin + " CLI at:", p);
entry.path = p;
entry.source = "fallback";
entry.version = probe.version;
entry.at = Date.now();
_cache.set(cliId, entry);
return _toResult(cliId, entry);
if (!found) {
for (const p of fallback) {
if (!canAccess(p)) { continue; }
const probe = await _probeVersion(cli, p);
if (probe.ok) {
console.log("[Phoenix AI] Validated " + cli.bin + " CLI at:", p);
found = { path: p, source: "fallback", version: probe.version };
break;
}
}
}
if (bundled && (!found || compareVersions(found.version, bundled.version) < 0)) {
console.log(found
? "[Phoenix AI] System claude " + found.version + " is older than the bundled " +
bundled.version + "; using the bundled one"
: "[Phoenix AI] No system claude; using the bundled " + bundled.version);
found = { path: bundled.path, source: "bundled", version: bundled.version };
}
if (found) {
entry.path = found.path;
entry.source = found.source;
entry.version = found.version;
entry.at = Date.now();
_cache.set(cliId, entry);
return _toResult(cliId, entry);
}
console.log("[Phoenix AI] Global " + cli.bin + " CLI not found");
entry.errorCode = ERROR_CODES.NOT_FOUND;
entry.at = Date.now();
Expand Down Expand Up @@ -680,5 +803,7 @@ exports.locateCli = locateCli;
exports.validateCliPath = validateCliPath;
exports.findDownloader = findDownloader;
exports.getSpawnProfile = getSpawnProfile;
exports.compareVersions = compareVersions;
exports.setBundledRoot = setBundledRoot;
exports.setOverrides = setOverrides;
exports.getOverride = getOverride;
Loading
Loading