/** * `dench identity / organisation / user / bootstrap / model / * mem aggregate` — CLI surface for the OpenClaw- * parity self-updating harness. * * Mirrors the auth pattern in `cli/cron.ts`: * • Convex calls go through the shared `ConvexHttpClient`. * • Auth resolves via `sessionToken` — accepts either a * `dch_agent_*` agent session token (from `dench login`) or a * unified Dench API key (`DENCH_API_KEY` baked into sandbox * envVars). Server-side `requireCronAccess` dispatches on * bearer prefix. * * Subcommands: * identity show | edit * organisation show | edit * user show | edit * tools show | edit * memory show | append | set (MEMORY.md aggregate) * bootstrap status | complete | reopen | template * model default | model default clear | * model thread | model thread clear * * `edit` / `template` open the * user's `$EDITOR` (default `vi`) to capture the new body. The * stdin path is also supported via `--stdin`, useful in scripts. */ import { execSync } from "node:child_process"; import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { CliArgError, getFlag, hasFlag } from "./lib/cli-args"; class AgentConfigCliError extends Error {} const api = { functions: { agentConfig: { getAgentConfig: makeFunctionReference<"query">( "functions/agentConfig:getAgentConfig", ), updateIdentity: makeFunctionReference<"mutation">( "functions/agentConfig:updateIdentity", ), updateOrganisation: makeFunctionReference<"mutation">( "functions/agentConfig:updateOrganisation", ), updateMemory: makeFunctionReference<"mutation">( "functions/agentConfig:updateMemory", ), updateToolsNotes: makeFunctionReference<"mutation">( "functions/agentConfig:updateToolsNotes", ), updateUserProfile: makeFunctionReference<"mutation">( "functions/agentConfig:updateUserProfile", ), updateBootstrapTemplate: makeFunctionReference<"mutation">( "functions/agentConfig:updateBootstrapTemplate", ), completeBootstrap: makeFunctionReference<"mutation">( "functions/agentConfig:completeBootstrap", ), setDefaultChatModelV2: makeFunctionReference<"mutation">( "functions/agentConfig:setDefaultChatModelV2", ), setThreadModelOverride: makeFunctionReference<"mutation">( "functions/agentConfig:setThreadModelOverride", ), }, }, }; type CliCtx = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; }; function consumeOutputFlag(ctx: CliCtx): void { // Drain even when the parent already resolved JSON mode: otherwise a free // text body or an optional model positional would consume the literal flag. ctx.jsonOutput = hasFlag(ctx.args, "--json") || ctx.jsonOutput; } function out(ctx: CliCtx, value: unknown): void { if (ctx.jsonOutput) { console.log(JSON.stringify(value, null, 2)); return; } if (typeof value === "string") { console.log(value); return; } console.log(JSON.stringify(value, null, 2)); } async function runQuery( ctx: CliCtx, fn: Parameters[0], args: Record = {}, ): Promise { return ctx.convex.query(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } async function runMutation( ctx: CliCtx, fn: Parameters[0], args: Record = {}, ): Promise { return ctx.convex.mutation(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } // ── Editor helpers ──────────────────────────────────────────────────────── async function readContentFromStdin(): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk))); process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")), ); process.stdin.on("error", reject); }); } function openEditor(initial: string, suffix: string): string { const editor = process.env.EDITOR?.trim() || "vi"; const dir = join(tmpdir(), "dench-agent-cli"); mkdirSync(dir, { recursive: true }); const file = join(dir, `${Date.now()}-${suffix}.md`); writeFileSync(file, initial, "utf8"); try { execSync(`${editor} ${file}`, { stdio: "inherit" }); return readFileSync(file, "utf8"); } finally { try { unlinkSync(file); } catch { // best-effort cleanup } } } async function resolveContent(args: string[]): Promise { if (hasFlag(args, "--stdin")) { const text = await readContentFromStdin(); if (!text.trim().length) { throw new AgentConfigCliError("--stdin received empty input"); } return text; } const inline = getFlag(args, "--content"); if (inline) return inline; // Otherwise launch the editor with the existing content as the // starting point. Caller is expected to call this AFTER fetching // the current value. throw new AgentConfigCliError( "No content provided. Use --content '' or --stdin or run without flags to launch $EDITOR.", ); } // ── Loader (one round-trip for all reads + display) ────────────────────── async function loadAgentConfig(ctx: CliCtx): Promise<{ agentConfig: { identity: { content: string; updatedAt: number } | null; organisation: { content: string; updatedAt: number } | null; memoryAggregate: { content: string; updatedAt: number } | null; toolsNotes: { content: string; updatedAt: number } | null; bootstrap: { completed: boolean; completedAt?: number; template?: string; }; }; userProfile: { content: string; updatedAt: number } | null; organizationId: string; }> { const result = (await runQuery( ctx, api.functions.agentConfig.getAgentConfig, )) as { agentConfig: { identity: { content: string; updatedAt: number } | null; organisation: { content: string; updatedAt: number } | null; memoryAggregate: { content: string; updatedAt: number } | null; toolsNotes: { content: string; updatedAt: number } | null; bootstrap: { completed: boolean; completedAt?: number; template?: string; }; }; userProfile: { content: string; updatedAt: number } | null; organizationId: string; }; return result; } // ── Per-section handlers ───────────────────────────────────────────────── async function handleSimpleMagicFile( ctx: CliCtx, args: string[], opts: { label: "identity" | "organisation" | "tools" | "user" | "memory"; field: "identity" | "organisation" | "toolsNotes" | "memoryAggregate"; perMember?: boolean; }, ): Promise { const sub = args[0]; if (!sub || sub === "show") { const context = (await runQuery( ctx, makeFunctionReference<"query">("functions/agentConfig:workspaceContext"), { sections: [opts.label] }, )) as { documents: Array<{ name: string; content?: string }> }; out( ctx, context.documents.find((document) => document.name === opts.label) ?.content || "(empty)", ); return; } if (sub === "edit" || sub === "set") { let content: string; if (args.includes("--stdin") || args.includes("--content")) { content = await resolveContent(args); } else { const config = await loadAgentConfig(ctx); const initial = opts.perMember ? (config.userProfile?.content ?? "") : opts.field === "memoryAggregate" ? (config.agentConfig.memoryAggregate?.content ?? "") : opts.field === "toolsNotes" ? (config.agentConfig.toolsNotes?.content ?? "") : opts.field === "identity" ? (config.agentConfig.identity?.content ?? "") : (config.agentConfig.organisation?.content ?? ""); content = openEditor(initial, opts.label); } if (!content.trim().length) { throw new AgentConfigCliError(`${opts.label} content cannot be empty`); } const fn = (() => { switch (opts.field) { case "identity": return api.functions.agentConfig.updateIdentity; case "organisation": return api.functions.agentConfig.updateOrganisation; case "toolsNotes": return api.functions.agentConfig.updateToolsNotes; case "memoryAggregate": return api.functions.agentConfig.updateMemory; } })(); if (opts.perMember) { await runMutation(ctx, api.functions.agentConfig.updateUserProfile, { content, }); out(ctx, { ok: true, kind: "user_profile" }); return; } if (opts.field === "memoryAggregate") { await runMutation(ctx, api.functions.agentConfig.updateMemory, { content, append: hasFlag(args, "--append"), }); out(ctx, { ok: true, kind: "memory" }); return; } await runMutation(ctx, fn, { content }); out(ctx, { ok: true, kind: opts.label }); return; } throw new AgentConfigCliError( `Unknown ${opts.label} subcommand: "${sub}". Try "show" or "edit".`, ); } async function handleBootstrap(ctx: CliCtx, args: string[]): Promise { const sub = args[0]; if (!sub || sub === "status" || sub === "show") { const config = await loadAgentConfig(ctx); out( ctx, ctx.jsonOutput ? config.agentConfig.bootstrap : `completed=${config.agentConfig.bootstrap.completed}${ config.agentConfig.bootstrap.completedAt ? ` at ${new Date(config.agentConfig.bootstrap.completedAt).toISOString()}` : "" }`, ); return; } if (sub === "complete") { await runMutation(ctx, api.functions.agentConfig.completeBootstrap, { completed: true, }); out(ctx, { ok: true, completed: true }); return; } if (sub === "reopen") { await runMutation(ctx, api.functions.agentConfig.completeBootstrap, { completed: false, }); out(ctx, { ok: true, completed: false }); return; } if (sub === "template") { let content: string; if (args.includes("--stdin") || args.includes("--content")) { content = await resolveContent(args); } else { const config = await loadAgentConfig(ctx); content = openEditor( config.agentConfig.bootstrap.template ?? "", "bootstrap", ); } if (!content.trim().length) { throw new AgentConfigCliError("bootstrap template cannot be empty"); } await runMutation(ctx, api.functions.agentConfig.updateBootstrapTemplate, { content, }); out(ctx, { ok: true, kind: "bootstrap_template" }); return; } throw new AgentConfigCliError( `Unknown bootstrap subcommand: "${sub}". Try "status", "complete", "reopen", "template".`, ); } async function handleModel(ctx: CliCtx, args: string[]): Promise { const sub = args[0]; if (sub === "default") { const arg = args[1]; if (!arg || arg === "show") { const config = await loadAgentConfig(ctx); // org default lives outside agentConfig — use the same query // but pull from a separate getMyOrganization call. For now we // surface "(use the dashboard for the current value)". out( ctx, ctx.jsonOutput ? { organizationId: config.organizationId } : "Use `dench identity show` and your settings page to view the active default; this command sets the value.", ); return; } if (arg === "clear") { await runMutation(ctx, api.functions.agentConfig.setDefaultChatModelV2, { modelId: null, }); out(ctx, { ok: true, defaultChatModelId: null }); return; } await runMutation(ctx, api.functions.agentConfig.setDefaultChatModelV2, { modelId: arg, }); out(ctx, { ok: true, defaultChatModelId: arg }); return; } if (sub === "thread") { const threadId = args[1]; const target = args[2]; if (!threadId || !target) { throw new AgentConfigCliError( "Usage: dench model thread ", ); } if (target === "clear") { await runMutation(ctx, api.functions.agentConfig.setThreadModelOverride, { threadId: threadId as never, modelId: null, }); out(ctx, { ok: true, threadId, modelOverride: null }); return; } await runMutation(ctx, api.functions.agentConfig.setThreadModelOverride, { threadId: threadId as never, modelId: target, }); out(ctx, { ok: true, threadId, modelOverride: target }); return; } throw new AgentConfigCliError( `Unknown model subcommand: "${sub}". Try "default " or "thread ".`, ); } // ── Entry points consumed by cli/dench.ts ──────────────────────────────── export async function runIdentityCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); await handleSimpleMagicFile(ctx, ctx.args, { label: "identity", field: "identity", }); } export async function runOrganisationCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); await handleSimpleMagicFile(ctx, ctx.args, { label: "organisation", field: "organisation", }); } export async function runUserCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); await handleSimpleMagicFile(ctx, ctx.args, { label: "user", field: "identity", // unused (perMember=true) perMember: true, }); } export async function runToolsCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); await handleSimpleMagicFile(ctx, ctx.args, { label: "tools", field: "toolsNotes", }); } /** * Aggregate-MEMORY operations (separate from `dench memory * search/save` which still hit `workspaceMemories`). Subcommands: * show | append | set */ export async function runMemoryAggregateCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); const sub = ctx.args[0]; if (!sub || sub === "show") { const config = await loadAgentConfig(ctx); out(ctx, config.agentConfig.memoryAggregate?.content ?? "(empty)"); return; } if (sub === "append") { const text = ctx.args.slice(1).join(" ").trim(); if (!text) throw new AgentConfigCliError("Usage: dench mem append "); await runMutation(ctx, api.functions.agentConfig.updateMemory, { content: text, append: true, }); out(ctx, { ok: true, kind: "memory_aggregate", appended: true }); return; } if (sub === "set" || sub === "edit") { let content: string; if (ctx.args.includes("--stdin") || ctx.args.includes("--content")) { content = await resolveContent(ctx.args); } else { const config = await loadAgentConfig(ctx); content = openEditor( config.agentConfig.memoryAggregate?.content ?? "", "memory", ); } if (!content.trim().length) { throw new AgentConfigCliError("memory cannot be empty"); } await runMutation(ctx, api.functions.agentConfig.updateMemory, { content, append: false, }); out(ctx, { ok: true, kind: "memory_aggregate" }); return; } throw new AgentConfigCliError( `Unknown mem subcommand: "${sub}". Try "show", "append ", "set".`, ); } export async function runBootstrapCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); await handleBootstrap(ctx, ctx.args); } export async function runModelCommand(ctx: CliCtx): Promise { consumeOutputFlag(ctx); await handleModel(ctx, ctx.args); } export function printAgentConfigCliHelp(): void { console.log( `Workspace context compatibility aliases (prefer dench context --sections / context set): dench identity show | edit IDENTITY.md (org-wide) dench organisation show | edit ORGANISATION.md dench user show | edit USER.md (per-member) dench tools show | edit TOOLS.md notes section dench mem show | append | set MEMORY.md aggregate dench bootstrap status | complete | reopen | template dench model default | model thread Editing flags: --content "" Inline body --stdin Read body from stdin (default) Launch $EDITOR ($EDITOR or vi) Auth: --json Machine-readable output Bearer token via DENCH_API_KEY or \`dench login\` session.`, ); } // Re-export error so callers can branch on it export { AgentConfigCliError, CliArgError };