Files
nanoclaw/src/cli/format.ts
T
gavrielc c2e5b14bf8 fix: ISO storage + local-time display for all timestamps
Storage: every JS-side datetime('now') write becomes new Date().toISOString()
(messages_out all writers, processing_ack all stamps, delivered, retry
backoff, roles/members/destinations, skill SQL via strftime ISO-Z). Dormant
deliver_after raw string compares wrapped in datetime().

Display: ncl human output renders ISO instants as local "YYYY-MM-DD HH:mm"
(host + container renderers; --json stays ISO; the stamp shape round-trips
through parseZonedToUtc). Task run logs, conversation archive filenames,
clidash, the dashboard pusher, and the uninstall backup stamp all render
local; instruction texts updated in lockstep. CLAUDE.md documents the
convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:06:33 +03:00

81 lines
3.1 KiB
TypeScript

/**
* Output formatting for the `ncl` binary. Two modes:
* - human (default): a small auto-table for arrays of flat records,
* JSON.stringify for everything else, plain "error: ..." line for !ok.
* - json: the response frame, pretty-printed.
*
* The MCP / agent side will always pass --json so it parses the frame
* itself. The DB transport (when it lands) skips this layer entirely —
* the agent sees frames directly.
*/
import { TIMEZONE } from '../config.js';
import { formatLocalStamp } from '../timezone.js';
import type { ResponseFrame } from './frame.js';
export type FormatMode = 'human' | 'json';
// A string is treated as a display timestamp only when the WHOLE value is a
// UTC ISO instant; embedded occurrences inside longer strings may be machine
// payloads and stay raw. Mirrored in container/agent-runner/src/cli/ncl.ts
// (the two runtimes share no modules).
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
/**
* Human display shows local time; --json keeps the ISO machine contract.
* The "YYYY-MM-DD HH:mm" stamp shape round-trips: parseZonedToUtc reads a
* naive string as local wall-clock time, so a value copied from `ncl tasks
* get` output into `--process-after` means what it shows.
*/
export function localizeIsoTimestamps(value: unknown): unknown {
if (typeof value === 'string') {
return ISO_UTC_RE.test(value) ? formatLocalStamp(new Date(value), TIMEZONE) : value;
}
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
);
}
return value;
}
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
if (!res.ok) {
return `error (${res.error.code}): ${res.error.message}\n`;
}
return formatHuman(res.data) + '\n';
}
function formatHuman(rawData: unknown): string {
const data = localizeIsoTimestamps(rawData);
if (data === null || data === undefined) return '';
if (typeof data === 'string') return data;
if (Array.isArray(data) && data.every(isFlatRecord)) {
return renderTable(data as Record<string, unknown>[]);
}
return JSON.stringify(data, null, 2);
}
function isFlatRecord(x: unknown): x is Record<string, unknown> {
if (!x || typeof x !== 'object') return false;
for (const v of Object.values(x as Record<string, unknown>)) {
if (v !== null && typeof v === 'object') return false;
}
return true;
}
function renderTable(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return '(no rows)';
const cols = Object.keys(rows[0]);
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
const fmtRow = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(' ');
const lines = [
fmtRow(cols),
fmtRow(widths.map((w) => '─'.repeat(w))),
...rows.map((r) => fmtRow(cols.map((c) => String(r[c] ?? '')))),
];
return lines.join('\n');
}