fix(clis): Address Codex and Gemini CLIs not being detected by looking for agent CLIs in user toolchain paths (#346)

This commit is contained in:
Mohamad Yahia
2026-05-03 14:08:31 +08:00
committed by GitHub
parent 58bbc979e3
commit 72edd4fc60
2 changed files with 105 additions and 3 deletions
+70 -2
View File
@@ -1,9 +1,10 @@
// @ts-nocheck
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { homedir } from 'node:os';
import { detectAcpModels } from './acp.js';
import { parsePiModels } from './pi-rpc.js';
@@ -577,12 +578,79 @@ export const AGENT_DEFS = [
},
];
function existingDirsUnder(root, segments = []) {
const dirs = [];
let entries = [];
try {
entries = readdirSync(root, { withFileTypes: true });
} catch {
return dirs;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const full = path.join(root, entry.name, ...segments);
if (existsSync(full)) dirs.push(full);
}
return dirs;
}
const TOOLCHAIN_DIR_CACHE_TTL_MS = 5000;
let cachedToolchainHome = null;
let cachedToolchainDirs = null;
let cachedToolchainDirsAt = 0;
function userToolchainDirs() {
const homeOverride = process.env.OD_AGENT_HOME;
const home = homeOverride || homedir();
const now = Date.now();
if (
cachedToolchainHome === home &&
cachedToolchainDirs &&
now - cachedToolchainDirsAt < TOOLCHAIN_DIR_CACHE_TTL_MS
) {
return cachedToolchainDirs;
}
cachedToolchainHome = home;
cachedToolchainDirsAt = now;
cachedToolchainDirs = [
path.join(home, '.local', 'bin'),
path.join(home, '.opencode', 'bin'),
path.join(home, '.bun', 'bin'),
path.join(home, '.volta', 'bin'),
path.join(home, '.asdf', 'shims'),
path.join(home, 'Library', 'pnpm'),
path.join(home, '.cargo', 'bin'),
...(process.platform !== 'win32' && !homeOverride ? ['/opt/homebrew/bin', '/usr/local/bin'] : []),
...existingDirsUnder(path.join(home, '.local', 'share', 'mise', 'installs', 'node'), ['bin']),
...existingDirsUnder(path.join(home, '.nvm', 'versions', 'node'), ['bin']),
...existingDirsUnder(path.join(home, '.local', 'share', 'fnm', 'node-versions'), ['installation', 'bin']),
];
return cachedToolchainDirs;
}
function resolvePathDirs() {
const seen = new Set();
const dirs = [
...(process.env.PATH || '').split(delimiter),
// GUI launchers (macOS .app bundles, Linux .desktop files) often start
// with a minimal PATH. Include common user-level CLI install locations
// so agent detection matches the user's shell-installed tools,
// especially Node version managers.
...userToolchainDirs(),
];
return dirs.filter((dir) => {
if (!dir || seen.has(dir)) return false;
seen.add(dir);
return true;
});
}
export function resolveOnPath(bin) {
const exts =
process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
: [''];
const dirs = (process.env.PATH || '').split(delimiter);
const dirs = resolvePathDirs();
for (const dir of dirs) {
for (const ext of exts) {
const full = path.join(dir, bin + ext);
+35 -1
View File
@@ -1,7 +1,7 @@
// @ts-nocheck
import { afterEach, test } from 'vitest';
import assert from 'node:assert/strict';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AGENT_DEFS, resolveAgentExecutable } from '../src/agents.js';
@@ -14,6 +14,8 @@ const claude = AGENT_DEFS.find((agent) => agent.id === 'claude');
const devin = AGENT_DEFS.find((agent) => agent.id === 'devin');
const originalDisablePlugins = process.env.OD_CODEX_DISABLE_PLUGINS;
const originalPath = process.env.PATH;
const originalHome = process.env.HOME;
const originalAgentHome = process.env.OD_AGENT_HOME;
afterEach(() => {
if (originalDisablePlugins == null) {
@@ -22,6 +24,16 @@ afterEach(() => {
process.env.OD_CODEX_DISABLE_PLUGINS = originalDisablePlugins;
}
process.env.PATH = originalPath;
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalAgentHome == null) {
delete process.env.OD_AGENT_HOME;
} else {
process.env.OD_AGENT_HOME = originalAgentHome;
}
});
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
@@ -288,6 +300,7 @@ fsTest('resolveAgentExecutable prefers def.bin over fallbackBins when bin is on
writeFileSync(join(dir, 'openclaude'), '');
chmodSync(join(dir, 'claude'), 0o755);
chmodSync(join(dir, 'openclaude'), 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable({
@@ -306,6 +319,7 @@ fsTest('resolveAgentExecutable falls back through fallbackBins when def.bin is m
// Only `openclaude` is installed (Claude Code fork-only setup).
writeFileSync(join(dir, 'openclaude'), '');
chmodSync(join(dir, 'openclaude'), 0o755);
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable({
@@ -321,6 +335,7 @@ fsTest('resolveAgentExecutable falls back through fallbackBins when def.bin is m
fsTest('resolveAgentExecutable returns null when neither def.bin nor any fallback is on PATH', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agents-resolve-'));
try {
process.env.OD_AGENT_HOME = dir;
process.env.PATH = dir;
const resolved = resolveAgentExecutable({
@@ -333,6 +348,25 @@ fsTest('resolveAgentExecutable returns null when neither def.bin nor any fallbac
}
});
fsTest('resolveAgentExecutable searches mise node bins when PATH is minimal', () => {
const home = mkdtempSync(join(tmpdir(), 'od-agents-home-'));
try {
const dir = join(home, '.local', 'share', 'mise', 'installs', 'node', '24.14.1', 'bin');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'codex'), '');
chmodSync(join(dir, 'codex'), 0o755);
process.env.OD_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const resolved = resolveAgentExecutable({
bin: 'codex',
});
assert.equal(resolved, join(dir, 'codex'));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
fsTest('resolveAgentExecutable still resolves agents without a fallbackBins field', () => {
// Guard against a regression that would require every AGENT_DEF to
// declare fallbackBins. Most agents (codex / gemini / opencode / ...)