40 Commits

Author SHA1 Message Date
stefano e523b34d1b fix: codex uses --yolo not --full-auto 2026-05-18 19:15:59 -05:00
stefano fd1d2c3e92 fix: invoke all CLI clients in full-access/yolo mode
- codex: --full-auto
- claude: --dangerously-skip-permissions
- opencode: --dangerously-skip-permissions
2026-05-18 19:15:04 -05:00
stefano d3aa92be0d fix: use 'opencode run' instead of bare prompt for OpenCode client 2026-05-18 19:06:34 -05:00
stefano 0e273b59ec chore: add ai_plan/ to .gitignore 2026-05-18 18:51:32 -05:00
stefano 2e884e49c8 merge M6 into implement/2026-05-18-stef-openclaw-skills 2026-05-18 18:44:07 -05:00
stefano 775a665eaa feat(M6): Documentation 2026-05-18 18:44:07 -05:00
stefano 2103c424f4 merge S-604 into M6 2026-05-18 18:43:58 -05:00
stefano 32f8a23700 merge S-603 into M6 2026-05-18 18:43:58 -05:00
stefano 480958f12e feat(S-603): Create docs/architecture.md 2026-05-18 18:43:58 -05:00
stefano c188f09684 feat(S-604): Update README.md and docs/README.md 2026-05-18 18:43:08 -05:00
stefano 7818e78244 merge S-602 into M6 2026-05-18 18:41:58 -05:00
stefano c35ffe8af5 merge S-601 into M6 2026-05-18 18:41:58 -05:00
stefano a6f855c9d9 feat(S-602): Create docs/installation.md 2026-05-18 18:41:58 -05:00
stefano 52675f6dc1 feat(S-601): Create docs/ai-cli-dispatch.md 2026-05-18 18:41:58 -05:00
stefano d87038204b merge M5 into implement/2026-05-18-stef-openclaw-skills 2026-05-18 18:39:33 -05:00
stefano 4f59258b20 feat(M5): CLI Integration 2026-05-18 18:39:33 -05:00
stefano 0879ffe39f merge M4 into implement/2026-05-18-stef-openclaw-skills 2026-05-18 18:14:48 -05:00
stefano fe7a015ca4 feat(M4): Natural Language Dispatch 2026-05-18 18:14:48 -05:00
stefano 0c9248d5ca merge S-401 into M4 2026-05-18 18:14:13 -05:00
stefano 7fa959d115 feat(S-401): Test-drive and implement src/dispatch.ts 2026-05-18 18:14:13 -05:00
stefano 50443373bd merge M3 into implement/2026-05-18-stef-openclaw-skills 2026-05-18 18:11:45 -05:00
stefano a2cfa7027e feat(M3): Direct Execution 2026-05-18 18:11:45 -05:00
stefano fe94629797 merge S-301 into M3 2026-05-18 18:01:51 -05:00
stefano a99041f910 feat(S-301): Test-drive and implement src/execute.ts 2026-05-18 18:01:51 -05:00
stefano 360e27d952 merge M2 into implement/2026-05-18-stef-openclaw-skills 2026-05-18 17:53:47 -05:00
stefano 82fcd3363c feat(M2): Client Detection & Configuration 2026-05-18 17:53:47 -05:00
stefano 185083ace8 merge S-203 into M2 2026-05-18 17:51:44 -05:00
stefano 167cdb6ffe merge S-202 into M2 2026-05-18 17:51:44 -05:00
stefano f3458734d4 feat(S-203): Test-drive and implement src/config.ts 2026-05-18 17:51:44 -05:00
stefano 2642c280a2 feat(S-202): Test-drive and implement src/detect.ts 2026-05-18 17:51:07 -05:00
stefano 8340933f8a merge M1 into implement/2026-05-18-stef-openclaw-skills 2026-05-18 17:45:27 -05:00
stefano 4629fe17de feat(M1): Project Scaffold 2026-05-18 17:45:27 -05:00
stefano 949bd05420 merge S-103 into M1 2026-05-18 17:40:10 -05:00
stefano 162517c0e0 feat(S-103): Create scripts/ai-cli-dispatch launcher 2026-05-18 17:40:10 -05:00
stefano 4a6cacb21d merge S-201 into M1 2026-05-18 17:38:35 -05:00
stefano 47f555a367 feat(S-201): Create src/types.ts with shared type definitions 2026-05-18 17:38:35 -05:00
stefano 445d9bfdee merge S-102 into M1 2026-05-18 17:36:31 -05:00
stefano 28e6bbba74 merge S-101 into M1 2026-05-18 17:36:31 -05:00
stefano 50928313a1 feat(S-102): Create package.json, tsconfig.json, .gitignore 2026-05-18 17:36:31 -05:00
stefano fb01334273 feat(S-101): Create SKILL.md with YAML frontmatter 2026-05-18 17:36:21 -05:00
23 changed files with 2358 additions and 0 deletions
+1
View File
@@ -1,2 +1,3 @@
.worktrees/
node_modules/
ai_plan/
+8
View File
@@ -11,6 +11,8 @@ This repository contains practical OpenClaw skills and companion integrations. I
- Per-skill runtime instructions: `skills/<skill-name>/SKILL.md`
- Integration implementation files: `integrations/<integration-name>/`
- Integration docs: `docs/*.md`
- Tool implementation files: `tools/<tool-name>/`
- Tool docs: `docs/*.md`
## Skills
@@ -34,6 +36,12 @@ This repository contains practical OpenClaw skills and companion integrations. I
| `google-maps` | Traffic-aware ETA and leave-by calculations using Google Maps APIs. | `integrations/google-maps` |
| `google-workspace` | Gmail and Google Calendar helper CLI for profile, mail, attachment-capable send, calendar search, and event creation. | `integrations/google-workspace` |
## Tools
| Tool | What it does | Path |
|---|---|---|
| `ai-cli-dispatch` | Dispatch AI CLI coding tasks to available clients (Codex, Claude Code, OpenCode) with automatic discovery, version checking, and execution. | `tools/ai-cli-dispatch` |
## Operator docs
| Doc | What it covers |
+6
View File
@@ -20,6 +20,12 @@ This folder contains detailed docs for each skill in this repository.
- [`google-maps`](google-maps.md) — Traffic-aware ETA and leave-by calculations via Google Maps APIs
- [`google-workspace`](google-workspace.md) — Gmail and Google Calendar helper CLI with attachment-capable send support
## Tools
- [`ai-cli-dispatch`](ai-cli-dispatch.md) — Dispatch AI CLI coding tasks to available clients with automatic discovery, version checking, and execution
- [`installation`](installation.md) — Prerequisites, install steps, PATH configuration, and optional config file setup for `ai-cli-dispatch`
- [`architecture`](architecture.md) — Design decisions, module breakdown, data flow, coexistence with ACP, and extension points for `ai-cli-dispatch`
## Operator Docs
- [`openclaw-acp-orchestration`](openclaw-acp-orchestration.md) — OpenClaw ACP orchestration for Codex and Claude Code on the gateway host
+258
View File
@@ -0,0 +1,258 @@
# ai-cli-dispatch
Dispatch AI CLI coding tasks to available clients (Codex, Claude Code, OpenCode) with automatic discovery, version checking, and execution.
## Scope
- discover installed AI CLI clients on the host
- report client versions and availability
- dispatch a prompt to a specific client by name
- auto-resolve the best client from prompt keywords
- forward arguments natively to each client
The tool is a lightweight sync-only dispatcher. It does not implement streaming, chat sessions, or ACP orchestration. For ACP-based harnesses, see `docs/openclaw-acp-orchestration.md`.
## Setup
From the repo or installed skill directory:
```bash
cd tools/ai-cli-dispatch
npm install
```
The dispatcher itself requires only Node.js 20+ and `npm`. The actual AI CLI clients (`codex`, `claude`, `opencode`) are discovered from the host `PATH`; they are not bundled.
## Commands
```bash
ai-cli-dispatch list [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
ai-cli-dispatch --help
```
### `list`
Discover and report all supported clients.
```bash
ai-cli-dispatch list --json
```
Example JSON output:
```json
[
{
"name": "codex",
"path": "/usr/local/bin/codex",
"version": "1.2.3",
"found": true
},
{
"name": "claude",
"found": false
},
{
"name": "opencode",
"path": "/opt/homebrew/bin/opencode",
"version": "0.5.1",
"found": true
}
]
```
Use `--text` for human-readable checkmarks:
```bash
ai-cli-dispatch list --text
```
### `run`
Execute a prompt directly through a named client.
```bash
ai-cli-dispatch run --client codex --prompt "refactor this function"
ai-cli-dispatch run --client claude --prompt "add tests for auth middleware"
ai-cli-dispatch run --client opencode --prompt "migrate to ESM"
```
The prompt is forwarded with each clients native argument shape:
| Client | Arguments passed |
|---|---|
| `codex` | `exec "<prompt>"` |
| `claude` | `-p "<prompt>"` |
| `opencode` | `"<prompt>"` |
### `dispatch`
Auto-resolve the client from prompt keywords, then execute.
```bash
ai-cli-dispatch dispatch "use claude to write tests"
ai-cli-dispatch dispatch "codex refactor auth module"
ai-cli-dispatch dispatch "opencode migrate to ESM"
```
Keyword matching is case-insensitive and ordered:
1. `--client` flag (highest precedence)
2. `"open code"` (spaced variant) → `opencode`
3. `"claude"``claude`
4. `"codex"``codex`
5. `"opencode"``opencode`
6. `defaultClient` from config (lowest precedence)
Override auto-resolution explicitly:
```bash
ai-cli-dispatch dispatch "fix the bug" --client claude
```
## Client Discovery
Discovery searches `PATH` in this order for each client name:
1. `codex` — OpenAI Codex CLI
2. `claude` — Anthropic Claude Code
3. `opencode` — OpenCode CLI
The search uses `which` (or `where` on Windows) first, then falls back to a manual `PATH` directory scan. If a binary is found, `--version` is invoked to extract a semver string.
## Configuration
Optional config file:
```text
~/.openclaw/ai-cli-dispatch.json
```
Example:
```json
{
"paths": {
"codex": "/usr/local/bin/codex",
"claude": "/opt/homebrew/bin/claude"
},
"defaultClient": "claude"
}
```
Resolution priority for paths and default client (highest to lowest):
1. CLI flag (`--client`, `--codex-path`, etc.)
2. Environment variable (`AI_CLI_CODEX_PATH`, `AI_CLI_DEFAULT_CLIENT`, etc.)
3. Config file (`paths`, `defaultClient`)
4. `which` / `where` discovery
Supported env vars:
| Variable | Purpose |
|---|---|
| `AI_CLI_CODEX_PATH` | Override `codex` binary path |
| `AI_CLI_CLAUDE_PATH` | Override `claude` binary path |
| `AI_CLI_OPENCODE_PATH` | Override `opencode` binary path |
| `AI_CLI_DEFAULT_CLIENT` | Override default client (`codex`, `claude`, or `opencode`) |
## Output Model
Default output is JSON. Use `--text` to stream raw `stdout`/`stderr` directly.
JSON success shape (`run` and `dispatch`):
```json
{
"stdout": "...",
"stderr": "...",
"exitCode": 0
}
```
JSON error shape:
```json
{
"error": "..."
}
```
Exit codes:
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Missing/unknown command, missing argument, unknown client, resolution failure, or execution error |
## Error Handling Guidance
### `Client "<name>" not found or not installed`
Meaning: the requested client binary is not on `PATH` and not overridden by config.
Actions:
1. Confirm the client is installed (`codex --version`, `claude --version`, etc.)
2. Check that its directory is on `PATH`
3. Or override the path in `~/.openclaw/ai-cli-dispatch.json`
### `Prompt cannot be empty`
Meaning: the prompt string was empty or whitespace-only.
Action: supply a non-empty `--prompt` or positional prompt argument.
### `Execution timed out after 300000ms`
Meaning: the client subprocess did not finish within the default 5-minute timeout.
Action: the client may be waiting for interactive input or the task is too large. Break the prompt into smaller pieces, or run the client directly to diagnose.
### `Could not resolve client from prompt`
Meaning: `dispatch` found no matching keyword and no `defaultClient` is configured.
Action: include a client name in the prompt (e.g., `"use claude to ..."`) or set `defaultClient` in config.
## Common Flows
### Check what is installed
```bash
ai-cli-dispatch list --json
```
### Run a quick task through a specific client
```bash
ai-cli-dispatch run --client codex --prompt "fix lint errors in src/app.ts"
```
### Let the tool pick the client from the prompt
```bash
ai-cli-dispatch dispatch "claude: add unit tests for utils.ts"
```
### Force a client when the prompt is ambiguous
```bash
ai-cli-dispatch dispatch "review this PR" --client claude
```
## Coexistence with ACP
`ai-cli-dispatch` is a direct subprocess dispatcher. It runs the client binary synchronously and returns its output. It is not an ACP agent and does not participate in ACP orchestration.
- Use `ai-cli-dispatch` when you need a quick, local, one-shot CLI execution.
- Use ACP (`docs/openclaw-acp-orchestration.md`) when you need session-bound coding harnesses with thread context, multi-turn review, or orchestrator-managed verification gates.
## Implementation Notes
- The dispatcher is TypeScript/Node.js with a single external dependency (`minimist`).
- Client arguments are hardcoded per tool to match each clients stable CLI contract.
- The default timeout is 5 minutes (`300_000` ms).
- On Windows, discovery uses `where` instead of `which` and `.exe` extensions are assumed.
+213
View File
@@ -0,0 +1,213 @@
# 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
```text
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:
1. Invoke `which <name>` (or `where <name>` on Windows).
2. If that fails, walk `PATH` segments manually and test `existsSync()`.
3. If a binary is found, run `<binary> --version` and 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):
1. **CLI flags**`--codex-path`, `--claude-path`, `--opencode-path`, `--default-client`
2. **Environment variables**`AI_CLI_CODEX_PATH`, `AI_CLI_CLAUDE_PATH`, `AI_CLI_OPENCODE_PATH`, `AI_CLI_DEFAULT_CLIENT`
3. **Config file**`~/.openclaw/ai-cli-dispatch.json` (`paths` and `defaultClient` keys)
4. **PATH discovery**`which`/`where` fallback via `defaultWhichSync()`
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:
1. If `config.client` is a valid `ClientName`, return it immediately.
2. Lower-case the prompt and scan for substrings in order:
- `"open code"``opencode`
- `"claude"``claude`
- `"codex"``codex`
- `"opencode"``opencode`
3. If no keyword matches, return `config.defaultClient` or `null`.
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:
1. Reject empty or whitespace-only prompts with `ExecError`.
2. Validate that an explicit `clientPath` exists on disk (if provided).
3. Map the client to its native argument array via `CLIENT_ARGS`:
- `codex``["exec", prompt]`
- `claude``["-p", prompt]`
- `opencode``[prompt]`
4. `spawn()` the process with `shell: false`.
5. Buffer `stdout` and `stderr` via `"data"` listeners.
6. Start a `setTimeout`; if it fires, `child.kill()` is sent.
7. On `close`, resolve with `{ stdout, stderr, exitCode }`.
8. On `error`, reject with `ClientNotFoundError` for `ENOENT` or `ExecError` for anything else.
9. On timeout, reject with `ExecError` containing 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-dispatch` when 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 ACPs 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 callers 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:
1. **`src/types.ts`** — Add the new name to the `ClientName` union type.
2. **`src/constants.ts`** — Append the new name to `CLIENT_NAMES`.
3. **`src/execute.ts`** — Add an entry to `CLIENT_ARGS` with the clients native argument shape.
4. **`src/config.ts`** — No change required; the existing loop over `CLIENT_NAMES` automatically picks up the new env/flag/file keys.
5. **`src/dispatch.ts`** — Add a keyword check for the new client in `resolveClient`. Decide its precedence relative to existing keywords.
6. **Tests** — Add colocated test cases in `tests/dispatch.test.ts`, `tests/execute.test.ts`, and `tests/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`:
```typescript
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 `onData` so 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.
+219
View File
@@ -0,0 +1,219 @@
# Installation
This page covers installing the `ai-cli-dispatch` tool, its prerequisites, and post-install verification.
## Prerequisites
- **Node.js** ≥ 20 (required for `tsx`, `import` attributes, and modern `node:child_process` APIs)
- **npm** (bundled with Node.js)
- **Homebrew** (macOS/Linux) — recommended for installing the underlying AI CLI clients (`codex`, `claude`, `opencode`)
- One or more supported AI CLI clients:
- [Codex CLI](https://github.com/openai/codex)
- [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
- [OpenCode](https://github.com/opencode-ai/opencode)
## Install the tool
### 1. Clone the repository
```bash
git clone <repository-url> ~/.openclaw/workspace/skills/ai-cli-dispatch
cd ~/.openclaw/workspace/skills/ai-cli-dispatch
```
If you already have the full `stef-openclaw-skills` repo checked out, use the path inside it instead:
```bash
cd ~/.openclaw/workspace/skills/stef-openclaw-skills/tools/ai-cli-dispatch
```
### 2. Install Node dependencies
```bash
npm install
```
This installs:
- `tsx` — TypeScript execution runtime
- `minimist` — argument parsing
- `typescript` — type checking
## PATH configuration
The helper script lives at `scripts/ai-cli-dispatch`. Add it to your shell PATH so OpenClaw (or your terminal) can invoke it without a full path:
```bash
# ~/.zshrc or ~/.bashrc
export PATH="$HOME/.openclaw/workspace/skills/ai-cli-dispatch/scripts:$PATH"
```
Reload your shell:
```bash
source ~/.zshrc # or ~/.bashrc
```
Verify the script is reachable:
```bash
which ai-cli-dispatch
ai-cli-dispatch --help
```
## Optional configuration file
Create `~/.openclaw/ai-cli-dispatch.json` to customize client paths and set a default client:
```bash
mkdir -p ~/.openclaw
$EDITOR ~/.openclaw/ai-cli-dispatch.json
```
Example configuration:
```json
{
"paths": {
"codex": "/opt/homebrew/bin/codex",
"claude": "/opt/homebrew/bin/claude",
"opencode": "/opt/homebrew/bin/opencode"
},
"defaultClient": "claude"
}
```
### Configuration precedence
When resolving a client binary, the tool checks sources in this order (first match wins):
1. CLI flag: `--codex-path`, `--claude-path`, `--opencode-path`
2. Environment variable: `AI_CLI_CODEX_PATH`, `AI_CLI_CLAUDE_PATH`, `AI_CLI_OPENCODE_PATH`
3. File config: `paths.<client>` in `~/.openclaw/ai-cli-dispatch.json`
4. System `PATH` lookup via `which` / `where`
The `defaultClient` follows the same precedence:
1. CLI flag: `--default-client`
2. Environment variable: `AI_CLI_DEFAULT_CLIENT`
3. File config: `defaultClient` in `~/.openclaw/ai-cli-dispatch.json`
## Install AI CLI clients
### Codex
```bash
npm install -g @openai/codex
```
### Claude Code
```bash
npm install -g @anthropic-ai/claude-code
```
### OpenCode
```bash
npm install -g @opencode-ai/opencode
```
Or via Homebrew where formulas are available:
```bash
brew install codex # if available in your tap
brew install claude-code # if available in your tap
```
## Verification
### 1. Check local tool health
```bash
ai-cli-dispatch --help
```
Expected output:
```text
AI CLI Dispatch
Usage:
ai-cli-dispatch list [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
ai-cli-dispatch --help
Clients: codex, claude, opencode
```
### 2. List discovered clients
```bash
ai-cli-dispatch list --json
```
Example output when two clients are installed:
```json
[
{
"name": "codex",
"path": "/opt/homebrew/bin/codex",
"version": "1.2.3",
"found": true
},
{
"name": "claude",
"path": "/opt/homebrew/bin/claude",
"version": "0.7.8",
"found": true
},
{
"name": "opencode",
"found": false
}
]
```
### 3. Run a quick dispatch
```bash
ai-cli-dispatch run --client codex --prompt "hello" --json
```
This should return a JSON result with `stdout`, `stderr`, and `exitCode`.
### 4. Test keyword dispatch
```bash
ai-cli-dispatch dispatch "refactor this using claude"
```
The tool inspects the prompt for client keywords (`claude`, `codex`, `opencode`, `open code`) and routes to the matching client.
## Troubleshooting
### `Missing local Node dependencies for ai-cli-dispatch`
Run `npm install` from the skill directory:
```bash
cd ~/.openclaw/workspace/skills/ai-cli-dispatch
npm install
```
### `Client "codex" not found or not installed`
- Ensure the client is installed globally or via Homebrew
- Verify it is on your PATH: `which codex`
- Or override the path in `~/.openclaw/ai-cli-dispatch.json` or with an environment variable
### `Prompt cannot be empty`
The `run` and `dispatch` commands require a non-empty `--prompt` or trailing prompt text.
### Config file is not being read
- Verify the file is at exactly `~/.openclaw/ai-cli-dispatch.json`
- Check for JSON syntax errors (trailing commas are not allowed)
- Use `--debug` for deeper troubleshooting if supported by the calling context
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+51
View File
@@ -0,0 +1,51 @@
---
name: ai-cli-dispatch
description: Dispatch AI CLI coding tasks to available clients (Codex, Claude Code, OpenCode) with automatic discovery, version checking, and execution.
metadata: {"clawdbot":{"emoji":"robot","requires":{"bins":["node"]}}}
---
# AI CLI Dispatch
Use this skill when the user wants to run a coding task through an AI CLI client such as Codex, Claude Code, or OpenCode.
The skill discovers installed clients, resolves versions, selects the best available tool, and forwards the task with arguments intact.
Use the local helper from the installed skill directory:
```bash
cd ~/.openclaw/workspace/skills/ai-cli-dispatch
scripts/ai-cli-dispatch --help
```
## Setup
```bash
cd ~/.openclaw/workspace/skills/ai-cli-dispatch
npm install
```
## Commands
```bash
scripts/ai-cli-dispatch list --json
scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function"
scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware"
scripts/ai-cli-dispatch exec --client opencode --prompt "migrate to ESM"
```
Use `--json` for machine-readable command output.
## Client Discovery
The skill searches for the following clients in order:
- `codex` — OpenAI Codex CLI
- `claude` — Anthropic Claude Code
- `opencode` — OpenCode CLI
Run `list` to see which clients are installed and their resolved versions.
## Output Rules
- Normal JSON output redacts local file paths and credential metadata.
- Use `--debug` only when deeper troubleshooting requires internal paths and resolved config metadata.
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ai-cli-dispatch",
"version": "1.0.0",
"description": "AI CLI dispatch tool for OpenClaw skills",
"type": "module",
"scripts": {
"ai-cli-dispatch": "tsx src/cli.ts",
"test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"minimist": "^1.2.8"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.8.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const skillDir = resolve(scriptDir, "..");
const tsxBin = join(skillDir, "node_modules", ".bin", "tsx");
if (!existsSync(tsxBin)) {
process.stderr.write(`Missing local Node dependencies for ai-cli-dispatch. Run 'cd ${skillDir} && npm install' first.\n`);
process.exit(1);
}
const result = spawnSync(process.execPath, [tsxBin, join(skillDir, "src", "cli.ts"), ...process.argv.slice(2)], {
stdio: "inherit"
});
if (result.error) {
process.stderr.write(`${result.error.message}\n`);
process.exit(1);
}
process.exit(typeof result.status === "number" ? result.status : 1);
+206
View File
@@ -0,0 +1,206 @@
import minimist from "minimist";
import { detectClients as realDetectClients } from "./detect.js";
import { executePrompt as realExecutePrompt } from "./execute.js";
import { resolveClient as realResolveClient } from "./dispatch.js";
import { resolveConfig as realResolveConfig } from "./config.js";
import { CLIENT_NAMES } from "./constants.js";
import {
type ClientName,
type ClientInfo,
type ExecResult,
ClientNotFoundError,
} from "./types.js";
export interface CliDeps {
detectClients?: () => ClientInfo[];
executePrompt?: (
client: ClientName,
prompt: string
) => Promise<ExecResult>;
resolveClient?: (
prompt: string,
config?: { client?: ClientName; defaultClient?: ClientName }
) => ClientName | null;
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName };
stdoutWrite?: (chunk: string) => void;
stderrWrite?: (chunk: string) => void;
}
function printHelp(): void {
console.log(`AI CLI Dispatch
Usage:
ai-cli-dispatch list [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
ai-cli-dispatch --help
Clients: codex, claude, opencode`);
}
export async function main(
argv: string[],
deps: CliDeps = {}
): Promise<number> {
const detectClients = deps.detectClients ?? realDetectClients;
const executePrompt = deps.executePrompt ?? realExecutePrompt;
const resolveClient = deps.resolveClient ?? realResolveClient;
const resolveConfig = deps.resolveConfig ?? realResolveConfig;
const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c));
const stderrWrite = deps.stderrWrite ?? ((c: string) => process.stderr.write(c));
const rawArgs = argv.slice(2);
const parseArgs =
rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs;
const args = minimist(parseArgs, {
string: ["client", "prompt"],
boolean: ["json", "text", "help", "debug"],
alias: { h: "help" },
});
const jsonMode = !args.text;
if (args.help) {
printHelp();
return 0;
}
const command = args._[0];
if (!command) {
printHelp();
return 1;
}
if (command === "list") {
const clients = detectClients();
if (jsonMode) {
console.log(JSON.stringify(clients, null, 2));
} else {
for (const c of clients) {
const status = c.found
? `${c.version ?? "unknown version"}`
: "✗ not found";
console.log(`${c.name}: ${status}`);
}
}
return 0;
}
if (command === "run") {
const client = args.client as ClientName | undefined;
const prompt = args.prompt as string | undefined;
if (!client || !CLIENT_NAMES.includes(client)) {
const message = !client
? "--client is required"
: `Unknown client: ${client}`;
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
}
if (!prompt) {
const message = "--prompt is required";
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
}
try {
const result = await executePrompt(client, prompt);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.stdout) stdoutWrite(result.stdout);
if (result.stderr) stderrWrite(result.stderr);
}
return 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
}
return 1;
}
}
if (command === "dispatch") {
let prompt = args.prompt as string | undefined;
if (!prompt && args._.length > 1) {
prompt = args._.slice(1).join(" ");
}
if (!prompt) {
const message = "prompt is required";
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
}
const config = resolveConfig();
const explicitClient = args.client as ClientName | undefined;
const client = resolveClient(prompt, {
client: explicitClient,
defaultClient: config.defaultClient,
});
if (!client) {
const message = "Could not resolve client from prompt";
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
}
try {
const result = await executePrompt(client, prompt);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.stdout) stdoutWrite(result.stdout);
if (result.stderr) stderrWrite(result.stderr);
}
return 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
}
return 1;
}
}
const message = `Unknown command: ${command}`;
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
}
return 1;
}
const isMain =
import.meta.url.startsWith("file://") &&
!!process.argv[1] &&
import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/"));
if (isMain) {
main(process.argv).then((code) => process.exit(code));
}
+93
View File
@@ -0,0 +1,93 @@
import { homedir } from "node:os";
import {
readFileSync as realReadFileSync,
existsSync as realExistsSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import type { ClientName } from "./types.js";
import { CLIENT_NAMES, isWindows } from "./constants.js";
export interface ResolvedConfig {
paths: Partial<Record<ClientName, string>>;
defaultClient?: ClientName;
}
export interface ResolveConfigOptions {
flags?: Record<string, string | boolean | undefined>;
env?: NodeJS.ProcessEnv;
homeDir?: string;
readFileSync?: (path: string, encoding: BufferEncoding) => string;
existsSync?: (path: string) => boolean;
whichSync?: (cmd: string) => string | undefined;
}
function defaultWhichSync(cmd: string): string | undefined {
const result = spawnSync(isWindows() ? "where" : "which", [cmd], {
encoding: "utf-8",
});
if (result.status === 0) {
return result.stdout.trim().split("\n")[0];
}
return undefined;
}
export function resolveConfig(
options: ResolveConfigOptions = {}
): ResolvedConfig {
const {
flags = {},
env = process.env,
homeDir = homedir(),
readFileSync = realReadFileSync,
existsSync = realExistsSync,
whichSync = defaultWhichSync,
} = options;
const configPath = `${homeDir}/.openclaw/ai-cli-dispatch.json`;
let fileConfig: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
fileConfig = JSON.parse(readFileSync(configPath, "utf-8"));
} catch {
fileConfig = {};
}
}
const filePaths = (fileConfig.paths ?? {}) as Partial<
Record<ClientName, string>
>;
const fileDefault = fileConfig.defaultClient as ClientName | undefined;
const paths: Partial<Record<ClientName, string>> = {};
for (const name of CLIENT_NAMES) {
const flagKey = `${name}-path`;
const envKey = `AI_CLI_${name.toUpperCase()}_PATH`;
const resolved =
(typeof flags[flagKey] === "string"
? (flags[flagKey] as string)
: undefined) ??
env[envKey] ??
filePaths[name] ??
whichSync(name);
if (resolved !== undefined) {
paths[name] = resolved;
}
}
const defaultClient =
(typeof flags["default-client"] === "string"
? (flags["default-client"] as string)
: undefined) ??
env.AI_CLI_DEFAULT_CLIENT ??
fileDefault;
const result: ResolvedConfig = { paths };
if (
defaultClient !== undefined &&
CLIENT_NAMES.includes(defaultClient as ClientName)
) {
result.defaultClient = defaultClient as ClientName;
}
return result;
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClientName } from "./types.js";
export const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"];
export function isWindows(): boolean {
return process.platform === "win32";
}
+70
View File
@@ -0,0 +1,70 @@
import { spawnSync as defaultSpawnSync } from "node:child_process";
import { existsSync as defaultExistsSync } from "node:fs";
import { join } from "node:path";
import { type ClientName, type ClientInfo } from "./types.js";
import { CLIENT_NAMES, isWindows } from "./constants.js";
export interface DetectOptions {
pathEnv?: string;
spawnSync?: typeof defaultSpawnSync;
existsSync?: typeof defaultExistsSync;
}
function parseVersion(stdout: string): string | undefined {
const match = stdout.match(/v?\d+\.\d+\.\d+(?:[-+.]\w+)*/);
return match ? match[0].replace(/^v/, "") : undefined;
}
function findBinary(
name: string,
pathEnv: string,
spawnSyncImpl: typeof defaultSpawnSync,
existsSyncImpl: typeof defaultExistsSync
): string | undefined {
// Try system `which` / `where` first
const whichResult = spawnSyncImpl(isWindows() ? "where" : "which", [name], {
encoding: "utf-8",
env: { ...process.env, PATH: pathEnv },
});
if (whichResult.status === 0) {
const line = whichResult.stdout.trim().split("\n")[0];
if (line) return line;
}
// Fallback: walk PATH directories manually
const sep = isWindows() ? ";" : ":";
const ext = isWindows() ? ".exe" : "";
const dirs = pathEnv.split(sep);
for (const dir of dirs) {
if (!dir) continue;
const fullPath = join(dir, name + ext);
if (existsSyncImpl(fullPath)) {
return fullPath;
}
}
return undefined;
}
export function detectClients(options?: DetectOptions): ClientInfo[] {
const spawnSyncImpl = options?.spawnSync ?? defaultSpawnSync;
const existsSyncImpl = options?.existsSync ?? defaultExistsSync;
const pathEnv = options?.pathEnv ?? process.env.PATH ?? "";
return CLIENT_NAMES.map((name) => {
const path = findBinary(name, pathEnv, spawnSyncImpl, existsSyncImpl);
if (!path) {
return { name, found: false };
}
const versionResult = spawnSyncImpl(path, ["--version"], {
encoding: "utf-8",
});
const version =
versionResult.status === 0
? parseVersion(versionResult.stdout)
: undefined;
return { name, path, version, found: true };
});
}
+39
View File
@@ -0,0 +1,39 @@
import type { ClientName } from "./types.js";
export interface DispatchConfig {
defaultClient?: ClientName;
client?: ClientName;
}
const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"];
export function resolveClient(
prompt: string,
config?: DispatchConfig
): ClientName | null {
// Explicit --client flag takes highest precedence
if (config?.client && CLIENT_NAMES.includes(config.client)) {
return config.client;
}
const lower = prompt.toLowerCase();
// Check for "open code" before "opencode" to handle the spaced variant
if (lower.includes("open code")) {
return "opencode";
}
if (lower.includes("claude")) {
return "claude";
}
if (lower.includes("codex")) {
return "codex";
}
if (lower.includes("opencode")) {
return "opencode";
}
return config?.defaultClient ?? null;
}
+111
View File
@@ -0,0 +1,111 @@
import type { ChildProcess } from "node:child_process";
import { spawn as defaultSpawn } from "node:child_process";
import type { PathLike } from "node:fs";
import { existsSync as defaultExistsSync } from "node:fs";
import type { ClientName, ExecResult } from "./types.js";
import { ClientNotFoundError, ExecError } from "./types.js";
const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
codex: (p) => ["exec", "--yolo", p],
claude: (p) => ["-p", p, "--dangerously-skip-permissions"],
opencode: (p) => ["run", "--dangerously-skip-permissions", p],
};
export interface ExecuteOptions {
clientPath?: string;
timeoutMs?: number;
spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess;
existsSync?: (path: PathLike) => boolean;
}
export async function executePrompt(
client: ClientName,
prompt: string,
options: ExecuteOptions = {}
): Promise<ExecResult> {
if (prompt.trim() === "") {
throw new ExecError("Prompt cannot be empty", {
stdout: "",
stderr: "",
exitCode: -1,
});
}
const spawnImpl = options.spawn ?? defaultSpawn;
const existsSyncImpl = options.existsSync ?? defaultExistsSync;
const timeoutMs = options.timeoutMs ?? 300_000;
const command = options.clientPath ?? client;
if (options.clientPath && !existsSyncImpl(options.clientPath)) {
throw new ClientNotFoundError(client);
}
const argBuilder = (CLIENT_ARGS as Record<string, (prompt: string) => string[]>)[client];
if (!argBuilder) {
throw new ExecError(`Unknown client: ${client}`, {
stdout: "",
stderr: "",
exitCode: -1,
});
}
const args = argBuilder(prompt);
return new Promise((resolve, reject) => {
let settled = false;
let timedOut = false;
let stdout = "";
let stderr = "";
const child = spawnImpl(command, args, {
shell: false,
});
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
const timeout = setTimeout(() => {
timedOut = true;
child.kill();
}, timeoutMs);
function settle(
err?: Error | undefined,
result?: ExecResult | undefined
): void {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (err) reject(err);
else resolve(result!);
}
child.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
settle(new ClientNotFoundError(client));
} else {
settle(
new ExecError(err.message, { stdout, stderr, exitCode: -1 })
);
}
});
child.on("close", (code: number | null) => {
if (timedOut) {
settle(
new ExecError(`Execution timed out after ${timeoutMs}ms`, {
stdout,
stderr,
exitCode: -1,
})
);
} else {
settle(undefined, { stdout, stderr, exitCode: code ?? -1 });
}
});
});
}
+36
View File
@@ -0,0 +1,36 @@
export type ClientName = "codex" | "claude" | "opencode";
export interface ClientInfo {
name: ClientName;
path?: string;
version?: string;
found: boolean;
}
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface ToolConfig {
clients: ClientName[];
defaultClient?: ClientName;
}
export class ClientNotFoundError extends Error {
constructor(client: string) {
super(`Client "${client}" not found or not installed.`);
this.name = "ClientNotFoundError";
}
}
export class ExecError extends Error {
readonly result: ExecResult;
constructor(message: string, result: ExecResult) {
super(message);
this.name = "ExecError";
this.result = result;
}
}
+403
View File
@@ -0,0 +1,403 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { main } from "../src/cli.js";
import { ClientNotFoundError } from "../src/types.js";
import type { ClientInfo, ExecResult, ClientName } from "../src/types.js";
function captureOutput() {
const logs: string[] = [];
const errors: string[] = [];
const origLog = console.log;
const origError = console.error;
console.log = (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
};
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
return {
logs,
errors,
restore() {
console.log = origLog;
console.error = origError;
},
};
}
describe("main", () => {
const mockClients: ClientInfo[] = [
{ name: "codex", found: true, path: "/usr/bin/codex", version: "1.0.0" },
{ name: "claude", found: true, path: "/usr/bin/claude", version: "2.0.0" },
{ name: "opencode", found: false },
];
const mockResult: ExecResult = {
stdout: "output",
stderr: "",
exitCode: 0,
};
it("returns 0 for --help and prints usage", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
assert.ok(out.logs.some((l) => l.includes("Usage:")));
} finally {
out.restore();
}
});
it("prints usage and returns 1 for bare invocation", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts"]);
assert.strictEqual(code, 1);
assert.ok(out.logs.some((l) => l.includes("Usage:")));
} finally {
out.restore();
}
});
it("returns 1 for unknown command", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "bogus"]);
assert.strictEqual(code, 1);
const err = out.errors[0] ?? out.logs[0];
assert.ok(String(err).includes("Unknown command"));
} finally {
out.restore();
}
});
it("list prints JSON array of clients by default", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "list"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 0);
assert.strictEqual(out.logs.length, 1);
const parsed = JSON.parse(out.logs[0]);
assert.deepStrictEqual(parsed, mockClients);
} finally {
out.restore();
}
});
it("list prints human-readable output with --text", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "list", "--text"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 0);
assert.ok(out.logs.some((l) => l.includes("codex:")));
assert.ok(out.logs.some((l) => l.includes("claude:")));
assert.ok(out.logs.some((l) => l.includes("opencode:")));
} finally {
out.restore();
}
});
it("run executes client with prompt and prints JSON result", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed.length, 1);
assert.strictEqual(executed[0].client, "codex");
assert.strictEqual(executed[0].prompt, "hello");
const parsed = JSON.parse(out.logs[0]);
assert.deepStrictEqual(parsed, mockResult);
} finally {
out.restore();
}
});
it("run prints text output with --text", async () => {
const out = captureOutput();
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
try {
const code = await main(
[
"node",
"cli.ts",
"run",
"--client",
"codex",
"--prompt",
"hello",
"--text",
],
{
detectClients: () => mockClients,
executePrompt: async () => ({
stdout: "hello-out",
stderr: "hello-err",
exitCode: 0,
}),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdoutChunks.join(""), "hello-out");
assert.strictEqual(stderrChunks.join(""), "hello-err");
} finally {
out.restore();
}
});
it("run returns 1 and prints error JSON when client is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "run", "--prompt", "hello"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("client"));
} finally {
out.restore();
}
});
it("run returns 1 and prints error text when client is missing in text mode", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--prompt", "hello", "--text"],
{
detectClients: () => mockClients,
}
);
assert.strictEqual(code, 1);
assert.ok(out.errors[0].includes("client"));
} finally {
out.restore();
}
});
it("run returns 1 and prints error JSON when prompt is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "run", "--client", "codex"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("prompt"));
} finally {
out.restore();
}
});
it("run returns 1 for unknown client", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--client", "bogus", "--prompt", "hello"],
{
detectClients: () => mockClients,
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("client"));
} finally {
out.restore();
}
});
it("run returns 1 and prints error JSON when client not found", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
executePrompt: async () => {
throw new ClientNotFoundError("codex");
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("dispatch routes positional prompt and prints JSON result", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "use claude to write tests"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {} }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed.length, 1);
assert.strictEqual(executed[0].client, "claude");
assert.strictEqual(executed[0].prompt, "use claude to write tests");
const parsed = JSON.parse(out.logs[0]);
assert.deepStrictEqual(parsed, mockResult);
} finally {
out.restore();
}
});
it("dispatch routes --prompt flag and prints JSON result", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed.length, 1);
assert.strictEqual(executed[0].prompt, "use codex to refactor");
} finally {
out.restore();
}
});
it("dispatch respects --client override", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--client", "opencode"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
resolveClient: (_p, cfg) => cfg?.client ?? null,
resolveConfig: () => ({ paths: {}, defaultClient: "claude" }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed[0].client, "opencode");
} finally {
out.restore();
}
});
it("dispatch returns 1 when prompt cannot be resolved", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
executePrompt: async () => mockResult,
resolveClient: () => null,
resolveConfig: () => ({ paths: {} }),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("resolve"));
} finally {
out.restore();
}
});
it("dispatch returns 1 when resolved client is not found", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
executePrompt: async () => {
throw new ClientNotFoundError("claude");
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {} }),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("dispatch returns 1 when no prompt is given", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "dispatch"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("prompt"));
} finally {
out.restore();
}
});
it("dispatch prints text output with --text", async () => {
const out = captureOutput();
const stdoutChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--text"],
{
detectClients: () => mockClients,
executePrompt: async () => ({
stdout: "done",
stderr: "",
exitCode: 0,
}),
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdoutChunks.join(""), "done");
} finally {
out.restore();
}
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { resolveConfig } from "../src/config.js";
describe("resolveConfig", () => {
it("returns empty config when no sources are present", () => {
const config = resolveConfig({
existsSync: () => false,
whichSync: () => undefined,
});
assert.deepStrictEqual(config.paths, {});
assert.strictEqual(config.defaultClient, undefined);
});
it("loads config from file", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () =>
JSON.stringify({
paths: { codex: "/file/codex", claude: "/file/claude" },
defaultClient: "claude",
}),
whichSync: () => undefined,
});
assert.strictEqual(config.paths.codex, "/file/codex");
assert.strictEqual(config.paths.claude, "/file/claude");
assert.strictEqual(config.defaultClient, "claude");
});
it("overrides file config with env vars", () => {
const config = resolveConfig({
env: { AI_CLI_CODEX_PATH: "/env/codex" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ paths: { codex: "/file/codex" } }),
whichSync: () => undefined,
});
assert.strictEqual(config.paths.codex, "/env/codex");
});
it("overrides env vars with CLI flags", () => {
const config = resolveConfig({
flags: { "codex-path": "/flag/codex" },
env: { AI_CLI_CODEX_PATH: "/env/codex" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ paths: { codex: "/file/codex" } }),
whichSync: () => undefined,
});
assert.strictEqual(config.paths.codex, "/flag/codex");
});
it("falls back to PATH detection when config file is missing", () => {
const config = resolveConfig({
existsSync: () => false,
whichSync: (cmd) =>
cmd === "opencode" ? "/usr/bin/opencode" : undefined,
});
assert.strictEqual(config.paths.opencode, "/usr/bin/opencode");
assert.strictEqual(config.paths.codex, undefined);
assert.strictEqual(config.paths.claude, undefined);
});
it("respects full priority ordering: flag > env > file > PATH", () => {
const config = resolveConfig({
flags: {
"codex-path": "/flag/codex",
"claude-path": "/flag/claude",
"opencode-path": "/flag/opencode",
"default-client": "opencode",
},
env: {
AI_CLI_CODEX_PATH: "/env/codex",
AI_CLI_CLAUDE_PATH: "/env/claude",
AI_CLI_OPENCODE_PATH: "/env/opencode",
AI_CLI_DEFAULT_CLIENT: "claude",
},
existsSync: () => true,
readFileSync: () =>
JSON.stringify({
paths: {
codex: "/file/codex",
claude: "/file/claude",
opencode: "/file/opencode",
},
defaultClient: "codex",
}),
whichSync: (cmd) => `/path/${cmd}`,
});
assert.strictEqual(config.paths.codex, "/flag/codex");
assert.strictEqual(config.paths.claude, "/flag/claude");
assert.strictEqual(config.paths.opencode, "/flag/opencode");
assert.strictEqual(config.defaultClient, "opencode");
});
it("uses env var for default client when no flag is given", () => {
const config = resolveConfig({
env: { AI_CLI_DEFAULT_CLIENT: "claude" },
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.defaultClient, "claude");
});
it("uses file default client when no env var is given", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ defaultClient: "codex" }),
whichSync: () => undefined,
});
assert.strictEqual(config.defaultClient, "codex");
});
it("ignores invalid defaultClient from file", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ defaultClient: "foo" }),
whichSync: () => undefined,
});
assert.strictEqual(config.defaultClient, undefined);
});
it("ignores invalid defaultClient from env var", () => {
const config = resolveConfig({
env: { AI_CLI_DEFAULT_CLIENT: "bar" },
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.defaultClient, undefined);
});
it("ignores invalid defaultClient from flag", () => {
const config = resolveConfig({
flags: { "default-client": "baz" },
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.defaultClient, undefined);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { detectClients } from "../src/detect.js";
function mockSpawnSync(
responses: Map<string, { status: number; stdout: string }>
): any {
return (cmd: string, args: string[], _opts: unknown) => {
const key = [cmd, ...args].join(" ");
const hit = responses.get(key);
if (hit) {
return { status: hit.status, stdout: hit.stdout, stderr: "" };
}
return { status: 1, stdout: "", stderr: "" };
};
}
function mockExistsSync(
allowedPaths: Set<string>
): any {
return (p: string) => allowedPaths.has(p);
}
describe("detectClients", () => {
it("returns all three clients when found on PATH", () => {
const responses = new Map([
["which codex", { status: 0, stdout: "/usr/local/bin/codex\n" }],
["which claude", { status: 0, stdout: "/usr/local/bin/claude\n" }],
["which opencode", { status: 0, stdout: "/usr/local/bin/opencode\n" }],
["/usr/local/bin/codex --version", { status: 0, stdout: "1.0.0\n" }],
["/usr/local/bin/claude --version", { status: 0, stdout: "1.0.0\n" }],
["/usr/local/bin/opencode --version", { status: 0, stdout: "1.0.0\n" }],
]);
const clients = detectClients({
pathEnv: "/usr/local/bin",
spawnSync: mockSpawnSync(responses),
existsSync: () => false,
});
assert.strictEqual(clients.length, 3);
for (const c of clients) {
assert.strictEqual(c.found, true);
assert.ok(c.path?.includes(c.name));
assert.strictEqual(c.version, "1.0.0");
}
});
it("returns found: false for a missing client", () => {
const responses = new Map([
["which codex", { status: 0, stdout: "/usr/local/bin/codex\n" }],
["which claude", { status: 0, stdout: "/usr/local/bin/claude\n" }],
["which opencode", { status: 1, stdout: "" }],
["/usr/local/bin/codex --version", { status: 0, stdout: "1.0.0\n" }],
["/usr/local/bin/claude --version", { status: 0, stdout: "1.0.0\n" }],
]);
const clients = detectClients({
pathEnv: "/usr/local/bin",
spawnSync: mockSpawnSync(responses),
existsSync: () => false,
});
assert.strictEqual(clients.length, 3);
const opencode = clients.find((c) => c.name === "opencode");
assert.ok(opencode);
assert.strictEqual(opencode!.found, false);
assert.strictEqual(opencode!.path, undefined);
assert.strictEqual(opencode!.version, undefined);
const codex = clients.find((c) => c.name === "codex");
assert.ok(codex);
assert.strictEqual(codex!.found, true);
});
it("returns found: false for all when none are on PATH", () => {
const responses = new Map<string, { status: number; stdout: string }>();
const clients = detectClients({
pathEnv: "/usr/local/bin",
spawnSync: mockSpawnSync(responses),
existsSync: () => false,
});
assert.strictEqual(clients.length, 3);
for (const c of clients) {
assert.strictEqual(c.found, false);
assert.strictEqual(c.path, undefined);
assert.strictEqual(c.version, undefined);
}
});
it("handles PATH with duplicate entries via fallback walk", () => {
const responses = new Map<string, { status: number; stdout: string }>();
// which fails for all, so fallback walk is used
const allowedPaths = new Set([
"/usr/bin/codex",
"/usr/bin/claude",
"/usr/bin/opencode",
]);
const clients = detectClients({
pathEnv: "/usr/bin:/usr/bin:/usr/local/bin",
spawnSync: mockSpawnSync(responses),
existsSync: mockExistsSync(allowedPaths),
});
assert.strictEqual(clients.length, 3);
for (const c of clients) {
assert.strictEqual(c.found, true);
assert.strictEqual(c.path, `/usr/bin/${c.name}`);
}
});
it("parses version string from noisy output", () => {
const responses = new Map([
["which codex", { status: 0, stdout: "/opt/bin/codex\n" }],
["which claude", { status: 0, stdout: "/opt/bin/claude\n" }],
["which opencode", { status: 0, stdout: "/opt/bin/opencode\n" }],
[
"/opt/bin/codex --version",
{ status: 0, stdout: "codex version 1.2.3 (build abc)\n" },
],
[
"/opt/bin/claude --version",
{ status: 0, stdout: "Claude Code 0.4.5-beta\n" },
],
["/opt/bin/opencode --version", { status: 0, stdout: "v2.0.0\n" }],
]);
const clients = detectClients({
pathEnv: "/opt/bin",
spawnSync: mockSpawnSync(responses),
existsSync: () => false,
});
assert.strictEqual(clients.find((c) => c.name === "codex")!.version, "1.2.3");
assert.strictEqual(
clients.find((c) => c.name === "claude")!.version,
"0.4.5-beta"
);
assert.strictEqual(clients.find((c) => c.name === "opencode")!.version, "2.0.0");
});
});
@@ -0,0 +1,83 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { resolveClient } from "../src/dispatch.js";
import type { ClientName } from "../src/types.js";
describe("resolveClient", () => {
it('returns "codex" when prompt contains "use codex"', () => {
const result = resolveClient("use codex to refactor this");
assert.strictEqual(result, "codex");
});
it('returns "claude" when prompt contains "tell claude to..."', () => {
const result = resolveClient("tell claude to review my code");
assert.strictEqual(result, "claude");
});
it('returns "opencode" when prompt contains "run with opencode"', () => {
const result = resolveClient("run with opencode");
assert.strictEqual(result, "opencode");
});
it('returns "opencode" when prompt contains "open code"', () => {
const result = resolveClient("open code this file");
assert.strictEqual(result, "opencode");
});
it("returns null when no keyword matches and no default is configured", () => {
const result = resolveClient("hello world");
assert.strictEqual(result, null);
});
it("returns defaultClient when no keyword matches", () => {
const result = resolveClient("hello world", { defaultClient: "claude" });
assert.strictEqual(result, "claude");
});
it("prefers --client flag over keyword parsing", () => {
const result = resolveClient("use codex for this", { client: "claude" });
assert.strictEqual(result, "claude");
});
it("prefers --client flag over defaultClient", () => {
const result = resolveClient("hello world", {
client: "opencode",
defaultClient: "codex",
});
assert.strictEqual(result, "opencode");
});
it("handles uppercase CODEX", () => {
const result = resolveClient("Use CODEX please");
assert.strictEqual(result, "codex");
});
it("handles mixed-case Claude", () => {
const result = resolveClient("Tell Claude to fix this");
assert.strictEqual(result, "claude");
});
it("returns first match when multiple clients are mentioned", () => {
const result = resolveClient("ask claude or codex to help");
assert.strictEqual(result, "claude");
});
it("returns null for empty prompt with no default", () => {
const result = resolveClient("");
assert.strictEqual(result, null);
});
it("returns defaultClient for empty prompt", () => {
const result = resolveClient("", { defaultClient: "codex" });
assert.strictEqual(result, "codex");
});
it("validates --client flag value", () => {
// Invalid client flag should fall back to keyword/default behavior
const result = resolveClient("use codex", {
client: "invalid" as ClientName,
});
// If client flag is invalid, we should fall back to keyword matching
assert.strictEqual(result, "codex");
});
});
+206
View File
@@ -0,0 +1,206 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { executePrompt } from "../src/execute.js";
import { ClientNotFoundError, ExecError } from "../src/types.js";
import type { ClientName } from "../src/types.js";
interface MockScenario {
stdout?: string;
stderr?: string;
exitCode?: number;
error?: NodeJS.ErrnoException;
hang?: boolean;
}
function createMockChildProcess(scenario: MockScenario): any {
const child = new EventEmitter() as any;
child.stdout = Readable.from(
scenario.stdout !== undefined ? [scenario.stdout] : []
);
child.stderr = Readable.from(
scenario.stderr !== undefined ? [scenario.stderr] : []
);
child.killed = false;
child.kill = () => {
child.killed = true;
process.nextTick(() => {
child.emit("exit", null, "SIGTERM");
child.emit("close", null, "SIGTERM");
});
return true;
};
let stdoutEnded = scenario.stdout === undefined;
let stderrEnded = scenario.stderr === undefined;
child.stdout.on("end", () => {
stdoutEnded = true;
maybeClose();
});
child.stderr.on("end", () => {
stderrEnded = true;
maybeClose();
});
function maybeClose() {
if (stdoutEnded && stderrEnded && !scenario.hang) {
child.emit("exit", scenario.exitCode ?? 0, null);
child.emit("close", scenario.exitCode ?? 0, null);
}
}
process.nextTick(() => {
if (scenario.error) {
child.emit("error", scenario.error);
}
});
return child;
}
function mockSpawn(
scenarios: Map<string, MockScenario>
): any {
return (cmd: string, args: string[], _opts: any): any => {
const key = [cmd, ...args].join(" ");
const scenario = scenarios.get(key);
if (!scenario) {
return createMockChildProcess({
error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }),
});
}
return createMockChildProcess(scenario);
};
}
function mockExistsSync(allowedPaths: Set<string>): any {
return (p: string) => allowedPaths.has(p);
}
describe("executePrompt", () => {
it("captures stdout for a successful codex execution", async () => {
const scenarios = new Map<string, MockScenario>([
['codex exec --yolo "hello world"', { stdout: "result\n", exitCode: 0 }],
]);
const result = await executePrompt("codex", '"hello world"', {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.stdout, "result\n");
assert.strictEqual(result.stderr, "");
assert.strictEqual(result.exitCode, 0);
});
it("captures stderr for a successful claude execution", async () => {
const scenarios = new Map<string, MockScenario>([
["claude -p hello --dangerously-skip-permissions", { stdout: "", stderr: "warning\n", exitCode: 0 }],
]);
const result = await executePrompt("claude", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.stdout, "");
assert.strictEqual(result.stderr, "warning\n");
assert.strictEqual(result.exitCode, 0);
});
it("returns non-zero exit code without throwing", async () => {
const scenarios = new Map<string, MockScenario>([
["opencode run --dangerously-skip-permissions fail", { stdout: "", stderr: "error\n", exitCode: 1 }],
]);
const result = await executePrompt("opencode", "fail", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.exitCode, 1);
assert.strictEqual(result.stderr, "error\n");
});
it("throws ClientNotFoundError when binary emits ENOENT", async () => {
const scenarios = new Map<string, MockScenario>();
await assert.rejects(
executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
}),
(err: unknown) => err instanceof ClientNotFoundError
);
});
it("throws ClientNotFoundError when explicit clientPath does not exist", async () => {
await assert.rejects(
executePrompt("claude", "hello", {
clientPath: "/nonexistent/claude",
existsSync: mockExistsSync(new Set()),
}),
(err: unknown) => err instanceof ClientNotFoundError
);
});
it("rejects with ExecError when timeout is exceeded", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo slow", { hang: true }],
]);
await assert.rejects(
executePrompt("codex", "slow", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
timeoutMs: 10,
}),
(err: unknown) =>
err instanceof ExecError &&
err.message.includes("timed out") &&
err.result.exitCode === -1
);
});
it("passes prompts with special characters unchanged", async () => {
const scenarios = new Map<string, MockScenario>([
[
'codex exec --yolo "quotes\nnewlines"',
{ stdout: "ok", exitCode: 0 },
],
]);
const result = await executePrompt("codex", '"quotes\nnewlines"', {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.stdout, "ok");
assert.strictEqual(result.exitCode, 0);
});
it("rejects empty prompt", async () => {
await assert.rejects(
executePrompt("codex", "", {
spawn: mockSpawn(new Map()),
existsSync: () => true,
}),
(err: unknown) =>
err instanceof ExecError && err.message.includes("empty")
);
});
it("rejects whitespace-only prompt", async () => {
await assert.rejects(
executePrompt("claude", " ", {
spawn: mockSpawn(new Map()),
existsSync: () => true,
}),
(err: unknown) =>
err instanceof ExecError && err.message.includes("empty")
);
});
it("throws ExecError for invalid client at runtime", async () => {
await assert.rejects(
executePrompt("bogus" as unknown as ClientName, "hello", {
spawn: mockSpawn(new Map()),
existsSync: () => true,
}),
(err: unknown) =>
err instanceof ExecError && err.message.includes("Unknown client")
);
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "./dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}