merge M1 into implement/2026-05-18-stef-openclaw-skills
This commit is contained in:
@@ -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,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));
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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