From fb013342735a28a9a8627dbf5ea62cd03a4ea965 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:36:21 -0500 Subject: [PATCH 1/5] feat(S-101): Create SKILL.md with YAML frontmatter --- tools/ai-cli-dispatch/SKILL.md | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tools/ai-cli-dispatch/SKILL.md diff --git a/tools/ai-cli-dispatch/SKILL.md b/tools/ai-cli-dispatch/SKILL.md new file mode 100644 index 0000000..4d7efa4 --- /dev/null +++ b/tools/ai-cli-dispatch/SKILL.md @@ -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. From 50928313a13d3a323aa4faab006b659c5581546e Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:36:31 -0500 Subject: [PATCH 2/5] feat(S-102): Create package.json, tsconfig.json, .gitignore --- tools/ai-cli-dispatch/.gitignore | 3 +++ tools/ai-cli-dispatch/package.json | 20 ++++++++++++++++++++ tools/ai-cli-dispatch/tsconfig.json | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 tools/ai-cli-dispatch/.gitignore create mode 100644 tools/ai-cli-dispatch/package.json create mode 100644 tools/ai-cli-dispatch/tsconfig.json diff --git a/tools/ai-cli-dispatch/.gitignore b/tools/ai-cli-dispatch/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/tools/ai-cli-dispatch/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/tools/ai-cli-dispatch/package.json b/tools/ai-cli-dispatch/package.json new file mode 100644 index 0000000..0fdd00b --- /dev/null +++ b/tools/ai-cli-dispatch/package.json @@ -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" + } +} diff --git a/tools/ai-cli-dispatch/tsconfig.json b/tools/ai-cli-dispatch/tsconfig.json new file mode 100644 index 0000000..4f5d71e --- /dev/null +++ b/tools/ai-cli-dispatch/tsconfig.json @@ -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"] +} From 47f555a367169fd5e535da394d92d8a624299c87 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:38:35 -0500 Subject: [PATCH 3/5] feat(S-201): Create src/types.ts with shared type definitions --- tools/ai-cli-dispatch/src/types.ts | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tools/ai-cli-dispatch/src/types.ts diff --git a/tools/ai-cli-dispatch/src/types.ts b/tools/ai-cli-dispatch/src/types.ts new file mode 100644 index 0000000..3338814 --- /dev/null +++ b/tools/ai-cli-dispatch/src/types.ts @@ -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; + } +} From 162517c0e0bd64ad7255f15a83e289bb065c7c6c Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:40:10 -0500 Subject: [PATCH 4/5] feat(S-103): Create scripts/ai-cli-dispatch launcher --- tools/ai-cli-dispatch/scripts/ai-cli-dispatch | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100755 tools/ai-cli-dispatch/scripts/ai-cli-dispatch diff --git a/tools/ai-cli-dispatch/scripts/ai-cli-dispatch b/tools/ai-cli-dispatch/scripts/ai-cli-dispatch new file mode 100755 index 0000000..431d97b --- /dev/null +++ b/tools/ai-cli-dispatch/scripts/ai-cli-dispatch @@ -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); From 4629fe17de0ffb38d5c411ed46098255c4c28baf Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:45:27 -0500 Subject: [PATCH 5/5] feat(M1): Project Scaffold --- tools/ai-cli-dispatch/src/cli.ts | 152 ++++++++++++++++++++++++ tools/ai-cli-dispatch/tests/cli.test.ts | 66 ++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tools/ai-cli-dispatch/src/cli.ts create mode 100644 tools/ai-cli-dispatch/tests/cli.test.ts diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts new file mode 100644 index 0000000..b9fbbc9 --- /dev/null +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -0,0 +1,152 @@ +import minimist from "minimist"; +import { spawnSync } from "node:child_process"; +import { + type ClientName, + type ClientInfo, + type ExecResult, + ClientNotFoundError, +} from "./types.js"; + +const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; +const VERSION_FLAGS: Record = { + codex: "--version", + claude: "--version", + opencode: "--version", +}; + +function whichSync(cmd: string): string | undefined { + const isWin = process.platform === "win32"; + const result = spawnSync(isWin ? "where" : "which", [cmd], { + encoding: "utf-8", + }); + if (result.status === 0) { + return result.stdout.trim().split("\n")[0]; + } + return undefined; +} + +export function discoverClients(): ClientInfo[] { + return CLIENT_NAMES.map((name) => { + const path = whichSync(name); + if (!path) { + return { name, found: false }; + } + const versionResult = spawnSync(name, [VERSION_FLAGS[name]], { + encoding: "utf-8", + }); + const version = + versionResult.status === 0 ? versionResult.stdout.trim() : undefined; + return { name, path, version, found: true }; + }); +} + +export function execClient( + client: ClientName, + prompt: string, + clientPath?: string +): ExecResult { + const path = + clientPath ?? discoverClients().find((c) => c.name === client)?.path; + if (!path) { + throw new ClientNotFoundError(client); + } + const result = spawnSync(client, [prompt], { + encoding: "utf-8", + stdio: ["inherit", "pipe", "pipe"], + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: typeof result.status === "number" ? result.status : 1, + }; +} + +function printHelp(): void { + console.log(`AI CLI Dispatch + +Usage: + ai-cli-dispatch list [--json] + ai-cli-dispatch exec --client --prompt + ai-cli-dispatch --help + +Clients: codex, claude, opencode`); +} + +export async function main(argv: string[]): Promise { + const rawArgs = argv.slice(2); + // When run via tsx, argv[2] is the script path (e.g. src/cli.ts); skip it. + const parseArgs = + rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; + + const args = minimist(parseArgs, { + string: ["client", "prompt"], + boolean: ["json", "help", "debug"], + alias: { h: "help" }, + }); + + if (args.help) { + printHelp(); + return 0; + } + + const command = args._[0]; + + if (command === "list") { + const clients = discoverClients(); + if (args.json) { + 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 === "exec") { + const client = args.client as ClientName | undefined; + const prompt = args.prompt as string | undefined; + if (!client || !CLIENT_NAMES.includes(client)) { + console.error( + `Error: --client must be one of ${CLIENT_NAMES.join(", ")}` + ); + return 1; + } + if (!prompt) { + console.error("Error: --prompt is required"); + return 1; + } + try { + const result = execClient(client, prompt); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + return result.exitCode; + } catch (err) { + if (err instanceof ClientNotFoundError) { + console.error(err.message); + return 1; + } + throw err; + } + } + + if (command) { + console.error(`Unknown command: ${command}`); + } else { + console.error("Error: no command given"); + } + printHelp(); + 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)); +} diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts new file mode 100644 index 0000000..39fa9c5 --- /dev/null +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { main, discoverClients, execClient } from "../src/cli.js"; +import { ClientNotFoundError } from "../src/types.js"; + +describe("discoverClients", () => { + it("returns all three configured clients", () => { + const clients = discoverClients(); + assert.strictEqual(clients.length, 3); + const names = clients.map((c) => c.name); + assert.deepStrictEqual(names, ["codex", "claude", "opencode"]); + }); + + it("returns ClientInfo with found boolean", () => { + const clients = discoverClients(); + for (const c of clients) { + assert.strictEqual(typeof c.found, "boolean"); + } + }); +}); + +describe("main", () => { + it("returns 0 for --help", async () => { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + }); + + it("returns 0 for list", async () => { + const code = await main(["node", "cli.ts", "list"]); + assert.strictEqual(code, 0); + }); + + it("returns 0 for list --json", async () => { + const code = await main(["node", "cli.ts", "list", "--json"]); + assert.strictEqual(code, 0); + }); + + it("returns 1 for exec without --client", async () => { + const code = await main(["node", "cli.ts", "exec", "--prompt", "hello"]); + assert.strictEqual(code, 1); + }); + + it("returns 1 for exec without --prompt", async () => { + const code = await main(["node", "cli.ts", "exec", "--client", "codex"]); + assert.strictEqual(code, 1); + }); + + it("returns 1 for unknown command", async () => { + const code = await main(["node", "cli.ts", "bogus"]); + assert.strictEqual(code, 1); + }); + + it("returns 1 when no command is given", async () => { + const code = await main(["node", "cli.ts"]); + assert.strictEqual(code, 1); + }); +}); + +describe("execClient", () => { + it("throws ClientNotFoundError when client path is empty", () => { + assert.throws( + () => execClient("codex", "test prompt", ""), + (err: unknown) => err instanceof ClientNotFoundError + ); + }); +});