diff --git a/README.md b/README.md index 38de964..38f5d5e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,27 @@ logs --help - `logs scan` - `logs diagnose` +### Compact Output Defaults + +CLI list/status/search commands are compact by default so agent terminals do not +fill with large records. Human output uses capped row counts, shortened IDs, and +truncated long text. + +Use gradual disclosure when you need more: + +```bash +logs list --limit 25 --offset 25 +logs list --verbose +logs list --json +logs events list --json --include-raw +logs events get +logs test-reports get +logs health --json +``` + +Most list commands support `--limit`; larger result sets print a next-page +offset hint. Detail commands and `--json` retain full machine-readable records. + ## MCP Server ```bash @@ -34,6 +55,10 @@ logs-mcp Includes log search, raw event search/watch/export, projected test-report search/get, storage sync, scan, issue, and performance tools. +MCP list/search/status tools also default to compact payloads. Pass +`brief: false`, `verbose: true`, `include_raw: true`, or use a `*_get` detail +tool when an agent needs the complete object. + ## HTTP mode Run a shared Streamable HTTP MCP server (127.0.0.1 only): diff --git a/src/cli/compact-output.test.ts b/src/cli/compact-output.test.ts new file mode 100644 index 0000000..95d67a7 --- /dev/null +++ b/src/cli/compact-output.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +function runCli(args: string[], dataDir: string) { + return spawnSync("bun", ["src/cli/index.ts", ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + HASNA_LOGS_DATA_DIR: dataDir, + HASNA_LOGS_DB_PATH: join(dataDir, "logs.db"), + HASNA_LOGS_FSYNC: "0", + }, + }); +} + +describe("compact CLI output", () => { + test("logs list is compact by default and preserves full JSON output", () => { + const dataDir = mkdtempSync(join(tmpdir(), "open-logs-compact-list-")); + const longMessage = `compact output canary ${"x".repeat(220)} end`; + const longMetadata = JSON.stringify({ + payload: "metadata ".repeat(80), + nested: { secret_shape: "not printed in compact table output" }, + }); + + try { + const push = runCli( + [ + "push", + longMessage, + "--id", + "compact-log-1", + "--level", + "error", + "--service", + "compact-service-with-long-name", + "--trace", + "compact-trace-1", + ], + dataDir, + ); + expect(push.status).toBe(0); + + const dbPatch = spawnSync( + "bun", + [ + "-e", + `import { Database } from "bun:sqlite"; const db = new Database(${JSON.stringify(join(dataDir, "logs.db"))}); db.prepare("UPDATE logs SET metadata = ? WHERE id = ?").run(${JSON.stringify(longMetadata)}, "compact-log-1"); db.close();`, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + expect(dbPatch.status).toBe(0); + + const compact = runCli(["list", "--limit", "1"], dataDir); + expect(compact.status).toBe(0); + expect(compact.stdout).toContain("compact output canary"); + expect(compact.stdout).toContain("Use --verbose"); + expect(compact.stdout).not.toContain("end"); + expect(compact.stdout).not.toContain("metadata metadata metadata"); + expect(compact.stdout).not.toContain("secret_shape"); + + const json = runCli(["list", "--limit", "1", "--json"], dataDir); + expect(json.status).toBe(0); + const rows = JSON.parse(json.stdout) as Array<{ + message: string; + metadata: string | null; + }>; + expect(rows[0]?.message).toBe(longMessage); + expect(rows[0]?.metadata).toBe(longMetadata); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + test("events list is compact by default and points to detail/json paths", () => { + const dataDir = mkdtempSync(join(tmpdir(), "open-logs-compact-events-")); + const longEventId = "compact-event-id-abcdefghijklmnopqrstuvwxyz1234567890"; + const longMessage = `event compact canary ${"y".repeat(220)} end`; + + try { + const push = runCli( + [ + "events", + "push", + "--type", + "agent", + "--id", + longEventId, + "--source", + "cli-test", + "--severity", + "warn", + "--message", + longMessage, + "--attributes", + JSON.stringify({ noisy: "attributes ".repeat(80) }), + ], + dataDir, + ); + expect(push.status).toBe(0); + + const compact = runCli(["events", "list", "--limit", "1"], dataDir); + expect(compact.status).toBe(0); + expect(compact.stdout).toContain(longEventId); + expect(compact.stdout).toContain("event compact canary"); + expect(compact.stdout).toContain("events get "); + expect(compact.stdout).not.toContain("end"); + expect(compact.stdout).not.toContain("attributes attributes"); + + const json = runCli( + ["events", "list", "--limit", "1", "--json", "--include-raw"], + dataDir, + ); + expect(json.status).toBe(0); + const rows = JSON.parse(json.stdout) as Array<{ + message: string | null; + raw?: { message?: string | null }; + }>; + expect(rows[0]?.message).toBe(longMessage); + expect(rows[0]?.raw?.message).toBe(longMessage); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + test("health is summarized by default and full with --json", () => { + const dataDir = mkdtempSync(join(tmpdir(), "open-logs-compact-health-")); + try { + const compact = runCli(["health"], dataDir); + expect(compact.status).toBe(0); + expect(compact.stdout).toContain("Health: ok"); + expect(compact.stdout).toContain("Use --verbose"); + expect(() => JSON.parse(compact.stdout)).toThrow(); + + const json = runCli(["health", "--json"], dataDir); + expect(json.status).toBe(0); + expect(JSON.parse(json.stdout)).toMatchObject({ status: "ok" }); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 199bd2a..71ea2a7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -69,6 +69,11 @@ const LEVEL_COLOR: Record = { info: "", debug: C.gray, }; +const DEFAULT_HUMAN_LIMIT = 25; +const DEFAULT_JSON_LIMIT = 100; +const MAX_HUMAN_FIELD = 96; +const MAX_MESSAGE_FIELD = 140; + function colorRow(ts: string, level: string, svc: string, msg: string): string { const lc = LEVEL_COLOR[level.toLowerCase()] ?? ""; const isTTY = process.stdout.isTTY; @@ -112,6 +117,78 @@ function printJson(value: unknown): void { console.log(JSON.stringify(value, null, 2)); } +function compactText( + value: string | null | undefined, + max = MAX_HUMAN_FIELD, +): string { + if (!value) return "-"; + const singleLine = value.replace(/\s+/g, " ").trim(); + if (singleLine.length <= max) return singleLine; + return `${singleLine.slice(0, Math.max(0, max - 3))}...`; +} + +function compactId(value: string | null | undefined, max = 16): string { + if (!value) return "-"; + if (value.length <= max) return value; + return `${value.slice(0, max - 1)}...`; +} + +function compactTimestamp(value: string | null | undefined): string { + return value ? value.slice(0, 19) : "-"; +} + +function parseListLimit( + value: string | undefined, + label: string, + fallback = DEFAULT_HUMAN_LIMIT, +): number { + return parsePositiveIntOption(value, label, fallback); +} + +function parseListOffset(value: string | undefined): number { + return parseOptionalNonNegativeInt(value, "--offset") ?? 0; +} + +function splitVisibleRows( + rows: T[], + limit: number, +): { rows: T[]; hasMore: boolean } { + return { + rows: rows.slice(0, limit), + hasMore: rows.length > limit, + }; +} + +function printCompactFooter(opts: { + command: string; + count: number; + noun: string; + limit: number; + offset?: number; + hasMore?: boolean; + detailHint?: string; +}): void { + const offset = opts.offset ?? 0; + const range = + offset > 0 + ? `offset ${offset}, showing ${opts.count}` + : `showing ${opts.count}`; + console.log(`\n${range} ${opts.noun}(s).`); + if (opts.hasMore) { + console.log( + `More results available. Use ${opts.command} --limit ${opts.limit} --offset ${offset + opts.limit} for the next page.`, + ); + } + if (opts.detailHint) console.log(opts.detailHint); +} + +function tableFormat(opts: { + format?: string; + json?: boolean; +}): string { + return opts.json ? "json" : (opts.format ?? "table"); +} + function parseJsonObjectOption( value: string | undefined, label: string, @@ -207,12 +284,23 @@ program "Until timestamp or relative (e.g. logs list --since 2h --until 1h)", ) .option("--text ", "Full-text search") - .option("--limit ", "Max results", "100") + .option("--limit ", "Max results (default: 25 for table, 100 for JSON)") + .option("--offset ", "Skip this many results for pagination") + .option("--verbose", "Show full messages and metadata in table output") + .option("--json", "Alias for --format json") .option("--format ", "Output format: table|json|compact", "table") .action((opts) => { const db = getDb(); const since = parseRelativeTime(opts.since); const until = parseRelativeTime(opts.until); + const format = tableFormat(opts); + const isJson = format === "json"; + const limit = parseListLimit( + opts.limit, + "--limit", + isJson ? DEFAULT_JSON_LIMIT : DEFAULT_HUMAN_LIMIT, + ); + const offset = parseListOffset(opts.offset); const rows = searchLogs(db, { project_id: resolveProject(opts.project), page_id: opts.page, @@ -221,26 +309,41 @@ program since, until, text: opts.text, - limit: Number(opts.limit), + limit: isJson ? limit : limit + 1, + offset, }); - if (opts.format === "json") { - console.log(JSON.stringify(rows, null, 2)); + if (isJson) { + printJson(rows); return; } - if (opts.format === "compact") { - for (const r of rows) + const visible = splitVisibleRows(rows, limit); + if (format === "compact") { + for (const r of visible.rows) console.log( - `${r.timestamp} [${r.level.toUpperCase()}] ${r.service ?? "-"} ${r.message}`, + `${compactTimestamp(r.timestamp)} [${r.level.toUpperCase()}] ${compactText(r.service, 18)} ${compactText(r.message, MAX_MESSAGE_FIELD)}`, ); return; } - for (const r of rows) { - const meta = r.metadata ? ` ${r.metadata}` : ""; + for (const r of visible.rows) { + const message = opts.verbose + ? r.message + : compactText(r.message, MAX_MESSAGE_FIELD); + const service = compactText(r.service, 18); + const meta = opts.verbose && r.metadata ? ` metadata=${r.metadata}` : ""; console.log( - `${colorRow(r.timestamp, r.level, r.service ?? "-", r.message)}${meta}`, + `${colorRow(compactTimestamp(r.timestamp), r.level, service, message)} id=${compactId(r.id)}${r.trace_id ? ` trace=${compactId(r.trace_id)}` : ""}${meta}`, ); } - console.log(`\n${rows.length} log(s)`); + printCompactFooter({ + command: "logs list", + count: visible.rows.length, + noun: "log", + limit, + offset, + hasMore: visible.hasMore, + detailHint: + "Use --verbose for full messages/metadata or --json for full records.", + }); }); // ── logs tail ────────────────────────────────────────────── @@ -248,15 +351,31 @@ program .command("tail") .description("Show most recent logs") .option("--project ", "Project name or ID") - .option("--n ", "Number of logs", "50") + .option("--n ", "Number of logs", String(DEFAULT_HUMAN_LIMIT)) + .option("--verbose", "Show full messages in table output") + .option("--json", "Output full records as JSON") .action((opts) => { - const rows = tailLogs( - getDb(), - resolveProject(opts.project), - Number(opts.n), - ); - for (const r of rows) - console.log(colorRow(r.timestamp, r.level, r.service ?? "-", r.message)); + const limit = parseListLimit(opts.n, "--n", DEFAULT_HUMAN_LIMIT); + const rows = tailLogs(getDb(), resolveProject(opts.project), limit); + if (opts.json) { + printJson(rows); + return; + } + for (const r of rows) { + const message = opts.verbose + ? r.message + : compactText(r.message, MAX_MESSAGE_FIELD); + console.log( + `${colorRow(compactTimestamp(r.timestamp), r.level, compactText(r.service, 18), message)} id=${compactId(r.id)}`, + ); + } + printCompactFooter({ + command: "logs tail", + count: rows.length, + noun: "log", + limit, + detailHint: "Use --verbose for full messages or --json for full records.", + }); }); // ── logs summary ────────────────────────────────────────── @@ -457,6 +576,7 @@ const storageCmd = program storageCmd .command("status") .description("Show storage sync configuration") + .option("--verbose", "Show the full table list") .option("--json", "Output as JSON") .action((opts) => { const info = getStorageStatus(); @@ -466,7 +586,13 @@ storageCmd } console.log(`Storage configured: ${info.configured ? "yes" : "no"}`); console.log(`Mode: ${info.mode}`); - console.log(`Tables: ${info.tables.join(", ")}`); + if (opts.verbose) { + console.log(`Tables (${info.tables.length}): ${info.tables.join(", ")}`); + } else { + console.log( + `Tables: ${info.tables.length} service-owned table(s). Use --verbose to list names.`, + ); + } }); storageCmd @@ -747,9 +873,20 @@ eventsCmd "Substring search over event id, source id, message, and metadata", ) .option("--include-raw", "Include raw segment envelope") - .option("--limit ", "Max results", "100") + .option("--limit ", "Max results (default: 25 for table, 100 for JSON)") + .option("--offset ", "Skip this many results for pagination") + .option("--verbose", "Show wider event fields in table output") + .option("--json", "Alias for --format json") .option("--format ", "Output format: table|json", "table") .action((opts) => { + const format = tableFormat(opts); + const isJson = format === "json"; + const limit = parseListLimit( + opts.limit, + "--limit", + isJson ? DEFAULT_JSON_LIMIT : DEFAULT_HUMAN_LIMIT, + ); + const offset = parseListOffset(opts.offset); const rows = searchEvents(getDb(), { event_type: opts.type, source: opts.source, @@ -766,19 +903,36 @@ eventsCmd since: parseRelativeTime(opts.since), until: parseRelativeTime(opts.until), text: opts.text, - include_raw: Boolean(opts.includeRaw), - limit: Number(opts.limit), + include_raw: Boolean(opts.includeRaw) && isJson, + limit: isJson ? limit : limit + 1, + offset, }); - if (opts.format === "json") { + if (isJson) { printJson(rows); return; } - for (const row of rows) { + const visible = splitVisibleRows(rows, limit); + for (const row of visible.rows) { + const message = opts.verbose + ? (row.message ?? "") + : compactText(row.message, MAX_MESSAGE_FIELD); console.log( - `${row.event_time} ${pad(row.event_type, 10)} ${pad(row.source, 8)} ${pad(row.severity ?? "-", 5)} ${row.event_id} ${row.message ?? ""}`, + `${compactTimestamp(row.event_time)} ${pad(compactText(row.event_type, 10), 10)} ${pad(compactText(row.source, 10), 10)} ${pad(row.severity ?? "-", 5)} ${row.event_id} ${message}`, ); } - console.log(`\n${rows.length} event(s)`); + printCompactFooter({ + command: "logs events list", + count: visible.rows.length, + noun: "event", + limit, + offset, + hasMore: visible.hasMore, + detailHint: + "Use events get for one record, --verbose for wider rows, or --json for full records.", + }); + if (opts.includeRaw) { + console.log("Raw envelopes are available with --include-raw --json."); + } }); eventsCmd @@ -873,9 +1027,20 @@ testReportsCmd .option("--until