Agents: add pluggable CLIs

Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2025-12-02 10:42:27 +00:00
parent 52c311e47f
commit f31e89d5af
15 changed files with 624 additions and 150 deletions

67
src/agents/claude.ts Normal file
View File

@@ -0,0 +1,67 @@
import path from "node:path";
import {
CLAUDE_BIN,
CLAUDE_IDENTITY_PREFIX,
parseClaudeJson,
summarizeClaudeMetadata,
type ClaudeJsonParseResult,
} from "../auto-reply/claude.js";
import type {
AgentMeta,
AgentParseResult,
AgentSpec,
BuildArgsContext,
} from "./types.js";
function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
if (!parsed?.parsed) return undefined;
const summary = summarizeClaudeMetadata(parsed.parsed);
return summary ? { extra: { summary } } : undefined;
}
export const claudeSpec: AgentSpec = {
kind: "claude",
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === CLAUDE_BIN,
buildArgs: (ctx) => {
// Work off a split of "before body" and "after body" so we don't lose the
// body index when inserting flags.
const argv = [...ctx.argv];
const body = argv[ctx.bodyIndex] ?? "";
const beforeBody = argv.slice(0, ctx.bodyIndex);
const afterBody = argv.slice(ctx.bodyIndex + 1);
const wantsOutputFormat = typeof ctx.format === "string";
if (wantsOutputFormat) {
const hasOutputFormat = argv.some(
(part) => part === "--output-format" || part.startsWith("--output-format="),
);
if (!hasOutputFormat) {
beforeBody.push("--output-format", ctx.format!);
}
}
const hasPrintFlag = argv.some((part) => part === "-p" || part === "--print");
if (!hasPrintFlag) {
beforeBody.push("-p");
}
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity =
shouldPrependIdentity && body
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
.filter(Boolean)
.join("\n\n")
: body;
return [...beforeBody, bodyWithIdentity, ...afterBody];
},
parseOutput: (rawStdout) => {
const parsed = parseClaudeJson(rawStdout);
const text = parsed?.text ?? rawStdout.trim();
return {
text: text?.trim(),
meta: toMeta(parsed),
};
},
};

66
src/agents/codex.ts Normal file
View File

@@ -0,0 +1,66 @@
import path from "node:path";
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
function parseCodexJson(raw: string): AgentParseResult {
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
let text: string | undefined;
let meta: AgentMeta | undefined;
for (const line of lines) {
try {
const ev = JSON.parse(line) as { type?: string; item?: { type?: string; text?: string }; usage?: unknown };
if (ev.type === "item.completed" && ev.item?.type === "agent_message" && typeof ev.item.text === "string") {
text = ev.item.text;
}
if (ev.type === "turn.completed" && ev.usage && typeof ev.usage === "object") {
const u = ev.usage as {
input_tokens?: number;
cached_input_tokens?: number;
output_tokens?: number;
};
meta = {
usage: {
input: u.input_tokens,
output: u.output_tokens,
cacheRead: u.cached_input_tokens,
total:
(u.input_tokens ?? 0) +
(u.output_tokens ?? 0) +
(u.cached_input_tokens ?? 0),
},
};
}
} catch {
// ignore
}
}
return { text: text?.trim(), meta };
}
export const codexSpec: AgentSpec = {
kind: "codex",
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === "codex",
buildArgs: (ctx) => {
const argv = [...ctx.argv];
const hasExec = argv.length > 0 && argv[1] === "exec";
if (!hasExec) {
argv.splice(1, 0, "exec");
}
// Ensure JSON output
if (!argv.includes("--json")) {
argv.splice(argv.length - 1, 0, "--json");
}
// Safety defaults
if (!argv.includes("--skip-git-repo-check")) {
argv.splice(argv.length - 1, 0, "--skip-git-repo-check");
}
if (!argv.some((p) => p === "--sandbox" || p.startsWith("--sandbox="))) {
argv.splice(argv.length - 1, 0, "--sandbox", "read-only");
}
return argv;
},
parseOutput: parseCodexJson,
};

19
src/agents/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { claudeSpec } from "./claude.js";
import { codexSpec } from "./codex.js";
import { opencodeSpec } from "./opencode.js";
import { piSpec } from "./pi.js";
import type { AgentKind, AgentSpec } from "./types.js";
const specs: Record<AgentKind, AgentSpec> = {
claude: claudeSpec,
codex: codexSpec,
opencode: opencodeSpec,
pi: piSpec,
};
export function getAgentSpec(kind: AgentKind): AgentSpec {
return specs[kind];
}
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js";

