Compare commits
40 Commits
b3875858c7
...
e523b34d1b
| Author | SHA1 | Date | |
|---|---|---|---|
| e523b34d1b | |||
| fd1d2c3e92 | |||
| d3aa92be0d | |||
| 0e273b59ec | |||
| 2e884e49c8 | |||
| 775a665eaa | |||
| 2103c424f4 | |||
| 32f8a23700 | |||
| 480958f12e | |||
| c188f09684 | |||
| 7818e78244 | |||
| c35ffe8af5 | |||
| a6f855c9d9 | |||
| 52675f6dc1 | |||
| d87038204b | |||
| 4f59258b20 | |||
| 0879ffe39f | |||
| fe7a015ca4 | |||
| 0c9248d5ca | |||
| 7fa959d115 | |||
| 50443373bd | |||
| a2cfa7027e | |||
| fe94629797 | |||
| a99041f910 | |||
| 360e27d952 | |||
| 82fcd3363c | |||
| 185083ace8 | |||
| 167cdb6ffe | |||
| f3458734d4 | |||
| 2642c280a2 | |||
| 8340933f8a | |||
| 4629fe17de | |||
| 949bd05420 | |||
| 162517c0e0 | |||
| 4a6cacb21d | |||
| 47f555a367 | |||
| 445d9bfdee | |||
| 28e6bbba74 | |||
| 50928313a1 | |||
| fb01334273 |
@@ -1,2 +1,3 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
ai_plan/
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ This repository contains practical OpenClaw skills and companion integrations. I
|
|||||||
- Per-skill runtime instructions: `skills/<skill-name>/SKILL.md`
|
- Per-skill runtime instructions: `skills/<skill-name>/SKILL.md`
|
||||||
- Integration implementation files: `integrations/<integration-name>/`
|
- Integration implementation files: `integrations/<integration-name>/`
|
||||||
- Integration docs: `docs/*.md`
|
- Integration docs: `docs/*.md`
|
||||||
|
- Tool implementation files: `tools/<tool-name>/`
|
||||||
|
- Tool docs: `docs/*.md`
|
||||||
|
|
||||||
## Skills
|
## 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-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` |
|
| `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
|
## Operator docs
|
||||||
|
|
||||||
| Doc | What it covers |
|
| Doc | What it covers |
|
||||||
|
|||||||
@@ -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-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
|
- [`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
|
## Operator Docs
|
||||||
|
|
||||||
- [`openclaw-acp-orchestration`](openclaw-acp-orchestration.md) — OpenClaw ACP orchestration for Codex and Claude Code on the gateway host
|
- [`openclaw-acp-orchestration`](openclaw-acp-orchestration.md) — OpenClaw ACP orchestration for Codex and Claude Code on the gateway host
|
||||||
|
|||||||
@@ -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 client’s 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 client’s 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.
|
||||||
@@ -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 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:
|
||||||
|
|
||||||
|
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 client’s 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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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);
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user