mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 15:03:37 +00:00
481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
/**
|
|
* Fetches model/mode lists from agent backends and writes them to resources/.
|
|
*
|
|
* Usage:
|
|
* npx tsx dump.ts # Dump all agents
|
|
* npx tsx dump.ts --agent claude # Dump only Claude
|
|
* npx tsx dump.ts --agent opencode --opencode-url http://127.0.0.1:4096
|
|
*
|
|
* Sources:
|
|
* Claude — Anthropic API (GET /v1/models?beta=true). Extracts API key from
|
|
* ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku)
|
|
* on 401/403 or missing credentials.
|
|
* Codex — Codex app-server JSON-RPC (model/list over stdio, paginated).
|
|
* OpenCode — OpenCode HTTP server (GET {base_url}/config/providers, fallback /provider).
|
|
* Model IDs formatted as {provider_id}/{model_id}.
|
|
* Cursor — `cursor-agent models` CLI command. Parses the text output.
|
|
*
|
|
* Output goes to resources/ alongside this script. These JSON files are committed
|
|
* to the repo and included in the sandbox-agent binary at compile time via include_str!.
|
|
*/
|
|
|
|
import { execSync, spawn } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as readline from "node:readline";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface ModelEntry {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ModeEntry {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface AgentModelList {
|
|
defaultModel: string;
|
|
models: ModelEntry[];
|
|
defaultMode?: string;
|
|
modes?: ModeEntry[];
|
|
}
|
|
|
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
|
|
const args = process.argv.slice(2);
|
|
const agentFilter: string[] = [];
|
|
let opencodeUrl: string | undefined;
|
|
let codexPath: string | undefined;
|
|
let cursorPath: string | undefined;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === "--agent" && args[i + 1]) {
|
|
agentFilter.push(args[++i]);
|
|
} else if (args[i] === "--opencode-url" && args[i + 1]) {
|
|
opencodeUrl = args[++i];
|
|
} else if (args[i] === "--codex-path" && args[i + 1]) {
|
|
codexPath = args[++i];
|
|
} else if (args[i] === "--cursor-path" && args[i + 1]) {
|
|
cursorPath = args[++i];
|
|
}
|
|
}
|
|
|
|
const RESOURCES_DIR = path.join(__dirname, "resources");
|
|
const agents = agentFilter.length
|
|
? agentFilter
|
|
: ["claude", "codex", "opencode", "cursor"];
|
|
|
|
async function main() {
|
|
fs.mkdirSync(RESOURCES_DIR, { recursive: true });
|
|
|
|
for (const agent of agents) {
|
|
try {
|
|
switch (agent) {
|
|
case "claude":
|
|
await dumpClaude();
|
|
break;
|
|
case "codex":
|
|
await dumpCodex();
|
|
break;
|
|
case "opencode":
|
|
await dumpOpencode();
|
|
break;
|
|
case "cursor":
|
|
await dumpCursor();
|
|
break;
|
|
default:
|
|
console.error(`Unknown agent: ${agent}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(` Error for ${agent}: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeList(agent: string, list: AgentModelList) {
|
|
const filePath = path.join(RESOURCES_DIR, `${agent}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n");
|
|
const modeCount = list.modes?.length ?? 0;
|
|
console.log(
|
|
` Wrote ${list.models.length} models${modeCount ? `, ${modeCount} modes` : ""} to ${filePath} (default: ${list.defaultModel})`
|
|
);
|
|
}
|
|
|
|
// ─── Claude ───────────────────────────────────────────────────────────────────
|
|
|
|
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/models?beta=true";
|
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
|
|
const CLAUDE_FALLBACK: AgentModelList = {
|
|
defaultModel: "default",
|
|
models: [
|
|
{ id: "default", name: "Default (recommended)" },
|
|
{ id: "opus", name: "Opus" },
|
|
{ id: "sonnet", name: "Sonnet" },
|
|
{ id: "haiku", name: "Haiku" },
|
|
],
|
|
};
|
|
|
|
async function dumpClaude() {
|
|
console.log("Fetching Claude models...");
|
|
const apiKey = process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY;
|
|
if (!apiKey) {
|
|
console.log(" No ANTHROPIC_API_KEY set, using fallback aliases");
|
|
writeList("claude", CLAUDE_FALLBACK);
|
|
return;
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
"anthropic-version": ANTHROPIC_VERSION,
|
|
"x-api-key": apiKey,
|
|
};
|
|
|
|
const response = await fetch(ANTHROPIC_API_URL, { headers });
|
|
if (response.status === 401 || response.status === 403) {
|
|
console.log(
|
|
` API returned ${response.status}, using fallback aliases`
|
|
);
|
|
writeList("claude", CLAUDE_FALLBACK);
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Anthropic API returned ${response.status}: ${await response.text()}`
|
|
);
|
|
}
|
|
|
|
const body = (await response.json()) as {
|
|
data?: Array<{
|
|
id: string;
|
|
display_name?: string;
|
|
created_at?: string;
|
|
}>;
|
|
};
|
|
const data = body.data ?? [];
|
|
|
|
let defaultModel: string | undefined;
|
|
let defaultCreated: string | undefined;
|
|
const models: ModelEntry[] = [];
|
|
|
|
for (const item of data) {
|
|
models.push({
|
|
id: item.id,
|
|
name: item.display_name ?? item.id,
|
|
});
|
|
if (item.created_at) {
|
|
if (!defaultCreated || item.created_at > defaultCreated) {
|
|
defaultCreated = item.created_at;
|
|
defaultModel = item.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
models.sort((a, b) => a.id.localeCompare(b.id));
|
|
|
|
if (models.length === 0) {
|
|
console.log(" API returned empty model list, using fallback aliases");
|
|
writeList("claude", CLAUDE_FALLBACK);
|
|
return;
|
|
}
|
|
|
|
writeList("claude", {
|
|
defaultModel: defaultModel ?? models[0]?.id ?? "default",
|
|
models,
|
|
});
|
|
}
|
|
|
|
// ─── Codex ────────────────────────────────────────────────────────────────────
|
|
|
|
async function dumpCodex() {
|
|
console.log("Fetching Codex models...");
|
|
const binary = codexPath ?? findBinary("codex");
|
|
if (!binary) {
|
|
throw new Error("codex binary not found (set --codex-path or add to PATH)");
|
|
}
|
|
console.log(` Using binary: ${binary}`);
|
|
|
|
const child = spawn(binary, ["app-server"], {
|
|
stdio: ["pipe", "pipe", "ignore"],
|
|
});
|
|
|
|
const rl = readline.createInterface({ input: child.stdout! });
|
|
|
|
const models: ModelEntry[] = [];
|
|
let defaultModel: string | undefined;
|
|
const seen = new Set<string>();
|
|
let cursor: string | null = null;
|
|
let requestId = 1;
|
|
|
|
try {
|
|
// Initialize handshake (required before model/list)
|
|
const initRequest = JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: requestId++,
|
|
method: "initialize",
|
|
params: {
|
|
clientInfo: {
|
|
name: "agent-configs-dump",
|
|
title: "agent-configs-dump",
|
|
version: "1.0.0",
|
|
},
|
|
},
|
|
});
|
|
child.stdin!.write(initRequest + "\n");
|
|
const initLine = await readLineWithTimeout(rl, 10_000);
|
|
const initValue = JSON.parse(initLine);
|
|
if (initValue.error) {
|
|
throw new Error(`Codex initialize error: ${JSON.stringify(initValue.error)}`);
|
|
}
|
|
// Send initialized notification
|
|
child.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method: "initialized" }) + "\n");
|
|
|
|
while (true) {
|
|
const request = JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: requestId++,
|
|
method: "model/list",
|
|
params: { cursor, limit: null },
|
|
});
|
|
child.stdin!.write(request + "\n");
|
|
|
|
const line = await readLineWithTimeout(rl, 10_000);
|
|
const value = JSON.parse(line);
|
|
|
|
if (value.error) {
|
|
throw new Error(`Codex error: ${JSON.stringify(value.error)}`);
|
|
}
|
|
|
|
const result = value.result ?? value;
|
|
const data = result.data ?? [];
|
|
|
|
for (const item of data) {
|
|
const modelId = item.model ?? item.id;
|
|
if (!modelId || seen.has(modelId)) continue;
|
|
seen.add(modelId);
|
|
|
|
models.push({
|
|
id: modelId,
|
|
name: item.displayName ?? modelId,
|
|
});
|
|
|
|
if (!defaultModel && item.isDefault) {
|
|
defaultModel = modelId;
|
|
}
|
|
}
|
|
|
|
const nextCursor = result.nextCursor;
|
|
if (!nextCursor) break;
|
|
cursor = nextCursor;
|
|
}
|
|
} finally {
|
|
child.kill();
|
|
}
|
|
|
|
models.sort((a, b) => a.id.localeCompare(b.id));
|
|
|
|
writeList("codex", {
|
|
defaultModel: defaultModel ?? models[0]?.id ?? "",
|
|
models,
|
|
});
|
|
}
|
|
|
|
function readLineWithTimeout(
|
|
rl: readline.Interface,
|
|
timeoutMs: number
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
reject(new Error("readline timeout"));
|
|
}, timeoutMs);
|
|
rl.once("line", (line) => {
|
|
clearTimeout(timer);
|
|
resolve(line);
|
|
});
|
|
rl.once("close", () => {
|
|
clearTimeout(timer);
|
|
reject(new Error("readline closed"));
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── OpenCode ─────────────────────────────────────────────────────────────────
|
|
|
|
async function dumpOpencode() {
|
|
const baseUrl = opencodeUrl ?? process.env.OPENCODE_URL;
|
|
if (!baseUrl) {
|
|
console.log(
|
|
" Skipped: --opencode-url not provided (set OPENCODE_URL or pass --opencode-url)"
|
|
);
|
|
return;
|
|
}
|
|
console.log(`Fetching OpenCode models from ${baseUrl}...`);
|
|
|
|
const endpoints = [
|
|
`${baseUrl}/config/providers`,
|
|
`${baseUrl}/provider`,
|
|
];
|
|
|
|
for (const url of endpoints) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
console.log(` ${url} returned ${response.status}`);
|
|
continue;
|
|
}
|
|
const value = await response.json();
|
|
const list = parseOpencodeProviders(value as Record<string, unknown>);
|
|
if (list) {
|
|
writeList("opencode", list);
|
|
return;
|
|
}
|
|
console.log(` ${url} returned no parseable models`);
|
|
} catch (err) {
|
|
console.log(` ${url} failed: ${err}`);
|
|
}
|
|
}
|
|
|
|
throw new Error("OpenCode model endpoints unavailable");
|
|
}
|
|
|
|
function parseOpencodeProviders(
|
|
value: Record<string, unknown>
|
|
): AgentModelList | null {
|
|
const providers = (
|
|
(value.providers as unknown[]) ?? (value.all as unknown[])
|
|
) as Array<{
|
|
id: string;
|
|
name?: string;
|
|
models?: Record<string, { id?: string; name?: string }>;
|
|
}> | undefined;
|
|
if (!providers) return null;
|
|
|
|
const defaultMap = (value.default as Record<string, string>) ?? {};
|
|
const models: ModelEntry[] = [];
|
|
const providerOrder: string[] = [];
|
|
|
|
for (const provider of providers) {
|
|
if (!provider.id) continue;
|
|
providerOrder.push(provider.id);
|
|
if (!provider.models) continue;
|
|
const providerName = provider.name ?? provider.id;
|
|
for (const [key, model] of Object.entries(provider.models)) {
|
|
const modelId = model.id ?? key;
|
|
const modelName = model.name ?? modelId;
|
|
models.push({
|
|
id: `${provider.id}/${modelId}`,
|
|
name: `${providerName}/${modelName}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
models.sort((a, b) => a.id.localeCompare(b.id));
|
|
|
|
let defaultModel: string | undefined;
|
|
for (const providerId of providerOrder) {
|
|
if (defaultMap[providerId]) {
|
|
defaultModel = `${providerId}/${defaultMap[providerId]}`;
|
|
break;
|
|
}
|
|
}
|
|
if (!defaultModel) {
|
|
defaultModel = models[0]?.id;
|
|
}
|
|
|
|
return {
|
|
defaultModel: defaultModel ?? "",
|
|
models,
|
|
// OpenCode modes are not available via /config/providers — hardcode known ones
|
|
defaultMode: "build",
|
|
modes: [
|
|
{
|
|
id: "build",
|
|
name: "Build",
|
|
description:
|
|
"The default agent. Executes tools based on configured permissions.",
|
|
},
|
|
{
|
|
id: "plan",
|
|
name: "Plan",
|
|
description: "Plan mode. Disallows all edit tools.",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ─── Cursor ───────────────────────────────────────────────────────────────────
|
|
|
|
async function dumpCursor() {
|
|
console.log("Fetching Cursor models...");
|
|
const binary = cursorPath ?? findBinary("cursor-agent");
|
|
if (!binary) {
|
|
throw new Error(
|
|
"cursor-agent binary not found (set --cursor-path or add to PATH)"
|
|
);
|
|
}
|
|
console.log(` Using binary: ${binary}`);
|
|
|
|
const output = execSync(`${binary} models`, {
|
|
encoding: "utf-8",
|
|
timeout: 15_000,
|
|
});
|
|
|
|
const models: ModelEntry[] = [];
|
|
let defaultModel: string | undefined;
|
|
|
|
// Parse lines like: "model-id - Display Name (current)" or "(default)"
|
|
for (const rawLine of output.split("\n")) {
|
|
// Strip ANSI escape codes
|
|
const line = rawLine.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\[?[0-9;]*[A-Za-z]/g, "").trim();
|
|
const match = line.match(
|
|
/^(\S+)\s+-\s+(.+?)(?:\s+\((current|default)\))?$/
|
|
);
|
|
if (!match) continue;
|
|
const [, id, name, tag] = match;
|
|
models.push({ id, name: name.trim() });
|
|
if (tag === "current" || tag === "default") {
|
|
defaultModel = id;
|
|
}
|
|
}
|
|
|
|
if (models.length === 0) {
|
|
throw new Error(
|
|
"cursor-agent models returned no parseable models"
|
|
);
|
|
}
|
|
|
|
writeList("cursor", {
|
|
defaultModel: defaultModel ?? models[0]?.id ?? "auto",
|
|
models,
|
|
});
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function findBinary(name: string): string | null {
|
|
// Check sandbox-agent install dir
|
|
const installDir = path.join(
|
|
process.env.HOME ?? "",
|
|
".local",
|
|
"share",
|
|
"sandbox-agent",
|
|
"bin"
|
|
);
|
|
const installed = path.join(installDir, name);
|
|
if (fs.existsSync(installed)) return installed;
|
|
|
|
// Search PATH
|
|
try {
|
|
return execSync(`which ${name}`, { encoding: "utf-8" }).trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|