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
112 lines (111 loc) • 5.01 kB
JavaScript
// 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/.
/**
* `--format html` rendering primitives (#582).
*
* Templates live in `src/assets/templates/html/` (mirrored to
* `dist/assets/templates/html/` by `scripts/copy-assets.ts`). `akm health` is
* the only command with a bespoke `<command>.html` template (substituted here
* via plain `%%TOKEN%%` string replacement — no template engine, by design);
* `src/cli/shared.ts`'s `output()` calls it directly for `command === "health"`
* and every other command falls back to `renderGenericHtml` (`./generic-render`).
* `--format html` therefore works for every command (D7) — this module's
* template path is just health's opt-in bespoke rendering, not a format-wide
* gate.
*/
import fs from "node:fs";
import path from "node:path";
import healthTemplate from "../assets/templates/html/health.html" with { type: "text" };
import { getDirname } from "../runtime.js";
import { writeStdout } from "./stdout.js";
const TEMPLATES_DIR = path.join(getDirname(import.meta.url), "../assets/templates/html");
/**
* Templates embedded at build time, keyed by command name.
*
* `bun build --compile` embeds only what is imported `with { type: "text" }`;
* a plain `readFileSync` from a path relative to `import.meta.url` resolves
* into the virtual `/$bunfs` tree and misses. `akm health --report --format
* html` therefore crashed with ENOENT (exit 70) on the standalone binary that
* the CLI's own install error and the CHANGELOG promote as the runtime-free
* option. The text import works on all three runtimes: natively on Bun, via
* scripts/node-runtime/text-import-hook.mjs on Node, and embedded in the
* compiled binary.
*/
const EMBEDDED_TEMPLATES = {
// bun-types declares `*.html` as an `HTMLBundle` (its HTML-bundler entrypoint
// feature), which is not what a `type: "text"` import yields — the value is
// the file's contents as a string on every runtime. The cast reconciles the
// ambient declaration with the actual import attribute.
health: healthTemplate,
};
/**
* Resolve the on-disk template path for a command's bespoke `<command>.html`.
* The command name is sanitized to a bare basename so a hostile command
* string can never escape the templates directory.
*/
export function resolveTemplatePath(command) {
const name = path.basename(command.trim());
return path.join(TEMPLATES_DIR, `${name}.html`);
}
/** Matches a `%%TOKEN%%` placeholder (uppercase + underscore key). */
const TOKEN_RE = /%%[A-Z_]+%%/g;
/**
* Read a template and substitute every `%%TOKEN%%` in `replacements` in a
* single pass. Substitution is order-independent: a value that happens to
* contain another token's literal text is never re-processed (the pass scans
* the original template, not the growing output). Unknown tokens in the
* template are left in place (the health template is verified token-complete by
* tests); replacement keys missing from the template are silently ignored,
* matching the skill renderer's behaviour.
*/
export function renderHtml(templatePath, replacements) {
const html = readTemplate(templatePath);
return html.replace(TOKEN_RE, (token) => replacements[token] ?? token);
}
/**
* Read a template from disk, falling back to the embedded copy.
*
* Disk stays primary so an operator (or a test) editing
* `src/assets/templates/html/<name>.html` sees the change without a rebuild.
* The fallback covers the standalone binary, where the file does not exist on
* any real filesystem.
*/
function readTemplate(templatePath) {
try {
return fs.readFileSync(templatePath, "utf8");
}
catch (err) {
const embedded = EMBEDDED_TEMPLATES[path.basename(templatePath, ".html")];
if (embedded !== undefined)
return embedded;
throw err;
}
}
/**
* Minimal HTML entity escaping for text interpolated into a document — a
* template substitution here, or a value placed into `generic-render.ts`'s
* envelope rendering. Escapes the single quote as well as the double quote so
* escaped values are safe in both `"…"` and `'…'` attribute contexts, not only
* the double-quoted attributes the bundled templates use today.
*/
export function escapeHtml(value) {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
/**
* Deliver a rendered document: write to `outputPath` when set (`--output`),
* otherwise print to stdout.
*/
export function deliverRendered(content, outputPath) {
if (outputPath) {
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
fs.writeFileSync(outputPath, content.endsWith("\n") ? content : `${content}\n`);
return;
}
writeStdout(content);
}