import { spawnSync as nodeSpawnSync } from "node:child_process"; import type { Tale } from "@taleseal/core"; import { draftTale, publishTale, type TaleResult } from "@taleseal/sdk"; import { resolveApiKeyState, resolveUrl } from "./config"; import { redactDeep } from "./redact"; /** * `taleseal init [claude|codex|cursor]` — the landing page's one command. Sets up the * chosen tool's plugin (marketplace install where the tool has a CLI, printed steps where * it does not), then publishes a live welcome tale — anonymously when no key is stored, * to the account when one is. Whatever happens to the tool setup, the publish half runs: * a failed plugin install prints manual steps and the page still goes live. * * Exit code doctrine: 0 for every HANDLED outcome (published; anonymous publishing * switched off; at capacity; tool CLI absent) — if init printed a next step a human can * follow, it did its job. 1 only for usage errors, a 426, and unexpected failures. */ export type InitTool = "claude" | "codex" | "cursor"; export const INIT_TOOLS = ["claude", "codex", "cursor"] as const; const TOOL_LABEL: Record = { claude: "Claude Code", codex: "Codex", cursor: "Cursor", }; const MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ] as const; /** "09:14 UTC on 21 July 2026" — manual formatting so no locale can vary the page. */ const utcStamp = (now: Date): string => { const hh = String(now.getUTCHours()).padStart(2, "0"); const mm = String(now.getUTCMinutes()).padStart(2, "0"); return `${hh}:${mm} UTC on ${now.getUTCDate()} ${MONTHS[now.getUTCMonth()]} ${now.getUTCFullYear()}`; }; /** * The welcome tale — the first page a new user ever sees, and a working demo of the * block vocabulary. Everything it states is true at publish time; no image blocks * (the anonymous route rejects them) and no line_chart (nothing to chart honestly). */ export function welcomeTale(tool: InitTool | undefined, opts: { mode: "anonymous" | "account"; now?: Date }): Tale { const label = tool !== undefined ? TOOL_LABEL[tool] : "your AI tool"; const anonymous = opts.mode === "anonymous"; const stamp = utcStamp(opts.now ?? new Date()); const askItem = tool === "codex" ? { text: "Ask Codex to “write this up as a page”" } : tool !== undefined ? { text: `In ${label}, ask: “write this up as a page”` } : { text: "Compose tale.json and run: npx taleseal draft tale.json" }; return { version: 1, title: "Your first tale, already live", standfirst: "Published by one command a moment ago. This is the kind of page your AI writes for you: you review it, then send the link.", sender: { name: "taleseal init" }, stationery: "letter", cta: { label: "Get started", url: "https://taleseal.com/start" }, blocks: [ { kind: "lead", text: anonymous ? `Your AI writes the page; you send the link. This page went live at ${stamp} from a single command: no account, no sign-up. It is anonymous, so it is deleted in 24 hours unless you claim it: the claim link is in your terminal, shown once.` : `Your AI writes the page; you send the link. This page went live at ${stamp} from a single command, published straight to your account; it is already yours.`, }, { kind: "prose", markdown: tool !== undefined ? `You have just set up taleseal for **${label}**. From now on, when a piece of work deserves better than a wall of chat (a decision, an answer, a report, the thing you just finished), ask for a page. Your AI composes it as a **tale**: real blocks at an unguessable link, readable by anyone you send it to. No reader ever needs an account.` : "You have just set up taleseal. When a piece of work deserves better than a wall of chat (a decision, an answer, a report, the thing you just finished), ask your AI for a page. It composes a **tale**: real blocks at an unguessable link, readable by anyone you send it to. No reader ever needs an account.", }, { kind: "heading", text: "What just happened" }, { kind: "stat_tiles", tiles: anonymous ? [ { label: "Commands run", value: "1" }, { label: "Accounts created", value: "0" }, { label: "Time to a live page", value: "seconds", tone: "good" }, { label: "What readers need", value: "nothing" }, ] : [ { label: "Commands run", value: "1" }, { label: "Time to a live page", value: "seconds", tone: "good" }, { label: "What readers need", value: "nothing" }, ], }, { kind: "heading", text: "What a page can carry" }, { kind: "table", columns: ["Blocks", "Good for"], rows: [ ["Prose, headings, callouts", "The write-up itself"], ["Tables, charts, stat tiles", "Numbers that need shape"], ["Code, diffs, before/after", "The exact change, highlighted"], ["Timelines and checklists", "What happened, and what is next"], ["Diagrams: sequence, flow, mock", "How the pieces fit"], ["Evidence", "Sources a reader can follow"], ], }, { kind: "code", language: "text", caption: "The whole workflow, from where you already work.", code: "you: “write this up as a page”\n\n" + "your AI: composes the tale, runs the redaction scrubber,\n" + " and prints a link like this page’s;\n" + " you review it in the browser, then send it on.", }, { kind: "checklist", items: anonymous ? [ { text: "Keep this page: open the claim link from your terminal and sign in (free)" }, askItem, { text: "Send the link; readers need no account" }, ] : [askItem, { text: "Send the link; readers need no account" }], }, { kind: "callout", tone: "info", title: "Private by default", body: "Every page lives at an unguessable link: never listed, never indexed. With an account, drafts get a private review link before anyone else can see them, and the redaction scrubber runs on your machine before anything leaves it.", }, ], }; } // ---------- per-tool setup ---------- export type SetupOutcome = | { status: "installed"; lines: string[] } | { status: "skipped"; lines: string[] } | { status: "manual"; lines: string[] } | { status: "absent"; lines: string[] } | { status: "failed"; detail: string; lines: string[] }; /** the spawnSync surface init uses — injectable so tests never shell out */ export type Runner = ( cmd: string, args: string[], ) => { status: number | null; error?: NodeJS.ErrnoException; stderr?: Buffer | string }; const defaultRunner: Runner = (cmd, args) => nodeSpawnSync(cmd, args, { stdio: "pipe", timeout: 120_000, // marketplace add is a git clone shell: process.platform === "win32", // resolves claude.cmd / codex.cmd }); interface PluginSteps { bin: string; steps: string[][]; } /** The marketplace is the plugins' provenance — init drives each tool's own installer. */ const PLUGIN_STEPS: Record<"claude" | "codex", PluginSteps> = { claude: { bin: "claude", steps: [ ["plugin", "marketplace", "add", "Taleseal/taleseal"], ["plugin", "install", "taleseal@taleseal"], ], }, codex: { bin: "codex", steps: [ ["plugin", "marketplace", "add", "Taleseal/taleseal"], ["plugin", "add", "taleseal@taleseal"], ], }, }; const CURSOR_MANUAL_LINES = [ "Cursor has no marketplace install yet; two manual steps:", " 1. git clone https://github.com/Taleseal/taleseal", " 2. add plugins/cursor via Cursor's Customize page (or symlink it under ~/.cursor/plugins/)", ]; const lastStderrLine = (stderr: Buffer | string | undefined): string => { const text = (stderr ?? "").toString().trim(); const lines = text.split("\n").filter((l) => l.trim() !== ""); return lines[lines.length - 1] ?? "no error output"; }; export function runToolSetup(tool: InitTool | undefined, run: Runner = defaultRunner): SetupOutcome { if (tool === undefined) return { status: "skipped", lines: [] }; if (tool === "cursor") return { status: "manual", lines: CURSOR_MANUAL_LINES }; const { bin, steps } = PLUGIN_STEPS[tool]; const manual = steps.map((args) => ` ${bin} ${args.join(" ")}`); const probe = run(bin, ["--version"]); if (probe.error?.code === "ENOENT" || (probe.error !== undefined && probe.status === null)) { return { status: "absent", lines: [`the \`${bin}\` command was not found; install ${TOOL_LABEL[tool]}, then run:`, ...manual], }; } // the marketplace may already be registered — a failing `marketplace add` is non-fatal // and the install step still runs; only a failing install reports as failed const marketplaceStep = steps[0]; const installStep = steps[1]; if (marketplaceStep === undefined || installStep === undefined) return { status: "skipped", lines: [] }; run(bin, marketplaceStep); const install = run(bin, installStep); if (install.status !== 0) { return { status: "failed", detail: lastStderrLine(install.stderr), lines: ["plugin install failed; run these yourself:", ...manual], }; } return { status: "installed", lines: steps.map((args) => ` ✓ ${bin} ${args.join(" ")}`), }; } // ---------- the command ---------- interface InitJson { tool: InitTool | null; setup: SetupOutcome["status"]; publish: "live" | "account" | "disabled" | "busy"; url?: string; claimUrl?: string; claimExpiresAt?: string; } const err = (message: string): void => void process.stderr.write(`${message}\n`); const nextAsk = (tool: InitTool | undefined): string => tool === "codex" ? "Now try it. Ask Codex:\n “write this up as a page”" : tool !== undefined ? `Now try it. In ${TOOL_LABEL[tool]}, ask:\n “write this up as a page”` : "Now try it: compose a tale.json and run\n npx taleseal draft tale.json"; const liveBox = (url: string): string => { const rule = "─".repeat(Math.max(url.length + 22, 54)); return ` ${rule}\n Your page is live: ${url}\n ${rule}`; }; export async function initCommand( toolArg: string | undefined, opts: { json: boolean; client: string; run?: Runner }, ): Promise { let tool: InitTool | undefined; if (toolArg !== undefined) { if (!(INIT_TOOLS as readonly string[]).includes(toolArg)) { err(`taleseal: unknown tool "${toolArg}"; pass one of: claude, codex, cursor (or nothing for plain CLI).`); process.exitCode = 1; return; } tool = toolArg as InitTool; } const label = tool !== undefined ? ` (${TOOL_LABEL[tool]})` : ""; if (!opts.json) err(`\u{1F9AD} taleseal init${label}\n`); // 1. tool setup — whatever happens here, the publish half still runs const setup = runToolSetup(tool, opts.run); if (!opts.json && setup.lines.length > 0) { if (setup.status === "installed") err(`Setting up the ${TOOL_LABEL[tool as InitTool]} plugin…`); err(setup.lines.join("\n")); err(""); } // 2. publish the welcome tale const keyState = resolveApiKeyState(); if (keyState.state === "empty") { // the CI footgun doctrine from `draft`: a blank key is a misloaded secret, never // consent to publish an ownerless page — refuse loudly err( keyState.source === "env" ? "taleseal: TALESEAL_API_KEY is set but empty: a misloaded secret. Set the key, then run init again." : "taleseal: the stored config has an empty apiKey; run `npx -y taleseal@latest login`, then init again.", ); process.exitCode = 1; return; } const mode = keyState.state === "key" ? "account" : "anonymous"; // our own template, but the code path stays honest: it goes through the scrubber too const tale = redactDeep(welcomeTale(tool, { mode })); const baseUrl = resolveUrl(); const json: InitJson = { tool: tool ?? null, setup: setup.status, publish: "live" }; let result: TaleResult; try { if (mode === "account") { if (!opts.json) err("Publishing your first page to your account…\n"); const draft = await draftTale(tale, { baseUrl, client: opts.client }); result = await publishTale(draft.id, { baseUrl, client: opts.client }); json.publish = "account"; } else { if (!opts.json) err("Publishing your first page, no account needed…\n"); result = await draftTale(tale, { baseUrl, anonymous: true, client: opts.client }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("(404)")) { json.publish = "disabled"; if (opts.json) process.stdout.write(`${JSON.stringify(json)}\n`); else err( "Set-up is done. Instant publishing is not switched on right now. To publish your\n" + "first page, sign in (free): npx -y taleseal@latest login", ); return; } if (message.includes("(503)")) { json.publish = "busy"; if (opts.json) process.stdout.write(`${JSON.stringify(json)}\n`); else err("Set-up is done, but anonymous publishing is at capacity; try again shortly, or sign in (free)."); return; } // a 426 message IS the upgrade instruction — print it verbatim; anything else is unexpected err(`taleseal: ${message}`); if (setup.status === "installed") err("Your tool setup still completed."); process.exitCode = 1; return; } json.url = result.url; json.claimUrl = result.claimUrl; json.claimExpiresAt = result.claimExpiresAt; if (opts.json) { process.stdout.write(`${JSON.stringify(json)}\n`); return; } process.stdout.write(`${liveBox(result.url)}\n\n`); if (mode === "account") { process.stdout.write("It is published to your account: already yours.\n\n"); } else { process.stdout.write( `It is anonymous, so it is deleted in 24 hours${ result.claimExpiresAt !== undefined ? ` (at ${result.claimExpiresAt})` : "" }.\n` + (result.claimUrl !== undefined ? "To KEEP it, open this claim link in your browser and sign in (free):\n" + ` ${result.claimUrl}\n` + "The claim link is shown once: this is it.\n\n" : "\n"), ); } process.stdout.write(`${nextAsk(tool)}\n`); }