From 7e4a20645cc7ba5db9f5d571bf0bcb7c9b204524 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Wed, 18 Feb 2026 13:02:12 -0600 Subject: [PATCH] Improve CLI: config store, heartbeat-run, and onboarding Rework config store with better file handling. Expand heartbeat-run command with richer output and error reporting. Improve configure and onboard commands. Update doctor checks. Co-Authored-By: Claude Opus 4.6 --- cli/src/checks/config-check.ts | 2 +- cli/src/commands/configure.ts | 38 ++++++- cli/src/commands/doctor.ts | 18 +++- cli/src/commands/heartbeat-run.ts | 172 +++++++++++++++++++++++++++--- cli/src/commands/onboard.ts | 27 +++-- cli/src/config/store.ts | 60 ++++++++++- cli/src/index.ts | 1 + package.json | 2 +- 8 files changed, 286 insertions(+), 34 deletions(-) diff --git a/cli/src/checks/config-check.ts b/cli/src/checks/config-check.ts index 6b81b7a1..e74a24d0 100644 --- a/cli/src/checks/config-check.ts +++ b/cli/src/checks/config-check.ts @@ -27,7 +27,7 @@ export function configCheck(configPath?: string): CheckResult { status: "fail", message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`, canRepair: false, - repairHint: "Run `paperclip onboard` to recreate", + repairHint: "Run `paperclip configure --section database` (or `paperclip onboard` to recreate)", }; } } diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index aaaf1a1c..aef663ae 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -16,6 +16,29 @@ const SECTION_LABELS: Record = { server: "Server", }; +function defaultConfig(): PaperclipConfig { + return { + $meta: { + version: 1, + updatedAt: new Date().toISOString(), + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "./data/embedded-postgres", + embeddedPostgresPort: 54329, + }, + logging: { + mode: "file", + logDir: "./data/logs", + }, + server: { + port: 3100, + serveUi: false, + }, + }; +} + export async function configure(opts: { config?: string; section?: string; @@ -28,11 +51,16 @@ export async function configure(opts: { return; } - const config = readConfig(opts.config); - if (!config) { - p.log.error("Could not read config file. Run `paperclip onboard` to recreate."); - p.outro(""); - return; + let config: PaperclipConfig; + try { + config = readConfig(opts.config) ?? defaultConfig(); + } catch (err) { + p.log.message( + pc.yellow( + `Existing config is invalid. Loading defaults so you can repair it now.\n${err instanceof Error ? err.message : String(err)}`, + ), + ); + config = defaultConfig(); } let section: Section | undefined = opts.section as Section | undefined; diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index e870ac72..ac1b87c3 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,5 +1,6 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; +import type { PaperclipConfig } from "../config/schema.js"; import { readConfig } from "../config/store.js"; import { configCheck, @@ -35,7 +36,22 @@ export async function doctor(opts: { return; } - const config = readConfig(opts.config)!; + let config: PaperclipConfig; + try { + config = readConfig(opts.config)!; + } catch (err) { + const readResult: CheckResult = { + name: "Config file", + status: "fail", + message: `Could not read config: ${err instanceof Error ? err.message : String(err)}`, + canRepair: false, + repairHint: "Run `paperclip configure --section database` or `paperclip onboard`", + }; + results.push(readResult); + printResult(readResult); + printSummary(results); + return; + } // 2. Database check const dbResult = await databaseCheck(config); diff --git a/cli/src/commands/heartbeat-run.ts b/cli/src/commands/heartbeat-run.ts index 2ad958e6..82975076 100644 --- a/cli/src/commands/heartbeat-run.ts +++ b/cli/src/commands/heartbeat-run.ts @@ -23,9 +23,11 @@ interface HeartbeatRunOptions { source: string; trigger: string; timeoutMs: string; + debug?: boolean; } export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { + const debug = Boolean(opts.debug); const parsedTimeout = Number.parseInt(opts.timeoutMs, 10); const timeoutMs = Number.isFinite(parsedTimeout) ? parsedTimeout : 0; const source = HEARTBEAT_SOURCES.includes(opts.source as HeartbeatSource) @@ -35,7 +37,16 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { ? (opts.trigger as HeartbeatTrigger) : "manual"; - const config = readConfig(opts.config); + let config: PaperclipConfig | null = null; + try { + config = readConfig(opts.config); + } catch (err) { + console.error( + pc.yellow( + `Config warning: ${err instanceof Error ? err.message : String(err)}\nContinuing with API base fallback settings.`, + ), + ); + } const apiBase = getApiBase(config, opts.apiBase); const agent = await requestJson(`${apiBase}/api/agents/${opts.agentId}`, { @@ -73,6 +84,143 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { let activeRunId: string | null = null; let lastEventSeq = 0; let logOffset = 0; + let stdoutJsonBuffer = ""; + + const printRawChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => { + if (stream === "stdout") process.stdout.write(pc.green("[stdout] ") + chunk); + else if (stream === "stderr") process.stdout.write(pc.red("[stderr] ") + chunk); + else process.stdout.write(pc.yellow("[system] ") + chunk); + }; + + const printAdapterInvoke = (payload: Record) => { + const adapterType = typeof payload.adapterType === "string" ? payload.adapterType : "unknown"; + const command = typeof payload.command === "string" ? payload.command : ""; + const cwd = typeof payload.cwd === "string" ? payload.cwd : ""; + const args = + Array.isArray(payload.commandArgs) && + (payload.commandArgs as unknown[]).every((v) => typeof v === "string") + ? (payload.commandArgs as string[]) + : []; + const env = + typeof payload.env === "object" && payload.env !== null && !Array.isArray(payload.env) + ? (payload.env as Record) + : null; + const prompt = typeof payload.prompt === "string" ? payload.prompt : ""; + const context = + typeof payload.context === "object" && payload.context !== null && !Array.isArray(payload.context) + ? (payload.context as Record) + : null; + + console.log(pc.cyan(`Adapter: ${adapterType}`)); + if (cwd) console.log(pc.cyan(`Working dir: ${cwd}`)); + if (command) { + const rendered = args.length > 0 ? `${command} ${args.join(" ")}` : command; + console.log(pc.cyan(`Command: ${rendered}`)); + } + if (env) { + console.log(pc.cyan("Env:")); + console.log(pc.gray(JSON.stringify(env, null, 2))); + } + if (context) { + console.log(pc.cyan("Context:")); + console.log(pc.gray(JSON.stringify(context, null, 2))); + } + if (prompt) { + console.log(pc.cyan("Prompt:")); + console.log(prompt); + } + }; + + const printClaudeStreamEvent = (raw: string) => { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = typeof parsed.type === "string" ? parsed.type : ""; + + if (type === "system" && parsed.subtype === "init") { + const model = typeof parsed.model === "string" ? parsed.model : "unknown"; + const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : ""; + console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`)); + return; + } + + if (type === "assistant") { + const message = + typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message) + ? (parsed.message as Record) + : {}; + const content = Array.isArray(message.content) ? message.content : []; + for (const blockRaw of content) { + if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue; + const block = blockRaw as Record; + const blockType = typeof block.type === "string" ? block.type : ""; + if (blockType === "text") { + const text = typeof block.text === "string" ? block.text : ""; + if (text) console.log(pc.green(`assistant: ${text}`)); + } else if (blockType === "tool_use") { + const name = typeof block.name === "string" ? block.name : "unknown"; + console.log(pc.yellow(`tool_call: ${name}`)); + if (block.input !== undefined) { + console.log(pc.gray(JSON.stringify(block.input, null, 2))); + } + } + } + return; + } + + if (type === "result") { + const usage = + typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage) + ? (parsed.usage as Record) + : {}; + const input = Number(usage.input_tokens ?? 0); + const output = Number(usage.output_tokens ?? 0); + const cached = Number(usage.cache_read_input_tokens ?? 0); + const cost = Number(parsed.total_cost_usd ?? 0); + const resultText = typeof parsed.result === "string" ? parsed.result : ""; + if (resultText) { + console.log(pc.green("result:")); + console.log(resultText); + } + console.log( + pc.blue( + `tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`, + ), + ); + return; + } + + if (debug) { + console.log(pc.gray(line)); + } + }; + + const handleStreamChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => { + if (debug) { + printRawChunk(stream, chunk); + return; + } + + if (stream !== "stdout") { + printRawChunk(stream, chunk); + return; + } + + const combined = stdoutJsonBuffer + chunk; + const lines = combined.split(/\r?\n/); + stdoutJsonBuffer = lines.pop() ?? ""; + for (const line of lines) { + printClaudeStreamEvent(line); + } + }; const handleEvent = (event: HeartbeatRunEventRecord) => { const payload = normalizePayload(event.payload); @@ -88,16 +236,14 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { if (status) { console.log(pc.blue(`[status] ${status}`)); } + } else if (eventType === "adapter.invoke") { + printAdapterInvoke(payload); } else if (eventType === "heartbeat.run.log") { const stream = typeof payload.stream === "string" ? payload.stream : "system"; const chunk = typeof payload.chunk === "string" ? payload.chunk : ""; if (!chunk) return; - if (stream === "stdout") { - process.stdout.write(pc.green("[stdout] ") + chunk); - } else if (stream === "stderr") { - process.stdout.write(pc.red("[stderr] ") + chunk); - } else { - process.stdout.write(pc.yellow("[system] ") + chunk); + if (stream === "stdout" || stream === "stderr" || stream === "system") { + handleStreamChunk(stream, chunk); } } else if (typeof event.message === "string") { console.log(pc.gray(`[event] ${eventType || "heartbeat.run.event"}: ${event.message}`)); @@ -164,13 +310,7 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { if (!chunk) continue; const parsed = safeParseLogLine(chunk); if (!parsed) continue; - if (parsed.stream === "stdout") { - process.stdout.write(pc.green("[stdout] ") + parsed.chunk); - } else if (parsed.stream === "stderr") { - process.stdout.write(pc.red("[stderr] ") + parsed.chunk); - } else { - process.stdout.write(pc.yellow("[system] ") + parsed.chunk); - } + handleStreamChunk(parsed.stream, parsed.chunk); } if (typeof logResult.nextOffset === "number") { logOffset = logResult.nextOffset; @@ -183,6 +323,10 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { } if (finalStatus) { + if (!debug && stdoutJsonBuffer.trim()) { + printClaudeStreamEvent(stdoutJsonBuffer); + stdoutJsonBuffer = ""; + } const label = `Run ${activeRunId} completed with status ${finalStatus}`; if (finalStatus === "succeeded") { console.log(pc.green(label)); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index eb860777..b92463fb 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -12,17 +12,24 @@ export async function onboard(opts: { config?: string }): Promise { // Check for existing config if (configExists(opts.config)) { - const existing = readConfig(opts.config); - if (existing) { - const overwrite = await p.confirm({ - message: "A config file already exists. Overwrite it?", - initialValue: false, - }); + try { + readConfig(opts.config); + } catch (err) { + p.log.message( + pc.yellow( + `Existing config appears invalid and will be replaced if you continue.\n${err instanceof Error ? err.message : String(err)}`, + ), + ); + } - if (p.isCancel(overwrite) || !overwrite) { - p.cancel("Keeping existing configuration."); - return; - } + const overwrite = await p.confirm({ + message: "A config file already exists. Overwrite it?", + initialValue: false, + }); + + if (p.isCancel(overwrite) || !overwrite) { + p.cancel("Keeping existing configuration."); + return; } } diff --git a/cli/src/config/store.ts b/cli/src/config/store.ts index 18e91785..e7b59ebf 100644 --- a/cli/src/config/store.ts +++ b/cli/src/config/store.ts @@ -10,11 +10,67 @@ export function resolveConfigPath(overridePath?: string): string { return path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); } +function parseJson(filePath: string): unknown { + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")); + } catch (err) { + throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function migrateLegacyConfig(raw: unknown): unknown { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw; + const config = { ...(raw as Record) }; + const databaseRaw = config.database; + if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) { + return config; + } + + const database = { ...(databaseRaw as Record) }; + if (database.mode === "pglite") { + database.mode = "embedded-postgres"; + + if (typeof database.embeddedPostgresDataDir !== "string" && typeof database.pgliteDataDir === "string") { + database.embeddedPostgresDataDir = database.pgliteDataDir; + } + if ( + typeof database.embeddedPostgresPort !== "number" && + typeof database.pglitePort === "number" && + Number.isFinite(database.pglitePort) + ) { + database.embeddedPostgresPort = database.pglitePort; + } + } + + config.database = database; + return config; +} + +function formatValidationError(err: unknown): string { + const issues = (err as { issues?: Array<{ path?: unknown; message?: unknown }> })?.issues; + if (Array.isArray(issues) && issues.length > 0) { + return issues + .map((issue) => { + const pathParts = Array.isArray(issue.path) ? issue.path.map(String) : []; + const issuePath = pathParts.length > 0 ? pathParts.join(".") : "config"; + const message = typeof issue.message === "string" ? issue.message : "Invalid value"; + return `${issuePath}: ${message}`; + }) + .join("; "); + } + return err instanceof Error ? err.message : String(err); +} + export function readConfig(configPath?: string): PaperclipConfig | null { const filePath = resolveConfigPath(configPath); if (!fs.existsSync(filePath)) return null; - const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")); - return paperclipConfigSchema.parse(raw); + const raw = parseJson(filePath); + const migrated = migrateLegacyConfig(raw); + const parsed = paperclipConfigSchema.safeParse(migrated); + if (!parsed.success) { + throw new Error(`Invalid config at ${filePath}: ${formatValidationError(parsed.error)}`); + } + return parsed.data; } export function writeConfig( diff --git a/cli/src/index.ts b/cli/src/index.ts index 765b6b63..ff052fbf 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -49,6 +49,7 @@ heartbeat ) .option("--trigger ", "Trigger detail (manual | ping | callback | system)", "manual") .option("--timeout-ms ", "Max time to wait before giving up", "0") + .option("--debug", "Show raw adapter stdout/stderr JSON chunks") .action(heartbeatRun); program.parse(); diff --git a/package.json b/package.json index 8b430942..e4fad585 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:run": "vitest run", "db:generate": "pnpm --filter @paperclip/db generate", "db:migrate": "pnpm --filter @paperclip/db migrate", - "paperclip": "tsx cli/src/index.ts" + "paperclip": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts" }, "devDependencies": { "typescript": "^5.7.3",