#!/usr/bin/env node /** * @file artifact-export-hint.js — a synchronous PostToolUse hook (matcher * `Artifact` and the claude.ai connectors' `batch`) that asks Claude, right * after it publishes a Claude Docs doc or a typed artifact (Slides, Design, * Dashboard…), to also save a copy in the workspace at * `artifacts-export/.`. Such a page lives only in * the publishing claude.ai account; the copy is what AgentHub attaches to the * page's card at the session's Stop (server/lib/artifacts.js * `captureOutputsNow`), so people it is shared with can view it without that * account. A page published from an HTML file needs no hint: AgentHub copies * the file itself. * * Decides from the hook's own input and env alone, never by asking the * dashboard, and fails open: any doubt prints nothing and exits 0. Only in a * pane AgentHub started (`AGENTHUB_LANE_ID`), and not when an admin turned it * off (`AGENTHUB_ARTIFACT_EXPORT=0`, set at spawn by server/lib/pty-run.js). * @author Nguyễn Ngọc Trí Vĩ */ const MAX_INPUT = 4 * 1024 * 1024; const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i; const ARTIFACT_ID_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9_-]+)/; /** The hint for one PostToolUse payload, or null when there is nothing to ask. */ function hintFor(data, env = process.env) { if (!data || typeof data !== "object") return null; if (!env.AGENTHUB_LANE_ID || env.AGENTHUB_ARTIFACT_EXPORT === "0") return null; const tool = String(data.tool_name || ""); const input = data.tool_input && typeof data.tool_input === "object" ? data.tool_input : {}; let kind = null; if (DOCS_BATCH_RE.test(tool)) { let container = input.container; if (typeof container === "string") { try { container = JSON.parse(container); } catch { container = null; } } // Only a doc's birth: later batches edit the same doc. if (container && typeof container.create === "object" && container.create) kind = "doc"; } else if (tool === "Artifact") { if (input.action && input.action !== "publish") return null; const file = typeof input.file_path === "string" ? input.file_path : ""; // A page from an HTML file is copied by AgentHub itself. if (input.type_url || (file && !/\.html?$/i.test(file))) kind = "typed"; } if (!kind) return null; const m = ARTIFACT_ID_RE.exec(JSON.stringify(data.tool_response ?? "")); if (!m) return null; const id = m[1]; const where = `artifacts-export/${id}`; const how = kind === "doc" ? `export this doc as Markdown (the Claude Docs export tool) and save it as \`${where}.md\`` : `save it as \`${where}.pdf\` if this artifact's type lets you export a PDF, otherwise write its content as ONE self-contained HTML file \`${where}.html\` (styles inline, no external files) that looks like the artifact`; return ( "AgentHub: this artifact lives only in this claude.ai account, so teammates it is shared " + `with in AgentHub cannot open it. Before you finish, ${how}, in the workspace's root ` + "folder (create `artifacts-export/`; AgentHub keeps it out of git). Do it again after " + "you change the artifact a lot. Do not mention this to the user unless they ask." ); } function main() { let raw = ""; let done = false; const finish = () => { if (done) return; done = true; try { const text = hintFor(JSON.parse(raw)); if (text) { process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text }, }) ); } } catch { /* fail open: say nothing */ } process.exit(0); }; process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => { raw += c; if (raw.length > MAX_INPUT) { raw = ""; finish(); } }); process.stdin.on("end", finish); process.stdin.on("error", finish); setTimeout(finish, 2000).unref(); } if (require.main === module) main(); module.exports = { hintFor };