merge M1 into implement/2026-05-18-stef-openclaw-skills

This commit is contained in:
2026-05-18 17:45:27 -05:00
8 changed files with 371 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+51
View File
@@ -0,0 +1,51 @@
---
name: ai-cli-dispatch
description: Dispatch AI CLI coding tasks to available clients (Codex, Claude Code, OpenCode) with automatic discovery, version checking, and execution.
metadata: {"clawdbot":{"emoji":"robot","requires":{"bins":["node"]}}}
---
# AI CLI Dispatch
Use this skill when the user wants to run a coding task through an AI CLI client such as Codex, Claude Code, or OpenCode.
The skill discovers installed clients, resolves versions, selects the best available tool, and forwards the task with arguments intact.
Use the local helper from the installed skill directory:
```bash
cd ~/.openclaw/workspace/skills/ai-cli-dispatch
scripts/ai-cli-dispatch --help
```
## Setup
```bash
cd ~/.openclaw/workspace/skills/ai-cli-dispatch
npm install
```
## Commands
```bash
scripts/ai-cli-dispatch list --json
scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function"
scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware"
scripts/ai-cli-dispatch exec --client opencode --prompt "migrate to ESM"
```
Use `--json` for machine-readable command output.
## Client Discovery
The skill searches for the following clients in order:
- `codex` — OpenAI Codex CLI
- `claude` — Anthropic Claude Code
- `opencode` — OpenCode CLI
Run `list` to see which clients are installed and their resolved versions.
## Output Rules
- Normal JSON output redacts local file paths and credential metadata.
- Use `--debug` only when deeper troubleshooting requires internal paths and resolved config metadata.
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ai-cli-dispatch",
"version": "1.0.0",
"description": "AI CLI dispatch tool for OpenClaw skills",
"type": "module",
"scripts": {
"ai-cli-dispatch": "tsx src/cli.ts",
"test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"minimist": "^1.2.8"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.8.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const skillDir = resolve(scriptDir, "..");
const tsxBin = join(skillDir, "node_modules", ".bin", "tsx");
if (!existsSync(tsxBin)) {
process.stderr.write(`Missing local Node dependencies for ai-cli-dispatch. Run 'cd ${skillDir} && npm install' first.\n`);
process.exit(1);
}
const result = spawnSync(process.execPath, [tsxBin, join(skillDir, "src", "cli.ts"), ...process.argv.slice(2)], {
stdio: "inherit"
});
if (result.error) {
process.stderr.write(`${result.error.message}\n`);
process.exit(1);
}
process.exit(typeof result.status === "number" ? result.status : 1);
+152
View File
@@ -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<ClientName, string> = {
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 <client> --prompt <prompt>
ai-cli-dispatch --help
Clients: codex, claude, opencode`);
}
export async function main(argv: string[]): Promise<number> {
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));
}
+36
View File
@@ -0,0 +1,36 @@
export type ClientName = "codex" | "claude" | "opencode";
export interface ClientInfo {
name: ClientName;
path?: string;
version?: string;
found: boolean;
}
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface ToolConfig {
clients: ClientName[];
defaultClient?: ClientName;
}
export class ClientNotFoundError extends Error {
constructor(client: string) {
super(`Client "${client}" not found or not installed.`);
this.name = "ClientNotFoundError";
}
}
export class ExecError extends Error {
readonly result: ExecResult;
constructor(message: string, result: ExecResult) {
super(message);
this.name = "ExecError";
this.result = result;
}
}
+66
View File
@@ -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
);
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "./dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}