import { readFileSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline/promises"; import { MAX_TALE_BYTES, type OpIssue, safeParseTale, type Tale, taleByteSize, taleOps } from "@taleseal/core"; import { applyTaleOps, draftTale, getTale, getTaleBlocks, getTaleOutline, publishTale, type RequestOptions, retractTale, reviseTale, type TaleOpsResult, } from "@taleseal/sdk"; import { readConfig, resolveApiKey, resolveApiKeyState, resolveUrl } from "./config"; import { buildExposureReport, buildRedactionReport, decideGate, type ExposureReport, type RedactionReport, } from "./gate"; import { redactDeep } from "./redact"; /** * The tale flow — the whole of the CLI's document surface: * * compose tale.json → validate → draft (gate + push, prints the draft URL) → * human reviews in the browser → publish → (revise → publish)* → retract. * * `validate` is the agent's repair loop: deterministic zod-path errors, exit code 1 on * any issue. `draft` runs the redaction + exposure gate — nothing leaves the machine * unseen. Visual review happens at the draft URL, which renders with the production * view and a DRAFT banner; only the holder of the unguessable URL can see it. * * With no API key at all (or --anon), `draft` instead publishes ANONYMOUSLY: live the * moment the POST lands, no draft step, immutable, hard-deleted after 24 hours unless * the printed claim link is used to keep it. The gate is the only human step on that * path, so it still runs. Anonymous applies to draft/create only — publish, revise and * retract keep refusing without a key. */ export const TALE_USAGE = `Usage: taleseal [options] Compose a whole tale, or edit an existing draft block by block. Editing is id-addressed and atomic: read the outline for the block ids and the draftSeq, then send ops echoing that draftSeq as --base. A stale base is a conflict (nothing applies, you re-base on the outline it hands back); an invalid batch comes back with the exact fixes; a batch applies whole or not at all. Compose and publish: validate Check against the schema: each problem with its exact JSON path; exit code 1 on any. The compose/repair loop. draft Redact, show the exposure gate, then create the tale as a DRAFT and print its URL (carries a DRAFT banner). With no API key at all (or --anon) it publishes LIVE anonymously instead: no draft step, no account; the page is hard-deleted after 24 hours unless the claim link it prints is used to keep it. publish Promote the current draft to the revision the recipient sees. Prompts on a terminal; --yes skips. revise Store a new whole-tale draft body. The recipient keeps seeing the published revision until the next publish. retract Destroy the tale and every revision. The URL answers 410 Gone forever. No prompt: the emergency stop. Read a draft: pull [file] Write the current draft body to (or stdout); how a fresh session fetches a tale to revise. outline The draftSeq you echo as --base, plus one line per block. get [blockId...] Full JSON of the named blocks (or all), to see a block's current content before you replace it. Edit a draft (id-addressed, atomic, optimistic concurrency): ops Apply a JSON array of ops in one atomic batch. insert --after | --before | --start | --end replace remove move --after | --before | --start | --end set-envelope --title … | --standfirst … | --recipient … | --stationery … | --sender-name … | --sender-org … | --cta-label … | --cta-url … | --expires-at … | --clear Options: --base The draftSeq from \`outline\`, echoed as an edit's base (the concurrency token). Omit it and the CLI reads the current draftSeq itself: convenient, but a concurrent edit would be clobbered; pass --base to be safe. --idem Idempotency key: re-sending a batch with the same key never re-applies. --anon Draft only: publish anonymously even when a key is available; live at once, immutable, deleted after 24 hours unless claimed. --yes Skip the pre-draft/pre-publish prompt (hooks, CI) --json Print machine-readable results -h, --help Show this help A tale is versioned: revising keeps the recipient's link stable. Cite your sources in an "evidence" block. Branding (colours, logo) is not a tale or CLI concern: it is a paid add-on the account owner configures once at https://taleseal.com/dashboard/brand, and every tale picks it up automatically. Other commands: upload Upload an image and print the reference an "image" block carries. login Sign in via the browser and store an API key. logout Remove the stored API key.`; interface TaleCliOptions { yes: boolean; json: boolean; /** --anon: publish the draft anonymously even when a key is available (draft only) */ anon: boolean; /** --base : the draftSeq echoed as the edit's CAS token; omitted ⇒ read the current one */ base?: number; after?: string; before?: string; start: boolean; end: boolean; idem?: string; // set-envelope fields title?: string; standfirst?: string; recipient?: string; stationery?: string; senderName?: string; senderOrg?: string; ctaLabel?: string; ctaUrl?: string; expiresAt?: string; clear?: string; } function fail(message: string): never { process.stderr.write(`taleseal: ${message}\n`); process.exit(1); } const zodIssues = (issues: readonly { path: PropertyKey[]; message: string }[]): string => issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n"); function readTaleFile(path: string | undefined): Tale { if (path === undefined) fail("pass the tale JSON file, e.g. taleseal validate tale.json"); let raw: string; try { raw = readFileSync(path, "utf8"); } catch (error) { fail(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { fail(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); } const result = safeParseTale(parsed); if (!result.success) { fail(`${path} is not a valid tale:\n${zodIssues(result.error.issues)}\nFix the paths above and re-run.`); } const bytes = taleByteSize(result.data); if (bytes > MAX_TALE_BYTES) { fail(`the tale is ${bytes} bytes; the cap is ${MAX_TALE_BYTES} (1 MiB). Trim the largest blocks.`); } return result.data; } function transport(): RequestOptions & { baseUrl: string } { const config = readConfig(); const baseUrl = resolveUrl(process.env, config); const apiKey = resolveApiKey(process.env, config); if (apiKey === undefined) { fail( "no API key: run `npx -y taleseal@latest login`, or set TALESEAL_API_KEY. " + "Only `draft` works without one (anonymously: live at once, deleted after 24 hours unless claimed).", ); } return { baseUrl, apiKey }; } /** * Transport for the anonymous create — resolves the URL alone and never touches the key, * so it cannot trip transport()'s refusal. Draft-only: an anonymous tale is immutable, * so nothing else has an anonymous form. */ const anonTransport = (): RequestOptions & { baseUrl: string } => ({ baseUrl: resolveUrl(process.env, readConfig()), anonymous: true, client: taleClient(), }); /** * Evidence items without a `url` render as plain, unfollowable text — and the evidence * box is the tale's whole credibility layer (tales carry no receipts). Advisory, * never an error: a repo read at a local commit legitimately has no URL. */ const unlinkedEvidenceCount = (tale: Tale): number => tale.blocks.flatMap((b) => (b.kind === "evidence" ? b.items : [])).filter((i) => i.url === undefined).length; /** The tale gate preview — what the page will say, plus what the scrubber caught. */ function renderTaleGate(tale: Tale, redaction: RedactionReport, exposure: ExposureReport): string { const lines: string[] = [ `Tale: ${tale.title}`, ` from ${tale.sender.name}${tale.sender.org ? ` (${tale.sender.org})` : ""}` + `${tale.recipient ? ` · ${tale.recipient}` : ""}`, ` ${tale.blocks.length} block(s) · stationery "${tale.stationery ?? "letter"}"`, "", ]; for (const block of tale.blocks) { const label = block.kind === "heading" ? `heading ${block.text}` : block.kind === "code" ? `code ${block.filename ?? block.language}` : block.kind === "diff" ? `diff ${block.filePath}` : block.kind === "evidence" ? `evidence ${block.items.length} source(s)` + (block.items.some((i) => i.url === undefined) ? `, ${block.items.filter((i) => i.url === undefined).length} without a url` : "") : block.kind; lines.push(` · ${label}`); } lines.push(""); lines.push( redaction.total === 0 ? "Secrets: none detected by the client scrubber." : `Secrets: ${redaction.total} removed, ${Object.entries(redaction.counts) .map(([k, n]) => `${k} ×${n}`) .join(", ")}`, ); if (exposure.outsidePaths.length + exposure.hosts.length + exposure.emails.length > 0) { lines.push("Discloses (only you can judge if these belong in a recipient's hands):"); for (const p of exposure.outsidePaths) lines.push(` path ${p}`); for (const h of exposure.hosts) lines.push(` host ${h}`); for (const e of exposure.emails) lines.push(` email ${e}`); } return lines.join("\n"); } async function confirmOr(preview: string, yes: boolean, question: string): Promise { const decision = decideGate({ yes, tty: process.stdout.isTTY === true }); if (decision.action === "refuse") fail(decision.message); if (decision.action === "prompt") { process.stderr.write(`${preview}\n`); const readline = createInterface({ input: process.stdin, output: process.stderr }); const answer = (await readline.question(`${question} [y/N] `)).trim().toLowerCase(); readline.close(); if (answer !== "y" && answer !== "yes") fail("cancelled; nothing left the machine."); } } /** Transport + the CLI's own client identity, for every SDK tale call. */ const tOpts = (): RequestOptions & { baseUrl: string } => ({ ...transport(), client: taleClient() }); /** Read and JSON-parse an argument file (ops, blocks, a single block); fails with a clean message. */ function readJsonArg(path: string | undefined, what: string): unknown { if (path === undefined) fail(`pass the ${what} JSON file.`); let raw: string; try { raw = readFileSync(path, "utf8"); } catch (error) { fail(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`); } try { return JSON.parse(raw); } catch (error) { fail(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); } } /** * A read that failed is almost always a tale that is gone or not yours — turn the SDK's raw * transport error into the same clean message the model reads elsewhere. ponytail: keys off the * SDK's "(404)"/"(410)" wording; anything else passes through verbatim rather than being masked. */ function cleanRead(error: unknown, id: string): never { const message = error instanceof Error ? error.message : String(error); if (message.includes("(404)")) fail(`no tale ${id}: it does not exist, or it is not yours.`); if (message.includes("(410)")) fail(`tale ${id} was retracted; it answers 410 now and cannot be read or edited.`); fail(message); } /** Turn --after/--before/--start/--end into the engine's `where`, enforcing exactly one. */ function buildWhere(opts: TaleCliOptions): { after: string } | { before: string } | { at: "start" | "end" } { const chosen = [opts.after !== undefined, opts.before !== undefined, opts.start, opts.end].filter(Boolean).length; if (chosen === 0) fail("say where: --after , --before , --start or --end."); if (chosen > 1) fail("give exactly one of --after, --before, --start, --end."); if (opts.after !== undefined) return { after: opts.after }; if (opts.before !== undefined) return { before: opts.before }; return { at: opts.start ? "start" : "end" }; } /** The base revision: the one you passed with --base, or the current draftSeq (read + warned). */ async function resolveBase(id: string, opts: TaleCliOptions): Promise { if (opts.base !== undefined) return opts.base; const outline = await getTaleOutline(id, tOpts()).catch((error: unknown) => cleanRead(error, id)); process.stderr.write( `Using the current draftSeq ${outline.draftSeq} (no --base given). Pass --base to guard against a concurrent edit.\n`, ); return outline.draftSeq; } /** The engine's OpIssues, one per line — opIndex, the block, the repair. Mirrors the MCP receipt. */ const formatIssues = (issues: readonly OpIssue[]): string => issues .map((issue) => { const where = issue.opIndex < 0 ? "whole tale" : `op ${issue.opIndex}`; const block = issue.blockId ? ` (block ${issue.blockId})` : ""; const fix = issue.repair ? ` → ${issue.repair}` : ""; return ` [${issue.code}] ${where}${block}: ${issue.message}${fix}`; }) .join("\n"); /** Print an ops outcome and set the exit code: applied/replayed succeed, conflict/invalid exit 1. */ function printOpsResult(result: TaleOpsResult, json: boolean): void { if (json) { process.stdout.write(`${JSON.stringify(result)}\n`); if (result.status === "conflict" || result.status === "invalid") process.exit(1); return; } switch (result.status) { case "applied": process.stdout.write(`Applied at draftSeq ${result.draftSeq}.\n\n${result.outline}\n`); return; case "replayed": process.stdout.write( `Replayed: this idempotency key already applied; the draft is unchanged at draftSeq ${result.draftSeq}.\n\n${result.outline}\n`, ); return; case "conflict": process.stderr.write( `CONFLICT: the draft moved since your base (now draftSeq ${result.draftSeq}). Nothing applied. Re-base on this and retry:\n\n${result.outline}\n`, ); process.exit(1); return; case "invalid": process.stderr.write( `NOT APPLIED: ${result.issues.length} issue(s) to fix, then retry:\n${formatIssues(result.issues)}\n`, ); process.exit(1); return; } } /** Validate ops locally (the wire is untrusted), resolve the base, apply, print the receipt. */ async function runOpsCli(id: string, rawOps: readonly unknown[], opts: TaleCliOptions): Promise { const parsed = taleOps.safeParse(rawOps); if (!parsed.success) fail(`malformed ops; nothing sent:\n${zodIssues(parsed.error.issues)}`); const baseRevision = await resolveBase(id, opts); const result = await applyTaleOps(id, { baseRevision, ops: parsed.data, idempotencyKey: opts.idem }, tOpts()).catch( (error: unknown) => cleanRead(error, id), ); printOpsResult(result, opts.json); } export async function taleCommand(positionals: string[], opts: TaleCliOptions): Promise { const sub = positionals[0]; if (sub === "validate") { const tale = readTaleFile(positionals[1]); const bytes = taleByteSize(tale); const unlinked = unlinkedEvidenceCount(tale); const note = unlinked > 0 ? `Note: ${unlinked} evidence source(s) carry no url; recipients cannot follow an unlinked source. Add urls wherever the source has one.\n` : ""; process.stdout.write( opts.json ? `${JSON.stringify({ valid: true, blocks: tale.blocks.length, bytes, unlinkedEvidence: unlinked })}\n` : `Valid tale: ${tale.blocks.length} block(s), ${(bytes / 1024).toFixed(1)} KiB, stationery "${tale.stationery ?? "letter"}".\n${note}Next: taleseal draft ${positionals[1]}\n`, ); return; } if (sub === "draft") { // defence in depth, same as sealing: the composing agent's prose can quote a secret const tale = redactDeep(readTaleFile(positionals[1])); const gate = renderTaleGate(tale, buildRedactionReport(tale), buildExposureReport(tale)); // Anonymous only on explicit --anon, or a key that was never set ANYWHERE. A key // that is present but blank is a misloaded secret (the CI footgun), never consent // to publish an ownerless page that dies in 24 hours while the job stays green. const keyState = resolveApiKeyState(); if (!opts.anon && keyState.state === "empty") { fail( keyState.source === "env" ? "TALESEAL_API_KEY is set but empty: a misloaded secret, not a request to go anonymous. " + "Set the key, or pass --anon to publish an anonymous page deliberately." : "the stored config has an empty apiKey; run `npx -y taleseal@latest login` again, " + "or pass --anon to publish an anonymous page deliberately.", ); } if (opts.anon || keyState.state === "absent") { // No draft step on this path, so the gate is the only human review there is. await confirmOr( `${gate}\n\nNo API key in play: this publishes ANONYMOUSLY, live the moment it lands, ` + "immutable, and hard-deleted after 24 hours unless claimed.", opts.yes, "Publish live, anonymously?", ); const result = await draftTale(tale, anonTransport()); if (opts.json) { // one JSON contract, same as revise: the server result passes through whole // (id, url, status, claimUrl, claimExpiresAt, next) process.stdout.write(`${JSON.stringify(result)}\n`); return; } process.stdout.write( `Published live (anonymous): ${result.url}\n` + `Unclaimed pages are hard-deleted after 24 hours${ result.claimExpiresAt !== undefined ? ` (this one at ${result.claimExpiresAt})` : "" }.\n` + (result.claimUrl !== undefined ? `To KEEP the page, a human opens this claim link in a browser and signs in (free):\n` + ` ${result.claimUrl}\n` + `The claim link is shown once and never again; pass it on now.\n` : `${result.next ?? ""}\n`), ); return; } await confirmOr(gate, opts.yes, "Create the draft?"); const result = await draftTale(tale, { ...transport(), client: taleClient() }); process.stdout.write( opts.json ? `${JSON.stringify(result)}\n` : `Draft created: ${result.url}\n${result.next ?? ""}\n`, ); return; } if (sub === "publish") { const id = positionals[1] ?? fail("pass the tale id: taleseal publish "); await confirmOr( `Publish tale ${id}? The DRAFT banner comes off and the recipient sees this revision.`, opts.yes, "Publish?", ); const result = await publishTale(id, { ...transport(), client: taleClient() }); process.stdout.write( opts.json ? `${JSON.stringify({ id: result.id, url: result.url, revision: result.revision })}\n` : `Published revision ${result.revision}: ${result.url}\n`, ); return; } if (sub === "revise") { const id = positionals[1] ?? fail("pass the tale id: taleseal revise tale.json"); const tale = redactDeep(readTaleFile(positionals[2])); const gate = renderTaleGate(tale, buildRedactionReport(tale), buildExposureReport(tale)); await confirmOr(gate, opts.yes, "Store this as the new draft?"); const result = await reviseTale(id, tale, { ...transport(), client: taleClient() }); process.stdout.write( opts.json ? `${JSON.stringify(result)}\n` : `Draft stored for ${result.url}\n${result.next ?? ""}\n`, ); return; } if (sub === "retract") { const id = positionals[1] ?? fail("pass the tale id: taleseal retract "); // no prompt, same reasoning as tale retraction: this is the emergency stop const { retracted } = await retractTale(id, { ...transport(), client: taleClient() }); process.stdout.write( opts.json ? `${JSON.stringify({ retracted })}\n` : `Retracted: the tale's contents and every revision are destroyed; the link now 410s.\n`, ); return; } // ---- reads ---- if (sub === "pull") { const id = positionals[1] ?? fail("pass the tale id: taleseal pull [file]"); const { body, draftSeq } = await getTale(id, tOpts()).catch((error: unknown) => cleanRead(error, id)); const serialised = `${JSON.stringify(body, null, 2)}\n`; const outPath = positionals[2]; if (outPath === undefined) { process.stdout.write(serialised); return; } try { writeFileSync(outPath, serialised); } catch (error) { fail(`could not write ${outPath}: ${error instanceof Error ? error.message : String(error)}`); } process.stdout.write( opts.json ? `${JSON.stringify({ id, draftSeq, file: outPath })}\n` : `Pulled draft (draftSeq ${draftSeq}) to ${outPath}.\nNext: edit it, then taleseal revise ${id} ${outPath}\n`, ); return; } if (sub === "outline") { const id = positionals[1] ?? fail("pass the tale id: taleseal outline "); const result = await getTaleOutline(id, tOpts()).catch((error: unknown) => cleanRead(error, id)); process.stdout.write(opts.json ? `${JSON.stringify(result)}\n` : `${result.outline}\n`); return; } if (sub === "get") { const id = positionals[1] ?? fail("pass the tale id: taleseal get [blockId...]"); const result = await getTaleBlocks(id, positionals.slice(2), tOpts()).catch((error: unknown) => cleanRead(error, id), ); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; } // ---- edits (id-addressed, atomic, optimistic concurrency) ---- if (sub === "ops") { const id = positionals[1] ?? fail("pass the tale id: taleseal ops "); const raw = readJsonArg(positionals[2], "ops"); const rawOps = Array.isArray(raw) ? raw : raw !== null && typeof raw === "object" && Array.isArray((raw as { ops?: unknown }).ops) ? (raw as { ops: unknown[] }).ops : fail("the ops file must be a JSON array of ops (or an object with an `ops` array)."); await runOpsCli(id, rawOps, opts); return; } if (sub === "insert") { const id = positionals[1] ?? fail("pass the tale id: taleseal insert --after |--before |--start|--end"); const raw = readJsonArg(positionals[2], "blocks"); const blocks = Array.isArray(raw) ? raw : [raw]; await runOpsCli(id, [{ op: "insert", where: buildWhere(opts), blocks }], opts); return; } if (sub === "replace") { const id = positionals[1] ?? fail("pass the tale id: taleseal replace "); const target = positionals[2] ?? fail("pass the block id to replace: taleseal replace "); const block = readJsonArg(positionals[3], "block"); await runOpsCli(id, [{ op: "replace", id: target, block }], opts); return; } if (sub === "remove") { const id = positionals[1] ?? fail("pass the tale id: taleseal remove "); const ids = positionals.slice(2); if (ids.length === 0) fail("pass at least one block id: taleseal remove "); await runOpsCli(id, [{ op: "remove", ids }], opts); return; } if (sub === "move") { const id = positionals[1] ?? fail("pass the tale id: taleseal move --after |--before |--start|--end"); const target = positionals[2] ?? fail("pass the block id to move: taleseal move --after |--before |--start|--end"); await runOpsCli(id, [{ op: "move", id: target, where: buildWhere(opts) }], opts); return; } if (sub === "set-envelope") { const id = positionals[1] ?? fail("pass the tale id: taleseal set-envelope [--title …] …"); const op: Record = { op: "set_envelope" }; if (opts.title !== undefined) op.title = opts.title; if (opts.standfirst !== undefined) op.standfirst = opts.standfirst; if (opts.recipient !== undefined) op.recipient = opts.recipient; if (opts.stationery !== undefined) op.stationery = opts.stationery; if (opts.senderName !== undefined || opts.senderOrg !== undefined) { op.sender = { ...(opts.senderName !== undefined ? { name: opts.senderName } : {}), ...(opts.senderOrg !== undefined ? { org: opts.senderOrg } : {}), }; } if (opts.ctaLabel !== undefined || opts.ctaUrl !== undefined) { op.cta = { ...(opts.ctaLabel !== undefined ? { label: opts.ctaLabel } : {}), ...(opts.ctaUrl !== undefined ? { url: opts.ctaUrl } : {}), }; } if (opts.expiresAt !== undefined) op.expiresAt = opts.expiresAt; const clearable = new Set(["standfirst", "recipient", "cta", "expiresAt"]); for (const field of (opts.clear ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean)) { if (!clearable.has(field)) { fail(`--clear: "${field}" is not clearable (clearable: standfirst, recipient, cta, expiresAt).`); } op[field] = null; } if (Object.keys(op).length === 1) { fail("set-envelope needs at least one field to change, e.g. --title, --stationery, or --clear standfirst."); } await runOpsCli(id, [op], opts); return; } // The self-teaching refusal: an agent that runs bare `taleseal` learns the whole // flow from this output — the refusal IS the documentation. fail(sub === undefined ? TALE_USAGE : `unknown command "${sub}"\n\n${TALE_USAGE}`); } /** set by cli.ts so the API sees the CLI's identity, not the SDK's */ let clientOverride: string | undefined; export function setTaleClient(client: string): void { clientOverride = client; } const taleClient = (): string | undefined => clientOverride;