54
src/agents/opencode.ts Normal file
View File

@@ -0,0 +1,54 @@
import path from "node:path";
import {
OPENCODE_BIN,
OPENCODE_IDENTITY_PREFIX,
parseOpencodeJson,
summarizeOpencodeMetadata,
} from "../auto-reply/opencode.js";
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
function toMeta(parsed: ReturnType<typeof parseOpencodeJson>): AgentMeta | undefined {
const summary = summarizeOpencodeMetadata(parsed.meta);
return summary ? { extra: { summary } } : undefined;
}
export const opencodeSpec: AgentSpec = {
kind: "opencode",
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN,
buildArgs: (ctx) => {
const argv = [...ctx.argv];
const wantsJson = ctx.format === "json";
// Ensure format json for parsing
if (wantsJson) {
const hasFormat = argv.some(
(part) => part === "--format" || part.startsWith("--format="),
);
if (!hasFormat) {
const insertBeforeBody = Math.max(argv.length - 1, 0);
argv.splice(insertBeforeBody, 0, "--format", "json");
}
}
// Session args default to --session
// Identity prefix
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
const existingBody = argv[ctx.bodyIndex];
argv[ctx.bodyIndex] = [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, existingBody]
.filter(Boolean)
.join("\n\n");
}
return argv;
},
parseOutput: (rawStdout) => {
const parsed = parseOpencodeJson(rawStdout);
const text = parsed.text ?? rawStdout.trim();
return {
text: text?.trim(),
meta: toMeta(parsed),
};
},
};

65
src/agents/pi.ts Normal file
View File

@@ -0,0 +1,65 @@
import path from "node:path";
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
type PiAssistantMessage = {
role?: string;
content?: Array<{ type?: string; text?: string }>;
usage?: { input?: number; output?: number };
model?: string;
provider?: string;
stopReason?: string;
};
function parsePiJson(raw: string): AgentParseResult {
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
let lastMessage: PiAssistantMessage | undefined;
for (const line of lines) {
try {
const ev = JSON.parse(line) as { type?: string; message?: PiAssistantMessage };
if (ev.type === "message_end" && ev.message?.role === "assistant") {
lastMessage = ev.message;
}
} catch {
// ignore
}
}
const text =
lastMessage?.content
?.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n")
?.trim() ?? undefined;
const meta: AgentMeta | undefined = lastMessage
? {
model: lastMessage.model,
provider: lastMessage.provider,
stopReason: lastMessage.stopReason,
usage: lastMessage.usage,
}
: undefined;
return { text, meta };
}
export const piSpec: AgentSpec = {
kind: "pi",
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === "pi",
buildArgs: (ctx) => {
const argv = [...ctx.argv];
// Non-interactive print + JSON
if (!argv.includes("-p") && !argv.includes("--print")) {
argv.splice(argv.length - 1, 0, "-p");
}
if (ctx.format === "json" && !argv.includes("--mode") && !argv.some((a) => a === "--mode")) {
argv.splice(argv.length - 1, 0, "--mode", "json");
}
// Session defaults
// Identity prefix optional; Pi usually doesn't need, but allow
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) {
const existingBody = argv[ctx.bodyIndex];
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody].filter(Boolean).join("\n\n");
}
return argv;
},
parseOutput: parsePiJson,
};

42
src/agents/types.ts Normal file
View File

@@ -0,0 +1,42 @@
export type AgentKind = "claude" | "opencode" | "pi" | "codex";
export type AgentMeta = {
model?: string;
provider?: string;
stopReason?: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
extra?: Record<string, unknown>;
};
export type AgentParseResult = {
text?: string;
mediaUrls?: string[];
meta?: AgentMeta;
};
export type BuildArgsContext = {
argv: string[];
bodyIndex: number; // index of prompt/body argument in argv
isNewSession: boolean;
sessionId?: string;
sendSystemOnce: boolean;
systemSent: boolean;
identityPrefix?: string;
format?: "text" | "json";
sessionArgNew?: string[];
sessionArgResume?: string[];
};
export interface AgentSpec {
kind: AgentKind;
isInvocation: (argv: string[]) => boolean;
buildArgs: (ctx: BuildArgsContext) => string[];
parseOutput: (rawStdout: string) => AgentParseResult;
}