Files
pikasTech-agentrun/scripts/src/cli.ts
T

862 lines
47 KiB
TypeScript

import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { execFileSync, spawn } from "node:child_process";
import { closeSync, existsSync, openSync } from "node:fs";
import path from "node:path";
import { startManagerServer } from "../../src/mgr/server.js";
import { MemoryAgentRunStore } from "../../src/mgr/store.js";
import { ManagerClient } from "../../src/mgr/client.js";
import { runOnce } from "../../src/runner/run-once.js";
import { renderRunnerJobDryRun } from "../../src/runner/k8s-job.js";
import type { RunnerSessionPvcOptions } from "../../src/runner/k8s-job.js";
import { renderCodexProviderSecretPlan } from "./secret-render.js";
import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RunRecord, SessionSummary } from "../../src/common/types.js";
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js";
interface ParsedArgs {
positional: string[];
flags: Map<string, string | boolean>;
}
export async function runCli(argv: string[]): Promise<void> {
try {
const result = await dispatch(parseArgs(argv));
print({ ok: true, data: result });
} catch (error) {
const status = error instanceof AgentRunError ? error.httpStatus : 1;
print({ ok: false, ...(error instanceof AgentRunError ? { failureKind: error.failureKind, message: error.message } : { failureKind: "infra-failed", message: error instanceof Error ? error.message : String(error) }), error: errorToJson(error) });
process.exitCode = status === 0 ? 1 : status > 255 ? 1 : status;
}
}
async function dispatch(args: ParsedArgs): Promise<JsonValue> {
const [group, command, id] = args.positional;
if (!group || group === "help" || group === "--help") return help(args);
if (args.flags.get("help") === true) return help(args, group);
if (command === "help" || command === "--help") return help(args, group);
if (group === "manager" && (command === "url" || command === "resolve-url" || command === "status")) return managerEndpoint(args);
if (group === "server" && command === "start") return startServer(args);
if (group === "server" && command === "status") return serverStatus(args);
if (group === "server" && command === "logs") return serverLogs(args);
if (group === "server" && command === "stop") return stopServer(args);
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles");
if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`);
if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`);
if (group === "provider-profiles" && (command === "remove" || command === "delete" || command === "rm") && id) return removeProviderProfileCli(id, args);
if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id);
if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id);
if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id);
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
if (group === "sessions" && command === "ps") return listSessions(args);
if (group === "sessions" && command === "create") return sessionCreate(args, id ?? null);
if (group === "sessions" && command === "storage" && id) return sessionStorageGet(args, id);
if (group === "sessions" && command === "storage" && !id) throw new AgentRunError("schema-invalid", "sessions storage requires a sessionId", { httpStatus: 2 });
if (group === "sessions" && command === "show" && id) return client(args).get(`/api/v1/sessions/${encodeURIComponent(id)}${readerQuery(args)}`);
if (group === "sessions" && command === "read" && id) return client(args).post(`/api/v1/sessions/${encodeURIComponent(id)}/read`, { readerId: optionalFlag(args, "reader-id") ?? "cli" });
if (group === "sessions" && command === "trace" && id) return sessionEvents(args, id, "trace");
if (group === "sessions" && command === "output" && id) return sessionEvents(args, id, "output");
if (group === "sessions" && command === "turn") return sessionTurn(args, id ?? null);
if (group === "sessions" && command === "steer" && id) return sessionSteer(args, id);
if (group === "sessions" && command === "cancel" && id) return sessionCancel(args, id);
const sessionStorageCmd = group === "sessions" && (command === "storage-delete" || (command === "storage" && id && optionalFlag(args, "delete") === "true"));
if (sessionStorageCmd && id) return sessionStorageDelete(args, id);
if (group === "queue" && command === "submit") return submitQueueTask(args);
if (group === "queue" && command === "list") return listQueueTasks(args);
if (group === "queue" && command === "show" && id) return client(args).get(`/api/v1/queue/tasks/${encodeURIComponent(id)}`);
if (group === "queue" && command === "stats") return client(args).get(`/api/v1/queue/stats${queueQuery(args)}`);
if (group === "queue" && command === "commander") return client(args).get(`/api/v1/queue/commander${queueQuery(args)}`);
if (group === "queue" && command === "read" && id) return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(id)}/read`, { readerId: optionalFlag(args, "reader-id") ?? "cli" });
if (group === "queue" && command === "cancel" && id) return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(id)}/cancel`, cancelBody(args));
if (group === "queue" && command === "dispatch" && id) return dispatchQueueTask(args, id);
if (group === "queue" && command === "refresh" && id) return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(id)}/refresh`, {});
if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args));
if (group === "runs" && command === "show" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}`);
if (group === "runs" && command === "events" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/events?afterSeq=${flag(args, "after-seq", "0")}&limit=${flag(args, "limit", "100")}`);
if (group === "runs" && command === "result" && id) {
const commandId = optionalFlag(args, "command-id");
return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/result${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
}
if (group === "runs" && command === "cancel" && id) return client(args).post(`/api/v1/runs/${encodeURIComponent(id)}/cancel`, cancelBody(args));
if (group === "commands" && command === "create" && id) {
const body = await jsonFile(args);
if (!body.type) body.type = flag(args, "type", "turn");
const idempotencyKey = optionalFlag(args, "idempotency-key");
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
return client(args).post(`/api/v1/runs/${encodeURIComponent(id)}/commands`, body);
}
if (group === "commands" && command === "show" && id) {
const runId = flag(args, "run-id", "");
if (!runId) throw new AgentRunError("schema-invalid", "commands show requires --run-id", { httpStatus: 2 });
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(id)}`);
}
if (group === "commands" && command === "result" && id) {
const runId = flag(args, "run-id", "");
if (!runId) throw new AgentRunError("schema-invalid", "commands result requires --run-id", { httpStatus: 2 });
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(id)}/result`);
}
if (group === "commands" && command === "cancel" && id) return client(args).post(`/api/v1/commands/${encodeURIComponent(id)}/cancel`, cancelBody(args));
if (group === "runner" && command === "start") {
const runId = flag(args, "run-id", "");
if (!runId) throw new AgentRunError("schema-invalid", "runner start requires --run-id", { httpStatus: 2 });
const options: RunnerOnceOptions = {
managerUrl: managerUrl(args),
runId,
};
const runnerId = optionalFlag(args, "runner-id");
const backend = optionalFlag(args, "backend");
const codexCommand = optionalFlag(args, "codex-command");
const codexHome = optionalFlag(args, "codex-home") ?? process.env.CODEX_HOME;
if (runnerId) options.runnerId = runnerId;
if (backend) {
if (!isBackendProfile(backend)) throw new AgentRunError("schema-invalid", `runner start --backend ${backend} is not supported in v0.1`, { httpStatus: 2 });
options.backendProfile = backend;
}
if (codexCommand) options.codexCommand = codexCommand;
if (codexHome) options.codexHome = codexHome;
const idleTimeoutMs = optionalFlag(args, "idle-timeout-ms");
const pollIntervalMs = optionalFlag(args, "poll-interval-ms");
if (idleTimeoutMs) options.idleTimeoutMs = Number(idleTimeoutMs);
if (pollIntervalMs) options.pollIntervalMs = Number(pollIntervalMs);
if (args.flags.get("one-shot") === true) options.oneShot = true;
return runOnce(options) as unknown as JsonValue;
}
if (group === "runner" && command === "job") return renderRunnerJob(args);
if (group === "runner" && command === "jobs") return listRunnerJobs(args);
if (group === "runner" && command === "job-status") return showRunnerJobStatus(args);
throw new AgentRunError("schema-invalid", `unsupported command: ${args.positional.join(" ")}`, { httpStatus: 2 });
}
async function listRunnerJobs(args: ParsedArgs): Promise<JsonValue> {
const runId = flag(args, "run-id", "");
if (!runId) throw new AgentRunError("schema-invalid", "runner jobs requires --run-id", { httpStatus: 2 });
const commandId = optionalFlag(args, "command-id");
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
}
async function listSessions(args: ParsedArgs): Promise<JsonValue> {
const params = new URLSearchParams();
const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null);
const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile");
const readerId = optionalFlag(args, "reader-id");
const cursor = optionalFlag(args, "cursor");
const limit = optionalFlag(args, "limit");
if (state) params.set("state", state);
if (profile) params.set("profile", normalizeProfile(profile));
if (readerId) params.set("readerId", readerId);
if (cursor) params.set("cursor", cursor);
if (limit) params.set("limit", limit);
const query = params.toString();
return client(args).get(`/api/v1/sessions${query ? `?${query}` : ""}`);
}
async function sessionEvents(args: ParsedArgs, sessionId: string, kind: "trace" | "output"): Promise<JsonValue> {
const params = new URLSearchParams();
const afterSeq = optionalFlag(args, "after-seq");
const limit = optionalFlag(args, "limit");
const runId = optionalFlag(args, "run-id");
if (afterSeq) params.set("afterSeq", afterSeq);
if (limit) params.set("limit", limit);
if (runId) params.set("runId", runId);
const query = params.toString();
return client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/${kind}${query ? `?${query}` : ""}`);
}
async function sessionCreate(args: ParsedArgs, positionalSessionId: string | null): Promise<JsonRecord> {
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
const profile = normalizeProfile(optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? "codex");
const tenantId = optionalFlag(args, "tenant-id") ?? "unidesk";
const projectId = optionalFlag(args, "project-id") ?? "default";
const providerId = optionalFlag(args, "provider-id") ?? "G14";
const expiresInDays = Number(optionalFlag(args, "expires-in-days") ?? 30);
const expiresAt = new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString();
const created = await client(args).post("/api/v1/sessions", {
sessionId,
tenantId,
projectId,
backendProfile: profile,
expiresAt,
}) as { action: string; pvc: { pvcName: string; pvcPhase: string }; session: { sessionId: string; storageKind: string; codexRolloutSubdir: string } };
const storage = await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`) as { pvcName: string; pvcPhase: string; storageSizeBytes: number | null };
return {
action: created.action,
session: created.session,
pvc: created.pvc,
storage,
pollCommands: {
show: `./scripts/agentrun sessions show ${sessionId} --reader-id cli`,
storage: `./scripts/agentrun sessions storage ${sessionId}`,
trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`,
turn: `./scripts/agentrun sessions turn ${sessionId} --prompt "..."`,
},
};
}
async function sessionStorageGet(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
return (await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`)) as JsonRecord;
}
async function sessionStorageDelete(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
return (await client(args).delete(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`)) as JsonRecord;
}
async function sessionTurn(args: ParsedArgs, positionalSessionId: string | null): Promise<JsonRecord> {
const body = await optionalJsonFile(args);
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
const requestedProfile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? (typeof body.backendProfile === "string" ? String(body.backendProfile) : "codex");
const profile = normalizeProfile(requestedProfile);
if (positionalSessionId || optionalFlag(args, "session-id")) {
try {
await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`);
} catch (error) {
const expiresInDays = Number(optionalFlag(args, "expires-in-days") ?? 30);
await client(args).post("/api/v1/sessions", {
sessionId,
tenantId: optionalFlag(args, "tenant-id") ?? "unidesk",
projectId: optionalFlag(args, "project-id") ?? "default",
backendProfile: profile,
expiresAt: new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString(),
});
}
}
const prompt = await readPrompt(args);
body.tenantId = optionalFlag(args, "tenant-id") ?? stringField(body, "tenantId", "unidesk");
body.projectId = optionalFlag(args, "project-id") ?? stringField(body, "projectId", "default");
body.providerId = optionalFlag(args, "provider-id") ?? stringField(body, "providerId", "G14");
body.backendProfile = profile;
body.workspaceRef = jsonObjectFlag(args, "workspace-json") ?? objectField(body, "workspaceRef", { kind: "opaque", path: "." });
body.executionPolicy = jsonObjectFlag(args, "execution-policy-json") ?? objectField(body, "executionPolicy", defaultExecutionPolicy(profile));
body.traceSink = body.traceSink ?? null;
const sessionRef = objectField(body, "sessionRef", {});
const sessionMetadata = objectField(sessionRef, "metadata", {});
const title = optionalFlag(args, "title");
if (title) sessionMetadata.title = title;
body.sessionRef = { ...sessionRef, sessionId, metadata: sessionMetadata };
const run = await client(args).post("/api/v1/runs", body) as RunRecord;
const commandBody: JsonRecord = { type: "turn", payload: { prompt } };
const commandIdempotencyKey = optionalFlag(args, "command-idempotency-key") ?? optionalFlag(args, "idempotency-key");
if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey;
const command = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/commands`, commandBody) as CommandRecord;
let runnerJob: JsonValue = null;
if (args.flags.get("no-runner-job") !== true) {
const runnerBody = await optionalRunnerJsonFile(args);
runnerBody.commandId = command.id;
copyOptionalFlag(args, runnerBody, "image");
copyOptionalFlag(args, runnerBody, "namespace");
copyOptionalFlag(args, runnerBody, "attempt-id", "attemptId");
copyOptionalFlag(args, runnerBody, "runner-id", "runnerId");
copyOptionalFlag(args, runnerBody, "source-commit", "sourceCommit");
copyOptionalFlag(args, runnerBody, "runner-manager-url", "managerUrl");
copyOptionalFlag(args, runnerBody, "service-account-name", "serviceAccountName");
const runnerIdempotencyKey = optionalFlag(args, "runner-idempotency-key");
if (runnerIdempotencyKey) runnerBody.idempotencyKey = runnerIdempotencyKey;
runnerJob = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/runner-jobs`, runnerBody);
}
return { action: "session-turn", sessionId, profile, run, command, runnerJob, pollCommands: { ps: `./scripts/agentrun sessions ps --reader-id cli --profile ${profile}`, show: `./scripts/agentrun sessions show ${sessionId} --reader-id cli`, trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`, output: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100`, read: `./scripts/agentrun sessions read ${sessionId} --reader-id cli`, steer: `./scripts/agentrun sessions steer ${sessionId} --prompt-file <file>`, cancel: `./scripts/agentrun sessions cancel ${sessionId}` } };
}
async function sessionSteer(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
const session = await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}${readerQuery(args)}`) as SessionSummary;
const runId = session.activeRunId ?? session.lastRunId;
if (!runId) throw new AgentRunError("schema-invalid", `session ${sessionId} has no run to steer`, { httpStatus: 2 });
const prompt = await readPrompt(args);
const body: JsonRecord = { type: "steer", payload: { prompt } };
const idempotencyKey = optionalFlag(args, "idempotency-key");
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
const command = await client(args).post(`/api/v1/runs/${encodeURIComponent(runId)}/commands`, body);
return { action: "session-steer", sessionId, runId, command };
}
async function sessionCancel(args: ParsedArgs, sessionId: string): Promise<JsonRecord> {
const result = await client(args).post(`/api/v1/sessions/${encodeURIComponent(sessionId)}/control`, { action: "cancel", ...cancelBody(args) });
return { action: "session-cancel", sessionId, result };
}
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
const body = await jsonFile(args);
const idempotencyKey = optionalFlag(args, "idempotency-key");
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
return client(args).post("/api/v1/queue/tasks", body);
}
async function listQueueTasks(args: ParsedArgs): Promise<JsonValue> {
const params = new URLSearchParams();
const queue = optionalFlag(args, "queue");
const state = optionalFlag(args, "state");
const cursor = optionalFlag(args, "cursor");
const limit = optionalFlag(args, "limit");
const updatedAfter = optionalFlag(args, "updated-after");
if (queue) params.set("queue", queue);
if (state) params.set("state", state);
if (cursor) params.set("cursor", cursor);
if (limit) params.set("limit", limit);
if (updatedAfter) params.set("updatedAfter", updatedAfter);
const query = params.toString();
return client(args).get(`/api/v1/queue/tasks${query ? `?${query}` : ""}`);
}
function queueQuery(args: ParsedArgs): string {
const queue = optionalFlag(args, "queue");
return queue ? `?queue=${encodeURIComponent(queue)}` : "";
}
async function dispatchQueueTask(args: ParsedArgs, taskId: string): Promise<JsonValue> {
const body = await optionalJsonFile(args);
const copy = (flagName: string, key = flagName.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase())): void => {
const value = optionalFlag(args, flagName);
if (value) body[key] = value;
};
copy("idempotency-key", "idempotencyKey");
copy("image");
copy("namespace");
copy("attempt-id", "attemptId");
copy("runner-id", "runnerId");
copy("source-commit", "sourceCommit");
copy("runner-manager-url", "managerUrl");
copy("service-account-name", "serviceAccountName");
return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`, body);
}
async function showRunnerJobStatus(args: ParsedArgs): Promise<JsonValue> {
const runId = flag(args, "run-id", "");
if (!runId) throw new AgentRunError("schema-invalid", "runner job-status requires --run-id", { httpStatus: 2 });
const runnerJobId = args.positional[2] ?? optionalFlag(args, "runner-job-id");
if (!runnerJobId) return listRunnerJobs(args);
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs/${encodeURIComponent(runnerJobId)}`);
}
async function renderRunnerJob(args: ParsedArgs): Promise<JsonRecord> {
const runId = flag(args, "run-id", "");
const commandId = flag(args, "command-id", "");
if (!runId) throw new AgentRunError("schema-invalid", "runner job requires --run-id", { httpStatus: 2 });
if (!commandId) throw new AgentRunError("schema-invalid", "runner job requires --command-id", { httpStatus: 2 });
const image = optionalFlag(args, "image");
if (args.flags.get("dry-run") !== true) {
const body: JsonRecord = { commandId };
if (image) body.image = image;
const namespace = optionalFlag(args, "namespace");
const attemptId = optionalFlag(args, "attempt-id");
const runnerId = optionalFlag(args, "runner-id");
const sourceCommit = optionalFlag(args, "source-commit");
const runnerManagerUrl = optionalFlag(args, "runner-manager-url");
const idempotencyKey = optionalFlag(args, "idempotency-key");
if (namespace) body.namespace = namespace;
if (attemptId) body.attemptId = attemptId;
if (runnerId) body.runnerId = runnerId;
if (sourceCommit) body.sourceCommit = sourceCommit;
if (runnerManagerUrl) body.managerUrl = runnerManagerUrl;
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
return await client(args).post(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, body) as JsonRecord;
}
if (!image) throw new AgentRunError("schema-invalid", "runner job --dry-run requires --image", { httpStatus: 2 });
const run = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}`) as RunRecord;
let sessionPvc: RunnerSessionPvcOptions | undefined;
if (run.sessionRef?.sessionId) {
try {
const session = await client(args).get(`/api/v1/sessions/${encodeURIComponent(run.sessionRef.sessionId)}`) as { storageKind?: string; storagePvcName?: string; storageNamespace?: string; codexRolloutSubdir?: string };
if (session?.storageKind === "pvc" && session.storagePvcName) {
const subdir = session.codexRolloutSubdir ?? "sessions";
sessionPvc = { pvcName: session.storagePvcName, namespace: session.storageNamespace ?? "agentrun-v01", mountPath: `/home/agentrun/.codex-${run.backendProfile}/${subdir}`, codexRolloutSubdir: subdir };
}
} catch { /* session not found, skip */ }
}
const options = {
run,
commandId,
image,
managerUrl: managerUrl(args),
namespace: optionalFlag(args, "namespace") ?? "agentrun-v01",
...(sessionPvc ? { sessionPvc } : {}),
};
const attemptId = optionalFlag(args, "attempt-id");
const runnerId = optionalFlag(args, "runner-id");
const sourceCommit = optionalFlag(args, "source-commit");
return renderRunnerJobDryRun({
...options,
...(attemptId ? { attemptId } : {}),
...(runnerId ? { runnerId } : {}),
...(sourceCommit ? { sourceCommit } : {}),
});
}
async function renderCodexSecret(args: ParsedArgs): Promise<JsonRecord> {
if (args.flags.get("dry-run") !== true) {
throw new AgentRunError("schema-invalid", "secrets codex render requires --dry-run", { httpStatus: 2 });
}
const options: Parameters<typeof renderCodexProviderSecretPlan>[0] = { dryRun: true };
const profile = optionalFlag(args, "profile");
const codexHome = optionalFlag(args, "codex-home");
const authFile = optionalFlag(args, "auth-file");
const configFile = optionalFlag(args, "config-file");
const modelCatalogFile = optionalFlag(args, "model-catalog-file");
const namespace = optionalFlag(args, "namespace");
const secretName = optionalFlag(args, "secret-name");
if (profile) options.profile = profile;
if (codexHome) options.codexHome = codexHome;
if (authFile) options.authFile = authFile;
if (configFile) options.configFile = configFile;
if (modelCatalogFile) options.modelCatalogFile = modelCatalogFile;
if (namespace) options.namespace = namespace;
if (secretName) options.secretName = secretName;
return renderCodexProviderSecretPlan(options);
}
async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
const profile = normalizeProfile(profileValue);
if (args.flags.get("key-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-key requires --key-stdin", { httpStatus: 2 });
const apiKey = (await readStdinText()).trim();
if (apiKey.length === 0) throw new AgentRunError("schema-invalid", "stdin api key is empty", { httpStatus: 2 });
const body: JsonRecord = {
apiKey,
reason: optionalFlag(args, "reason") ?? "operator-cli",
delegatedBy: {
system: optionalFlag(args, "delegated-system") ?? "operator-cli",
userId: optionalFlag(args, "delegated-user-id") ?? null,
username: optionalFlag(args, "delegated-username") ?? null,
requestId: optionalFlag(args, "delegated-request-id") ?? null,
},
};
const config: JsonRecord = {};
copyOptionalFlag(args, config, "model");
copyOptionalFlag(args, config, "base-url", "baseUrl");
copyOptionalFlag(args, config, "provider-name", "providerName");
copyOptionalFlag(args, config, "env-key", "envKey");
if (Object.keys(config).length > 0) body.config = config;
return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/credential`, body) as JsonRecord;
}
async function setProviderProfileConfig(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
const profile = normalizeProfile(profileValue);
if (args.flags.get("config-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-config requires --config-stdin", { httpStatus: 2 });
const configToml = await readStdinText();
if (configToml.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin config.toml is empty", { httpStatus: 2 });
return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/config`, {
configToml,
reason: optionalFlag(args, "reason") ?? "operator-cli",
delegatedBy: {
system: optionalFlag(args, "delegated-system") ?? "operator-cli",
userId: optionalFlag(args, "delegated-user-id") ?? null,
username: optionalFlag(args, "delegated-username") ?? null,
requestId: optionalFlag(args, "delegated-request-id") ?? null,
},
}) as JsonRecord;
}
async function removeProviderProfileCli(profileValue: string, args: ParsedArgs): Promise<JsonRecord> {
const profile = normalizeProfile(profileValue);
return await client(args).delete(`/api/v1/provider-profiles/${encodeURIComponent(profile)}`) as JsonRecord;
}
async function validateProviderProfileCli(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
const profile = normalizeProfile(profileValue);
const started = await client(args).post(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validate`, {}) as JsonRecord;
if (args.flags.get("wait") !== true) return started;
const validationId = typeof started.validationId === "string" ? started.validationId : "";
if (!validationId) throw new AgentRunError("infra-failed", "provider profile validate response omitted validationId", { httpStatus: 1, details: started });
const timeoutMs = Math.max(1, Number(optionalFlag(args, "timeout-ms") ?? 60_000));
const deadline = Date.now() + timeoutMs;
let latest: JsonRecord = started;
while (Date.now() < deadline) {
await sleep(2_000);
latest = await client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`) as JsonRecord;
if (latest.status === "completed" || latest.status === "failed" || latest.status === "cancelled") return { action: "provider-profile-validation", initial: started, latest, valuesPrinted: false };
}
return { action: "provider-profile-validation", initial: started, latest, timedOut: true, timeoutMs, valuesPrinted: false };
}
async function startServer(args: ParsedArgs): Promise<JsonRecord> {
if (args.flags.get("foreground") === true) return startServerForeground(args);
const port = Number(flag(args, "port", "8080"));
const host = flag(args, "host", "0.0.0.0");
const state = await readServerState(port);
if (state.pidAlive || state.portListening) {
throw new AgentRunError("infra-failed", `agentrun-mgr already appears to be running on port ${port}; use server status or server stop first`, { httpStatus: 409, details: state as JsonRecord });
}
await ensureDir(stateDir());
await ensureDir(logDir());
const logPath = serverLogPath(port);
const argsForChild = [process.argv[1] ?? "scripts/agentrun-cli.ts", "server", "start", "--foreground", "--host", host, "--port", String(port)];
const store = optionalFlag(args, "store");
if (store) argsForChild.push("--store", store);
const stdoutFd = openSync(logPath, "a");
const stderrFd = openSync(logPath, "a");
const child = spawn(process.execPath, argsForChild, {
cwd: process.cwd(),
env: process.env,
detached: true,
stdio: ["ignore", stdoutFd, stderrFd],
});
closeSync(stdoutFd);
closeSync(stderrFd);
child.unref();
const pidFile = pidFilePath(port);
await writeFile(pidFile, JSON.stringify({ pid: child.pid, port, host, logPath, startedAt: new Date().toISOString() }) + "\n", "utf8");
const localBaseUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
return { action: "server-start", mode: "background", serviceId: "agentrun-mgr", pid: child.pid ?? null, port, host, baseUrl: localBaseUrl, pidFile, logPath, pollCommands: { status: `./scripts/agentrun server status --port ${port}`, logs: `./scripts/agentrun server logs --port ${port}`, stop: `./scripts/agentrun server stop --port ${port}` } };
}
async function startServerForeground(args: ParsedArgs): Promise<JsonRecord> {
const port = Number(flag(args, "port", "8080"));
const host = flag(args, "host", "0.0.0.0");
const storeMode = optionalFlag(args, "store") ?? process.env.AGENTRUN_STORE ?? process.env.AGENTRUN_MGR_STORE;
const started = await startManagerServer({ port, host, ...(storeMode === "memory" ? { store: new MemoryAgentRunStore() } : {}) });
const database = await started.store.health();
return { serviceId: "agentrun-mgr", baseUrl: started.baseUrl, pid: process.pid, database, mode: "foreground", note: "foreground process; use server start without --foreground for local background mode" };
}
async function serverStatus(args: ParsedArgs): Promise<JsonRecord> {
const explicitPort = optionalFlag(args, "port");
const baseUrl = explicitPort ? `http://127.0.0.1:${Number(explicitPort)}` : managerUrl(args);
const port = Number(explicitPort ?? portFromUrl(baseUrl));
const state = await readServerState(port);
let readiness: JsonValue = null;
let readinessFailure: JsonRecord | null = null;
if (explicitPort ? state.portListening : true) {
try {
readiness = await new ManagerClient(baseUrl).get("/health/readiness");
} catch (error) {
readinessFailure = errorToJson(error);
}
}
return { action: "server-status", serviceId: "agentrun-mgr", port, baseUrl, local: state as JsonRecord, readiness, readinessFailure, pollCommands: { status: `./scripts/agentrun server status --port ${port}`, logs: `./scripts/agentrun server logs --port ${port}`, stop: `./scripts/agentrun server stop --port ${port}` } };
}
async function serverLogs(args: ParsedArgs): Promise<JsonRecord> {
const port = Number(flag(args, "port", portFromManagerUrl(args)));
const state = await readServerState(port);
const logPath = optionalFlag(args, "log-file") ?? (typeof state.logPath === "string" ? state.logPath : null);
const tailBytes = Number(flag(args, "tail-bytes", "12000"));
if (!logPath) return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath: null, exists: false, tail: "", bytes: 0, truncated: false, message: "no log file recorded for this port" };
if (!existsSync(logPath)) return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath, exists: false, tail: "", bytes: 0, truncated: false, message: "log file does not exist" };
const bytes = await readFile(logPath);
const start = Math.max(0, bytes.byteLength - tailBytes);
return { action: "server-logs", serviceId: "agentrun-mgr", port, logPath, exists: true, bytes: bytes.byteLength, tailBytes, truncated: start > 0, tail: bytes.subarray(start).toString("utf8") };
}
async function stopServer(args: ParsedArgs): Promise<JsonRecord> {
const port = Number(flag(args, "port", portFromManagerUrl(args)));
const before = await readServerState(port);
let signalSent = false;
const beforePid = typeof before.pid === "number" ? before.pid : null;
const beforePortPid = typeof before.portPid === "number" ? before.portPid : null;
if (before.pidAlive === true && beforePid !== null) {
process.kill(beforePid, "SIGTERM");
signalSent = true;
} else if (beforePortPid !== null) {
process.kill(beforePortPid, "SIGTERM");
signalSent = true;
}
await sleep(500);
const after = await readServerState(port);
if (!after.pidAlive && !after.portListening && existsSync(pidFilePath(port))) await rm(pidFilePath(port), { force: true });
return { action: "server-stop", serviceId: "agentrun-mgr", port, signalSent, before: before as JsonRecord, after: after as JsonRecord, stopped: !after.pidAlive && !after.portListening };
}
function client(args: ParsedArgs): ManagerClient {
return new ManagerClient(managerUrl(args));
}
function managerUrl(args: ParsedArgs): string {
const explicit = optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL;
if (explicit && explicit !== "auto") return explicit;
if (explicit === "auto") return resolveRuntimeManagerUrl();
return "http://127.0.0.1:8080";
}
function managerEndpoint(args: ParsedArgs): JsonRecord {
const url = managerUrl(args);
const explicit = optionalFlag(args, "manager-url") ?? process.env.AGENTRUN_MGR_URL ?? null;
return {
action: "manager-endpoint",
managerUrl: url,
source: explicit === "auto" ? "auto" : explicit ? (optionalFlag(args, "manager-url") ? "--manager-url" : "AGENTRUN_MGR_URL") : "default-localhost",
runtimeNamespace: "agentrun-v01",
serviceName: "agentrun-mgr",
valuesPrinted: false,
examples: {
commander: "./scripts/agentrun --manager-url auto queue commander",
sessions: "./scripts/agentrun --manager-url auto sessions ps --state default --reader-id cli",
explicit: "./scripts/agentrun --manager-url http://<reachable-host>:8080 queue commander",
},
};
}
function resolveRuntimeManagerUrl(): string {
const fromEnv = process.env.AGENTRUN_RUNTIME_MANAGER_URL;
if (fromEnv) return fromEnv;
try {
const serviceIp = execFileSync("kubectl", ["-n", "agentrun-v01", "get", "svc", "agentrun-mgr", "-o", "jsonpath={.spec.clusterIP}"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
if (serviceIp.length > 0 && serviceIp !== "None") return `http://${serviceIp}:8080`;
} catch {
// Fall through to localhost so the following request produces a structured connection failure.
}
return "http://127.0.0.1:8080";
}
function portFromManagerUrl(args: ParsedArgs): string {
return portFromUrl(managerUrl(args));
}
function portFromUrl(value: string): string {
try {
const url = new URL(value);
return url.port || (url.protocol === "https:" ? "443" : "80");
} catch {
return "8080";
}
}
function stateDir(): string {
return path.join(process.cwd(), ".state");
}
function logDir(): string {
return path.join(process.cwd(), "logs", new Date().toISOString().slice(0, 10).replace(/-/gu, ""));
}
function pidFilePath(port: number): string {
return path.join(stateDir(), `agentrun-mgr-${port}.pid.json`);
}
function serverLogPath(port: number): string {
return path.join(logDir(), `agentrun-mgr-${port}-${new Date().toISOString().replace(/[:.]/gu, "-")}.jsonl`);
}
async function ensureDir(dir: string): Promise<void> {
await mkdir(dir, { recursive: true });
}
async function readServerState(port: number): Promise<JsonRecord> {
const pidFile = pidFilePath(port);
const pidRecord = await readPidFile(pidFile);
const pid = typeof pidRecord?.pid === "number" ? pidRecord.pid : null;
const pidAlive = pid !== null && isPidAlive(pid);
const portPid = await pidForPort(port);
return { pidFile, pid, pidAlive, port, portListening: portPid !== null, portPid, logPath: typeof pidRecord?.logPath === "string" ? pidRecord.logPath : null, startedAt: typeof pidRecord?.startedAt === "string" ? pidRecord.startedAt : null };
}
async function readPidFile(pidFile: string): Promise<JsonRecord | null> {
try {
return JSON.parse(await readFile(pidFile, "utf8")) as JsonRecord;
} catch {
return null;
}
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function pidForPort(port: number): Promise<number | null> {
const proc = spawn("sh", ["-c", `command -v ss >/dev/null 2>&1 && ss -ltnp 'sport = :${port}' || true`], { stdio: ["ignore", "pipe", "ignore"] });
const chunks: Buffer[] = [];
proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
await new Promise<void>((resolve) => proc.on("close", () => resolve()));
const text = Buffer.concat(chunks).toString("utf8");
const match = text.match(/pid=(\d+)/u);
return match ? Number(match[1]) : null;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function jsonFile(args: ParsedArgs): Promise<JsonRecord> {
const file = optionalFlag(args, "json-file");
if (!file) throw new AgentRunError("schema-invalid", "--json-file is required", { httpStatus: 2 });
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
throw new AgentRunError("schema-invalid", "json file must contain an object", { httpStatus: 2 });
}
async function optionalJsonFile(args: ParsedArgs): Promise<JsonRecord> {
const file = optionalFlag(args, "json-file");
if (!file) return {};
return jsonFile(args);
}
async function optionalRunnerJsonFile(args: ParsedArgs): Promise<JsonRecord> {
const file = optionalFlag(args, "runner-json-file");
if (!file) return {};
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
throw new AgentRunError("schema-invalid", "runner json file must contain an object", { httpStatus: 2 });
}
async function readPrompt(args: ParsedArgs): Promise<string> {
const promptFlag = optionalFlag(args, "prompt");
if (promptFlag) return promptFlag;
const promptFile = optionalFlag(args, "prompt-file");
if (promptFile) {
const text = await readFile(promptFile, "utf8");
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "prompt file is empty", { httpStatus: 2 });
return text;
}
if (args.flags.get("prompt-stdin") === true) {
const text = await readStdinText();
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin prompt is empty", { httpStatus: 2 });
return text;
}
const inline = args.positional.slice(3).join(" ").trim();
if (inline.length > 0) return inline;
throw new AgentRunError("schema-invalid", "prompt is required; use --prompt, --prompt-file, --prompt-stdin, or trailing text", { httpStatus: 2 });
}
async function readStdinText(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
function normalizeProfile(value: string): BackendProfile {
const normalized = value.trim().toLowerCase();
const profile = normalized === "m3" || normalized === "minimax" || normalized === "minimax_m3" ? "minimax-m3" : normalized;
if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `backend profile ${value} is not supported in v0.1`, { httpStatus: 2 });
return profile;
}
function newSessionId(): string {
return `ses_${randomUUID().replace(/-/gu, "")}`;
}
function stringField(record: JsonRecord, key: string, fallback: string): string {
const value = record[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
}
function objectField(record: JsonRecord, key: string, fallback: JsonRecord): JsonRecord {
const value = record[key];
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : fallback;
}
function jsonObjectFlag(args: ParsedArgs, name: string): JsonRecord | null {
const value = optionalFlag(args, name);
if (!value) return null;
const parsed = JSON.parse(value) as unknown;
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as JsonRecord;
throw new AgentRunError("schema-invalid", `--${name} must be a JSON object`, { httpStatus: 2 });
}
function defaultExecutionPolicy(profile: BackendProfile): JsonRecord {
const keys = [...(backendProfileSpec(profile)?.requiredSecretKeys ?? ["auth.json", "config.toml"])] as string[];
return { sandbox: "workspace-write", approval: "never", timeoutMs: 900000, network: "enabled", secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile, secretRef: { name: `agentrun-v01-provider-${profile}`, keys } }] } };
}
function copyOptionalFlag(args: ParsedArgs, target: JsonRecord, flagName: string, key = flagName.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase())): void {
const value = optionalFlag(args, flagName);
if (value) target[key] = value;
}
function readerQuery(args: ParsedArgs): string {
const readerId = optionalFlag(args, "reader-id");
return readerId ? `?readerId=${encodeURIComponent(readerId)}` : "";
}
function parseArgs(argv: string[]): ParsedArgs {
const positional: string[] = [];
const flags = new Map<string, string | boolean>();
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index] ?? "";
if (!item.startsWith("--")) {
positional.push(item);
continue;
}
const key = item.slice(2);
const next = argv[index + 1];
if (next === undefined || next.startsWith("--")) flags.set(key, true);
else {
flags.set(key, next);
index += 1;
}
}
return { positional, flags };
}
function flag(args: ParsedArgs, name: string, fallback: string): string {
const value = args.flags.get(name);
return typeof value === "string" ? value : fallback;
}
function optionalFlag(args: ParsedArgs, name: string): string | null {
const value = args.flags.get(name);
return typeof value === "string" && value.length > 0 ? value : null;
}
function cancelBody(args: ParsedArgs): JsonRecord {
const reason = optionalFlag(args, "reason");
return reason ? { reason } : {};
}
function help(args: ParsedArgs, group?: string): JsonRecord {
const commands = [
"runs create --json-file <run.json>",
"runs show <runId>",
"runs events <runId> --after-seq <n> --limit <n>",
"runs result <runId> [--command-id <commandId>]",
"runs cancel <runId> [--reason <text>]",
"sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--reader-id <reader>]",
"sessions create [sessionId] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--expires-in-days <n>]",
"sessions storage <sessionId>",
"sessions storage <sessionId> --delete",
"sessions show <sessionId> [--reader-id <reader>]",
"sessions turn [sessionId] --json-file <run-base.json> --prompt-file <file> [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-file <job.json>]",
"sessions steer <sessionId> --prompt-file <file>",
"sessions cancel <sessionId> [--reason <text>]",
"sessions trace <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>]",
"sessions output <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>]",
"sessions read <sessionId> [--reader-id <reader>]",
"commands create <runId> --type turn|steer|interrupt --json-file <payload.json>",
"commands show <commandId> --run-id <runId>",
"commands result <commandId> --run-id <runId>",
"commands cancel <commandId> [--reason <text>]",
"runner start --run-id <runId> [--backend codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>]",
"runner job --run-id <runId> --command-id <commandId> [--image <image>] [--runner-manager-url <url>] [--idempotency-key <key>]",
"runner job --dry-run --run-id <runId> --command-id <commandId> --image <image>",
"runner jobs --run-id <runId> [--command-id <commandId>]",
"runner job-status [runnerJobId] --run-id <runId>",
"queue submit --json-file <task.json> [--idempotency-key <key>]",
"queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] [--updated-after <version>]",
"queue show <taskId>",
"queue stats [--queue <queue>]",
"queue commander [--queue <queue>]",
"queue read <taskId> [--reader-id <reader>]",
"queue cancel <taskId> [--reason <text>]",
"queue dispatch <taskId> [--json-file <dispatch.json>] [--idempotency-key <key>] [--image <image>] [--namespace <namespace>]",
"queue refresh <taskId>",
"secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>] [--codex-home <dir>] [--model-catalog-file <file>] [--namespace agentrun-v01] [--secret-name <name>]",
"provider-profiles list",
"provider-profiles show <profile>",
"provider-profiles config <profile>",
"provider-profiles remove <profile>",
"provider-profiles set-key <profile> --key-stdin [--model <model>] [--base-url <url>]",
"provider-profiles set-config <profile> --config-stdin",
"provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]",
"backends list",
"server start [--port <port>] [--host <host>] [--foreground]",
"server status [--port <port>]",
"server logs [--port <port>] [--tail-bytes <bytes>] [--log-file <path>]",
"server stop [--port <port>]",
];
if (group) {
const prefix = `${group} `;
const groupCommands = commands.filter((item) => item === group || item.startsWith(prefix));
return { group, commands: groupCommands, commandCount: groupCommands.length, supported: groupCommands.length > 0, manager: managerEndpoint(args) };
}
return {
groups: [...new Set(commands.map((item) => item.split(" ")[0]).filter(Boolean))].sort(),
commands,
manager: managerEndpoint(args),
};
}
function print(value: JsonRecord): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}