11 KiB
ai-cli-dispatch Architecture
This document describes the internal design of ai-cli-dispatch, the module breakdown, data flow, key design decisions, and how to extend the tool.
Module Breakdown
src/
├── cli.ts — Entry point: argument parsing, command routing, I/O formatting
├── types.ts — Shared types and error classes
├── constants.ts — Client name registry and platform helpers
├── config.ts — Layered configuration resolution (flags → env → file → PATH)
├── detect.ts — Client discovery: binary lookup and version extraction
├── dispatch.ts — Prompt-to-client resolution (explicit flag → keywords → default)
└── execute.ts — Subprocess spawning, stdout/stderr capture, timeout handling
Responsibilities
| Module | Responsibility |
|---|---|
cli.ts |
Parses argv with minimist, routes to list / run / dispatch, prints JSON or text output, and controls the process exit code. |
types.ts |
Defines ClientName, ClientInfo, ExecResult, ToolConfig, and the error hierarchy (ClientNotFoundError, ExecError). |
constants.ts |
Holds the canonical CLIENT_NAMES array and isWindows() helper used by discovery and config. |
config.ts |
Resolves per-client binary paths and the optional defaultClient from four layered sources. |
detect.ts |
Locates each client binary on PATH, falls back to a manual directory scan, and invokes --version to extract a semver string. |
dispatch.ts |
Chooses the target client from a prompt string using ordered keyword matching, with overrides for explicit --client and defaultClient. |
execute.ts |
Spawns the chosen client with its native argument shape, buffers stdout/stderr, enforces a timeout, and returns an ExecResult or throws a typed error. |
Data Flow
A typical dispatch invocation flows through four stages:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ detect │ ──► │ config │ ──► │ dispatch │ ──► │ execute │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
which/where flags/env/file keyword scan spawn child
PATH walk defaultClient --client override capture output
--version fallback default timeout / exitCode
1. Detect
detectClients() iterates over CLIENT_NAMES and attempts to locate each binary:
- Invoke
which <name>(orwhere <name>on Windows). - If that fails, walk
PATHsegments manually and testexistsSync(). - If a binary is found, run
<binary> --versionand parse the first semver-like match.
Result: an array of ClientInfo objects with name, found, path, and version.
2. Config
resolveConfig() builds a ResolvedConfig by layering sources (highest to lowest precedence):
- CLI flags —
--codex-path,--claude-path,--opencode-path,--default-client - Environment variables —
AI_CLI_CODEX_PATH,AI_CLI_CLAUDE_PATH,AI_CLI_OPENCODE_PATH,AI_CLI_DEFAULT_CLIENT - Config file —
~/.openclaw/ai-cli-dispatch.json(pathsanddefaultClientkeys) - PATH discovery —
which/wherefallback viadefaultWhichSync()
Only values for the three known ClientName entries are accepted; unknown defaultClient values are ignored.
3. Dispatch
resolveClient(prompt, config) decides which client to use:
- If
config.clientis a validClientName, return it immediately. - Lower-case the prompt and scan for substrings in order:
"open code"→opencode"claude"→claude"codex"→codex"opencode"→opencode
- If no keyword matches, return
config.defaultClientornull.
This ordering intentionally prioritizes "open code" before "opencode" so the spaced natural-language variant wins.
4. Execute
executePrompt(client, prompt, options) runs the selected client:
- Reject empty or whitespace-only prompts with
ExecError. - Validate that an explicit
clientPathexists on disk (if provided). - Map the client to its native argument array via
CLIENT_ARGS:codex→["exec", prompt]claude→["-p", prompt]opencode→[prompt]
spawn()the process withshell: false.- Buffer
stdoutandstderrvia"data"listeners. - Start a
setTimeout; if it fires,child.kill()is sent. - On
close, resolve with{ stdout, stderr, exitCode }. - On
error, reject withClientNotFoundErrorforENOENTorExecErrorfor anything else. - On timeout, reject with
ExecErrorcontaining the buffered output so far.
The default timeout is 5 minutes (300_000 ms).
Design Decisions
Coexistence with ACP
ai-cli-dispatch is intentionally not an ACP agent. It is a thin, local subprocess wrapper with no session state, no thread binding, and no orchestrator protocol.
- Use
ai-cli-dispatchwhen you need a quick, one-shot CLI execution on the gateway host. - Use ACP (
docs/openclaw-acp-orchestration.md) when you need session-bound coding harnesses, multi-turn review, or orchestrator-managed verification gates.
This separation keeps the dispatcher small and avoids duplicating ACP’s scheduling, context persistence, and review-loop responsibilities.
Keyword Dispatch vs NLP
Client resolution uses deterministic substring matching instead of natural-language parsing or an LLM call.
Rationale:
- Speed: No network round-trip or model load; resolution is synchronous and sub-millisecond.
- Predictability: The same prompt always resolves to the same client. There is no temperature, context window, or model-version drift.
- Debuggability: A user can read the ordered keyword list and know exactly why a given prompt resolved to a given client.
- Scope fit: The dispatcher only needs to distinguish three clients. A full NLP pipeline would be overkill.
The trade-off is that prompts like "compare codex and claude" resolve to codex because "codex" is checked first. Users can always override with --client.
Sync-Only Initial Release
The current implementation is entirely synchronous from the caller’s perspective: executePrompt returns a promise that resolves only when the child process exits or the timeout fires.
Rationale:
- The primary use case is one-shot tasks (refactor, add tests, migrate) where the agent needs the complete output before proceeding.
- Streaming would require a different output contract (callbacks, generators, or an event emitter) and complicates the JSON error model.
- ACP already covers interactive, streaming, and session-based use cases.
Streaming is an intentional future extension point (see below).
Error Taxonomy
All runtime failures are represented as typed errors so callers and tests can branch precisely:
| Error | When thrown | Data carried |
|---|---|---|
ClientNotFoundError |
Binary not on PATH, explicit clientPath missing, or ENOENT from spawn |
message with client name |
ExecError |
Empty prompt, unknown client, timeout, non-ENOENT spawn error, or child exit |
message + full ExecResult (stdout, stderr, exitCode) |
ExecError carries the ExecResult so that timeout handlers still return partial output. This avoids losing buffered stdout/stderr when a long-running task is killed.
Injection-Friendly Module Boundaries
Every non-trivial module accepts an options bag with injectable dependencies (spawnSync, spawn, existsSync, whichSync, readFileSync, etc.).
Rationale:
- Unit tests can run without touching the real filesystem,
PATH, or subprocess layer. - The CLI itself injects its real dependencies through default parameters, so production behavior is unchanged.
- There is no global mocking required; each test provides its own narrow fakes.
Minimal Dependency Surface
The runtime dependency graph contains exactly one external package: minimist (argument parsing). Everything else uses Node.js built-ins (child_process, fs, os, path).
Rationale:
- Reduces supply-chain risk and install time.
- Avoids version-lock issues across Node.js 20+ environments.
- Keeps the compiled/bundled footprint negligible for a tool that is often installed as a sidecar.
Extension Points
Adding a New Client
To support a fourth (or fifth) AI CLI client, change four files in src/ and the corresponding tests:
src/types.ts— Add the new name to theClientNameunion type.src/constants.ts— Append the new name toCLIENT_NAMES.src/execute.ts— Add an entry toCLIENT_ARGSwith the client’s native argument shape.src/config.ts— No change required; the existing loop overCLIENT_NAMESautomatically picks up the new env/flag/file keys.src/dispatch.ts— Add a keyword check for the new client inresolveClient. Decide its precedence relative to existing keywords.- Tests — Add colocated test cases in
tests/dispatch.test.ts,tests/execute.test.ts, andtests/detect.test.ts.
No changes are needed in cli.ts because it iterates over CLIENT_NAMES for validation.
Streaming Support
If a future use case requires real-time output (e.g., long-running codegen with progressive feedback), the cleanest extension is to add an optional onData callback to ExecuteOptions:
export interface ExecuteOptions {
clientPath?: string;
timeoutMs?: number;
spawn?: ...;
existsSync?: ...;
onData?: (chunk: string, stream: "stdout" | "stderr") => void;
}
When onData is provided, executePrompt would:
- Continue buffering internally for the final
ExecResult. - Also emit each chunk through
onDataso the caller can stream to a UI or logger. - Reject/resolve with the same error taxonomy.
This preserves backward compatibility: existing callers that omit onData receive the exact same buffered ExecResult they get today.
Platform Backends
The current Windows support is limited to discovery (where instead of which, .exe extension assumptions). If future clients require platform-specific spawn options (e.g., PowerShell quoting rules), the extension point is CLIENT_ARGS or a new CLIENT_SPAWN_OPTIONS record keyed by ClientName.
Testing Strategy
The test suite in tests/ mirrors the src/ structure:
| Test file | Coverage |
|---|---|
cli.test.ts |
Argument parsing, command routing, JSON/text output modes, exit codes, error formatting |
config.test.ts |
Layered precedence of flags, env, file, and which fallback; malformed JSON tolerance |
detect.test.ts |
which success/failure, PATH directory fallback, version parsing, missing binary handling |
dispatch.test.ts |
Keyword matching, case insensitivity, --client precedence, defaultClient fallback, invalid flag handling |
execute.test.ts |
Successful execution, stderr capture, non-zero exit codes, ENOENT → ClientNotFoundError, timeout, empty prompt rejection, special-character preservation |
All tests use injected mocks; no test spawns real client binaries or reads the real filesystem.