mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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);
|
||||
|
||||
@@ -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 / ...)
|
||||
|
||||
Reference in New Issue
Block a user