merge M3 into implement/2026-05-18-stef-openclaw-skills
This commit is contained in:
@@ -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", p],
|
||||||
|
claude: (p) => ["-p", p],
|
||||||
|
opencode: (p) => [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,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 "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", { 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 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 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 "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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user