/** Artifacts extension — registers the `artifact` tool (create/update/open/list) and a TUI result card. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { artifactUrl, isRunning, notifyReload, runningPort, stopServer } from "./server.js"; import { slugify, isSafeSlug, writeArtifact, artifactExists, listArtifacts, openInBrowser, artifactPath } from "./utils.js"; import { renderMarkdownDocument, renderHtmlDocument } from "./templates.js"; interface ArtifactDetails { action: string; slug: string; title: string; kind: "markdown" | "html"; url?: string; absPath: string; } function errResult(message: string, details: Partial = {}) { return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true, details: { ...details } as Record, }; } /** Resolve content from inline `content` or `path` (file read, with a size cap). */ function resolveContent(params: { content?: string; path?: string }): { content: string } | { error: string } { if (params.content != null) return { content: params.content }; if (params.path) { const abs = join(process.cwd(), params.path); try { const size = statSync(abs).size; const MAX = 2 * 1024 * 1024; // 2 MB — a stray path at a big log becomes a sad browser tab if (size > MAX) { return { error: `file is ${Math.round(size / 1024 / 1024)} MB — exceeds the 2 MB limit. Excerpt the relevant portion into \`content\` instead of reading the whole file via \`path\`.` }; } return { content: readFileSync(abs, "utf-8") }; } catch { return { error: `could not read file at "${params.path}".` }; } } return { error: "provide `content` or `path` for create/update." }; } export default function artifacts(pi: ExtensionAPI) { pi.on("session_shutdown", () => { stopServer(); }); // ─── /artifacts command — open the index page (starts the server lazily) ── pi.registerCommand("artifacts", { description: "Open the artifacts index page in the browser (starts the localhost server if not running)", handler: async (_args, ctx) => { const url = await artifactUrl(); // no slug → index; ensureServer starts lazily openInBrowser(url); if (ctx.hasUI) ctx.ui.notify(`Artifacts: ${url}`, "info"); }, }); pi.registerTool({ name: "artifact", label: "Artifact", description: "Create, update, open, or list HTML artifacts rendered from markdown or raw HTML and served from a lazy localhost server (opened in the browser). Two kinds: `markdown` (rendered to styled HTML — GFM tables, fenced ```diff blocks render as diffs, fenced code blocks get syntax highlighting, fenced ```mermaid blocks render as diagrams) and `html` (escape hatch — body fragment injected into a styled shell, or a full document passed through unchanged). `create`/`update` write the file and return the slug + localhost URL + absolute path; `update` on a slug whose file is missing creates it. `update` on an already-open artifact refreshes the browser tab in place via live reload. `open` starts the server and opens the artifact. `list` lists existing artifacts (does not start the server). Set `path` to read content from a file instead of passing `content` (kind is still required). html fragments inherit the artifact stylesheet (system fonts, light/dark scheme) and its CSS variables — `--bg`, `--fg`, `--muted`, `--border`, `--code-bg`, `--accent` — so write semantic HTML and use those variables in any scoped