UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

272 lines (271 loc) • 12.7 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Generic `md` / `html` / `text` rendering of a shaped output envelope (D7). * * Every command gets `json`, `jsonl`, and `yaml` for free because those are * serializations of the envelope rather than renderings of it. `md`, `html`, * and `text` are renderings, and before D7 only `akm health` had `md`/`html`: * `md` silently emitted the JSON envelope everywhere else and `html` threw. * `text` had the same silent-JSON gap for any command with no registered * text formatter, closed later than `md`/`html` — see `renderGenericText` * below for why it is its own function rather than a reuse of * `renderGenericMarkdown`. * * These functions close that gap structurally rather than per command. A * command that registers a bespoke renderer (`akm health`) still wins; every * other command falls back to a real rendering derived from whatever its * envelope happens to contain. The shape is discovered, not declared, so a new * command is covered the day it is added. * * Deliberately dependency-free apart from the shared detail types and * `escapeHtml`: this module sits underneath the per-command renderer * registries and must not import them. */ import { escapeHtml } from "./html-render.js"; /** * True when `value` is a non-empty array of plain objects — the shape worth * rendering as a table rather than as a nested list. Uniformity is judged on * the union of keys, not on strict equality, so one row carrying an extra * optional field still tabulates (its column is simply blank elsewhere). */ function isTabular(value) { if (!Array.isArray(value) || value.length === 0) return false; return value.every((row) => row !== null && typeof row === "object" && !Array.isArray(row)); } /** Column order for a table: first appearance across all rows, order-stable. */ function tableColumns(rows) { const columns = []; for (const row of rows) { for (const key of Object.keys(row)) { if (!columns.includes(key)) columns.push(key); } } return columns; } /** * Render a leaf value for display. Objects and arrays that reach a cell are * JSON-encoded rather than expanded — a table cell is not a place to nest. */ function displayScalar(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return JSON.stringify(value); } /** * Root-level envelope metadata the shape registry stamps on top of a * command's actual result: `shape` (the discriminator) and `schemaVersion` * (the envelope's own version), both added by the passthrough stamp in * `src/output/shapes/passthrough.ts` and equivalent per-command shapers * elsewhere. Transport bookkeeping, not a result anyone asked for — a `## * shape` / `## schemaVersion` section (`renderGenericMarkdown`), an * `<h2>shape</h2>` block (`renderGenericHtml`), or a `shape=…` line * (`renderGenericText`) is noise in every command's output, so every generic * renderer drops both. ONLY at the root, though: some shapes reuse * `schemaVersion` as genuine per-entry content one level down (each event in * `log list`'s `events[]` carries its own `schemaVersion`, see * `shapeEventEntry` in `src/output/shapes/helpers.ts`), and that must * survive untouched — every renderer below applies this filter only to the * top-level `Object.entries` loop over the envelope, never recursively. */ const ENVELOPE_META_KEYS = new Set(["shape", "schemaVersion"]); // ── Markdown ───────────────────────────────────────────────────────────────── /** Escape the one character that can break a Markdown table row. */ function escapeCell(text) { return text.replace(/\|/g, "\\|").replace(/\n/g, " "); } function markdownTable(rows) { const columns = tableColumns(rows); if (columns.length === 0) return []; const lines = [ `| ${columns.join(" | ")} |`, `| ${columns.map(() => "---").join(" | ")} |`, ...rows.map((row) => `| ${columns.map((c) => escapeCell(displayScalar(row[c]))).join(" | ")} |`), ]; return lines; } function markdownValue(value, depth) { if (isTabular(value)) return markdownTable(value); if (Array.isArray(value)) { if (value.length === 0) return ["_(none)_"]; return value.map((item) => `- ${escapeCell(displayScalar(item))}`); } if (value !== null && typeof value === "object") { const lines = []; for (const [key, nested] of Object.entries(value)) { // Headings stop at h6; deeper nesting continues as bolded labels. const heading = "#".repeat(Math.min(depth, 6)); lines.push(depth <= 6 ? `${heading} ${key}` : `**${key}**`, "", ...markdownValue(nested, depth + 1), ""); } return lines; } return [displayScalar(value) === "" ? "_(empty)_" : displayScalar(value)]; } /** * Render a shaped envelope as Markdown. `command` titles the document so a * rendered file identifies itself once detached from the invocation. */ export function renderGenericMarkdown(command, value) { const body = value !== null && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) .filter(([key]) => !ENVELOPE_META_KEYS.has(key)) // transport metadata, not a result .flatMap(([key, nested]) => [`## ${key}`, "", ...markdownValue(nested, 3), ""]) : markdownValue(value, 2); const lines = [`# ${command}`, "", ...body]; const rendered = lines .join("\n") .replace(/\n{3,}/g, "\n\n") .trimEnd(); return `${rendered}\n`; } // ── HTML ───────────────────────────────────────────────────────────────────── function htmlTable(rows) { const columns = tableColumns(rows); if (columns.length === 0) return ""; const head = `<tr>${columns.map((c) => `<th scope="col">${escapeHtml(c)}</th>`).join("")}</tr>`; const body = rows .map((row) => `<tr>${columns.map((c) => `<td>${escapeHtml(displayScalar(row[c]))}</td>`).join("")}</tr>`) .join(""); return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`; } function htmlValue(value, depth) { if (isTabular(value)) return htmlTable(value); if (Array.isArray(value)) { if (value.length === 0) return "<p><em>(none)</em></p>"; return `<ul>${value.map((item) => `<li>${escapeHtml(displayScalar(item))}</li>`).join("")}</ul>`; } if (value !== null && typeof value === "object") { const level = Math.min(depth, 6); return Object.entries(value) .map(([key, nested]) => `<h${level}>${escapeHtml(key)}</h${level}>${htmlValue(nested, depth + 1)}`) .join(""); } const scalar = displayScalar(value); return scalar === "" ? "<p><em>(empty)</em></p>" : `<p>${escapeHtml(scalar)}</p>`; } /** * Render a shaped envelope as a self-contained HTML document. * * Self-contained matters: the output is routinely redirected to a file and * opened directly, so it carries its own minimal styling and references * nothing external. */ export function renderGenericHtml(command, value) { const body = value !== null && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) .filter(([key]) => !ENVELOPE_META_KEYS.has(key)) // transport metadata, not a result .map(([key, nested]) => `<h2>${escapeHtml(key)}</h2>${htmlValue(nested, 3)}`) .join("") : htmlValue(value, 2); return [ "<!doctype html>", '<html lang="en">', "<head>", '<meta charset="utf-8">', '<meta name="viewport" content="width=device-width, initial-scale=1">', `<title>akm ${escapeHtml(command)}</title>`, "<style>", "body{font:16px/1.5 system-ui,sans-serif;margin:2rem auto;max-width:60rem;padding:0 1rem}", "table{border-collapse:collapse;width:100%;margin:0 0 1rem}", "th,td{border:1px solid #8884;padding:.35rem .6rem;text-align:left}", "th{background:#8881}", "h1{margin-bottom:1.5rem}", "@media(prefers-color-scheme:dark){body{background:#111;color:#eee}}", "</style>", "</head>", "<body>", `<h1>akm ${escapeHtml(command)}</h1>`, body === "" ? "<p><em>(no output)</em></p>" : body, "</body>", "</html>", "", ].join("\n"); } // ── Text ───────────────────────────────────────────────────────────────────── /** * Flatten `value` onto `lines` as `dotted.path=value` entries — the exact * algorithm `formatConfigPlain` (`src/output/text/command-format.ts`) * already established as this CLI's real plain-text house style for `akm * config list`. Arrays serialize as one compact-JSON line rather than * index-per-line (`items.0.name=…`), matching that same precedent instead of * inventing a second convention: the arrays that reach this generic * fallback are typically short id/tag lists, where one JSON-array line reads * better than N extra `path.0=`, `path.1=`, … lines. * * Exported so command-specific text formatters (e.g. `formatHealthPlain` in * `src/output/text/health-format.ts`) can reuse the SAME scalar-tree * convention for the parts of their envelope that are plain nested * scalars/objects, while overriding just the parts that need bespoke * handling (arrays of status-bearing records, where this function's * one-JSON-line-per-array behavior is the exact defect those formatters * exist to fix). Reuse beats a second flattener. */ export function flattenForText(value, path, lines) { if (value === null || value === undefined) { lines.push(`${path}=`); } else if (Array.isArray(value)) { lines.push(`${path}=${JSON.stringify(value)}`); } else if (typeof value === "object") { const entries = Object.entries(value); if (entries.length === 0) { lines.push(`${path}={}`); return; } for (const [key, nested] of entries) { flattenForText(nested, `${path}.${key}`, lines); } } else { lines.push(`${path}=${String(value)}`); } } /** * Render a shaped envelope as flat `key=value` text — no markup characters. * * This is a DISTINCT function from `renderGenericMarkdown`, not a reuse of * it, and that is a deliberate reversal of this fallback's first cut, which * called `renderGenericMarkdown` directly for `text` on the reasoning that * it emits no HTML and is therefore "plain-text-safe." That reasoning was * wrong: `#` heading markers, `_..._` emphasis, and `| ... |` table syntax * ARE markup — a terminal happens to print them as literal characters * instead of throwing, but a user piping `--format text` into `grep` or a * line-oriented script still sees literal `#`/`_`/`|` noise that plain JSON * at least didn't have. That is the exact "one format wearing another * format's flag" defect this whole fallback exists to close, with Markdown * substituted for JSON instead of JSON itself. * * `akm config list --format text` and `akm info --format text` already * establish this CLI's real plain-text convention — flat `dotted.path=value` * lines, no markup — so the generic fallback now matches its registered * siblings (what a command looks like once someone writes it a bespoke * formatter) instead of matching `md`'s. */ export function renderGenericText(command, value) { const lines = []; if (value !== null && typeof value === "object" && !Array.isArray(value)) { for (const [key, nested] of Object.entries(value)) { if (ENVELOPE_META_KEYS.has(key)) continue; // transport metadata, not a result flattenForText(nested, key, lines); } } else { flattenForText(value, command, lines); } return lines.length === 0 ? `${command}: (empty)\n` : `${lines.join("\n")}\n`; }