import { readFileSync, writeFileSync } from "node:fs"; import chalk from "chalk"; import { parseDocument } from "yaml"; import { CONFIG_SCHEMA_URL, DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, getDashboardNotificationStorePath, isCanonicalGlobalConfigPath, findConfigFile, normalizeDashboardNotificationLimit, readDashboardNotificationsFromFile, } from "@aoagents/ao-core"; import { applyNotifierRoutingPreset, getNotifierRoutingState, promptNotifierRoutingPreset, resolveRoutingPresetOption, type ClackPrompts, type NotifierRoutingPreset, } from "./notifier-routing.js"; export interface DashboardSetupOptions { limit?: string; refresh?: boolean; status?: boolean; force?: boolean; nonInteractive?: boolean; routingPreset?: string; } interface ConfigContext { configPath: string; rawConfig: Record; } interface DashboardConfig { plugin: "dashboard"; limit: number; } interface ResolvedDashboardSetup { limit: number; routingPreset?: NotifierRoutingPreset; } export class DashboardSetupError extends Error { constructor( message: string, public readonly exitCode = 1, ) { super(message); this.name = "DashboardSetupError"; } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } function readConfigContext(): ConfigContext { const configPath = findConfigFile() ?? undefined; if (!configPath) { throw new DashboardSetupError( "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", ); } const rawYaml = readFileSync(configPath, "utf-8"); const doc = parseDocument(rawYaml); const rawConfig = (doc.toJS() as Record) ?? {}; return { configPath, rawConfig }; } function getExistingDashboard(rawConfig: Record): Record { const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; const existing = notifiers["dashboard"]; return isRecord(existing) ? existing : {}; } function parseLimit(value: unknown): number { if (value === undefined || value === null || value === "") { return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; } const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value.trim(), 10) : Number.NaN; if (!Number.isFinite(parsed)) { throw new DashboardSetupError("Dashboard notification limit must be a number."); } return normalizeDashboardNotificationLimit(parsed); } function resolveDashboardRoutingPreset( value: string | undefined, ): NotifierRoutingPreset | undefined { try { return resolveRoutingPresetOption(value, "dashboard") as NotifierRoutingPreset | undefined; } catch (error) { throw new DashboardSetupError(error instanceof Error ? error.message : String(error)); } } function toDashboardConfig(resolved: ResolvedDashboardSetup): DashboardConfig { return { plugin: "dashboard", limit: resolved.limit, }; } function writeDashboardConfig(configPath: string, resolved: ResolvedDashboardSetup): void { const rawYaml = readFileSync(configPath, "utf-8"); const doc = parseDocument(rawYaml); const rawConfig = (doc.toJS() as Record) ?? {}; const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; notifiers["dashboard"] = toDashboardConfig(resolved); rawConfig["notifiers"] = notifiers; const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; rawConfig["defaults"] = defaults; applyNotifierRoutingPreset(rawConfig, "dashboard", resolved.routingPreset); if (!isCanonicalGlobalConfigPath(configPath)) { const currentSchema = doc.get("$schema"); if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { doc.set("$schema", CONFIG_SCHEMA_URL); } } doc.setIn(["notifiers"], rawConfig["notifiers"]); doc.setIn(["defaults"], rawConfig["defaults"]); if (rawConfig["notificationRouting"] !== undefined) { doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); } writeFileSync(configPath, doc.toString({ indent: 2 })); } function cancelSetup(clack: ClackPrompts): never { clack.cancel("Dashboard setup cancelled."); throw new DashboardSetupError("Dashboard setup cancelled.", 0); } async function shouldReplaceConflictingDashboard( plugin: unknown, force: boolean, nonInteractive: boolean, ): Promise { if (plugin === undefined || plugin === "dashboard" || force) return true; if (nonInteractive) { throw new DashboardSetupError( `notifiers.dashboard already uses plugin "${String(plugin)}". Pass --force to replace it.`, ); } const clack = await import("@clack/prompts"); const answer = await clack.confirm({ message: `notifiers.dashboard already uses plugin "${String(plugin)}". Replace it?`, initialValue: false, }); if (clack.isCancel(answer)) { cancelSetup(clack); } return answer === true; } async function resolveInteractiveSetup( opts: DashboardSetupOptions, existingDashboard: Record, rawConfig: Record, ): Promise { const clack = await import("@clack/prompts"); const optionRoutingPreset = resolveDashboardRoutingPreset(opts.routingPreset); const existingLimit = parseLimit(existingDashboard["limit"]); clack.intro(chalk.bgCyan(chalk.black(" ao setup dashboard "))); while (true) { const limitInput = await clack.text({ message: "How many dashboard notifications should AO keep?", placeholder: String(DEFAULT_DASHBOARD_NOTIFICATION_LIMIT), initialValue: stringValue(opts.limit) ?? String(existingLimit), validate: (value) => { try { parseLimit(value); } catch (error) { return error instanceof Error ? error.message : String(error); } }, }); if (clack.isCancel(limitInput)) { cancelSetup(clack); } const routingSelection = optionRoutingPreset ?? (await promptNotifierRoutingPreset(clack, rawConfig, "dashboard", "dashboard", () => cancelSetup(clack), )); if (routingSelection === "back") continue; return { limit: parseLimit(limitInput), routingPreset: routingSelection === "preserve" ? undefined : routingSelection, }; } } function resolveNonInteractiveSetup( opts: DashboardSetupOptions, existingDashboard: Record, ): ResolvedDashboardSetup { const limit = opts.limit !== undefined ? parseLimit(opts.limit) : opts.refresh ? parseLimit(existingDashboard["limit"]) : DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; const routingPreset = resolveDashboardRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "urgent-action"); return { limit, routingPreset }; } function printStatus(): void { const context = readConfigContext(); const existingDashboard = getExistingDashboard(context.rawConfig); const plugin = stringValue(existingDashboard["plugin"]); const limit = parseLimit(existingDashboard["limit"]); const storePath = getDashboardNotificationStorePath(context.configPath); const records = readDashboardNotificationsFromFile(storePath, limit); const latest = records.at(-1); console.log(chalk.bold("Dashboard notifier status")); console.log(` Config: ${context.configPath}`); console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); console.log(` Limit: ${limit}`); console.log(` Store: ${storePath}`); console.log(` Stored: ${records.length}`); console.log(` Latest: ${latest?.receivedAt ?? chalk.dim("none")}`); console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "dashboard").label}`); } export async function runDashboardSetupAction(opts: DashboardSetupOptions): Promise { const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; const force = Boolean(opts.force); if (opts.status) { printStatus(); return; } const context = readConfigContext(); const existingDashboard = getExistingDashboard(context.rawConfig); const shouldWire = await shouldReplaceConflictingDashboard( existingDashboard["plugin"], force, nonInteractive, ); if (!shouldWire) return; const resolved = nonInteractive ? resolveNonInteractiveSetup(opts, existingDashboard) : await resolveInteractiveSetup(opts, existingDashboard, context.rawConfig); writeDashboardConfig(context.configPath, resolved); console.log(chalk.green(`Config written to ${context.configPath}`)); if (!nonInteractive) { const clack = await import("@clack/prompts"); clack.outro( `${chalk.green("Dashboard setup complete!")} AO will retain the latest ${resolved.limit} dashboard notifications.\n` + chalk.dim(" Test it with: ao notify test --to dashboard --template basic"), ); } else { console.log(chalk.green("\nDashboard setup complete.")); console.log(chalk.dim("Test it with: ao notify test --to dashboard --template basic")); } }