mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 15:12:23 +03:00
- canonical executable manifest (bin/cli/cli-manifest.mjs): run/configure/completion derive targets, aliases and --model wiring from one table; drift test cross-checks manifest x cliRuntime x UI catalog (tests/unit/cli/cli-manifest-drift.test.ts) - dashboard Codex generator converged to ~/.codex/config.toml (modern Codex v0.137+, verified against codex-cli 0.147.0): conservative merge, env_key auth (key never written), refuses invalid TOML, reports legacy config.yaml as migration note - omniroute run gemini: launcher over OmniRoute's /v1beta surface via GOOGLE_GEMINI_BASE_URL + isolated GEMINI_CLI_HOME forcing gemini-api-key auth (contract proven against @google/gemini-cli 0.50.0); ACP registration kept distinct - opt-in real smoke harness for upstream CLIs (RUN_CLI_SMOKE=1, credential by env NAME, redacted output): tests/integration/upstream-cli-smoke.int.test.ts - container-guard homologation for POST /api/cli-tools/apply (422 in container, dry-run preview allowed, host write passes) + docs; guard untouched - typecheck: omniglyphAdapter union narrowing, usageTracking typed signatures (UsageLike, no any), models.ts isValidModel params — typecheck:core and typecheck:noimplicit:core now clean - relay core (prior session of this effort): omniroute run for 6 CLIs, configure picker with per-context favorites/recents, contexts with optional keychain + 0600 fallback, provider CRUD with recursive redaction, completion updates, docs
421 lines
9.9 KiB
TypeScript
421 lines
9.9 KiB
TypeScript
/**
|
|
* ACP (Agent Client Protocol) — CLI Agent Registry
|
|
*
|
|
* Discovers installed CLI tools on the system by checking standard paths
|
|
* and running version commands. Used to offer ACP transport as an alternative
|
|
* to the HTTP proxy method.
|
|
*
|
|
* Supports built-in agents + user-defined custom agents from settings.
|
|
*
|
|
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
|
|
*/
|
|
|
|
import { execFileSync } from "child_process";
|
|
import path from "path";
|
|
|
|
export interface CliAgentInfo {
|
|
/** Agent identifier (e.g., "codex", "claude", "goose") */
|
|
id: string;
|
|
/** Display name */
|
|
name: string;
|
|
/** Binary name to spawn */
|
|
binary: string;
|
|
/** Version detection command */
|
|
versionCommand: string;
|
|
/** Detected version (null if not installed) */
|
|
version: string | null;
|
|
/** Whether the agent is installed and available */
|
|
installed: boolean;
|
|
/** Provider ID that this agent maps to in OmniRoute */
|
|
providerAlias: string;
|
|
/** Arguments to pass when spawning for ACP */
|
|
spawnArgs: string[];
|
|
/** Protocol used for communication */
|
|
protocol: "stdio" | "http";
|
|
/** Whether this is a user-defined custom agent */
|
|
isCustom?: boolean;
|
|
}
|
|
|
|
/** Shape stored in settings DB for custom agents */
|
|
export interface CustomAgentDef {
|
|
id: string;
|
|
name: string;
|
|
binary: string;
|
|
versionCommand: string;
|
|
providerAlias: string;
|
|
spawnArgs: string[];
|
|
protocol: "stdio" | "http";
|
|
}
|
|
|
|
/**
|
|
* Registry of known CLI agents that support ACP or similar protocols.
|
|
*/
|
|
const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
|
|
{
|
|
id: "codex",
|
|
name: "OpenAI Codex CLI",
|
|
binary: "codex",
|
|
versionCommand: "codex --version",
|
|
providerAlias: "codex",
|
|
spawnArgs: ["--quiet"],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "claude",
|
|
name: "Claude Code CLI",
|
|
binary: "claude",
|
|
versionCommand: "claude --version",
|
|
providerAlias: "claude",
|
|
spawnArgs: ["--print", "--output-format", "json"],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "gemini",
|
|
name: "Google Gemini CLI",
|
|
binary: "gemini",
|
|
versionCommand: "gemini --version",
|
|
providerAlias: "gemini",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "goose",
|
|
name: "Goose CLI",
|
|
binary: "goose",
|
|
versionCommand: "goose --version",
|
|
providerAlias: "goose",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "openclaw",
|
|
name: "OpenClaw",
|
|
binary: "openclaw",
|
|
versionCommand: "openclaw --version",
|
|
providerAlias: "openclaw",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "aider",
|
|
name: "Aider",
|
|
binary: "aider",
|
|
versionCommand: "aider --version",
|
|
providerAlias: "aider",
|
|
spawnArgs: ["--no-auto-commits"],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "zcode",
|
|
name: "ZCode (GLM Coding Plan)",
|
|
binary: "zcode",
|
|
versionCommand: "zcode --version",
|
|
providerAlias: "zcode",
|
|
spawnArgs: ["app-server"],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "opencode",
|
|
name: "OpenCode",
|
|
binary: "opencode",
|
|
versionCommand: "opencode --version",
|
|
providerAlias: "opencode",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "cline",
|
|
name: "Cline",
|
|
binary: "cline",
|
|
versionCommand: "cline --version",
|
|
providerAlias: "cline",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "qwen",
|
|
name: "Qwen Code",
|
|
binary: "qwen",
|
|
versionCommand: "qwen --version",
|
|
providerAlias: "qwen-code",
|
|
spawnArgs: ["--acp"],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "forge",
|
|
name: "ForgeCode",
|
|
binary: "forge",
|
|
versionCommand: "forge --version",
|
|
providerAlias: "forge",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "amazon-q",
|
|
name: "Amazon Q Developer",
|
|
binary: "q",
|
|
versionCommand: "q --version",
|
|
providerAlias: "amazon-q",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "interpreter",
|
|
name: "Open Interpreter",
|
|
binary: "interpreter",
|
|
versionCommand: "interpreter --version",
|
|
providerAlias: "interpreter",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "cursor-cli",
|
|
name: "Cursor CLI",
|
|
binary: "cursor",
|
|
versionCommand: "cursor --version",
|
|
providerAlias: "cursor",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
{
|
|
id: "warp",
|
|
name: "Warp AI",
|
|
binary: "warp",
|
|
versionCommand: "warp --version",
|
|
providerAlias: "warp",
|
|
spawnArgs: [],
|
|
protocol: "stdio",
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detection cache (60 seconds)
|
|
// ---------------------------------------------------------------------------
|
|
let _cachedAgents: CliAgentInfo[] | null = null;
|
|
let _cacheTimestamp = 0;
|
|
const CACHE_TTL_MS = 60_000;
|
|
|
|
/** Custom agents loaded from settings */
|
|
let _customAgentDefs: CustomAgentDef[] = [];
|
|
|
|
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
|
|
|
|
/**
|
|
* Set custom agent definitions from settings.
|
|
*/
|
|
export function setCustomAgents(agents: CustomAgentDef[]): void {
|
|
_customAgentDefs = agents || [];
|
|
_cachedAgents = null; // invalidate cache
|
|
}
|
|
|
|
/**
|
|
* Get current custom agent definitions.
|
|
*/
|
|
export function getCustomAgentDefs(): CustomAgentDef[] {
|
|
return _customAgentDefs;
|
|
}
|
|
|
|
function tokenizeVersionCommand(command: string): string[] | null {
|
|
if (!command || DISALLOWED_VERSION_COMMAND_CHARS.test(command)) {
|
|
return null;
|
|
}
|
|
|
|
const tokens: string[] = [];
|
|
let current = "";
|
|
let quote: '"' | "'" | null = null;
|
|
|
|
for (let index = 0; index < command.length; index += 1) {
|
|
const char = command[index];
|
|
|
|
if (quote) {
|
|
if (char === quote) {
|
|
quote = null;
|
|
} else {
|
|
current += char;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
|
|
if (/\s/.test(char)) {
|
|
if (current) {
|
|
tokens.push(current);
|
|
current = "";
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === "\\") {
|
|
const next = command[index + 1];
|
|
if (next) {
|
|
current += next;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
current += char;
|
|
}
|
|
|
|
if (quote) {
|
|
return null;
|
|
}
|
|
|
|
if (current) {
|
|
tokens.push(current);
|
|
}
|
|
|
|
return tokens.length > 0 ? tokens : null;
|
|
}
|
|
|
|
function normalizeCommandToken(command: string): string {
|
|
return path.normalize(command).replace(/\\/g, "/").toLowerCase();
|
|
}
|
|
|
|
export function resolveVersionProbe(
|
|
binary: string,
|
|
versionCommand: string,
|
|
requireBinaryMatch = false
|
|
): { command: string; args: string[] } | null {
|
|
const tokens = tokenizeVersionCommand(versionCommand);
|
|
if (!tokens) {
|
|
return null;
|
|
}
|
|
|
|
const [command, ...args] = tokens;
|
|
if (!command) {
|
|
return null;
|
|
}
|
|
|
|
if (requireBinaryMatch) {
|
|
const normalizedCommand = normalizeCommandToken(command);
|
|
const allowed = new Set([
|
|
normalizeCommandToken(binary),
|
|
normalizeCommandToken(path.basename(binary)),
|
|
]);
|
|
if (!allowed.has(normalizedCommand)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return { command, args };
|
|
}
|
|
|
|
export function shouldUseShellForVersionProbe(
|
|
command: string,
|
|
platform = process.platform
|
|
): boolean {
|
|
if (platform !== "win32") return false;
|
|
|
|
const normalized = command.trim().toLowerCase();
|
|
if (!normalized) return false;
|
|
|
|
return (
|
|
normalized.endsWith(".cmd") || normalized.endsWith(".bat") || path.extname(normalized) === ""
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect a single agent by running its version command.
|
|
*/
|
|
function detectAgent(
|
|
def: Omit<CliAgentInfo, "version" | "installed">,
|
|
isCustom = false
|
|
): CliAgentInfo {
|
|
let version: string | null = null;
|
|
let installed = false;
|
|
|
|
try {
|
|
const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
|
|
if (!probe) {
|
|
return { ...def, version, installed, isCustom };
|
|
}
|
|
|
|
const output = execFileSync(probe.command, probe.args, {
|
|
timeout: 5000,
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
|
|
}).trim();
|
|
|
|
// Extract version number from output
|
|
const versionMatch = output.match(/(\d+\.\d+\.\d+(?:-\w+)?)/);
|
|
version = versionMatch ? versionMatch[1] : output.split("\n")[0];
|
|
installed = true;
|
|
} catch {
|
|
// Not installed or not runnable
|
|
}
|
|
|
|
return { ...def, version, installed, isCustom };
|
|
}
|
|
|
|
/**
|
|
* Detect installed CLI agents on the system.
|
|
* Results are cached for 60 seconds.
|
|
*/
|
|
export function detectInstalledAgents(): CliAgentInfo[] {
|
|
const now = Date.now();
|
|
if (_cachedAgents && now - _cacheTimestamp < CACHE_TTL_MS) {
|
|
return _cachedAgents;
|
|
}
|
|
|
|
// Merge built-in + custom definitions
|
|
const allDefs = [
|
|
...AGENT_DEFINITIONS.map((d) => ({ ...d, _custom: false })),
|
|
..._customAgentDefs.map((d) => ({ ...d, _custom: true })),
|
|
];
|
|
|
|
_cachedAgents = allDefs.map((def) => {
|
|
const { _custom, ...rest } = def;
|
|
return detectAgent(rest, _custom);
|
|
});
|
|
_cacheTimestamp = now;
|
|
|
|
return _cachedAgents;
|
|
}
|
|
|
|
/**
|
|
* Force refresh detection cache.
|
|
*/
|
|
export function refreshAgentCache(): CliAgentInfo[] {
|
|
_cachedAgents = null;
|
|
return detectInstalledAgents();
|
|
}
|
|
|
|
/**
|
|
* Get a specific agent by ID.
|
|
*/
|
|
export function getAgentById(id: string): CliAgentInfo | undefined {
|
|
const agents = detectInstalledAgents();
|
|
return agents.find((a) => a.id === id);
|
|
}
|
|
|
|
/**
|
|
* Check registration without probing every executable on PATH.
|
|
*
|
|
* Process lifecycle callers need an allowlist decision, not a fresh health
|
|
* scan. Keeping this lookup pure avoids making `spawn()` wait on one timeout
|
|
* per uninstalled agent while preserving detectInstalledAgents() for UI/status
|
|
* consumers.
|
|
*/
|
|
export function hasRegisteredAgent(id: string): boolean {
|
|
const normalized = String(id || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return (
|
|
AGENT_DEFINITIONS.some((agent) => agent.id === normalized) ||
|
|
_customAgentDefs.some((agent) => agent.id === normalized)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get agents that are installed and available for ACP.
|
|
*/
|
|
export function getAvailableAgents(): CliAgentInfo[] {
|
|
return detectInstalledAgents().filter((a) => a.installed);
|
|
}
|