|
| 1 | +/** |
| 2 | + * Best-effort local launcher for coding-agent CLIs surfaced in the config UI. |
| 3 | + * |
| 4 | + * The command for each agent is taken from a fixed allowlist keyed by the |
| 5 | + * agent id, so no user-controlled string is ever executed. Every child process |
| 6 | + * is spawned via `execFile` (array args, no shell) to avoid injection. |
| 7 | + */ |
| 8 | +import { execFile } from "node:child_process"; |
| 9 | + |
| 10 | +/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */ |
| 11 | +export const AGENT_COMMANDS: Record<string, string> = { |
| 12 | + "claude-code": "claude", |
| 13 | + "qwen-code": "qwen", |
| 14 | + opencode: "opencode", |
| 15 | + openclaw: "openclaw", |
| 16 | + hermes: "hermes", |
| 17 | + codex: "codex", |
| 18 | +}; |
| 19 | + |
| 20 | +/** The launch binary for a known agent id, or undefined when unknown. */ |
| 21 | +export function agentCommand(id: string): string | undefined { |
| 22 | + return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined; |
| 23 | +} |
| 24 | + |
| 25 | +/** |
| 26 | + * Per-agent argv that passes an initial task prompt while keeping the agent |
| 27 | + * interactive in the terminal. Only verified contracts are listed; an agent |
| 28 | + * absent here cannot be dispatched a prompt (its bare launch still works). |
| 29 | + * - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive) |
| 30 | + * - claude-code: `claude "<prompt>"` (positional initial prompt) |
| 31 | + * - codex: `codex "<prompt>"` (positional initial prompt) |
| 32 | + */ |
| 33 | +const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = { |
| 34 | + "qwen-code": (p) => ["-i", p], |
| 35 | + "claude-code": (p) => [p], |
| 36 | + codex: (p) => [p], |
| 37 | +}; |
| 38 | + |
| 39 | +/** Whether a known agent supports being dispatched an initial task prompt. */ |
| 40 | +export function agentSupportsPrompt(id: string): boolean { |
| 41 | + return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id); |
| 42 | +} |
| 43 | + |
| 44 | +/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */ |
| 45 | +function onPath(bin: string): Promise<boolean> { |
| 46 | + const cmd = process.platform === "win32" ? "where" : "which"; |
| 47 | + return new Promise((resolve) => { |
| 48 | + execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err)); |
| 49 | + }); |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Whether a known agent can actually be quick-launched right now: its id maps to |
| 54 | + * a launch binary and that binary is reachable on PATH. Unknown ids resolve to |
| 55 | + * false. Used to gate the UI's Quick launch button so "Connected" agents whose |
| 56 | + * CLI is not installed do not offer a launch that would immediately fail. |
| 57 | + */ |
| 58 | +export function agentLaunchable(id: string): Promise<boolean> { |
| 59 | + const command = agentCommand(id); |
| 60 | + if (!command) return Promise.resolve(false); |
| 61 | + return onPath(command); |
| 62 | +} |
| 63 | + |
| 64 | +/** Single-quote a path for a POSIX shell command line. */ |
| 65 | +function shQuote(p: string): string { |
| 66 | + return `'${p.replace(/'/g, "'\\''")}'`; |
| 67 | +} |
| 68 | + |
| 69 | +/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */ |
| 70 | +function spawnTerminal(command: string, cwd: string): Promise<void> { |
| 71 | + const platform = process.platform; |
| 72 | + return new Promise((resolve, reject) => { |
| 73 | + if (platform === "darwin") { |
| 74 | + const inner = `cd ${shQuote(cwd)} && ${command}`; |
| 75 | + const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); |
| 76 | + const args = [ |
| 77 | + "-e", |
| 78 | + `tell application "Terminal" to do script "${escaped}"`, |
| 79 | + "-e", |
| 80 | + 'tell application "Terminal" to activate', |
| 81 | + ]; |
| 82 | + execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve())); |
| 83 | + return; |
| 84 | + } |
| 85 | + if (platform === "win32") { |
| 86 | + const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`]; |
| 87 | + execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve())); |
| 88 | + return; |
| 89 | + } |
| 90 | + // Linux / other: best-effort via the distro's default terminal emulator. |
| 91 | + const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`; |
| 92 | + execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) => |
| 93 | + err ? reject(new Error("No supported terminal emulator was found")) : resolve(), |
| 94 | + ); |
| 95 | + }); |
| 96 | +} |
| 97 | + |
| 98 | +export interface LaunchResult { |
| 99 | + launched: boolean; |
| 100 | + command: string; |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Launch a known coding agent's local CLI in a new terminal window. When |
| 105 | + * `prompt` is provided, it is passed as a single quoted argument using the |
| 106 | + * agent's verified prompt contract so the agent starts with that task. |
| 107 | + * Rejects when the id is unknown, the binary is missing from PATH, the agent |
| 108 | + * does not support prompt dispatch, or the platform terminal could not open. |
| 109 | + */ |
| 110 | +export async function launchAgent( |
| 111 | + id: string, |
| 112 | + cwd: string = process.cwd(), |
| 113 | + prompt?: string, |
| 114 | +): Promise<LaunchResult> { |
| 115 | + const command = agentCommand(id); |
| 116 | + if (!command) throw new Error(`Unknown agent: ${id}`); |
| 117 | + if (!(await onPath(command))) { |
| 118 | + throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`); |
| 119 | + } |
| 120 | + let fullCommand = command; |
| 121 | + const task = (prompt ?? "").trim(); |
| 122 | + if (task) { |
| 123 | + const build = AGENT_PROMPT_ARGV[id]; |
| 124 | + if (!build) throw new Error(`${id} does not support dispatching a task prompt.`); |
| 125 | + // shQuote keeps the whole prompt as one shell argument (no injection); the |
| 126 | + // platform terminal layer escapes the resulting command line separately. |
| 127 | + fullCommand = [command, ...build(task).map(shQuote)].join(" "); |
| 128 | + } |
| 129 | + await spawnTerminal(fullCommand, cwd); |
| 130 | + return { launched: true, command: fullCommand }; |
| 131 | +} |
0 commit comments