Menu
popagent
publicLatest change b6d33c15415ad98991d8aaafd44b4b993d19db65 - Add popagent settings-patch and document agent step-budget mechanics by AkurAI Build
#!/usr/bin/env bun
const baseUrl = (process.env.POPAGENT_URL ?? "http://127.0.0.1:5180").replace(/\/$/, "");
const [command = "help", ...args] = Bun.argv.slice(2);
const usage = `Usage: popagent <command> [arguments]
One-shot commands:
popagent help
popagent api <METHOD> <path> [json]
popagent agents
popagent agent-health
popagent agent-update <agent> <json>
popagent skills <agent>
popagent skill-create <agent> <json>
popagent skill-update <agent> <skill> <json>
popagent skill-delete <agent> <skill>
popagent workspaces
popagent workspace-create <json>
popagent workspace-update <workspace> <json>
popagent workspace-delete <workspace>
popagent docs <workspace>
popagent doc-read <workspace> <path>
popagent doc-search <query> [workspace]
popagent repo-brief <workspace> <query>
popagent code-context <workspace> <query>
popagent symbol-context <workspace> <symbol> [file]
popagent change-impact <workspace> [json]
popagent doc-save <workspace> <json>
popagent doc-move <workspace> <json>
popagent doc-delete <workspace> <json>
popagent folder-create <workspace> <json>
popagent folder-delete <workspace> <json>
popagent docs-reindex <workspace>
popagent tasks [workspace]
popagent workflow-tasks [workspace]
popagent tasks-attention [workspace]
popagent task <task-id>
popagent task-create <workspace> <prompt> [model]
popagent workflow-create <workspace> <prompt> [model]
popagent task-cancel <task-id>
popagent task-requeue <task-id> [model]
popagent task-delete <task-id>
popagent task-resolve <task-id> <json>
popagent signals [limit]
popagent automation-status
popagent automation-reconcile
popagent automation-run <workspace>
popagent signal-ignore <signal-id>
popagent signal-retry <signal-id>
popagent schedules [workspace]
popagent schedule-create <json>
popagent schedule-update <schedule> <json>
popagent schedule-delete <schedule>
popagent settings <section>
popagent settings-update <section> <json>
popagent settings-patch <section> <json>
JSON arguments are passed unchanged to the API, except for settings-patch, which
merges the given fields onto the current section because settings PATCH bodies
are validated as complete objects.
Popagent Docs is canonical. Do not create or update AkurAI Notes.
Environment:
POPAGENT_URL API base URL (default: http://127.0.0.1:5180)
POPAGENT_API_KEY Sent only as x-popagent-key; never printed
`;
function fail(message: string): never {
console.error(`popagent: ${message}`);
process.exit(2);
}
async function api(method: string, path: string, body?: string): Promise<void> {
if (!path.startsWith("/api/")) fail("path must begin with /api/");
const headers: Record<string, string> = {};
if (process.env.POPAGENT_API_KEY) headers["x-popagent-key"] = process.env.POPAGENT_API_KEY;
if (body !== undefined) headers["content-type"] = "application/json";
const response = await fetch(`${baseUrl}${path}`, { method, headers, body });
const text = await response.text();
if (text) console.log(text);
if (!response.ok) process.exit(1);
}
const endpoint = (base: string, id: string, suffix = "") => `${base}/${encodeURIComponent(id)}${suffix}`;
async function collectionJson(method: string, path: string, values: string[], name: string) {
const [body] = values;
if (!body) fail(`${name} requires json`);
await api(method, path, body);
}
async function resource(method: string, base: string, values: string[], name: string, suffix = "") {
const [id] = values;
if (!id) fail(`${name} requires an id`);
await api(method, endpoint(base, id, suffix));
}
async function resourceGet(base: string, values: string[], name: string, suffix = "", method = "GET") {
await resource(method, base, values, name, suffix);
}
async function resourceJson(method: string, base: string, values: string[], name: string, suffix = "") {
const [id, body] = values;
if (!id || !body) fail(`${name} requires an id and json`);
await api(method, endpoint(base, id, suffix), body);
}
async function nestedJson(method: string, base: string, values: string[], name: string, suffix: string) {
const [id, body] = values;
if (!id || !body) fail(`${name} requires an id and json`);
await api(method, endpoint(base, id, suffix), body);
}
async function nestedResource(method: string, base: string, values: string[], name: string, suffix: string) {
const [parent, id] = values;
if (!parent || !id) fail(`${name} requires two ids`);
await api(method, `${endpoint(base, parent, suffix)}/${encodeURIComponent(id)}`);
}
async function nestedResourceJson(method: string, base: string, values: string[], name: string, suffix: string) {
const [parent, id, body] = values;
if (!parent || !id || !body) fail(`${name} requires two ids and json`);
await api(method, `${endpoint(base, parent, suffix)}/${encodeURIComponent(id)}`, body);
}
async function settingsPatch(values: string[]) {
const [section, body] = values;
if (!section || !body) fail("settings-patch requires a section and json");
let patch: unknown;
try {
patch = JSON.parse(body);
} catch {
fail("settings-patch requires a json object");
}
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
fail("settings-patch requires a json object");
}
const path = `/api/settings/${encodeURIComponent(section)}`;
const response = await fetch(`${baseUrl}${path}`, {
headers: process.env.POPAGENT_API_KEY ? { "x-popagent-key": process.env.POPAGENT_API_KEY } : {},
});
const current = (await response.json()) as Record<string, unknown>;
if (!response.ok) { console.error(JSON.stringify(current)); process.exit(1); }
// Settings routes validate the whole section and reject unknown keys, so the
// server-owned `updatedAt` is dropped rather than echoed back.
const { updatedAt: _updatedAt, ...settings } = current;
await api("PATCH", path, JSON.stringify({ ...settings, ...patch }));
}
async function optionalWorkspaceList(path: string, values: string[]) {
const [workspace] = values;
await api("GET", `${path}${workspace ? `?workspaceId=${encodeURIComponent(workspace)}` : ""}`);
}
switch (command) {
case "help":
case "--help":
case "-h":
console.log(usage);
break;
case "api": {
const [method, path, body] = args;
if (!method || !path) fail("api requires METHOD and path");
await api(method.toUpperCase(), path, body);
break;
}
case "agents": await api("GET", "/api/agents"); break;
case "agent-health": await api("GET", "/api/agents/health"); break;
case "agent-update": await resourceJson("PATCH", "/api/agents", args, "agent-update"); break;
case "skills": await resourceGet("/api/agents", args, "skills", "/skills"); break;
case "skill-create": await nestedJson("POST", "/api/agents", args, "skill-create", "/skills"); break;
case "skill-update": await nestedResourceJson("PATCH", "/api/agents", args, "skill-update", "/skills"); break;
case "skill-delete": await nestedResource("DELETE", "/api/agents", args, "skill-delete", "/skills"); break;
case "workspaces": await api("GET", "/api/workspaces"); break;
case "workspace-create": await collectionJson("POST", "/api/workspaces", args, "workspace-create"); break;
case "workspace-update": await resourceJson("PATCH", "/api/workspaces", args, "workspace-update"); break;
case "workspace-delete": await resource("DELETE", "/api/workspaces", args, "workspace-delete"); break;
case "docs": await resourceGet("/api/workspaces", args, "docs", "/docs"); break;
case "doc-read": {
const [workspace, path] = args;
if (!workspace || !path) fail("doc-read requires workspace and path");
await api("GET", `/api/workspaces/${encodeURIComponent(workspace)}/docs/content?path=${encodeURIComponent(path)}`);
break;
}
case "doc-search": {
const [query, workspace] = args;
if (!query) fail("doc-search requires a query");
await api("GET", `/api/docs/search?q=${encodeURIComponent(query)}${workspace ? `&workspaceId=${encodeURIComponent(workspace)}` : ""}`);
break;
}
case "repo-brief": {
const [workspace, query] = args;
if (!workspace || !query) fail("repo-brief requires workspace and query");
await api("GET", `/api/workspaces/${encodeURIComponent(workspace)}/repo-brief?q=${encodeURIComponent(query)}`);
break;
}
case "code-context": {
const [workspace, query] = args;
if (!workspace || !query) fail("code-context requires workspace and query");
await api("GET", `/api/workspaces/${encodeURIComponent(workspace)}/code-context?q=${encodeURIComponent(query)}`);
break;
}
case "symbol-context": {
const [workspace, symbol, filePath] = args;
if (!workspace || !symbol) fail("symbol-context requires workspace and symbol");
await api(
"GET",
`/api/workspaces/${encodeURIComponent(workspace)}/symbol-context?symbol=${encodeURIComponent(symbol)}${filePath ? `&filePath=${encodeURIComponent(filePath)}` : ""}`,
);
break;
}
case "change-impact": {
const [workspace, body = "{}"] = args;
if (!workspace) fail("change-impact requires workspace");
await api("POST", `/api/workspaces/${encodeURIComponent(workspace)}/change-impact`, body);
break;
}
case "doc-save": await nestedJson("POST", "/api/workspaces", args, "doc-save", "/docs"); break;
case "doc-move": await nestedJson("PATCH", "/api/workspaces", args, "doc-move", "/docs"); break;
case "doc-delete": await nestedJson("DELETE", "/api/workspaces", args, "doc-delete", "/docs"); break;
case "folder-create": await nestedJson("POST", "/api/workspaces", args, "folder-create", "/docs/folders"); break;
case "folder-delete": await nestedJson("DELETE", "/api/workspaces", args, "folder-delete", "/docs/folders"); break;
case "docs-reindex": await resourceGet("/api/workspaces", args, "docs-reindex", "/docs/reindex", "POST"); break;
case "tasks": await optionalWorkspaceList("/api/tasks", args); break;
case "workflow-tasks": {
const [workspace] = args;
await api("GET", `/api/tasks?workflow=true${workspace ? `&workspaceId=${encodeURIComponent(workspace)}` : ""}`);
break;
}
case "tasks-attention": {
const [workspace] = args;
const response = await fetch(
`${baseUrl}/api/tasks${workspace ? `?workspaceId=${encodeURIComponent(workspace)}` : ""}`,
{ headers: process.env.POPAGENT_API_KEY ? { "x-popagent-key": process.env.POPAGENT_API_KEY } : {} },
);
const body = (await response.json()) as { tasks: Array<{ status: string }> };
if (!response.ok) { console.error(JSON.stringify(body)); process.exit(1); }
const attention = body.tasks.filter((task) => task.status === "failed" || task.status === "cancelled" || task.status === "dead-letter");
console.log(JSON.stringify({ tasks: attention }));
break;
}
case "task": await resourceGet("/api/tasks", args, "task"); break;
case "task-create": {
const [workspaceId, prompt, model] = args;
if (!workspaceId || !prompt) fail("task-create requires workspace and prompt");
await api("POST", "/api/tasks", JSON.stringify({ workspaceId, prompt, ...(model ? { model } : {}) }));
break;
}
case "workflow-create": {
const [workspaceId, prompt, model] = args;
if (!workspaceId || !prompt) fail("workflow-create requires workspace and prompt");
await api("POST", "/api/tasks", JSON.stringify({ workspaceId, prompt, workflow: true, ...(model ? { model } : {}) }));
break;
}
case "task-cancel": await resource("POST", "/api/tasks", args, "task-cancel", "/cancel"); break;
case "task-requeue": {
const [taskId, model] = args;
if (!taskId) fail("task-requeue requires a task id");
const response = await fetch(`${baseUrl}/api/tasks/${encodeURIComponent(taskId)}`, {
headers: process.env.POPAGENT_API_KEY ? { "x-popagent-key": process.env.POPAGENT_API_KEY } : {},
});
const original = (await response.json()) as { workspaceId?: string; prompt?: string; model?: string; source?: string };
if (!response.ok) { console.error(JSON.stringify(original)); process.exit(1); }
if (!original.workspaceId || !original.prompt) fail(`task-requeue: task ${taskId} has no requeueable workspaceId/prompt`);
await api(
"POST",
"/api/tasks",
JSON.stringify({ workspaceId: original.workspaceId, prompt: original.prompt, ...(model ?? original.model ? { model: model ?? original.model } : {}) }),
);
break;
}
case "task-delete": await resource("DELETE", "/api/tasks", args, "task-delete"); break;
case "task-resolve": await resourceJson("POST", "/api/tasks", args, "task-resolve", "/resolve"); break;
case "automation-status": await api("GET", "/api/autonomy/status"); break;
case "automation-run": {
const [workspaceId] = args;
if (!workspaceId) fail("automation-run requires workspace");
await api("POST", "/api/autonomy/run", JSON.stringify({ workspaceId }));
break;
}
case "automation-reconcile": await api("POST", "/api/autonomy/reconcile"); break;
case "signals": {
const [limit] = args;
await api("GET", `/api/autonomy/signals${limit ? `?limit=${encodeURIComponent(limit)}` : ""}`);
break;
}
case "signal-ignore": await resource("POST", "/api/autonomy/signals", args, "signal-ignore", "/ignore"); break;
case "signal-retry": await resource("POST", "/api/autonomy/signals", args, "signal-retry", "/retry"); break;
case "schedules": await optionalWorkspaceList("/api/schedules", args); break;
case "schedule-create": await collectionJson("POST", "/api/schedules", args, "schedule-create"); break;
case "schedule-update": await resourceJson("PATCH", "/api/schedules", args, "schedule-update"); break;
case "schedule-delete": await resource("DELETE", "/api/schedules", args, "schedule-delete"); break;
case "settings": await resourceGet("/api/settings", args, "settings"); break;
case "settings-update": await resourceJson("PATCH", "/api/settings", args, "settings-update"); break;
case "settings-patch": await settingsPatch(args); break;
default: fail(`unknown command: ${command}\n\n${usage}`);
}