# paratext/csi > Every export of paratext/csi, with its signature and doc comment: kittyKeyboardPop, kittyKeyboardPush, kittyKeyboardQuery, cursorTo, cursorMove, cursorUp and 28 more. Source: https://paratext.interlace.tools/docs/api/csi ```ts import { kittyKeyboardPop, kittyKeyboardPush, kittyKeyboardQuery, … } from 'paratext/csi'; ``` ## Constants ### beginSynchronizedOutput ```ts const beginSynchronizedOutput = "\u001B[?2026h"; ``` ### clearScreen ```ts const clearScreen = "\u001Bc"; ``` ### clearTerminal Erase the screen, the scrollback, and home the cursor. ponytail: upstream has a second form for Windows 10 before build 10586 (November 2015), whose console predates these sequences; it is not carried, so such a host gets this form. Add the branch if one is ever reported. ```ts const clearTerminal = "\u001B[2J\u001B[3J\u001B[H"; ``` ### clearViewport ```ts const clearViewport = "\u001B[2J\u001B[H"; ``` ### cursorBackward ```ts const cursorBackward: (count?: number) => string; ``` ### cursorDown ```ts const cursorDown: (count?: number) => string; ``` ### cursorForward ```ts const cursorForward: (count?: number) => string; ``` ### cursorGetPosition ```ts const cursorGetPosition = "\u001B[6n"; ``` ### cursorHide ```ts const cursorHide = "\u001B[?25l"; ``` ### cursorLeft ```ts const cursorLeft = "\u001B[G"; ``` ### cursorMove Relative move: negative `x` is left, negative `y` is up; zero emits nothing on that axis. ```ts const cursorMove: (x: number, y?: number) => string; ``` ### cursorNextLine ```ts const cursorNextLine = "\u001B[E"; ``` ### cursorPrevLine ```ts const cursorPrevLine = "\u001B[F"; ``` ### cursorRestorePosition ```ts const cursorRestorePosition: string; ``` ### cursorSavePosition ```ts const cursorSavePosition: string; ``` ### cursorShow ```ts const cursorShow = "\u001B[?25h"; ``` ### cursorTo `cursorTo(x)` moves along the line; `cursorTo(x, y)` to a cell. Zero-based, as upstream. ```ts const cursorTo: (x: number, y?: number) => string; ``` ### cursorUp `ansi-escapes`' spelling exactly, zero included: `cursorUp(0)` is `ESC[0A`, and a terminal reads a zero count as one, so it **moves up a row**. A caller that can ask for zero guards it — `@inquirer/ansi` returns `''` there, and `caique/inquirer` keeps that answer by guarding before it calls this, rather than this changing and breaking the drop-in's byte-for-byte grade. ```ts const cursorUp: (count?: number) => string; ``` ### endSynchronizedOutput ```ts const endSynchronizedOutput = "\u001B[?2026l"; ``` ### enterAlternativeScreen ```ts const enterAlternativeScreen = "\u001B[?1049h"; ``` ### eraseDown ```ts const eraseDown = "\u001B[J"; ``` ### eraseEndLine ```ts const eraseEndLine = "\u001B[K"; ``` ### eraseLine ```ts const eraseLine = "\u001B[2K"; ``` ### eraseLines Erase `count` lines upward from the cursor's, then return to column one. ```ts const eraseLines: (count: number) => string; ``` ### eraseScreen ```ts const eraseScreen = "\u001B[2J"; ``` ### eraseStartLine ```ts const eraseStartLine = "\u001B[1K"; ``` ### eraseUp ```ts const eraseUp = "\u001B[1J"; ``` ### exitAlternativeScreen ```ts const exitAlternativeScreen = "\u001B[?1049l"; ``` ### kittyKeyboardPop ```ts const kittyKeyboardPop = "\u001B[ string; ``` ### kittyKeyboardQuery ```ts const kittyKeyboardQuery = "\u001B[?u"; ``` ### scrollDown ```ts const scrollDown = "\u001B[T"; ``` ### scrollUp ```ts const scrollUp = "\u001B[S"; ``` ### synchronizedOutput ```ts const synchronizedOutput: (text: string) => string; ``` --- # paratext > Every export of paratext, with its signature and doc comment: _default, ansiEscapesFor, beep, beginSynchronizedOutput, clearScreen, clearTerminal and 54 more, plus 7 types. Source: https://paratext.interlace.tools/docs/api paratext — everything around your terminal output that is not the output. *Paratext* is the literary term for what surrounds a text without being it: the title, the cover, the margins, the notes. This package owns the terminal equivalent — **OSC**, the escape class (`ESC ]`) that addresses the terminal *program* rather than the character grid. Hyperlinks, inline images, the window title, the clipboard, desktop notifications, the working directory, and the bell. The boundary is the terminal standard's, not ours, which is why it decides cases nobody has raised yet: SGR `ESC [ … m` how text looks roundel CSI `ESC [ …` the grid flagstaff CSI (to undo) cursor, alternate screen closeout OSC `ESC ] …` the terminal program here **Nothing in this layer is detectable.** No terminal answers "do you do OSC 1337", so every capability carries a static projection and `emit` returns it whenever support is absent or unknown (PRINCIPLES rule 6). Emitting the bytes and hoping is what puts `]1337;File=inline=1;…` across a user's screen, and it is what every incumbent does. **Terminals invent OSC codes faster than packages ship releases.** So the surface is a registry rather than a fixed list: `register` adds or replaces a capability, the built-ins use that same call, and a caller whose terminal we mis-detect can correct us without forking. See `capability.ts`. ```ts import _default from 'paratext'; import { ansiEscapesFor, beep, beginSynchronizedOutput, … } from 'paratext'; ``` ## Functions ### ansiEscapesFor The surface bound to a runtime you supply. The exported functions are this over {@link processRuntime }. A caller that has its own `Runtime` — a test, a host that knows better than our guesses, a server rendering for somebody else's terminal — calls this instead and nothing reads `process`. ```ts function ansiEscapesFor(runtime: Runtime): AnsiEscapes; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `AnsiEscapes` ### check Everything wrong with a plugin document — the `check` rule 7 asks for, usable before registering and by a command that validates a file. Two shapes are accepted, which is `$defs/capabilityDocument`'s `oneOf` in the schema: 1. the family shape, capabilities by name under `capabilities`, the key every other host reads its own section from; 2. one capability written as the whole document — what paratext's own schema was before the family schema absorbed it. (2) still validates, because a document that was correct yesterday is not made wrong by our housekeeping, and it comes back with a `deprecated:` line saying it goes at 1.0. A caller that wants only the blocking lines filters with `refusals()`. ```ts function check(candidate: object): string[]; ``` | Parameter | Type | | :-- | :-- | | `candidate` | `object` | **Returns** `string[]` ### emit Emit `name` for `fields`: the sequence when the terminal understands it, the static projection when it does not, and for an unknown name whatever text the caller supplied — never a throw, because output is not the place to discover a typo at three in the morning. ```ts function emit(runtime: Runtime, name: string, fields?: Fields): string; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | | `name` | `string` | | `fields` (optional) | `Fields` | **Returns** `string` ### fieldsUsed Every field a template reads, so `check` can say what a capability needs. ```ts function fieldsUsed(template: string): string[]; ``` | Parameter | Type | | :-- | :-- | | `template` | `string` | **Returns** `string[]` ### register Add a capability, or replace one by name — replacing is deliberate, so a caller whose terminal we mis-detect can correct the guess without patching the package. **This is the only way into the registry**, which is what makes one validator enough: `emit()` reads nothing else, the built-ins come through here, and `attach()` in `plugin.ts` hands its contributions to this same call. A capability whose `when` is not an object is refused here, and that refusal — not a guard further down — is the reason `supports()` is never asked about it. ```ts function register(capability: Capability): void; ``` | Parameter | Type | | :-- | :-- | | `capability` | `Capability` | **Returns** `void` ### registerBuiltins Registered through the public call, so the built-ins prove the extension surface works. ```ts function registerBuiltins(): void; ``` **Returns** `void` ### render Render a template against a caller's fields. Optional groups resolve first, and a group survives only if **every** field inside it has a value — an empty string counts as absent, because `Done: ` with nothing after the colon is worse output than `Done`. ```ts function render(template: string, fields: Fields): string; ``` | Parameter | Type | | :-- | :-- | | `template` | `string` | | `fields` | `Fields` | **Returns** `string` ### supports Whether this runtime is believed to understand `capability`. Takes the `when` clause structurally rather than a whole `Capability`, so this module owes `capability.ts` nothing at all and the subpath graph stays a leaf. A `Capability` satisfies the parameter, which is every existing caller. ```ts function supports(runtime: Runtime, capability: { readonly when: Support; }): boolean; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | | `capability` | `{ readonly when: Support; }` | **Returns** `boolean` ## Classes ### CapabilityError Thrown rather than returned: a malformed capability is a programming error at start-up. It carries a `code` from the family's one vocabulary, so that the same defect reported through `register()` and through `plugin.validate()` reads the same. See {@link CapabilityErrorCode} for where that vocabulary lives and why this file spells its two members out rather than importing them. ```ts class CapabilityError extends Error { readonly code: CapabilityErrorCode; constructor(code: CapabilityErrorCode, message: string); } ``` ## Constants ### default The default export, declared as `_default`. The default export, carrying what this package implements: the CSI half and the four OSC members. `iTerm` and `ConEmu` stay off it — a key present with an `undefined` value would read as a claim made and not kept. Frozen, because the incumbent's own suite asserts that a named export and the member are the same object and a caller reassigning one would make that quietly untrue. ```ts const _default: Readonly<{ beep: "\u0007"; image: (data: Uint8Array | string, options?: ImageOptions) => string; link: (text: string, url: string) => string; setCwd: (cwd?: string) => string; cursorTo: (x: number, y?: number) => string; cursorMove: (x: number, y?: number) => string; cursorUp: (count?: number) => string; cursorDown: (count?: number) => string; cursorForward: (count?: number) => string; cursorBackward: (count?: number) => string; cursorLeft: "\u001B[G"; cursorSavePosition: "\u001B7" | "\u001B[s"; cursorRestorePosition: "\u001B8" | "\u001B[u"; cursorGetPosition: "\u001B[6n"; cursorNextLine: "\u001B[E"; cursorPrevLine: "\u001B[F"; cursorHide: "\u001B[?25l"; cursorShow: "\u001B[?25h"; eraseEndLine: "\u001B[K"; eraseStartLine: "\u001B[1K"; eraseLine: "\u001B[2K"; eraseDown: "\u001B[J"; eraseUp: "\u001B[1J"; eraseScreen: "\u001B[2J"; scrollUp: "\u001B[S"; scrollDown: "\u001B[T"; eraseLines: (count: number) => string; clearScreen: "\u001Bc"; clearViewport: "\u001B[2J\u001B[H"; clearTerminal: "\u001B[2J\u001B[3J\u001B[H"; enterAlternativeScreen: "\u001B[?1049h"; exitAlternativeScreen: "\u001B[?1049l"; beginSynchronizedOutput: "\u001B[?2026h"; endSynchronizedOutput: "\u001B[?2026l"; synchronizedOutput: (text: string) => string; }>; ``` ### beep The bell, byte-identical to `ansi-escapes`' `beep`. The one member of this surface that does not degrade, because it is a value and not a call: there is no runtime to consult at the moment a constant is read. It is also one byte rather than a sequence, so a pipe that receives it gets a byte, not a smear of unreadable escape text. `emit(runtime, 'bell')` is the form that degrades. ```ts const beep = "\u0007"; ``` ### bell BEL — the oldest one, and the only member of this layer that is not an OSC sequence. ```ts const bell: Capability; ``` ### builtins Every capability this package ships, in one list a reader can check against the registry. ```ts const builtins: readonly Capability[]; ``` ### capabilities Every registered name, sorted — so `--json` and a check command can enumerate them. ```ts const capabilities: () => string[]; ``` ### capability One capability by name, for callers that want to inspect before they emit. ```ts const capability: (name: string) => Capability | undefined; ``` ### clipboard OSC 52 — put text on the *user's* clipboard through the terminal, which is the only way that works over ssh. `clipboardy` shells out to `pbcopy` and cannot. ```ts const clipboard: Capability; ``` ### ConEmu ```ts const ConEmu: NotImplemented; ``` ### cwd OSC 50 and 9;9 — tell the emulator where we are, so a new tab opens here. ```ts const cwd: Capability; ``` ### DEPRECATED The prefix on a line `check` returns that does **not** refuse the document. A capability written as the whole document is the shape paratext had before the family schema absorbed it. It still validates for one minor release (PLAN D2) and 1.0 removes it, so the two kinds of line travel back together and this is how a caller tells them apart: `refusals()` is what blocks, everything else is what to fix before 1.0. ```ts const DEPRECATED = "deprecated: "; ``` ### image OSC 1337 — `image(data, options)`, projecting to `options.caption`. ```ts const image: (data: Uint8Array | string, options?: ImageOptions) => string; ``` ### isDeprecation Whether a line `check` produced is a warning rather than a refusal. ```ts const isDeprecation: (line: string) => boolean; ``` ### iTerm ```ts const iTerm: NotImplemented; ``` ### link OSC 8 — `link(text, url)`, projecting to `text (url)` where hyperlinks are not understood. ```ts const link: (text: string, url: string) => string; ``` ### notify OSC 9 — a desktop notification, without `node-notifier`'s native binaries. ```ts const notify: Capability; ``` ### processRuntime What a real process looks like. Callers that have not got one pass their own. ```ts const processRuntime: () => Runtime; ``` ### refusals The lines that refuse the document — what `register` throws on, and what a CLI exits on. ```ts const refusals: (lines: readonly string[]) => string[]; ``` ### reset Registration is global, so tests and hosts need a way back. ```ts const reset: () => void; ``` ### setCwd OSC 50 + OSC 9;9 — `setCwd(cwd)`, defaulting to the runtime's own, projecting to nothing. Read per call: the working directory moves. ```ts const setCwd: (cwd?: string) => string; ``` ### title OSC 0 — the window and tab title. ```ts const title: Capability; ``` ## Interfaces ### AnsiEscapes The members of `ansi-escapes` paratext implements, bound to one runtime. ```ts interface AnsiEscapes { /** The bell, as a string — the incumbent's `beep`. */ beep: string; /** OSC 8. Projects to `text (url)`. */ link: (text: string, url: string) => string; /** OSC 1337. Projects to `options.caption`. */ image: (data: Uint8Array | string, options?: ImageOptions) => string; /** OSC 50 + OSC 9;9, the two halves upstream concatenates. Projects to nothing. */ setCwd: (cwd?: string) => string; } ``` ### Capability ```ts interface Capability { /** How callers name it: `link`, `image`, `clipboard`, or anything a third party invents. */ readonly name: string; /** The OSC code, or `BEL`. Documentation for a reader, and a key for `check`. */ readonly osc: number | 'BEL'; readonly when: Support; /** The bytes, as a template, for a terminal that does understand. */ readonly encode: string; /** * What to print when it does not — PRINCIPLES rule 6, and the reason this package exists. * An image becomes its caption, a notification a printed line, a hyperlink `text (url)`. * A capability without one is refused at `register`, because a sequence a terminal cannot * read is not a feature, it is `]1337;File=inline=1;…` across a user's screen. An empty * string is a legitimate projection — a window title has nothing to say in a log — but it * has to be written down rather than left out. */ readonly fallback: string; } ``` ### ImageOptions `ansi-escapes`' `image()` options, plus the field a projection needs. ```ts interface ImageOptions { /** Cells, pixels (`20px`) or percent (`50%`) — passed through as the incumbent passes it. */ width?: number | string; height?: number | string; /** `false` writes `preserveAspectRatio=0`, exactly as upstream does. */ preserveAspectRatio?: boolean; /** * What a terminal that cannot draw the image prints instead. paratext's addition, and not * optional in spirit: an image with no caption projects to nothing, which is silence where * the incumbent would have written bytes nobody can read. */ caption?: string; } ``` ### Runtime The slice of a runtime this package needs — the same seam `roundel/policy.ts` declares, for the same reason: nothing here reads `process` directly, so a test is a two-line literal and a host can lie about the terminal on purpose. ```ts interface Runtime { env: Record; /** * `stderr` is optional and falls back to `stdout` when absent. * * Only `paratext/terminal-link` reads it — its whole surface is a pair, `terminalLink` * against stdout and `terminalLink.stderr` against stderr, and a façade that decided both * from one stream would answer the wrong question for half of its API. Optional because a * two-line test literal should not have to carry a stream it is not asking about, and * because every existing caller of this type predates the field. */ isTTY: { stdout: boolean; stderr?: boolean; }; /** * The working directory, for the one capability that has to name it. * * Optional, because nothing that decides *support* reads it and a two-line test literal * should not have to carry it. `setCwd()` with no argument is the only caller, and it is * the reason this is here at all: `ansi-escapes`' `setCwd` defaults to `process.cwd()` * (R8), and a compatible default that read `process` itself would put a second process * reference in the package and break R5. */ cwd?: string; /** * The command line and the OS, for `paratext/terminal-link` alone: `supports-hyperlinks` * reads `--no-hyperlink` / `--color` flags and refuses win32 outside Windows Terminal, and * a drop-in that ignored them would link where the incumbent did not. Optional for the * reason `cwd` is. */ argv?: readonly string[]; platform?: string; /** * The output mode a host that composes has already decided — roundel's `outputMode(rt)`: * `tty`, `pipe`, `ci`, `accessible` or `json`. Anything but `tty` is a static projection, * so every capability renders its `fallback`, the rule flagstaff's `hoist()` follows. * * Handed in rather than worked out, for two reasons that are both rules. paratext is a leaf * and may not import roundel (no leaf-to-leaf edge), and the variables the mode is read * from, `CI` and `CLI_ACCESSIBLE`, have one reader in the family (`cli-output-stack` R2). * It is the same seam `json` uses: the caller passes what it knows (R14). Optional, and * absent means what it meant before 2026-09-30 — support is decided from `when` alone — * because `processRuntime()` cannot know it; D-20260930-paratext-standalone-mode is the * owner's call on that default. */ mode?: string; } ``` ### Support When a terminal is believed to understand a sequence. Every clause is optional and all of them must hold; `termProgram` and `envAny` are ORs within themselves. ```ts interface Support { /** Refuse a pipe. Almost always true: a file that receives OSC gets control bytes in it. */ readonly tty?: boolean; /** Any one of these `TERM_PROGRAM` values. */ readonly termProgram?: readonly string[]; /** Any one of these environment variables merely being set, as VTE announces itself. */ readonly envAny?: readonly string[]; /** An exact `TERM`, for the terminals that identify that way. */ readonly term?: string; } ``` ## Types ### Fields What a caller hands a capability. Flat and string-valued, and therefore describable. ```ts type Fields = Readonly>; ``` ### NotImplemented The type of a name that is declared and not implemented. Reading one gets `undefined`; *calling* one does not type-check, which is the point. A compile error that lands on this type is a sentence telling you which package owns the thing you asked for, and it arrives before you ship rather than after. ```ts type NotImplemented = undefined; ``` ## Re-exported Documented on the page of the entry point that declares them. | Export | Kind | Documented in | | :-- | :-- | :-- | | `beginSynchronizedOutput` | const | [`paratext/csi`](/docs/api/csi#beginsynchronizedoutput) | | `clearScreen` | const | [`paratext/csi`](/docs/api/csi#clearscreen) | | `clearTerminal` | const | [`paratext/csi`](/docs/api/csi#clearterminal) | | `clearViewport` | const | [`paratext/csi`](/docs/api/csi#clearviewport) | | `cursorBackward` | const | [`paratext/csi`](/docs/api/csi#cursorbackward) | | `cursorDown` | const | [`paratext/csi`](/docs/api/csi#cursordown) | | `cursorForward` | const | [`paratext/csi`](/docs/api/csi#cursorforward) | | `cursorGetPosition` | const | [`paratext/csi`](/docs/api/csi#cursorgetposition) | | `cursorHide` | const | [`paratext/csi`](/docs/api/csi#cursorhide) | | `cursorLeft` | const | [`paratext/csi`](/docs/api/csi#cursorleft) | | `cursorMove` | const | [`paratext/csi`](/docs/api/csi#cursormove) | | `cursorNextLine` | const | [`paratext/csi`](/docs/api/csi#cursornextline) | | `cursorPrevLine` | const | [`paratext/csi`](/docs/api/csi#cursorprevline) | | `cursorRestorePosition` | const | [`paratext/csi`](/docs/api/csi#cursorrestoreposition) | | `cursorSavePosition` | const | [`paratext/csi`](/docs/api/csi#cursorsaveposition) | | `cursorShow` | const | [`paratext/csi`](/docs/api/csi#cursorshow) | | `cursorTo` | const | [`paratext/csi`](/docs/api/csi#cursorto) | | `cursorUp` | const | [`paratext/csi`](/docs/api/csi#cursorup) | | `endSynchronizedOutput` | const | [`paratext/csi`](/docs/api/csi#endsynchronizedoutput) | | `enterAlternativeScreen` | const | [`paratext/csi`](/docs/api/csi#enteralternativescreen) | | `eraseDown` | const | [`paratext/csi`](/docs/api/csi#erasedown) | | `eraseEndLine` | const | [`paratext/csi`](/docs/api/csi#eraseendline) | | `eraseLine` | const | [`paratext/csi`](/docs/api/csi#eraseline) | | `eraseLines` | const | [`paratext/csi`](/docs/api/csi#eraselines) | | `eraseScreen` | const | [`paratext/csi`](/docs/api/csi#erasescreen) | | `eraseStartLine` | const | [`paratext/csi`](/docs/api/csi#erasestartline) | | `eraseUp` | const | [`paratext/csi`](/docs/api/csi#eraseup) | | `exitAlternativeScreen` | const | [`paratext/csi`](/docs/api/csi#exitalternativescreen) | | `scrollDown` | const | [`paratext/csi`](/docs/api/csi#scrolldown) | | `scrollUp` | const | [`paratext/csi`](/docs/api/csi#scrollup) | | `synchronizedOutput` | const | [`paratext/csi`](/docs/api/csi#synchronizedoutput) | --- # paratext/link > Every export of paratext/link, with its signature and doc comment: LINK, supportsLink, linkFor, link. Source: https://paratext.interlace.tools/docs/api/link `paratext/link` — OSC 8, and nothing else. **Why this subpath exists.** `burgee`'s `--help` wanted a real hyperlink on a supporting terminal and `Docs (https://x.dev)` on a pipe, and could not take the dependency: `help.ts` is imported *statically* by `execute.ts`, so anything help reaches is in the cold-start graph of `burgee foo --json` too, and paratext published nothing narrower than its 17,574 B root, which runs `registerBuiltins()` at import. This entry is 2,337 B and registers nothing — there is no registry in its graph. See spec.md R13. **What it gives up, said plainly.** `link()` here reads {@link LINK} directly, so a caller who corrected our guess by re-registering `link` on the global registry does not change what this returns — that registry is the thing this module deliberately does not load. A host that wants the registry's answer imports `paratext` and calls `emit(runtime, 'link', …)`. The record itself is shared: `builtins.ts` re-exports it as `link`, so the capability the registry ships and the one this subpath emits are one object, not two that drift. `weight.test.ts` pins the reachability and the budget; `link.test.ts` pins the bytes and the absent side effect. ```ts import { LINK, supportsLink, linkFor, … } from 'paratext/link'; ``` ## Constants ### link `link(text, url)` against the real process — the `ansi-escapes` and `terminal-link` shape, for a caller with no `Runtime` of its own. The OSC 8 sequence where the terminal is believed to understand it, `Docs (https://x.dev)` everywhere else; never raw OSC into a pipe, never a bare `Docs` with the destination silently dropped. ```ts const link: (text: string, url: string) => string; ``` ### LINK OSC 8 — a hyperlink. The widest support in this layer, and unusually semi-detectable: VTE publishes its version and Windows Terminal sets a session variable. The fallback is `text (url)`, not bare text: a link whose destination vanishes in a pipe has lost the half that mattered. The optional group makes a link with no url just its text. ```ts const LINK: Capability; ``` ### linkFor `link(text, url)` bound to a runtime you supply — the form a host should use. `burgee`'s help renderer already carries a `Runtime`, and taking it as an argument means the whole path is pure: a test is a two-line literal, and rendering help for somebody else's terminal is the same call. ```ts const linkFor: (runtime: Runtime) => (text: string, url: string) => string; ``` ### supportsLink Whether `runtime` is believed to render OSC 8. For a renderer deciding *layout* rather than bytes — a help screen that prints a `Docs` column only where the URL will not be repeated in parentheses. A caller that only wants the string does not need this: {@link linkFor} already answers it. The guess itself is `LINK.when`, and it is data a caller may replace. ```ts const supportsLink: (runtime: Runtime) => boolean; ``` --- # paratext/plugin > Every export of paratext/plugin, with its signature and doc comment: validate, register, reset, registered, contributions, attach and 2 more, plus 5 types. Source: https://paratext.interlace.tools/docs/api/plugin The plugin host for paratext's half of the contract (`plugin-contract` R1, R5a, R6, R7, R8). A plugin is one plain object shared by the whole family. This file keeps the key paratext understands — **`capabilities`** (R5a), OSC records by name — and **ignores every other key without complaining**, which is what makes the same object work on any subset of the family that is installed. A plugin written for flagstaff registers here and contributes nothing; its `tokens`, `spinners` and `components` are not paratext's business and are not an error. **Why paratext needed this file more than the other hosts did.** Until it existed, paratext was the one published package with no `src/plugin.ts` — and that file is the *marker* `scripts/plugin-schema-lock.test.ts` and `scripts/schema-sync.mjs` both look for. So paratext's copy of the family schema was outside the lock that keeps the copies byte-identical: it happened to match flagstaff's, and nothing in the repository would have said so if it stopped. **Nothing here imports another layer, and the plugin shape is declared rather than imported** (R3). It imports `./capability.js` because that is this package's own module: the capability shape, the registry and `check()` have to have exactly one home, or "fallback is required" means one thing to a plugin and another to a built-in. **R7, and why this host is the easy case.** R7 asks that no key require a function. Here none does: a capability is `{ name, osc, when, encode, fallback }`, five fields of plain data with two template strings, so a plugin can arrive as JSON, be diffed, and be printed by a `plugin check` without anybody running its author's code. closeout's `handlers` owes R7 an exemption; `capabilities` owes it nothing, which is the argument the design makes for holding capabilities as data in the first place. ```ts import { validate, register, reset, … } from 'paratext/plugin'; ``` ## Functions ### attach Hand every contributed capability to a registry. Defaults to paratext's own, which is what a program wants: `attach()` after importing the plugins, and `emit()` can then reach them by name exactly as it reaches the built-ins. Passing a host is for a caller keeping its own registry, and for a test that wants to see the order without touching the global one. ```ts function attach(host?: CapabilityHost): void; ``` | Parameter | Type | | :-- | :-- | | `host` (optional) | `CapabilityHost` | **Returns** `void` ### contributions Every capability every registered plugin contributed, with who won each name. This is the static projection of the plugin set (R7): a caller — or `burgee plugin check` — reads what *would* be registered, and who lost, without registering anything and without a terminal being involved. ```ts function contributions(): Contribution[]; ``` **Returns** `Contribution[]` ### register Register a plugin. Later wins, like ESLint flat config: the array is ordered, a caller reads it top to bottom, and the last word on a capability name is the one nearest the program — which is also the registry's own rule, since `register()` in `capability.ts` replaces by name so a caller can correct a guess we got wrong. **Registering does not emit, and does not even reach the capability registry.** It records the contribution; {@link attach} is what hands it over. That separation is what makes {@link contributions} a static projection rather than a side effect. ```ts function register(plugin: unknown): void; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `void` ### registered The plugins registered, in registration order. ```ts function registered(): readonly Plugin[]; ``` **Returns** `readonly Plugin[]` ### reset Forget every registered plugin. For tests, and for a program that re-plugs at runtime. ```ts function reset(): void; ``` **Returns** `void` ### validate Refuse a plugin that cannot contribute a capability, at the door. Every refusal is a refusal rather than a silent drop. A capability quietly ignored looks like it worked right up until the day somebody runs the program on the terminal it was written for, and then the thing they debug is the terminal. ```ts function validate(plugin: unknown): asserts plugin is Plugin; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `asserts plugin is Plugin` ## Classes ### PluginError A refused plugin says what is wrong and what to do about it — the family's one vocabulary. ```ts class PluginError extends Error { readonly code: PluginErrorCode; readonly fix: string; /** Each problem apart, with its own code and fix, where one refusal reports several. */ readonly problems?: readonly Refusal[] | undefined; constructor(code: PluginErrorCode, message: string, fix: string, /** Each problem apart, with its own code and fix, where one refusal reports several. */ problems?: readonly Refusal[] | undefined); } ``` ## Constants ### CONTRACT The plugin contract version. One number for the family — the same `1` flagstaff, caique and closeout declare, written out rather than imported for the reason in the file comment. ```ts const CONTRACT = 1; ``` ## Interfaces ### CapabilityHost The half of the capability registry this needs — declared structurally so a caller can attach to something it built itself, and so a host can watch what arrives. ```ts interface CapabilityHost { register(capability: Capability): void; } ``` ### Contribution One contributed capability, with the plugin it came from and the plugins it displaced. ```ts interface Contribution { name: string; from: string; /** Plugins that contributed this name earlier and were overridden, in order. */ shadowed: string[]; capability: Capability; } ``` ### Plugin The keys paratext reads. Declared structurally: any object with these fields is a plugin here, whatever else it carries. ```ts interface Plugin { name: string; contract?: number; /** Capabilities by name. The key and the record's own `name` must agree; see {@link validate}. */ capabilities?: Record; } ``` ### Refusal One thing wrong with a plugin, in the family's vocabulary: the code, the line, the fix. ```ts interface Refusal { code: PluginErrorCode; message: string; fix: string; } ``` ## Types ### PluginErrorCode Three codes, all of them already in flagstaff's `PluginErrorCode` — the vocabulary home (`scripts/plugin-error-vocabulary-lock.test.ts`). `E_NO_STATIC_PROJECTION` is the one worth noticing: flagstaff raises it for a component with no static form and caique for a widget with no `static`, and a capability with no `fallback` is the same defect wearing OSC. One code, one fix shape, three layers. ```ts type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_STATIC_PROJECTION' | 'E_NO_CONTRIBUTION'; ``` --- # paratext/term-img > Every export of paratext/term-img, with its signature and doc comment: supportsInlineImage, terminalImageFor, UnsupportedTerminalError, terminalImage, plus 3 types. Source: https://paratext.interlace.tools/docs/api/term-img ```ts import terminalImage from 'paratext/term-img'; import { supportsInlineImage, terminalImageFor, UnsupportedTerminalError } from 'paratext/term-img'; ``` ## Functions ### supportsInlineImage `term-img`'s own support question, from the environment alone. **One deliberate correction.** Upstream reads the iTerm2 major version as `Number(version[0])` — the first *character* — so it would read `10.2.1` as `1` and refuse a terminal five majors past its minimum. `Number.parseInt` is what it meant. Nothing in the vendored suite distinguishes the two (its case is `3.3.7`), so this is a fix that costs no compatibility and is written down rather than left to be re-discovered. Upstream reaches for `iterm2-version`, which falls back to reading the installed bundle's `Info.plist` through `app-path`. It never gets there when `TERM_PROGRAM` is `iTerm.app`, because that package returns `TERM_PROGRAM_VERSION` first — so an environment read is the whole of the reachable behaviour, and two dependencies buy nothing. ```ts function supportsInlineImage(runtime: Runtime): boolean; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `boolean` ### terminalImageFor `terminalImage` bound to a runtime you supply — the pure form, and what the export wraps. The order of the three checks is upstream's, exactly, because every one of its cases depends on it: the argument is validated *before* the terminal is consulted, and the terminal is consulted *before* the bytes are wanted. That is why a path handed to an unsupported terminal raises `UnsupportedTerminalError` (or calls `fallback`) without the file being opened — a missing file on a terminal that cannot draw it is not an `ENOENT`, because upstream would not have opened it either. ```ts function terminalImageFor(runtime: Runtime): (image?: TerminalImageInput, options?: TerminalImageOptions) => string | FallbackType; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `(image?: TerminalImageInput, options?: TerminalImageOptions) => string \| FallbackType` ## Classes ### UnsupportedTerminalError Thrown when the terminal is not believed to draw an inline image and the caller supplied no `fallback`. Upstream's class, upstream's message, upstream's `name` — a caller that matched on `error.name` or on `instanceof` keeps working, which is most of what drop-in means for an error type. ```ts class UnsupportedTerminalError extends Error { /** * The parameter is ours; upstream's constructor takes none. It is a superset — `new * UnsupportedTerminalError()` still produces upstream's exact message — and it exists so a * caller that knows more about *why* the terminal refused can say so. */ constructor(message?: string); } ``` ## Constants ### default The default export, declared as `terminalImage`. `terminalImage(image, options?)` against the real process — `term-img`'s default export. ```ts const terminalImage: (image?: TerminalImageInput, options?: TerminalImageOptions) => string | FallbackType; ``` ## Interfaces ### TerminalImageOptions `term-img`'s options: `ansi-escapes`' four, plus the escape hatch from the throw. Generic over what `fallback` returns, as upstream's `Options` is, so the idiom in term-img's own README — a `fallback` that does something and returns nothing — type-checks here too. The default is upstream's `unknown`, so a bare `Options` annotation takes any `fallback`; the *call* defaults to `never` instead, so a call with no `fallback` is typed `string`, which is what it returns, where upstream's is typed `unknown`. ```ts interface TerminalImageOptions extends ImageOptions { /** Called instead of throwing when the terminal cannot draw it. What it returns is returned. */ fallback?: () => FallbackType; } ``` ## Types ### Options Upstream's name for the options, so `import { type Options } from 'term-img'` migrates. ```ts type Options = TerminalImageOptions; ``` ### TerminalImageInput What upstream's argument accepts — bytes or a file path — plus a file `URL`, which `readFileSync` takes and upstream refuses as `Image required` only because a `URL` has no `length`. A superset: every call upstream accepts means the same thing here. ```ts type TerminalImageInput = Uint8Array | string | URL | null | undefined; ``` --- # paratext/terminal-link > Every export of paratext/terminal-link, with its signature and doc comment: terminalLinkFor, terminalLink, plus 4 types. Source: https://paratext.interlace.tools/docs/api/terminal-link ```ts import terminalLink from 'paratext/terminal-link'; import { terminalLinkFor } from 'paratext/terminal-link'; ``` ## Functions ### terminalLinkFor `terminalLink` bound to a runtime you supply — the pure form, and what the export wraps. ```ts function terminalLinkFor(runtime: Runtime): (text: string, url: string, options?: LinkOptions) => string; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `Runtime` | **Returns** `(text: string, url: string, options?: LinkOptions) => string` ## Constants ### default The default export, declared as `terminalLink`. ```ts const terminalLink: TerminalLink; ``` ## Interfaces ### LinkOptions ```ts interface LinkOptions { target?: Target; /** * What to print when the terminal is not believed to render OSC 8. * * `false` returns the text alone, a function is called with `(text, url)`, and anything * else — including absent — gives upstream's `text url`, a raw URL bounded by whitespace * because that is what terminal linkifiers detect reliably. */ fallback?: boolean | ((text: string, url: string) => string); } ``` ### TerminalLink `terminalLink(text, url, options?)` against the real process. `isSupported` is a snapshot taken at import, which is upstream's shape exactly — it reads `supportsHyperlinks.stdout` once at module scope. A caller who needs the live answer calls the function and compares, or builds its own through {@link terminalLinkFor}. ```ts interface TerminalLink { (text: string, url: string, options?: LinkOptions): string; isSupported: boolean; stderr: { (text: string, url: string, options?: Omit): string; isSupported: boolean; }; } ``` ## Types ### Options Upstream's name for the options, so `import { type Options } from 'terminal-link'` migrates. ```ts type Options = LinkOptions; ``` ### Target Which stream a call is bound for. Upstream's `target` option, spelled the same way. ```ts type Target = 'stdout' | 'stderr'; ``` --- # Changelog > Every release of paratext, newest first, from its CHANGELOG.md — what changed and the pull request it came from. Source: https://paratext.interlace.tools/docs/changelog ## 1.0.9 ### Patch Changes - [#1160](https://github.com/ofri-peretz/burgee/pull/1160) [`0b381a0`](https://github.com/ofri-peretz/burgee/commit/0b381a0d394092edf2ea21a9143a10111aa72fe4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Export `./package.json`. `require('/package.json')` threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which broke tools that read a dependency's manifest that way: bundlers, license scanners and version checks. ## 1.0.8 ### Patch Changes - [#1130](https://github.com/ofri-peretz/burgee/pull/1130) [`c05c86e`](https://github.com/ofri-peretz/burgee/commit/c05c86e2caa0fc3a89f97f7fe9c37bd7b876bfaf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every drop-in subpath now type-checks under `moduleResolution: node10`, and under `module: commonjs` with no resolution set, which is TypeScript's default there. `import … from 'burgee/commander'` used to fail with TS2307 in such a project, although `require()` of the same specifier worked. Each package now declares `types` and a `typesVersions` map, derived from `exports`, with one key per typed subpath pointing at the file `exports` already names. `exports` is unchanged, so `node16`, `nodenext` and `bundler` read exactly what they read before. `controlroom/ink`'s declarations no longer name the untyped `react-reconciler`, which was TS7016 for consumers with `skipLibCheck: false`. ## 1.0.7 ### Patch Changes - [#1082](https://github.com/ofri-peretz/burgee/pull/1082) [`4a62884`](https://github.com/ofri-peretz/burgee/commit/4a6288481a77dce34c496af5a5ffbaed900483c6) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext check` and `register()` refuse a plugin with every capability problem at once, each with its own code and `fix:`, where they used to stop at the first. A missing `osc`, `when`, `encode` or `name` now gets a fix that shows the field written (``add `when: { tty: true }` ``), not "compare against the schema". The README has a "Writing a plugin" section: a whole plugin module and what `paratext check` prints for it. ## 1.0.6 ### Patch Changes - [#1045](https://github.com/ofri-peretz/burgee/pull/1045) [`c4fd9a3`](https://github.com/ofri-peretz/burgee/commit/c4fd9a3cb10bc36bef8c468365addfc6deb572cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `locales`, burgee's locale-pack key. A plugin carrying `locales` validates against the shared schema as it does against `burgee/locale-pack-schema.json`, which lists every message id. This package reads nothing new, and a plugin carrying `locales` registers here as before. ## 1.0.5 ### Patch Changes - [#1011](https://github.com/ofri-peretz/burgee/pull/1011) [`1968eb4`](https://github.com/ofri-peretz/burgee/commit/1968eb46feec57bb047a9c4a4288e0cf4442f4b2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README quick start runs as written. On npm it was broken for flagstaff (`rt` undefined) and linegauge (`styled` undefined), controlroom's imported flagstaff without installing it, and paratext's printed nothing. No code changed; `readme-quick-start-lock` now runs every package's quick start. - [#1022](https://github.com/ofri-peretz/burgee/pull/1022) [`ecb0a00`](https://github.com/ofri-peretz/burgee/commit/ecb0a00ca68e9a3b3584aefabb9dcf97e3ff967a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The tarball no longer carries declaration files that no `exports` entry reaches. TypeScript could never open them, because a deep import is refused by `exports`. Every entry's types are unchanged. The ten packages drop 71 files and 118,312 bytes between them, burgee 35 files and 76,504 bytes, which takes its tarball from 251,814 to 225,278 bytes gzipped. ## 1.0.4 ### Patch Changes - [#938](https://github.com/ofri-peretz/burgee/pull/938) [`9babb6d`](https://github.com/ofri-peretz/burgee/commit/9babb6da1ae7783c7e8367f89e3da9dffc85ffdb) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - npm description and README: the description says what the package does, then its dependency fact in one of two wordings ("Zero dependencies." or "No dependency outside the burgee family."), then its drop-in paths. The README's family section states how the family is built — the leaves, one-way dependencies, nothing from outside the family, trusted publishing with provenance — and its table column is "Migrates from". No API, compatibility grade or benchmark changes. ## 1.0.3 ### Patch Changes - [#917](https://github.com/ofri-peretz/burgee/pull/917) [`735f0f6`](https://github.com/ofri-peretz/burgee/commit/735f0f68048f2d0f5e4effad151ed5132b480bb8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: the opening paragraph says what the package does first. The drop-in path follows it as one line, naming the incumbent and `npx burgee migrate`, and every compatibility row and benchmark is unchanged. ## 1.0.2 ### Patch Changes - [#897](https://github.com/ofri-peretz/burgee/pull/897) [`f29f159`](https://github.com/ofri-peretz/burgee/commit/f29f159ca0c9746f58d8ba84eec9be72b1140093) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom has its own docs site at https://controlroom.interlace.tools: its README's Docs line, its `homepage`, and every family table now link there. ## 1.0.1 ### Patch Changes - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` describes what five values look like. `contract` is `1`, and burgee refuses a plugin that declares none. A caique widget's `static(spec)` gets `{ kind, message, ...sample.done }`. A seniority `rank` sits between the built-in layers at flag 0, environment 10, config file 20, `package.json` 30 and default 40. A burgee hook's `filter` is `{ command: RegExp }`, now with `command` required, so the schema and the host refuse the same filters. - [#857](https://github.com/ofri-peretz/burgee/pull/857) [`d80b2d0`](https://github.com/ofri-peretz/burgee/commit/d80b2d0f4a535400c6825b42ac2d8e1be2827f4e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` closes a bellpull resolver's `when` with `additionalProperties: false`, so the schema and bellpull's own validation refuse the same unknown keys. ## 1.0.0 ### Major Changes - [#830](https://github.com/ofri-peretz/burgee/pull/830) [`1945d65`](https://github.com/ofri-peretz/burgee/commit/1945d65f22aafabd380d67f1e40341d6ef3aa6fa) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - paratext 1.0.0. The spec is fully built, every drop-in passes its incumbent's own suite at the latest release level with the incumbent itself, and coverage is 100% on all four measures (D-20261006-foundation-1-0-evidence). 1.0 makes the published entries, the plugin `schema.json` and the `paratext` bin a semver contract. No API changed in this release. ### Patch Changes - [#834](https://github.com/ofri-peretz/burgee/pull/834) [`9ebef65`](https://github.com/ofri-peretz/burgee/commit/9ebef654930ac4565f1ee04ec5f8b6f735c6a1d9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The root default (the `ansi-escapes` drop-in object) again carries exactly the incumbent's members. 0.9.0 spread `kittyKeyboardPush`, `kittyKeyboardPop` and `kittyKeyboardQuery` into it, which `ansi-escapes` does not have and which took the entry 128 B over its weight ceiling. They are still exported from `paratext/csi`. ## 0.9.0 ### Minor Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `controlroom/ink` negotiates the kitty keyboard protocol as ink does: pushed at once with `kittyKeyboard: { mode: 'enabled' }`, and in `auto` mode only once a known terminal answers the query, with every other byte handed back to stdin and the pop written at unmount. ink's own suite now passes 584 / 584. paratext's `csi` spells the protocol's three sequences (`kittyKeyboardPush`, `kittyKeyboardPop`, `kittyKeyboardQuery`). ### Patch Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom hosts plugins (R10): `keymaps` and `panes` register through `controlroom/plugin`'s `register()` against the family schema, a screen takes either by name, and `controlroom check ` reports what a plugin contributes. The family schema every host ships gains the `keymaps` and `panes` definitions. ## 0.8.0 ### Minor Changes - [#774](https://github.com/ofri-peretz/burgee/pull/774) [`b44f426`](https://github.com/ofri-peretz/burgee/commit/b44f426621ed799700cceda8c979de8f759f56b7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext/term-img` now takes a file path, as `term-img` does. `terminalImage('unicorn.jpg')` reads the file with `node:fs` and draws it, and a file `URL` works too. The read happens after the terminal check, so a path handed to a terminal that cannot draw it reaches your `fallback` (or `UnsupportedTerminalError`) without the file being opened. A missing file on a supported terminal throws `node:fs`'s `ENOENT`, as `term-img` does. The `Options` type is exported under term-img's name and is generic over what `fallback` returns, so a `fallback` that returns nothing type-checks. `paratext/term-img` now grades 18 / 18 against term-img 7.1.0's own suite, level with term-img itself. It was 12 / 18: the six cases that pass a path were refused under D-030. This supersedes D-030 for this subpath only (D-20260930-paratext-term-img-path). It is the only paratext entry that imports `node:fs`. The root `image()` still takes bytes, and a lock fails if `node:fs` reaches the root or any other entry. Because the row is level, `burgee migrate` now rewrites `term-img` to `paratext/term-img`. ## 0.7.5 ### Patch Changes - [#763](https://github.com/ofri-peretz/burgee/pull/763) [`6c2e9c5`](https://github.com/ofri-peretz/burgee/commit/6c2e9c5cee5d9962c0d75d84a766c76b76760f7f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Faster, and lighter: - `link()` from `paratext` reads the process once, on its first call, where it used to rebuild the runtime and re-read the environment on every call. - `paratext/terminal-link` decides hyperlink support once per stream, as `supports-hyperlinks` does at import, and emits a link as a single concatenation. - Templates are parsed once, and `eraseLines` keeps the strings for the counts a redraw uses. - In B5 (ours ÷ incumbent, in-process), `paratext` against ansi-escapes went from 8.9× to 0.97× locally, and `paratext/terminal-link` against terminal-link from 20.9× to 0.78×. - The root bundle is 1,356 B smaller (8,417 → 7,061), because the plugin-schema fragments paratext and flagstaff import to validate no longer carry the schema's prose. The published `schema.json` is unchanged. ## 0.7.4 ### Patch Changes - [#754](https://github.com/ofri-peretz/burgee/pull/754) [`12bac9c`](https://github.com/ofri-peretz/burgee/commit/12bac9c99cb87ec2b67a56b4afc38de2c21687c7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A capability now renders its fallback when the runtime says the output mode is not `tty` (`Runtime.mode`, the value of roundel's `outputMode`). A host that has decided this run is `accessible`, `ci`, `pipe` or `json` gets the text form even on a terminal that supports the sequence, as flagstaff's components do. Without `mode`, nothing changes. - [#752](https://github.com/ofri-peretz/burgee/pull/752) [`22e6dae`](https://github.com/ofri-peretz/burgee/commit/22e6dae1b71bfa478265829a1b98d20a6f9de4f7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A plugin that declares `contract: 0` or a negative contract is now refused with `E_PLUGIN_CONTRACT`, as `schema.json`'s minimum of 1 always said. These hosts checked only that a contract was not newer than the one they know, so 0 and below registered. ## 0.7.3 ### Patch Changes - [#674](https://github.com/ofri-peretz/burgee/pull/674) [`e9f45d8`](https://github.com/ofri-peretz/burgee/commit/e9f45d85d9db5b2e3dcaa1e43f292a1281a6952a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: family header, badges, install, migrating, the family table. Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub. - [#713](https://github.com/ofri-peretz/burgee/pull/713) [`8a338ba`](https://github.com/ofri-peretz/burgee/commit/8a338baa50bb754051b18c00dbd0972976cccba1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Code paths no input could reach are removed; behaviour is unchanged. `paratext check` no longer carries a "(replaces …)" suffix it could never print, because it loads one plugin into an emptied registry. `paratext/terminal-link`'s detection no longer carries `has-flag`'s short and bare flag forms, since every flag it asks about is a long one, nor a "not a tty" answer inside its colour check, which it only reaches for a tty. The root entry no longer registers the built-ins a second time after `ansi-escapes.js` has already done it at load. Three fallbacks for a regex group or a `split` element that is always there are gone, and the schema walk reads a type's first letter with `charAt`, which gives the same answer. - [#684](https://github.com/ofri-peretz/burgee/pull/684) [`6c7b55a`](https://github.com/ofri-peretz/burgee/commit/6c7b55a01ea2e3aa1993419f78fbf6858ade5f8b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext/csi`: the CSI half of `ansi-escapes` as a subpath of its own — cursor moves, erases, scrolling, the alternate screen and synchronized output, byte-exact with `ansi-escapes` 7.3.0. It is 2,700 bytes and, unlike the package root, registers no built-ins when imported. `flagstaff/log-update`, `flagstaff/ora`, `caique/raw` and `caique/inquirer` take their cursor sequences from it instead of carrying their own copies; caique now depends on paratext. Output is unchanged, with one spelling difference: `caique/raw`'s repaint clears with `ESC[J` rather than the equivalent `ESC[0J`. `caique/inquirer` keeps `@inquirer/ansi`'s answer of nothing for a zero-row move, where `ansi-escapes`' `cursorUp(0)` is `ESC[0A`. - [#678](https://github.com/ofri-peretz/burgee/pull/678) [`088cecc`](https://github.com/ofri-peretz/burgee/commit/088ceccb7dda1cbe878950c631f49f48980dd2e2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Whether anybody is there, whether to colour, and whether a tick can be drawn are roundel's questions, and three packages answered them by hand. - `roundel/terminal` (new subpath, 878 B, reaching nothing): `interactive(rt)` — a terminal on stdin, no `CI`, and no agent variable (`CLAUDECODE`, `AI_AGENT`, `CURSOR_AGENT`, `CODEX_THREAD_ID`, `GEMINI_CLI`, exported as `AGENTS`), with `FORCE_TTY=1` as the override — and `unicode(rt)`, is-unicode-supported 2.1.0's table over `{ env, platform }`. - `caique/decide` now depends on `roundel` and asks `interactive()`. **Behaviour change:** under an agent that has a terminal — `CLAUDECODE=1` and a TTY on stdin — a missing required value is refused with a usage error naming the flag (`--x is required when nobody is there to answer`) instead of prompting and hanging the agent. `FORCE_TTY=1` now prompts even without a terminal on stdin, as it does for burgee. - `caique/inquirer`'s tick and `flagstaff/ora`'s log symbols and spinner fallback use roundel's `unicode()`. caique's copy was a four-condition subset: the Linux console (`TERM=linux`) now gets `√` rather than `✔`, and ConEmu/Cmder, Terminus, Alacritty, rxvt-unicode and JetBrains' terminal on Windows now get `✔`, as `figures` draws them. - `burgee` help colour is roundel's `colorLevel(rt) > 0`. **Behaviour changes:** `NO_COLOR` now beats `FORCE_COLOR`; `--no-color` and `--color=…` on the command line are honoured; `CLI_ACCESSIBLE` turns help colour off; and a terminal that sets no `TERM` (Windows' conhost) gets plain help unless `FORCE_COLOR`, `--color` or `COLORTERM` asks for colour. - `burgee/contrast` rounds with roundel's `round2`; no output changes. - `paratext`: the supports-color fork behind `paratext/terminal-link` is unchanged, and now held to roundel's policy by a parity test everywhere their two incumbents agree. ## 0.7.2 ### Patch Changes - [#604](https://github.com/ofri-peretz/burgee/pull/604) [`0e7b1e8`](https://github.com/ofri-peretz/burgee/commit/0e7b1e88a7021350cf689f109c7728f799be1589) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes. ## 0.7.1 ### Patch Changes - [#588](https://github.com/ofri-peretz/burgee/pull/588) [`073037a`](https://github.com/ofri-peretz/burgee/commit/073037ab38b13490ab119c84122a46c7c605be14) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - READMEs and package descriptions now match what each drop-in path is graded at. bellpull names `bellpull/node-which` as the drop-in for npm `which` (5 / 5) and no longer lists execa as a drop-in. paratext's ansi-escapes row is 4 / 4, with the CSI half implemented. seniority's rc row is 1 / 1, dotenv's `config()` defaults to `process.env`, and lilconfig is 77 / 77. linegauge documents `ambiguousIsNarrow` and `strip` as shipped. No code changes. ## 0.7.0 ### Minor Changes - [#543](https://github.com/ofri-peretz/burgee/pull/543) [`dc1b1a7`](https://github.com/ofri-peretz/burgee/commit/dc1b1a7156f7548d7229b54b9f3d367bfda0af08) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - paratext implements `ansi-escapes`' CSI half — `cursorTo`, `cursorMove`, `eraseLines`, `clearTerminal`, `enterAlternativeScreen`, `synchronizedOutput` and the rest, byte-exact with ansi-escapes 7.3.0 — so `import ansiEscapes from 'paratext'` is a full drop-in, graded 4 / 4 by ansi-escapes' own suite (it was 1 / 4 with CSI declared `undefined`). `burgee migrate` now rewrites `ansi-escapes` to `paratext`. - [#546](https://github.com/ofri-peretz/burgee/pull/546) [`0592441`](https://github.com/ofri-peretz/burgee/commit/0592441c9ca8f81098a4aff48f15cfb141a0bece) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext/terminal-link` now links exactly where `supports-hyperlinks` 4.5.0 does. It honours `FORCE_HYPERLINK`, `--no-hyperlink` / `--hyperlink=always`, `CI`, win32 outside Windows Terminal, NETLIFY, and the incumbent's version floors for iTerm2, WezTerm, VS Code and VTE (0.50.0 segfaults on OSC 8). It also recognises kitty, alacritty, ghostty, zed, Orca and Cursor. Its previous guess disagreed with the incumbent in 30 of 55 environments. The compatibility row is now 8 / 8, so `burgee migrate` rewrites `terminal-link` imports to `paratext/terminal-link`. ### Patch Changes - [#518](https://github.com/ofri-peretz/burgee/pull/518) [`866b972`](https://github.com/ofri-peretz/burgee/commit/866b9724652bebea867a730b8f2ea5e0ca63f5ab) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README weight lines now count every incumbent with a graded drop-in: terminal-link and term-img (paratext), exit-hook (closeout), @inquirer/core (caique). - [#474](https://github.com/ofri-peretz/burgee/pull/474) [`1955419`](https://github.com/ofri-peretz/burgee/commit/19554194342b55f8893161f894a8c2a4df1b0f21) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - burgee plugins can hook two more stages. `parse` runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps `d` to `deploy`. `shutdown` runs once as the program exits, whether the command succeeded or failed. The family `schema.json` shipped in every package now describes both stages. ## 0.6.1 ### Patch Changes - [#508](https://github.com/ofri-peretz/burgee/pull/508) [`1aae1e2`](https://github.com/ofri-peretz/burgee/commit/1aae1e2186ce88421067df5317795773419e53d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - ` --help` and `--version` answer instead of crashing. The bin took its first argument as the plugin file to import, so `roundel --help` failed with `Cannot find module '…/--help'` and exit 1. `-h`/`--help` now print usage and exit 0, `-V`/`--version` print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2. ## 0.6.0 ### Minor Changes - [#507](https://github.com/ofri-peretz/burgee/pull/507) [`b8e97dc`](https://github.com/ofri-peretz/burgee/commit/b8e97dcb64772e413f0b6f9e17e063c73314d242) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Runs on Node 20 and 22, not just 24+: `engines.node` is now `^20.19.0 || >=22.13.0`. Those are the first releases where `require(esm)` loads without a warning, so the CommonJS `require()` path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call `Promise.withResolvers`, which Node 20 doesn't have. ### Patch Changes - [#505](https://github.com/ofri-peretz/burgee/pull/505) [`9800b43`](https://github.com/ofri-peretz/burgee/commit/9800b43d9c74a49dfb66d04a40fd0d1c48892e20) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that. ## 0.5.4 ### Patch Changes - [#494](https://github.com/ofri-peretz/burgee/pull/494) [`f7f6d4b`](https://github.com/ofri-peretz/burgee/commit/f7f6d4b8e8f9d9c7010bd4c81fda4b4d106fc9f0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each package's `homepage` and README docs link now point at its own documentation site, `https://.interlace.tools`, instead of a page on burgee's site. The old `burgee.interlace.tools/docs/packages/` URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (`npm:closeout@^0.4`; the 0.4.0 release left it at `^0.3`). ## 0.5.3 ### Patch Changes - [#480](https://github.com/ofri-peretz/burgee/pull/480) [`2dc573f`](https://github.com/ofri-peretz/burgee/commit/2dc573f884e7a4cc46829cd8f2c949a17f07710c) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README corrections: paratext shows terminal-link at its measured 8 / 10 (was the stale 0 / 10 floor); linegauge's and closeout's `npm:` override examples resolve to the current release instead of 0.2 / 0.1. ## 0.5.2 ### Patch Changes - [#465](https://github.com/ofri-peretz/burgee/pull/465) [`acf98f3`](https://github.com/ofri-peretz/burgee/commit/acf98f3e612c6d79e6c2b78a847abcd06a063cbc) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now opens with the incumbent it replaces and the agent surface it serves (`--json`, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed. ## 0.5.1 ### Patch Changes - [#454](https://github.com/ofri-peretz/burgee/pull/454) [`b4584e7`](https://github.com/ofri-peretz/burgee/commit/b4584e719bc0064b294aab5ea6da1c11f698f0e9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package's npm `homepage` now points at its page on the docs site, `https://burgee.interlace.tools/docs/packages/`, and each README links it under the header. The keywords add what people and models search for: `burgee` gains `cli-framework`, `argument-parser`, `subcommands`, `json-schema`, `mcp-server`, `model-context-protocol`, `ai-agent`, `llm`, `shell-completion`, `typescript`, `zero-dependency`, `commander-alternative` and `yargs-alternative`; the other eight gain `agent`, `ai-agent`, `non-tty`, `json` and `zero-dependency` where the package does that — `zero-dependency` only on the six that install nothing at all. `burgee`'s README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. `caique`'s README no longer calls a released package pre-release. - [#442](https://github.com/ofri-peretz/burgee/pull/442) [`bdaf364`](https://github.com/ofri-peretz/burgee/commit/bdaf364f81564c1700cf1adec18f927afe6c60c9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now lists `plugin`, `plugins` and `extensible` in its npm keywords, because every package takes plugins through one shared contract. A plugin is a plain object, validated against the `schema.json` that ships in every package, and checked with the package's own `check` command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The [plugins page](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/plugins.mdx) has a nine-layer example that every package's `check` accepts in CI. - [#435](https://github.com/ofri-peretz/burgee/pull/435) [`7888524`](https://github.com/ofri-peretz/burgee/commit/78885245eb292cd4a40541fe09382a198c9c45cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` now describes every plugin host in the family. The one schema each package ships as its plugin contract used to cover only four hosts: roundel's `tokens`, flagstaff's `glyphs`, `spinners`, `borders` and `components`, paratext's `capabilities`, and linegauge's `widths`. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them: - bellpull `resolvers`, including the absolute-path rule on `paths` - caique `widgets` - closeout `handlers`, including the phases a plugin may use - seniority `sources`, including the rank bounds - burgee `commands`, `hooks` and `enforce` Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (`static`, `run`, `read`, `handler`) are described and required, but not typed, because JSON Schema can't say "function". **flagstaff** now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it. - [#445](https://github.com/ofri-peretz/burgee/pull/445) [`dac303e`](https://github.com/ofri-peretz/burgee/commit/dac303e944e889ac4175ac38c94e4ca0f0ca5358) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now declares `sideEffects` truthfully, so bundlers can drop what you don't import. Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath: | import | before | after | | :------------------------------------ | ------: | ------: | | `import { explain } from 'seniority'` | 2,939 B | 1,067 B | | `import { decide } from 'caique'` | 1,235 B | 734 B | | `import { strip } from 'linegauge'` | 1,102 B | 940 B | | `import { once } from 'closeout'` | 353 B | 235 B | flagstaff and roundel used to declare `false`, but each ships a `check` command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load. ## 0.5.0 ### Minor Changes - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every plugin host has a `check` command. ```bash npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --json ``` PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a **`check` command that shows it every way it can be seen**, and the bar is measured. The first was built in all nine hosts; the second existed in `flagstaff` alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against. Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point **before and after** the override, paratext shows a capability's `encode` **and** its `fallback`, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a **document** rather than printing one, so `burgee check --json` is the form an agent that just wrote a plugin reads. They share one contract with the author, held identically across all nine: - a readable report, contribution by contribution, with **`ok` as the last line**; - a refusal with a code from the family's vocabulary and a `fix`, exit 1; - **`E_NO_CONTRIBUTION`** for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself; - exit 2 with no file. Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken. ### Patch Changes - [#430](https://github.com/ofri-peretz/burgee/pull/430) [`4d1b2b3`](https://github.com/ofri-peretz/burgee/commit/4d1b2b399cff354864d1e2e843a19fde80ef1f30) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` now reports every refusal with its code and its fix, wherever it was raised. Some plugin files register themselves on import: they call `register()` at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside `check`'s `import()`, before the only `try` that turns a `PluginError` into `E_PLUGIN_SCHEMA: …` plus a `fix:` line. The author got the bare message on stderr, with no code and no fix. Now the whole of `check` runs inside that one handler, so every refusal comes out the same way on every host. - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - linegauge hosts plugins — `widths`, and it is the ninth of nine. `scripts/extension-surface-lock.test.ts` has carried `linegauge: { plugin: false }` since it was written, and the row was empty honestly: a width function is not obviously extensible, and an extension point invented to fill a table is worse than a gap that says so. What makes `widths` real is that the package already admits the problem. `width.ts` says ambiguous-width characters are _"counted narrow, which is what a terminal does unless it has been told it is rendering an East Asian locale"_ — and that covers only the ambiguity Unicode sanctions. A Nerd Font putting a two-column icon in the Private Use Area, a code point added by a Unicode release newer than the table compiled into this build, a font drawing U+2500 wide: each is a real, local disagreement with the built-in answer, and until now a user had no way to settle it short of patching the package. ```js export default { name: "nerd-font", widths: { icons: { ranges: [[0xe000, 0xf8ff]], columns: 2, why: "Nerd Font patches two-column icons into the PUA; measured in WezTerm", }, }, }; ``` Three fields of plain data, so a plugin can arrive as JSON, be diffed, be generated and be printed without running its author's code (R7). **`why` is required**, which no other `$def` in the family does: a width table with no provenance cannot be audited when it turns out to be wrong, and _wrong_ is the normal outcome for ambiguous width. A later registration wins over an earlier one and over the built-in tables, which is the point — the built-in answer is right for most terminals and the user is the authority on theirs. An override applies **before** the zero-width and emoji rules, or it would be decorative. A program with no plugin pays one `length === 0` per cluster, and the ASCII fast path never reaches it. The family schema gains `widthRange`, `widthOverride` and `widths`, and because it is one byte-identical file across every host, **every host that validates against it grows by about 1.3 KB** — five flagstaff budgets and two paratext ones moved for a definition only linegauge reads. That trade is the design's and is recorded as D-108 rather than absorbed. ## 0.4.0 ### Minor Changes - [#380](https://github.com/ofri-peretz/burgee/pull/380) [`f3224f4`](https://github.com/ofri-peretz/burgee/commit/f3224f4f43da21bbeeac931c2ec8afc50f0c3235) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext/term-img` — the `term-img` surface as a drop-in subpath. `terminalImage(image, options?)` and `UnsupportedTerminalError`, graded at **12 / 18** against term-img's own suite, up from 0. A subpath rather than the package root because the root default export is already `ansi-escapes`' object and term-img's default export is a function — one default cannot be both, which is why the row measured zero: its whole TAP was a `SyntaxError` about a missing named export, not eighteen failing behaviours. The six cases that stay red are one decision. `term-img` accepts a **path** and reads it; this package takes bytes only, so that `node:fs` stays out of a package that otherwise touches nothing but strings. The refusal is a `TypeError` thrown at exactly the point upstream would have opened the file, which is what keeps the four path-to-an-unsupported- terminal cases passing. `terminalImage(await readFile(path))` is the migration. The five-terminal support table is term-img's own — iTerm2 ≥ 3, WezTerm ≥ 20220319, Konsole ≥ 22.04, Rio ≥ 0.1.13, VSCode ≥ 1.80 — read from the environment, with no `iterm2-version` and no `ansi-escapes` behind it, and with upstream's iTerm2 major-version comparison corrected so that 10.x is not read as 1.x. The OSC 1337 record moved from `builtins.ts` into its own module so the new subpath can reach it without loading the plugin registry. `paratext`'s exported `image` capability is the same object it always was. - [#372](https://github.com/ofri-peretz/burgee/pull/372) [`2f6cb16`](https://github.com/ofri-peretz/burgee/commit/2f6cb160f488668c56d61e3e3f0ed612137295af) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext/terminal-link` — the `terminal-link` surface as a drop-in subpath. `terminalLink(text, url, options?)`, `terminalLink.stderr`, and `isSupported` on both, graded at **8 / 10** against terminal-link's own suite, up from 0. A subpath rather than the package root because the root default export is already `ansi-escapes`' object and terminal-link's default export is a function — one default cannot be both. `Runtime.isTTY` gains an optional `stderr`, since this façade's whole surface is a pair and deciding both streams from one would answer the wrong question for half the API. ### Patch Changes - [#373](https://github.com/ofri-peretz/burgee/pull/373) [`a1f1d40`](https://github.com/ofri-peretz/burgee/commit/a1f1d40b7d1e7244f3a180943b641bb3b491d256) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Stage 2's artifact is now `spec.md`, the name Anthropic's AI-Native SDLC playbook gives it, so the source comments and README sections that cite a package's own design document point at `spec.md` rather than `design.md`. No behaviour changes. The published tarballs do move, by two bytes per surviving reference — `design.md` is nine characters and `spec.md` is seven — so the four packages carrying a weight band were re-measured against it: linegauge 83,538 to 83,536; paratext 66,343 to 66,341; closeout 84,455 to 84,453; bellpull 86,113 to 86,107. ## 0.3.0 ### Minor Changes - [#316](https://github.com/ofri-peretz/burgee/pull/316) [`c8acb28`](https://github.com/ofri-peretz/burgee/commit/c8acb2848714a1cbe3e26a2f9895d7498fb4f096) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `paratext` is now call-compatible with `ansi-escapes` for the four OSC members of its surface — `link`, `image`, `setCwd`, `beep` — as both a default export and named exports (`paratext/design.md` R8). The bytes are the incumbent's where the terminal understands them, and the static projection everywhere else: `Docs (https://x.dev)` for a link, the caption for an image, nothing for a `setCwd`. That difference is the reason to swap, and it is the difference between a pipe receiving something readable and a pipe receiving `\u001B]1337;File=inline=1;…`. Its **CSI half stays out of scope** — the cursor, erasing, scroll regions, the alternate screen, which `flagstaff` draws and `closeout` puts back. Those thirty-three names are nonetheless _declared_, as `undefined`, because ESM refuses a named import of a name a module does not export: without them `import ansiEscapes, { cursorTo } from 'paratext'` was a `SyntaxError` that took the whole file down before a line of it ran. TypeScript types them so that calling one is a compile error rather than a run-time surprise. Graded against `ansi-escapes@7.3.0`'s own suite the row moves **0 / 4 to 1 / 4**, which is its ceiling: three of its four cases assert CSI, so 25% there means complete. **Two root exports changed shape.** `link` and `image` are now the incumbent's _functions_ rather than this package's capability _records_, because a caller who swapped one import specifier would otherwise have got an object shaped nothing like a function, silently. The rule is stated rather than case-by-case — a record export survives unless `ansi-escapes` has that name — so `bell`, `clipboard`, `cwd`, `notify` and `title` are unchanged. The two that moved are reached as `capability('link')` and `capability('image')`, or through `builtins`. `image`'s encode template gained `preserveAspectRatio` and `size`, upstream's last two options in upstream's order, so `image()` is byte-identical to the incumbent rather than merely call-compatible. `size` is optional in the protocol and required by xterm.js, which is why upstream always writes it. New subpath **`paratext/plugin`**: paratext hosts `capabilities` (`plugin-contract` R5a). A plugin object registers, every key another layer owns is ignored without complaint, and `attach()` hands the contributions to the same `register()` the built-ins go through — so a third party's capability is not a second-class citizen of the registry. Refusals use the family vocabulary: `E_PLUGIN_SCHEMA`, `E_PLUGIN_CONTRACT`, and `E_NO_STATIC_PROJECTION` for a capability with no `fallback`, which is the same defect flagstaff raises it for in a component and caique in a widget. `Runtime` gained an optional `cwd`, which is the only thing `setCwd()` needs to default the way the incumbent defaults it to `process.cwd()` — and the way to add it without a second `process` reference in the package. - [#294](https://github.com/ofri-peretz/burgee/pull/294) [`3f92a60`](https://github.com/ofri-peretz/burgee/commit/3f92a6099b1b8d5d476c64405ca963d40bf9af45) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - paratext validates against the family plugin schema, with its shape under `capabilities`. paratext shipped its own `schema.json` whose root _was_ one capability, so the family had three plugin schemas where the contract says one (PRINCIPLES 14, `plugin-contract` R2). The capability shape is now `$defs/capability` of the shared file, reached through a `capabilities` key beside `spinners`, `tokens` and `components`, and `packages/*/src/schema.json` hashes to one value. flagstaff and roundel ship the same bytes: their published `./schema.json` gains the capability definitions and nothing about what they validate changes. `check()` follows the schema's `$defs/capabilityDocument` and takes either shape: - a plugin carrying its capabilities under `capabilities`, which is where they live from now on, and whose problems are reported at `capabilities.`; - **deprecated** — one capability written as the whole document, which is what a 0.2 capability file looks like. It still validates, and `check()` returns a `deprecated:` line saying to move it under `capabilities`. paratext 1.0 stops accepting it (`.sdlc/PLAN.md` D2). `refusals()` and `isDeprecation()` are exported to tell the two kinds of line apart; the lines that are not deprecations are the ones that block, and the ones `register()` throws on. `register(capability)` is unchanged: it takes one capability, not a document, so it neither reports nor accepts the document-level deprecation. - [#332](https://github.com/ofri-peretz/burgee/pull/332) [`3ea38c3`](https://github.com/ofri-peretz/burgee/commit/3ea38c363b9f0cee9f1c1c2dae4037b047e98328) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - New subpath **`paratext/link`** — OSC 8 on its own, **2,337 B**, and nothing registered at import. The root is 17,574 B and runs `registerBuiltins()` as an import-time side effect, which is correct for a program that wants all seven capabilities and unaffordable for one that wants a clickable URL in its `--help`. `burgee` is that consumer: `help.ts` is imported _statically_ by `execute.ts`, so anything help reaches is paid for by `burgee foo --json` as much as by `burgee --help`, and a dynamic `import('paratext')` of a barrel is not shakeable — burgee has a measured 54,986 B failure from exactly that. So the dependency was refused, and the refusal was correct. This entry is the answer to it. ```js import { link, linkFor, supportsLink } from "paratext/link"; link("Docs", "https://x.dev"); // a supporting terminal: ESC ] 8 ; ; https://x.dev BEL Docs ESC ] 8 ; ; BEL // a pipe: Docs (https://x.dev) ``` `linkFor(runtime)` is the form a host should use — `burgee`'s renderer already carries a `Runtime`, and passing it means nothing in the path reads `process`. `supportsLink(runtime)` answers the guess without emitting, for a renderer deciding layout rather than bytes. `LINK` is the capability record itself, and it is the _same object_ `builtins.link` is rather than a copy: the record moved into this module and `builtins.ts` re-exports it. **It does not consult the registry, on purpose.** A caller who corrected our terminal guess by re-registering `link` globally does not change what this returns — the registry is what this entry deliberately does not load. `emit(runtime, 'link', …)` on the root is the form that does. `Support` and `supports()` moved to an internal `supports.ts` so the subpath can ask "does this terminal do OSC 8" without pulling `schema.json`, which is 6,531 B of plugin contract. Both are re-exported from their old home unchanged; no import moved. Also new: **`packages/paratext/src/weight.test.ts`**, the per-entry-point lock `closeout`, `caique` and `roundel` already carry. Every published entry declares what it may reach, what it may never reach, and what it may weigh, and a new export cannot ship without a rule — `./link` may not reach `capability.js`, `builtins.js`, `index.js`, `plugin.js` or `schema.json`, which is what makes "registers nothing" true by construction rather than by inspection. Splitting the two modules out cost the root 619 B, 16,955 → 17,574. - [#334](https://github.com/ofri-peretz/burgee/pull/334) [`0f00f72`](https://github.com/ofri-peretz/burgee/commit/0f00f7273ab0ca111c5869e2b08eb79314df7f70) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check()` and `register()` now read `schema.json` rather than one array out of it. The package's own extension surface accepted this: ```js { name: 'x', osc: { nope: true }, when: 'not an object', encode: 'e{text}', fallback: '{text}', extra: 1 } ``` `when` is the one that mattered. `supports()` destructures it, a string destructures to four `undefined` clauses, every guard falls through, and the answer is `true` — so a capability with a typo there wrote OSC into whatever the caller had redirected to. Measured on the published build, `emit()` on a runtime with `isTTY.stdout: false` returned `"]8;;https://x.devDocs]8;;"`. That is the one failure this package exists to prevent, reached through its own documented plugin surface. `src/shape.ts` is a walk over the JSON-Schema keywords the file actually uses — `type`, `oneOf`, `const`, `minLength`, `minimum`, `items`, `properties` and `additionalProperties: false` — with no dependency added, because `ajv` is over 100 KB in a package whose root entry is under 20 KB. It is wired into `capabilityProblems()`, which both `check()` and `register()` already went through, so the same refusal closes the document path and the registry path: `register()` is the only way into the registry `emit()` reads, and it is that refusal, not a guard further down, that keeps a malformed `when` away from `supports()`. `supports()` additionally answers `false` for a `when` it cannot read, which is the fail-safe direction rule 6 asks for. Every refusal names the path it is about — `capabilities.link.when.tty`, not merely the capability — and carries a code from the family's one vocabulary: `CapabilityError` now has the `code` that `PluginError` always had, and `plugin.ts` stopped keeping its own copy of "fallback is required" beside `capability.ts`'s. **This is a behaviour change for a plugin that was already wrong.** A capability whose `osc`, `when`, `encode`, `fallback` or `name` does not match the published shape, or that carries a field the schema does not declare, is now refused at `register()` and reported by `check()` where it used to pass. Nothing that validated cleanly before is refused now. What is _not_ enforced is written down rather than left to be discovered: `$ref`, `pattern`, `minItems`, `maxLength`, `enum`, `allOf`, `anyOf` and `not`. None appears under a capability today, so nothing is silently unchecked — but a keyword added to the schema tomorrow would be, and the README says so instead of claiming the whole file is validated. ### Patch Changes - [#303](https://github.com/ofri-peretz/burgee/pull/303) [`fc640dd`](https://github.com/ofri-peretz/burgee/commit/fc640ddec11255c12d1e0948c5b8e99cc3f3263b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Stop claiming a drop-in path in the npm description until one is graded. The compat oracle now vendors `ansi-escapes`' own suite at 7.3.0 and grades it. The control is 4 / 4; `paratext` scores **0 / 4**, and the reason is one line of TAP — `SyntaxError: The requested module 'paratext' does not provide an export named 'default'`. The ansi-escapes-compatible default export (design R8) is not built, so "Drop-in paths for ansi-escapes, terminal-link and term-img" was a claim with a measured zero behind it. The description now says what is true — the OSC half of those three packages is covered — and defers the drop-in claim to the row that would prove it. No runtime behaviour changes. - [#339](https://github.com/ofri-peretz/burgee/pull/339) [`f295630`](https://github.com/ofri-peretz/burgee/commit/f2956301d5f9dcbcac0b001b00ebaf0315891fac) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` constrains token names, because it was promising something no host honours. `tokens` was described as any name to a `#rrggbb` colour. `roundel`'s `validate()` accepts ten semantic names — `error`, `warn`, `ok`, `hint`, `muted`, `command`, `flag`, `value`, `heading`, `ground` — and throws on everything else. So a plugin author doing exactly what their own `E_PLUGIN_SCHEMA` error tells them, comparing their object against `roundel/schema.json`, got a green from the schema and `"accent" is not a token` from `register()`. Measured 2026-09-16 with `{ accent: '#336699' }`. The schema now carries `propertyNames.enum`, and `scripts/plugin-contract-lock.test.ts` pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know. Every host ships a byte-identical copy of this file (`plugin-schema-lock.test.ts` asserts it), which is why nine packages are listed. Only the key `roundel` owns is constrained: describing `widgets`, `handlers`, `sources`, `resolvers` or `commands` in a file all eight hosts share is what made _flagstaff_ start validating caique's key last time (`PluginError: plugin.widgets.later: expected object, got boolean`), and those stay in `plugin-schema-lock`'s `UNDESCRIBED` list with that reason. `linegauge` is in the list for a different change: `ceilings.json`'s R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded `get-east-asian-width` bar beside it with the count of entries that cleared it. ## 0.2.0 ### Minor Changes - [#229](https://github.com/ofri-peretz/burgee/pull/229) [`6a6442e`](https://github.com/ofri-peretz/burgee/commit/6a6442e9d2cfc001bc3e05d68dab99aa2c9f451e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The OSC layer, with extensibility as its shape rather than an addition to it. Seven capabilities — `link` (OSC 8), `image` (OSC 1337), `title` (OSC 0), `clipboard` (OSC 52), `notify` (OSC 9), `cwd` (OSC 50 + 9;9) and `bell` — and **a capability is one plain object with no functions in it**: a name, an OSC code, a declaration of when a terminal is believed to understand it, and two templates. It can be written in a config file, generated, diffed and validated against the published `paratext/schema.json` without running anybody's code, which is what PRINCIPLES rule 7 asks for and what an agent needs to ship one in a turn. Nothing in this layer is detectable, so every capability carries a static projection and `register` refuses one without it: an image becomes its caption, a notification a printed line, a hyperlink `text (url)`, and a pipe receives no control byte at all. ### Patch Changes - [#229](https://github.com/ofri-peretz/burgee/pull/229) [`6a6442e`](https://github.com/ofri-peretz/burgee/commit/6a6442e9d2cfc001bc3e05d68dab99aa2c9f451e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Name reserved for the OSC layer. `paratext` will own the escape class that addresses the terminal _program_ rather than the character grid — hyperlinks, inline images, the window title, the clipboard, desktop notifications, the working directory and the bell — each with a static fallback for terminals that cannot do it. Exports only its own name until its intent and design are approved. --- # Coming from ansi-escapes > An ansi-escapes alternative with a drop-in path: import ansiEscapes from paratext, graded 4 / 4 by ansi-escapes' own test suite — then hyperlinks, images and the working directory that project to plain text for pipes and agents instead of raw escape bytes. Source: https://paratext.interlace.tools/docs/coming-from/ansi-escapes **paratext** is an **ansi-escapes alternative** you adopt by changing one import. The package root is ansi-escapes 7's surface — the CSI half that moves the cursor and erases the screen, and the OSC half that talks to the terminal *program* — and ansi-escapes' own test suite is the grade. ## Migrate from ansi-escapes in one import ```diff - import ansiEscapes, { cursorTo, eraseLines, link, image, beep } from 'ansi-escapes'; + import ansiEscapes, { cursorTo, eraseLines, link, image, beep } from 'paratext'; ``` Everything else stays. **The CSI half** — `cursorTo`, `cursorMove`, the cursor, erase and scroll members, `clearScreen`, `clearTerminal`, the alternate screen and synchronized output — is byte-exact with ansi-escapes 7.3.0, including the Terminal.app spelling of save and restore. It does not degrade, because the incumbent's does not: a cursor move silently dropped would corrupt the screen of a program that relied on it. **The OSC half** — `link`, `image`, `setCwd` and `beep` — gives the incumbent's exact bytes on a terminal believed to understand them, and a static projection everywhere else. That is the one deliberate difference, and it is covered under what you gain. Two names are declared and empty: `iTerm` and `ConEmu`. ansi-escapes hangs `iTerm.setCwd`, `iTerm.annotation` and `ConEmu.setCwd` off them, and paratext does not yet; importing them loads, and calling a member is a TypeScript compile error rather than a surprise at run time. The top-level `setCwd` carries both terminals' working-directory sequences, so only `iTerm.annotation` has no replacement — keep ansi-escapes for that call. ## Is paratext compatible with ansi-escapes? Graded, not claimed. ansi-escapes' own suite, vendored at 7.3.0 and unmodified apart from the import specifier, runs against `paratext` beside a control that runs it against real ansi-escapes: | | passing | rate | | :-- | --: | --: | | `paratext` | 4 / 4 | 100.0% | | ansi-escapes itself (control) | 4 / 4 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. The suite is small — four cases — and three of them assert CSI: `default export` and `clearTerminal` check the cursor and clear sequences, and `synchronized output` checks `ESC [ ? 2026 h` and `l`. The fourth, `named export(s)`, checks that a named export and the default's member are the same object. Until paratext implemented the CSI half, this row was 1 / 4. The same page grades the `paratext/terminal-link` and `paratext/term-img` paths against those packages' suites. ## What you gain over ansi-escapes No terminal answers "do you support OSC 1337", so emitting the bytes and hoping is what puts `\u001B]1337;File=inline=1;…` across the screen of anyone who piped your output to a file. paratext never hopes: - **Every capability has a static projection.** `emit()` returns it whenever support is absent or unknown: an image becomes its caption, a notification a printed line, a hyperlink `text (url)`. On a pipe, that projection is what an agent reads. - **The fallback is required.** A capability without one is refused at registration; there is no opt-out. - **A capability is data.** Plain objects, no functions, so a capability travels through JSON, is diffable, and is validated against `paratext/schema.json` without running its author's code. ```js import { emit, processRuntime } from 'paratext'; emit(processRuntime(), 'link', { text: 'Docs', url: 'https://x.dev' }); // a terminal that supports it: the OSC 8 hyperlink // a pipe: Docs (https://x.dev) ``` ## When to switch from ansi-escapes - Your CLI emits hyperlinks, images or notifications, and its output is ever piped, logged or read by an agent. - You want new terminal capabilities registered as data rather than waiting for a release. If your program depends on an OSC sequence reaching a pipe byte for byte, keep ansi-escapes for that call. The registry, the schema and the plugin host are on [paratext](/docs). --- # Coming from term-img > A term-img alternative you adopt by changing one import: import terminalImage from paratext/term-img, graded 18 / 18 by term-img's own test suite, level with term-img itself — file paths and bytes alike, with zero dependencies. Source: https://paratext.interlace.tools/docs/coming-from/term-img **paratext** is a **term-img alternative** you adopt by changing one import. `paratext/term-img` is term-img 7's API: a file path or image bytes in, the inline image out. term-img's own test suite is the grade, and it passes every case. ## Migrate from term-img: one import ```diff - import terminalImage, { UnsupportedTerminalError } from 'term-img'; + import terminalImage, { UnsupportedTerminalError } from 'paratext/term-img'; console.log(terminalImage('unicorn.jpg', { width: '50%' })); ``` Everything else stays: a path or a `Uint8Array`/`Buffer`; `width`, `height`, `preserveAspectRatio` and `fallback`; a missing or empty image throws a `TypeError`; an unsupported terminal calls your `fallback` or throws `UnsupportedTerminalError`, with term-img's message and `name`. Support is term-img's own terminal table — iTerm2 3+, WezTerm 20220319+, Konsole 22.04+, Rio 0.1.13+, VS Code 1.80+ — read from the environment, in term-img's order: the argument is checked, then the terminal, then the file is read. A path handed to an *unsupported* terminal reaches your `fallback` without the file being opened, as it does upstream. The `Options` type keeps its name and its `FallbackType` parameter, so a typed program compiles unchanged. `npx burgee migrate` makes this edit for you: the row is level, so the codemod rewrites it ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## Is paratext compatible with term-img? Yes, and graded rather than claimed. term-img's own suite, vendored at 7.1.0 (the latest release) and unmodified apart from the import specifier, runs against `paratext/term-img` beside a control that runs it against real term-img: | | passing | rate | | :-- | --: | --: | | `paratext/term-img` | 18 / 18 | 100.0% | | term-img itself (control) | 18 / 18 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. Until 2026-09-30 this row read 12 / 18: the six cases that hand a file path to a supported terminal failed, because `paratext/term-img` took bytes only. It now reads the path with `node:fs`, as term-img does. It is the one entry in paratext that does: the root and every other subpath touch nothing but strings, and a lock fails the build if `node:fs` reaches any of them. ## What you gain over term-img - **Zero dependencies.** term-img 7.1.0 depends on `ansi-escapes` and `iterm2-version`. - **iTerm2 10 and later are recognised.** term-img reads iTerm2's major version from its first character, so it would read `10.2.1` as `1` and refuse the terminal; the façade parses the whole number. Nothing in the suite tells the two apart, so this is a fix that costs no compatibility. - **A file `URL` is a path too.** term-img answers `Image required` to a `URL`; `paratext/term-img` reads it. - **A caption instead of a throw, when you want one.** paratext's root `image()` — ansi-escapes' signature, plus `caption` — projects an image to its caption wherever support is absent or unknown, so a pipe or an agent reads text rather than an exception or raw escape bytes. The root takes bytes and never opens a file. ```js import { image } from 'paratext'; image(bytes, { caption: 'Build graph' }); // iTerm2, on a TTY: the inline image // a pipe: Build graph ``` ## When to switch from term-img - You want term-img's behaviour without its two dependencies. - Your output is also piped or read by an agent, and an image should become its caption there — the root `image()` does that. The registry, the schema and the plugin host are on [paratext](/docs). --- # Coming from terminal-link > A terminal-link alternative with a drop-in path: import terminalLink from paratext/terminal-link, graded 8 / 8 by terminal-link's own test suite, with two cases excluded by name — then zero dependencies, and hyperlinks that project to plain text for pipes and agents. Source: https://paratext.interlace.tools/docs/coming-from/terminal-link **paratext** is a **terminal-link alternative** you adopt by changing one import. `paratext/terminal-link` is terminal-link 5's API, and terminal-link's own test suite is the grade — with two of its cases excluded by name, for the reason below. ## Migrate from terminal-link in one import ```diff - import terminalLink from 'terminal-link'; + import terminalLink from 'paratext/terminal-link'; ``` Everything else stays: `terminalLink(text, url, { target, fallback })`, `terminalLink.isSupported`, `terminalLink.stderr(text, url, options?)` and `terminalLink.stderr.isSupported`, and the `Options` type under terminal-link's name. The fallback is terminal-link's: `text url` by default, the text alone with `fallback: false`, or whatever your function returns. `isSupported` is a snapshot taken at import, as it is upstream. Whether a terminal links is decided by the incumbent's detection, carried in-package: `supports-hyperlinks` 4.5.0's table, checked against the real package in 55 environments. `FORCE_HYPERLINK`, `--no-hyperlink` and the version floors behave as they do in terminal-link. The one thing that does not carry is assigning `supportsHyperlinks.stdout` on the `supports-hyperlinks` module object — paratext takes no runtime dependency, so it never sees that object. Set `FORCE_HYPERLINK=1` instead. ## Is paratext compatible with terminal-link? Graded, not claimed. terminal-link's own suite, vendored at 5.0.0 and unmodified apart from the import specifier, runs against `paratext/terminal-link` beside a control that runs it against real terminal-link: | | passing | rate | | :-- | --: | --: | | `paratext/terminal-link` | 8 / 8 | 100.0% | | terminal-link itself (control) | 8 / 8 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. **The suite has ten cases, and two are excluded from the gate by exact title** — `main` and `stderr`. Both set `supportsHyperlinks.stdout` (or `.stderr`) to `true` on that module object and expect an OSC 8 link on a headless runner. paratext cannot see the assignment, answers from the detection that module would have computed, and on that runner the answer is no. Reading `supports-hyperlinks` whenever it happens to be installed would turn them green and would change what a caller gets based on what else is in `node_modules`, so it does not. Counted against it, the row is 8 / 10; the six cases that set the flag to `false`, and the two `isSupported` cases, pass. ## What you gain over terminal-link - **Zero dependencies.** terminal-link 5.0.0 depends on `ansi-escapes` and `supports-hyperlinks`. - **A testable detection.** `terminalLinkFor(runtime)` is the same function bound to an environment you hand it, so a test asks "would this terminal link?" without mutating `process.env` or another package's module object. - **The rest of the OSC layer, as data.** Beyond the façade, paratext's `emit()` gives every capability — hyperlinks, images, the title, the clipboard, notifications — a required static projection, so a pipe or an agent reads `Docs (https://x.dev)`, never raw escape bytes. ```js import { emit, processRuntime } from 'paratext'; emit(processRuntime(), 'link', { text: 'Docs', url: 'https://x.dev' }); // a terminal that supports it: the OSC 8 hyperlink // a pipe: Docs (https://x.dev) ``` ## When to switch from terminal-link - You print hyperlinks and want them without two more packages in the tree. - Your output is also piped, logged or read by an agent, and you want the fallback to be a rule rather than a per-call option. If your tests force links by assigning `supportsHyperlinks.stdout`, move them to `FORCE_HYPERLINK` or to `terminalLinkFor(runtime)` first. The registry, the schema and the plugin host are on [paratext](/docs). --- # Compatibility > How paratext's three drop-ins are graded — each incumbent's own test suite, unedited — the current grades, and the differences that remain. Source: https://paratext.interlace.tools/docs/drop-ins `paratext` (the ansi-escapes surface), `paratext/terminal-link` and `paratext/term-img` are graded, not described as compatible. Each is run against its incumbent's **own test suite**, by [compat-oracle](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/README.md), in CI. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Compatibility | Capability | **paratext** | ansi-escapes | terminal-link | term-img | | :-- | :-- | :-- | :-- | :-- | | **Passes ansi-escapes' own test suite** — paratext's root is graded by ansi-escapes 7.3.0's own tests, unedited, and its CSI half is byte-exact with it. | [✓ 4 / 4 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/ansi-escapes.json) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/ansi-escapes/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/terminal-link/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/term-img/test.js) | | **Passes terminal-link's own test suite** — `paratext/terminal-link` is graded by terminal-link 5.0.0's own tests, unedited. | [✓ 8 / 8 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/terminal-link.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/ansi-escapes/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/terminal-link/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/term-img/test.js) | | **Passes term-img's own test suite** — `paratext/term-img` is graded by term-img 7.1.0's own tests, unedited, and passes all 18, file paths included. | [✓ 18 / 18 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/term-img.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/ansi-escapes/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/terminal-link/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/term-img/test.js) | The counts are compat-oracle's baselines, the pass count each drop-in is held to. The family's [compatibility page](https://burgee.interlace.tools/docs/compatibility) is generated from the oracle's last run and is the authority for the current figures. | incumbent | graded version | drop-in | cases | | :-- | :-- | :-- | --: | | ansi-escapes | 7.3.0 | `import ansiEscapes from 'paratext'` | 4 / 4 | | terminal-link | 5.0.0 | `import terminalLink from 'paratext/terminal-link'` | 8 / 8 | | term-img | 7.1.0 | `import terminalImage from 'paratext/term-img'` | 18 / 18 | ## How a suite is graded 1. The incumbent's repository is cloned at the release tag of the graded version and its test file copied into `packages/compat-oracle/vendor/`. No incumbent ships its tests to npm. Each copy's `PROVENANCE` file names the tag, the commit and the command that reproduces it. 2. The only edit is the import that reaches the library: it is rewritten to a shim generated per run. Assertions and fixtures are upstream's, byte for byte. 3. A **control run** points the shim at the real incumbent first, which proves the harness and sets the total every rate is measured against. 4. The **target run** points the same shim at paratext's path. ## term-img: 18 of 18, file paths included Until 2026-09-30 this row read 12 / 18. The six cases that failed — `iTerm2 support`, `WezTerm support`, `Konsole support`, `Rio support`, `VSCode support` and `handles options parameter correctly` — each hand a file **path** to a terminal the suite has just declared supported, and `paratext/term-img` took image bytes only (decision D-030). It now reads a path with `node:fs`, at the point term-img calls `fs.readFileSync` (decision D-20260930-paratext-term-img-path). It is the one entry that imports `node:fs`: the root and every other subpath touch nothing but strings, and `weight.test.ts` fails if that changes. ## Known differences - **The root's OSC members project off a terminal.** `link`, `image` and `setCwd` from `paratext` return the incumbent's bytes where the terminal is believed to understand them, and the static projection everywhere else; ansi-escapes returns the bytes everywhere. The CSI half is byte-exact and never degrades ([The ansi-escapes surface](/docs/guides/ansi-escapes)). - **`iTerm` and `ConEmu` are declared and `undefined`.** A program that imports them loads; one that calls them is a compile error in TypeScript. - **`paratext/term-img` also reads a file `URL`.** term-img answers `Image required` to one. - **`paratext/term-img` reads iTerm2's whole version number.** term-img takes the first character, which would read iTerm2 10 as 1; nothing in the suite tells the two apart. - **`paratext/terminal-link` needs `argv` and `platform` for two of supports-hyperlinks' rules** — the `--no-hyperlink` flag and the Windows check — and reads them from the process, as the incumbent does. ## Types and CommonJS Each path exports its incumbent's names and types. Every entry is ESM with a `default` condition, so `require('paratext/terminal-link')` works from CommonJS on Node 20.19+ and 22.13+. --- # FAQ > Short answers about paratext — why a pipe gets text, detecting support, tmux, adding a capability, images from files, and the drop-ins — each with where to read more. Source: https://paratext.interlace.tools/docs/faq ## Why does my link print as `Docs (https://…)`? Because stdout is not a terminal, or the terminal is not one paratext believes understands OSC 8. That is the static projection, and it is the default whenever support is absent or unknown ([The static projection](/docs/guides/static-projection)). ## My terminal supports hyperlinks, and paratext does not think so. Register the `link` capability again with a `when` that matches your terminal; every later `emit` uses it ([Detecting support](/docs/guides/detection#when-the-guess-is-wrong)). ## Can I ask the terminal whether it supports something? Not for most OSC sequences: no terminal answers "do you do OSC 1337". The guess is data on the capability — `TERM`, `TERM_PROGRAM`, an announcing variable — which is why it can be replaced. ## How do I add a capability paratext does not ship? Register a plain object with `name`, `osc`, `when`, `encode` and `fallback`, or ship it as a plugin and check it with `npx paratext check` ([Capabilities as data](/docs/guides/registry)). ## Why must every capability have a fallback? So nothing registered can put raw bytes into a pipe. `fallback: ''` is allowed — a window title has nothing to say in a log — but a missing one is refused at registration. ## Does `paratext/term-img` take a file path? Yes, as term-img does: a path, a file `URL` or image bytes. It reads the path with `node:fs`, and it is the only entry in paratext that imports it. The root `image()` takes bytes and never opens a file (D-20260930-paratext-term-img-path, [Compatibility](/docs/drop-ins)). ## Does it move the cursor on a pipe? If you ask it to: the CSI half — `cursorTo`, `eraseLines` and the rest — is ansi-escapes' bytes and never degrades. Whether to redraw is a decision for your program, and roundel's `outputMode` is the question to ask. ## Can I use it from CommonJS? Yes, on Node 20.19+ and 22.13+: every entry is ESM with a `default` condition, so `require('paratext')` loads it through `require(esm)`. --- # Getting started > Install paratext, emit a hyperlink, a notification, a title and an image, and see what a supporting terminal gets and what a pipe gets instead. Source: https://paratext.interlace.tools/docs/getting-started paratext writes the escape sequences that go around the text — hyperlinks, inline images, the window title, the clipboard, notifications — and, for every one of them, says what to print instead when the terminal cannot show it. No terminal answers "do you do OSC 1337", so that second answer is not an extra: it is what a pipe, a log and an agent get, and it is the default whenever support is absent or unknown. ## Install ```bash npm install paratext ``` It is ESM with a `default` condition, so `require('paratext')` also works from CommonJS on Node 20.19+ and 22.13+. It depends on nothing ([`package-shape-lock.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds it at zero). ## A first program `emit(runtime, name, fields)` renders a capability for a runtime — the environment and whether stdout is a terminal. Here the same four calls go to a pipe and to iTerm2: ```js title="first.mjs" import { emit } from 'paratext'; const pipe = { env: {}, isTTY: { stdout: false } }; const iterm = { env: { TERM_PROGRAM: 'iTerm.app' }, isTTY: { stdout: true } }; for (const [name, runtime] of [['pipe', pipe], ['iTerm2', iterm]]) { console.log(name); console.log(' ', JSON.stringify(emit(runtime, 'link', { text: 'Docs', url: 'https://paratext.interlace.tools' }))); console.log(' ', JSON.stringify(emit(runtime, 'notify', { title: 'Build finished', body: '3 warnings' }))); console.log(' ', JSON.stringify(emit(runtime, 'title', { text: 'building' }))); console.log(' ', JSON.stringify(emit(runtime, 'image', { base64: 'iVBORw0KGgo=', caption: '[chart: build times]' }))); } ``` ```text title="node first.mjs" pipe "Docs (https://paratext.interlace.tools)" "Build finished: 3 warnings" "" "[chart: build times]" iTerm2 "\u001b]8;;https://paratext.interlace.tools\u0007Docs\u001b]8;;\u0007" "\u001b]9;Build finished: 3 warnings\u0007" "\u001b]0;building\u0007" "\u001b]1337;File=inline=1:iVBORw0KGgo=\u0007" ``` On the pipe, the link keeps its address, the notification becomes a line, the image becomes its caption, and the window title — chrome, not content — becomes nothing. On iTerm2 each is the sequence iTerm2 understands. `JSON.stringify` only makes the escape bytes visible here. For the real process, pass `processRuntime()`: ```js title="hello.mjs" import { emit, processRuntime } from 'paratext'; console.log(emit(processRuntime(), 'link', { text: 'Docs', url: 'https://paratext.interlace.tools' })); ``` ```text title="node hello.mjs" Docs (https://paratext.interlace.tools) ``` Every output block on this site is checked: `tests/examples.test.ts` writes each titled file, runs the command in the block's title, and compares. ## The seven built-ins | name | sequence | fields | on a pipe | | :-- | :-- | :-- | :-- | | `link` | OSC 8 | `text`, `url` | `text (url)` | | `image` | OSC 1337 | `base64`, `caption`, optional `width`, `height`, `preserveAspectRatio`, `size` | the caption | | `notify` | OSC 9 | `title`, optional `body` | `title: body` | | `title` | OSC 0 | `text` | nothing | | `clipboard` | OSC 52 | `text` | nothing | | `cwd` | OSC 50 and OSC 9;9 | `path` | nothing | | `bell` | BEL | — | nothing | A capability the package never heard of is a plain object away: [Capabilities as data](/docs/guides/registry). ## Where next - [Guides](/docs/guides/static-projection): the static projection, detecting support, the registry and plugins, the ansi-escapes surface, and images. - [Why paratext](/docs/why-paratext): what it does that ansi-escapes, terminal-link and term-img do not, cell by cell, with the evidence. - [Coming from ansi-escapes](/docs/coming-from/ansi-escapes) and the other two: change one import. - [API reference](/docs/api): every export of every entry point. --- # The ansi-escapes surface > paratext's root is ansi-escapes 7.3.0's API: the CSI half byte-exact, the four OSC members through emit(), so a link or an image on a pipe is its text rather than raw bytes. Source: https://paratext.interlace.tools/docs/guides/ansi-escapes paratext's root exports ansi-escapes' surface — the default export with every member on it, and each member by name — so `import ansiEscapes from 'paratext'` is the migration. ansi-escapes' own suite grades it, 4 of 4 ([Compatibility](/docs/drop-ins)). ansi-escapes is one module with two unrelated jobs, and paratext treats them differently. ## The CSI half: byte-exact, never degraded Cursor movement, erasing, scrolling, the alternate screen and synchronized output are the incumbent's bytes exactly: ```js title="csi.mjs" import ansiEscapes, { cursorTo, eraseLines, synchronizedOutput } from 'paratext'; console.log(JSON.stringify(cursorTo(2, 2))); console.log(JSON.stringify(eraseLines(2))); console.log(JSON.stringify(synchronizedOutput('frame'))); console.log(ansiEscapes.cursorTo === cursorTo); ``` ```text title="node csi.mjs" "\u001b[3;3H" "\u001b[2K\u001b[1A\u001b[2K\u001b[G" "\u001b[?2026hframe\u001b[?2026l" true ``` They do not degrade on a pipe, because the incumbent's do not, and a cursor move silently dropped would corrupt the screen of a program that relied on it. A program that should not move the cursor on a pipe decides that itself — roundel's [`outputMode`](https://roundel.interlace.tools/docs) is the question to ask. The CSI half is also published on its own, as `paratext/csi`: it registers none of the built-ins the root does, and it is where flagstaff and caique take their cursor moves from. ```js title="csi-only.mjs" import { cursorUp, eraseLines } from 'paratext/csi'; console.log(JSON.stringify(cursorUp(2) + eraseLines(1))); ``` ```text title="node csi-only.mjs" "\u001b[2A\u001b[2K\u001b[G" ``` ## The OSC half: the incumbent's bytes, or the projection `link`, `image`, `setCwd` and `beep` go through `emit()` over the real process, so on a terminal believed to understand them they are ansi-escapes' exact bytes, and everywhere else the static projection: ```js title="osc.mjs" import { beep, image, link, setCwd } from 'paratext'; console.log(JSON.stringify(link('Docs', 'https://x.dev'))); console.log(JSON.stringify(image(Buffer.from('iVBORw0KGgo=', 'base64'), { caption: '[logo]' }))); console.log(JSON.stringify(setCwd('/tmp'))); console.log(JSON.stringify(beep)); ``` ```text title="node osc.mjs" "Docs (https://x.dev)" "[logo]" "" "\u0007" ``` That is the one deliberate difference from ansi-escapes, and the reason to prefer this: through ansi-escapes the first three would be escape bytes in the pipe. `image` takes a `caption` option on top of ansi-escapes' own, which is what it projects to. `beep` is a value, as it is upstream. For a runtime other than the process — a test, or rendering for somebody else's terminal — `ansiEscapesFor(runtime)` returns the same four bound to it. ## What is declared and empty `iTerm` and `ConEmu` — `iTerm.annotation`, ConEmu's progress bar — are capabilities paratext does not ship yet. They are exported as `undefined`, so a program that imports them by name still loads, and typed so that calling one is a compile error rather than a surprise at run time. --- # Detecting support > A capability's when clause says which terminals are believed to understand it — tty, TERM, TERM_PROGRAM or an announcing variable — and supports() answers from a runtime, never from process. Source: https://paratext.interlace.tools/docs/guides/detection Whether a terminal understands a sequence is a **guess**, and paratext keeps the guess as data on the capability — its `when` clause — so it can be read, replaced and tested. ## The `when` clause | clause | means | | :-- | :-- | | `tty: true` | stdout must be a terminal. Almost always set: a file that receives OSC gets control bytes in it. | | `term` | `TERM` must be exactly this — for terminals that identify that way, like `xterm-kitty`. | | `termProgram` | `TERM_PROGRAM` must be one of these. | | `envAny` | any one of these variables merely being set, as VTE announces itself with `VTE_VERSION`. | `termProgram` and `envAny` are alternatives: either one matching is enough. `TERM=dumb` is always no, and a `when` that is not an object is always no. ```js title="supports.mjs" import { capability, supports } from 'paratext'; const link = capability('link'); const at = (env, tty = true) => supports({ env, isTTY: { stdout: tty } }, link); console.log(JSON.stringify(link.when)); console.log(at({ TERM_PROGRAM: 'iTerm.app' })); console.log(at({ VTE_VERSION: '7600' })); console.log(at({ WT_SESSION: 'x' })); console.log(at({ TERM_PROGRAM: 'iTerm.app' }, false)); console.log(at({ TERM_PROGRAM: 'iTerm.app', TERM: 'dumb' })); console.log(at({})); ``` ```text title="node supports.mjs" {"tty":true,"termProgram":["iTerm.app","WezTerm","ghostty","vscode","Hyper","Apple_Terminal"],"envAny":["VTE_VERSION","WT_SESSION"]} true true true false false false ``` ## A pure answer `supports(runtime, capability)` and `emit(runtime, …)` read only the runtime they are handed — `env` and `isTTY.stdout` — never `process`. A test passes a two-line literal, and a host can render for a terminal other than its own. `processRuntime()` builds one from the real process. ## When the guess is wrong A terminal that understands a sequence without announcing it, or one that announces it and does not, is the caller's to fix: register the capability again under the same name with a different `when`, and every later `emit` uses it. ```js title="override.mjs" import { capability, emit, register } from 'paratext'; const tmux = { env: { TERM: 'tmux-256color' }, isTTY: { stdout: true } }; const args = { text: 'Docs', url: 'https://x.dev' }; console.log(JSON.stringify(emit(tmux, 'link', args))); register({ ...capability('link'), when: { tty: true, term: 'tmux-256color' } }); console.log(JSON.stringify(emit(tmux, 'link', args))); ``` ```text title="node override.mjs" "Docs (https://x.dev)" "\u001b]8;;https://x.dev\u0007Docs\u001b]8;;\u0007" ``` ## `paratext/terminal-link` detects differently, on purpose The terminal-link drop-in keeps its incumbent's detection: it links where supports-hyperlinks 4.5.0 says a terminal does, including `FORCE_HYPERLINK`, the `--no-hyperlink` flag and the stderr stream when the link is bound for stderr. `hyperlinks.test.ts` grades the port against supports-hyperlinks case by case ([Links and images](/docs/guides/links-and-images)). --- # Links and images > paratext/link for one hyperlink without the registry, paratext/terminal-link for terminal-link's API and detection, and paratext/term-img for term-img's API over a file path or image bytes. Source: https://paratext.interlace.tools/docs/guides/links-and-images Three subpaths cover the two capabilities most programs want, without loading the registry or the schema. ## `paratext/link` `link(text, url)` over the real process, `linkFor(runtime)` for any other, and `supportsLink(runtime)` for a renderer deciding **layout** — a help screen that prints a separate `Docs` column only where the URL would otherwise be repeated in parentheses. ```js title="link.mjs" import { linkFor, supportsLink } from 'paratext/link'; const pipe = { env: {}, isTTY: { stdout: false } }; const wezterm = { env: { TERM_PROGRAM: 'WezTerm' }, isTTY: { stdout: true } }; console.log(JSON.stringify(linkFor(pipe)('Docs', 'https://x.dev')), supportsLink(pipe)); console.log(JSON.stringify(linkFor(wezterm)('Docs', 'https://x.dev')), supportsLink(wezterm)); ``` ```text title="node link.mjs" "Docs (https://x.dev)" false "\u001b]8;;https://x.dev\u0007Docs\u001b]8;;\u0007" true ``` The subpath registers nothing: it emits through the same `link` record the registry ships. ## `paratext/terminal-link` terminal-link 5's API — `terminalLink(text, url, options)`, `terminalLink.stderr`, `isSupported` — with terminal-link's detection, which is supports-hyperlinks'. Its fallback is terminal-link's too: the text, a space and the URL, or your `fallback` function, or the text alone with `fallback: false`. ```js title="terminal-link.mjs" import terminalLink from 'paratext/terminal-link'; console.log(terminalLink('Docs', 'https://x.dev')); console.log(terminalLink('Docs', 'https://x.dev', { fallback: (text, url) => `${text} <${url}>` })); console.log(terminalLink('Docs', 'https://x.dev', { fallback: false })); console.log(terminalLink.isSupported); ``` ```text title="node terminal-link.mjs" Docs https://x.dev Docs Docs false ``` `FORCE_HYPERLINK=1` forces a link, as it does with terminal-link: ```js title="forced.mjs" import terminalLink from 'paratext/terminal-link'; console.log(JSON.stringify(terminalLink('Docs', 'https://x.dev'))); ``` ```text title="FORCE_HYPERLINK=1 node forced.mjs" "\u001b]8;;https://x.dev\u0007Docs\u001b]8;;\u0007" ``` ## `paratext/term-img` term-img 7's API for a file **path** or image **bytes**: `terminalImage(pathOrBytes, { width, height, preserveAspectRatio, fallback })` and `UnsupportedTerminalError`. Support is term-img's own table — iTerm2 3+, WezTerm 20220319+, Konsole 22.04+, Rio 0.1.13+ and VS Code 1.80+ — read from the environment, as term-img reads it. An unsupported terminal calls your `fallback`, or throws: ```js title="image.mjs" import terminalImage, { UnsupportedTerminalError } from 'paratext/term-img'; const png = Buffer.from('iVBORw0KGgo=', 'base64'); console.log(terminalImage(png, { fallback: () => '[an image your terminal cannot show]' })); try { terminalImage(png); } catch (error) { console.log(error instanceof UnsupportedTerminalError, error.name); } ``` ```text title="node image.mjs" [an image your terminal cannot show] true UnsupportedTerminalError ``` A path (or a file `URL`) is read with `node:fs`, at the point term-img reads it: after the terminal check, so a path handed to an unsupported terminal reaches your `fallback` without the file being opened. It is the only entry in paratext that imports `node:fs`; the root `image()` takes bytes (D-20260930-paratext-term-img-path). Like term-img, `paratext/term-img` reads the environment and not the terminal: a program run inside iTerm2 with its output piped still gets the image bytes. `image()` from the root is the form that projects to a caption off a terminal ([The ansi-escapes surface](/docs/guides/ansi-escapes#the-osc-half-the-incumbents-bytes-or-the-projection)). --- # Capabilities as data > Register a capability as a plain object — encode, fallback and when — validate it against the family schema with check(), ship it as a plugin, and check the plugin with npx paratext check. Source: https://paratext.interlace.tools/docs/guides/registry Terminals invent OSC codes faster than any package ships releases — Kitty's graphics protocol, WezTerm's user variables, Ghostty's progress bar. So paratext's surface is a **registry** of plain objects, and the seven built-ins register through the same public call a third party makes. ## A capability ```js title="kitty.mjs" export default { name: 'kitty-graphics', capabilities: { 'kitty-image': { name: 'kitty-image', osc: 'BEL', when: { tty: true, term: 'xterm-kitty' }, encode: '\u001B_Ga=T,f=100;{base64}\u001B\\', fallback: '{caption}', }, }, }; ``` That file is a **plugin**: a `name`, and under `capabilities` one entry per capability, each filed under its own name. A capability is `name`, `osc` (its number, or `'BEL'`), `when` ([Detecting support](/docs/guides/detection)), `encode` and `fallback`, written in the [template language](/docs/guides/static-projection#templates). No functions, so it travels through JSON, diffs, and can be validated without running its author's code. ## Registering it ```js title="kitty-app.mjs" import { emit } from 'paratext'; import { attach, register } from 'paratext/plugin'; import kitty from './kitty.mjs'; register(kitty); attach(); const fields = { base64: 'iVBORw0KGgo=', caption: '[logo]' }; console.log(JSON.stringify(emit({ env: { TERM: 'xterm-kitty' }, isTTY: { stdout: true } }, 'kitty-image', fields))); console.log(JSON.stringify(emit({ env: { TERM: 'xterm-kitty' }, isTTY: { stdout: false } }, 'kitty-image', fields))); ``` ```text title="node kitty-app.mjs" "\u001b_Ga=T,f=100;iVBORw0KGgo=\u001b\\" "[logo]" ``` `register()` from `paratext/plugin` validates the plugin; `attach()` hands every registered plugin's capabilities to paratext's registry, in order, so a later plugin replaces an earlier one's capability by name and `contributions()` says whom it shadowed. Keys another package reads — roundel's `tokens`, flagstaff's `spinners` — are ignored without complaint, so one plugin file can carry all of them. A single capability can also be registered directly with `register()` from `paratext`. ## A capability must carry a text form A capability with no `fallback` is refused, at registration rather than at output, with the family's code and a fix. `""` is a legitimate projection; absence is not: ```js title="no-fallback.mjs" import { register } from 'paratext'; try { register({ name: 'raw', osc: 'BEL', when: { tty: true }, encode: '\u0007' }); } catch (error) { console.log(`${error.code}: ${error.message}`); } ``` ```text title="node no-fallback.mjs" E_NO_STATIC_PROJECTION: raw: fallback must be a template, even if it is empty — rule 6 has no opt-out ``` Everything else is checked against [`paratext/schema.json`](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/schema.json), the file every package in the family ships: required fields, types, `additionalProperties: false` and the rest, each refusal naming the path — `capabilities.link.when.tty`, not just the capability. `check(capability)` returns the same findings as a list, without throwing. ## Checking before it ships ```text title="npx paratext check kitty.mjs" kitty-graphics — 1 capabilities kitty-image osc BEL encode "\u001b_Ga=T,f=100;{base64}\u001b\\" fallback "{caption}" kitty-graphics: ok ``` ```js title="raw.mjs" export default { name: 'raw', capabilities: { beep2: { name: 'beep2', osc: 'BEL', when: { tty: true }, encode: '\u0007' } } }; ``` ```text title="npx paratext check raw.mjs" exit="1" E_NO_STATIC_PROJECTION: plugin "raw": capabilities.beep2: fallback must be a template, even if it is empty — rule 6 has no opt-out fix: add `fallback: "…"` — what prints where the terminal cannot do it; `""` is a legitimate answer, absence is not ``` `npx paratext check` loads the file without registering it and exits 0, 1 with the code and the fix, or 2 on a usage error. --- # The static projection > Every capability carries a fallback template, and emit() returns it whenever support is absent or unknown — so a pipe gets Docs (url), a caption or a line, never OSC bytes. Source: https://paratext.interlace.tools/docs/guides/static-projection A **static projection** is what a capability prints where its escape sequence would not be understood. Every capability has one — `fallback` is required, and a capability without it is refused at registration — and `emit` returns it whenever support is absent **or unknown**. ## Why unknown means no No terminal answers "do you do OSC 1337", and most OSC sequences cannot be asked about at all. A package that emits the bytes and hopes is what puts `]1337;File=inline=1;…` across the screen of anyone who piped the output to a file, or into the transcript an agent reads back. So the safe default is the projection, and a capability says, as data, which terminals it believes understand it ([Detecting support](/docs/guides/detection)). ```js title="projections.mjs" import { emit } from 'paratext'; const pipe = { env: {}, isTTY: { stdout: false } }; console.log(JSON.stringify(emit(pipe, 'link', { text: 'Docs', url: 'https://x.dev' }))); console.log(JSON.stringify(emit(pipe, 'link', { text: 'Docs' }))); console.log(JSON.stringify(emit(pipe, 'notify', { title: 'Done' }))); console.log(JSON.stringify(emit(pipe, 'notify', { title: 'Done', body: '3 files' }))); console.log(JSON.stringify(emit(pipe, 'image', { base64: 'iVBORw0KGgo=', caption: '[logo]' }))); console.log(JSON.stringify(emit(pipe, 'clipboard', { text: 'copied' }))); console.log(JSON.stringify(emit(pipe, 'not-registered', { text: 'plain text' }))); ``` ```text title="node projections.mjs" "Docs (https://x.dev)" "Docs" "Done" "Done: 3 files" "[logo]" "" "plain text" ``` - **A link keeps its address.** `text (url)`, not bare text: a link whose destination vanishes in a pipe has lost the half that mattered. With no URL it is just the text. - **Chrome projects to nothing.** The window title, the clipboard, the working directory and the bell are about the terminal, not the content, so a log gets nothing rather than noise. - **A name nobody registered** prints the caller's `text` rather than throwing. ## Templates `encode` and `fallback` are strings, not functions, so a capability can travel as JSON. They are written in a three-rule language: | rule | means | | :-- | :-- | | `{field}` | the field's value, empty when absent | | `{field\|base64}` | the value, base64-encoded (OSC 52 needs it) | | `[ … {a} … ]` | emitted only when every field named inside it has a value | The third rule is what lets `notify` be `{title}[: {body}]` and print `Done` or `Done: 3 files` without a branch. An empty string counts as absent, because `Done: ` with nothing after it is worse than `Done`. `render(template, fields)` and `fieldsUsed(template)` are exported for a caller writing its own. ## Where the projection is not used The CSI half of the ansi-escapes surface — cursor movement, erasing, scroll regions — does not degrade, because a cursor move silently dropped would corrupt the screen of a program that relied on it, and ansi-escapes' own does not degrade either. The two drop-ins that keep their incumbent's behaviour, `paratext/terminal-link` and `paratext/term-img`, project where their incumbents do ([Links and images](/docs/guides/links-and-images)). --- # paratext > Hyperlinks, inline images, the window title, the clipboard, notifications and the bell, each with a static fallback for a terminal, pipe or agent that cannot show it. Zero dependencies. Drop-in paths for ansi-escapes, terminal-link and term-img. Source: https://paratext.interlace.tools/docs **paratext** owns everything around terminal output that is not the output: hyperlinks, inline images, the window title, the clipboard, desktop notifications, the working directory and the bell — **OSC**, the escape class (`ESC ]`) that addresses the terminal *program* rather than the character grid. Every OSC capability has a static projection, so a pipe or an agent gets `Docs (https://x.dev)`, never raw escape bytes. Zero dependencies. Drop-in for **ansi-escapes** (the package root, 4 / 4), **terminal-link** (`paratext/terminal-link`, 8 / 8) and **term-img** (`paratext/term-img`, 18 / 18): `npx burgee migrate` moves the imports, each graded by the incumbent's own suite and level with it. *Paratext* is the literary term for everything around a text that is not the text — the title, the cover, the margins, the notes. This package owns the terminal equivalent. The intent and design live at [`.sdlc/intents/paratext/`](https://github.com/ofri-peretz/burgee/tree/main/.sdlc/intents/paratext). ## Install ```bash npm install paratext pnpm add paratext yarn add paratext bun add paratext ``` ## Quick start ```js import { emit, processRuntime } from 'paratext'; const runtime = processRuntime(); console.log(emit(runtime, 'link', { text: 'Docs', url: 'https://x.dev' })); // iTerm2: \e]8;;https://x.dev\aDocs\e]8;;\a // a pipe: Docs (https://x.dev) ``` The same call gives the terminal's bytes where it understands them and the static projection everywhere else; the next section is why that is the only safe default. ## Nothing in this layer is detectable No terminal answers *"do you do OSC 1337"*. So every capability carries a **static projection**, and `emit` returns it whenever support is absent or unknown: an image becomes its caption, a notification a printed line, a hyperlink `text (url)`. Emitting the bytes and hoping is what puts `\u001B]1337;File=inline=1;…` across the screen of anyone who piped your output to a file, and it is what every incumbent does. ## A capability is data Terminals invent OSC codes faster than any package ships releases — Kitty's graphics protocol, WezTerm's user-vars, Ghostty's progress bar. So the surface is a registry of plain objects rather than a fixed list of functions, and the seven built-ins register through the same public call a third party makes. ```js import { register } from 'paratext'; register({ name: 'kitty-image', osc: 'BEL', when: { tty: true, term: 'xterm-kitty' }, encode: '\u001B_Ga=T,f=100;{base64}\u001B\\', fallback: '{caption}', // required — there is no opt-out }); ``` No functions, so a capability travels through JSON, is diffable, and can be validated without running its author's code. `check(document)` grades one against [`paratext/schema.json`](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/schema.json), the same file every package in the family ships. `paratext/plugin` is the host: a plugin's capabilities arrive under `capabilities`, and every other key — another layer's `tokens`, `spinners`, `handlers` — is ignored without complaint. **What "grades against the schema" means, exactly.** `check()` and `register()` read the capability entry of that file and enforce `required`, `type`, `oneOf`, `const`, `minLength`, `minimum`, `items`, and `additionalProperties: false`, which between them is every keyword the capability shape writes. Each refusal names the path — `capabilities.link.when.tty`, not just the capability — and carries the family's error code. What it does **not** read is `$ref`, `pattern`, `minItems`, `maxLength`, `enum`, `allOf`, `anyOf` and `not`: none of them appears under a capability, so nothing is silently unchecked today, but a keyword added to the file tomorrow would be. Until 0.3 this was presence-checking only, and `when: 'not an object'` was therefore accepted — a string destructures to four empty clauses, so the support guess said *yes* and the sequence went into the pipe. That is now a refusal. ## Writing a plugin A plugin is one module whose default export is a plain object: a `name`, the family's `contract: 1`, and its capabilities under `capabilities`, each filed under its own `name`. This one sets the window title (OSC 2, ended by BEL, `\u0007`) and prints nothing where it cannot: ```js // title-plugin.mjs export default { name: 'titler', contract: 1, capabilities: { title: { name: 'title', osc: 2, // the OSC code, or 'BEL' for the bell and for protocols that are not OSC when: { tty: true }, // every clause must hold; {} is always encode: '\u001B]2;{text}\u0007', // the bytes, as a template fallback: '', // what prints where it cannot; '' is an answer, leaving it out is not }, }, }; ``` `paratext check` loads it, validates it against the schema, and prints both projections of each capability, then `ok` — or, exit 1, every problem it found at once, each with its code and a `fix:` to apply: ```console $ npx paratext check title-plugin.mjs titler — 1 capabilities title osc 2 encode "\u001b]2;{text}\u0007" fallback "" titler: ok ``` ## Migrating One import per incumbent: ```diff - import ansiEscapes, { cursorTo, eraseLines, link, image, beep } from 'ansi-escapes'; + import ansiEscapes, { cursorTo, eraseLines, link, image, beep } from 'paratext'; ``` ```diff - import terminalLink from 'terminal-link'; + import terminalLink from 'paratext/terminal-link'; ``` ```diff - import terminalImage, { UnsupportedTerminalError } from 'term-img'; + import terminalImage, { UnsupportedTerminalError } from 'paratext/term-img'; ``` `paratext/term-img` takes a file path or image bytes, as term-img does. It is the one entry that imports `node:fs`, to read that path; the root and every other entry touch nothing but strings ([Coming from term-img](https://paratext.interlace.tools/docs/coming-from/term-img)). The root is `ansi-escapes`' surface, both halves of it — `setCwd` included. **The CSI half** — the cursor, erasing, scroll regions, the alternate screen, synchronized output — is byte-exact with `ansi-escapes@7.3.0`. It does not degrade, because the incumbent's does not: a cursor move silently dropped would corrupt the screen of a program that relied on it. The CSI half is also published on its own, as `paratext/csi` — 2,700 bytes, and it registers none of the built-ins the root does. It is where `flagstaff` and `caique` take their cursor moves from: ```js import { cursorUp, eraseLines, synchronizedOutput } from 'paratext/csi'; ``` **The OSC half** — `link`, `image`, `setCwd`, `beep` — gives the incumbent's bytes where the terminal understands them and the static projection everywhere else. That is the one deliberate difference. `iTerm` and `ConEmu` are still *declared*, as `undefined`, so a drop-in module loads rather than dying on an ESM named import — and TypeScript types them such that calling one is a compile error, not a surprise at run time. `iTerm.annotation` has no equivalent yet. Or let the codemod make the change: `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes it ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## Compatibility Each drop-in path is graded by its incumbent's own suite, unedited, through `compat-oracle`. Graded by the compat oracle against `ansi-escapes@7.3.0`'s own suite: **4 / 4**, level with the control. Three of its four cases assert CSI; until the CSI half landed, that row was 1 / 4. `paratext/terminal-link` is graded by terminal-link's suite and `paratext/term-img` by term-img's, and both are level with the incumbent. term-img's six path cases went green when `paratext/term-img` began reading a path the way term-img does. The current grades are generated under *Benchmarks* below and published on the [compatibility page](https://burgee.interlace.tools/docs/compatibility). ## Benchmarks Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks). Graded by the incumbent's own test suite: | suite | passing | | :-- | --: | | `ansi-escapes` | 4 / 4 | | `term-img` | 18 / 18 | | `terminal-link` | 8 / 8 | Weight, installed and tree-inclusive: **119,655 bytes** against **2,235,987** for the incumbents it replaces — a ratio of **0.0535**. ## For agents - **A pipe or an agent never sees raw escape bytes.** Every capability has a static projection, and `emit` returns it whenever support is absent or unknown: `Docs (https://x.dev)`, not `\u001B]8;;…`. - **A capability is data.** No functions, so a capability travels through JSON, is diffable, and `check(document)` grades it against [`paratext/schema.json`](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/schema.json) without running its author's code; each refusal names the path and the family's error code. - **A plugin can be checked before it ships.** `npx paratext check ./kitty.mjs` validates it and exits 0, 1 with a code and a fix, or 2 on a usage error. - **The docs are machine-readable** at [paratext.interlace.tools/llms.txt](https://paratext.interlace.tools/llms.txt) and [llms-full.txt](https://paratext.interlace.tools/llms-full.txt). ## API `emit`, `register`, `check` and `processRuntime` from the root, beside the whole `ansi-escapes` surface; `paratext/plugin` for the host. Every export, with its types, is on [paratext.interlace.tools](https://paratext.interlace.tools/docs). ## Where it sits Plugins register under the `capabilities` key, against the one schema the whole family shares. `caique`, `controlroom`, `flagstaff` build on it, and it builds on nothing in this family. ## The family Ten packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — `bellpull`, `closeout`, `linegauge`, `paratext`, `roundel`, `seniority` — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the [layers](https://burgee.interlace.tools/docs/concepts/family). No package declares a dependency from outside the family; `react` and `react-reconciler` are optional peers of `controlroom`, which npm does not install. [`package-shape-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds that for every manifest, and [`independence-install-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/independence-install-lock.test.ts) installs each package alone and finds nothing but the family packages it declares. Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which `npm view dist.attestations` shows. [`trusted-publishing-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/trusted-publishing-lock.test.ts) keeps both true. | Package | What it is | Migrates from | | :-- | :-- | :-- | | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander, yargs and meow | | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk and picocolors | | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | @inquirer/core | | [linegauge](https://linegauge.interlace.tools/docs) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | **paratext** (this package) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which | | [controlroom](https://controlroom.interlace.tools/docs) | Keyboard-driven terminal screens, inline or full-screen | ink | Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility) and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages). ## Contributing Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue. ## Licence MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/LICENSE). --- # Images with captions > Read an image yourself, pass the bytes with a caption, and get an inline image on iTerm2 and the caption in a pipe, a CI log or an agent's transcript. Source: https://paratext.interlace.tools/docs/recipes/images-with-captions `image()` from the root takes bytes and an optional `caption`. On a terminal that draws inline images it is the image; everywhere else it is the caption, so a transcript says what was there instead of carrying a base64 blob. ```js title="chart.mjs" import { readFile } from 'node:fs/promises'; import { image } from 'paratext'; const bytes = await readFile(new URL('./chart.mjs', import.meta.url)); // any bytes; a PNG in practice console.log(image(bytes, { caption: '[chart: build times, last 30 days]', width: '40' })); ``` ```text title="node chart.mjs" [chart: build times, last 30 days] ``` The root `image()` never reads the file itself — it imports no `node:fs` (D-030) — so the read, and its error handling, are yours. A good caption is the sentence a screen reader or an agent needs: what the image shows, not its filename. For term-img's API, which takes a path as well as bytes and has its own terminal table and its throw on an unsupported terminal, use `paratext/term-img` ([Links and images](/docs/guides/links-and-images)). --- # Incremental migration > Move from ansi-escapes, terminal-link and term-img one import at a time — each is one changed specifier, file paths included. Source: https://paratext.interlace.tools/docs/recipes/incremental-migration Each drop-in is graded on its own, so a program can move one import at a time and ship in between. ## One import at a time ```diff - import ansiEscapes, { cursorTo, eraseLines, link } from 'ansi-escapes'; + import ansiEscapes, { cursorTo, eraseLines, link } from 'paratext'; - import terminalLink from 'terminal-link'; + import terminalLink from 'paratext/terminal-link'; - import terminalImage from 'term-img'; + import terminalImage from 'paratext/term-img'; ``` Or let the codemod do it: `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent, which all three are — and `npx burgee migrate` rewrites them ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## term-img: paths stay paths `paratext/term-img` reads a file path as term-img does, so `terminalImage('logo.png')` needs no change. Bytes work as before. ## What changes after the move The ansi-escapes CSI calls are byte-for-byte what they were. `link`, `image` and `setCwd` now write their text form on a pipe instead of escape bytes, which is the reason to move ([Compatibility](/docs/drop-ins#known-differences)). terminal-link and term-img keep their incumbents' detection and fallbacks. ## Checking each step ```bash npm uninstall ansi-escapes terminal-link term-img npm ls ansi-escapes ``` `npm ls` shows what still pulls ansi-escapes in transitively — log-update and terminal-link both depend on it. --- # Links in help output > Print a clickable command name where the terminal understands OSC 8, and a separate URL column only where it does not, deciding the layout with supportsLink(). Source: https://paratext.interlace.tools/docs/recipes/links-in-help A help screen with a documentation link per command wants two layouts: on a terminal that understands hyperlinks, the command name itself is the link; everywhere else, the URL has to be printed. `supportsLink(runtime)` decides the layout, and `linkFor(runtime)` writes the link. ```js title="help.mjs" import { linkFor, supportsLink } from 'paratext/link'; const commands = [ ['build', 'https://example.dev/docs/build'], ['deploy', 'https://example.dev/docs/deploy'], ]; function help(runtime) { const link = linkFor(runtime); return supportsLink(runtime) ? commands.map(([name, url]) => ` ${link(name, url)}`).join('\n') : commands.map(([name, url]) => ` ${name.padEnd(8)}${url}`).join('\n'); } console.log(JSON.stringify(help({ env: { TERM_PROGRAM: 'ghostty' }, isTTY: { stdout: true } }))); console.log(help({ env: {}, isTTY: { stdout: false } })); ``` ```text title="node help.mjs" " \u001b]8;;https://example.dev/docs/build\u0007build\u001b]8;;\u0007\n \u001b]8;;https://example.dev/docs/deploy\u0007deploy\u001b]8;;\u0007" build https://example.dev/docs/build deploy https://example.dev/docs/deploy ``` Without `supportsLink`, the pipe would get `build (https://…)` from the projection, which is correct but reads worse in a column. `paratext/link` loads neither the registry nor the schema, so a CLI pays for one capability. flagstaff's boxes and tables render their linked cells through the same capability. --- # A notification when a long task ends > Send an OSC 9 desktop notification on terminals that show one, and print the same message as a line everywhere else — with no native binaries. Source: https://paratext.interlace.tools/docs/recipes/notify-when-done A build that takes ten minutes should say when it is done. On iTerm2, WezTerm and Ghostty, OSC 9 raises a desktop notification with no native helper installed; everywhere else the same call prints the message as a line, which is what a CI log wants anyway. ```js title="done.mjs" import { emit, processRuntime } from 'paratext'; const started = Date.now(); // … the long task … const seconds = Math.round((Date.now() - started) / 1000); process.stdout.write(`${emit(processRuntime(), 'notify', { title: 'Build finished', body: `${seconds}s` })}\n`); ``` ```text title="node done.mjs" Build finished: 0s ``` On a supporting terminal the line is the notification's bytes instead, and the terminal shows the notification rather than the text. `body` is optional: `{title}[: {body}]` prints the title alone when there is no body. --- # Testing escape output > Assert what your CLI writes on a supporting terminal and on a pipe by passing a literal runtime to emit() — no environment patching. Source: https://paratext.interlace.tools/docs/recipes/testing-escapes `emit` reads only the runtime it is handed, so a test asserts both outputs, on any machine and in any CI, with two literals: ```js title="escapes-check.mjs" import assert from 'node:assert/strict'; import { emit } from 'paratext'; const iterm = { env: { TERM_PROGRAM: 'iTerm.app' }, isTTY: { stdout: true } }; const pipe = { env: { TERM_PROGRAM: 'iTerm.app' }, isTTY: { stdout: false } }; const args = { text: 'Docs', url: 'https://x.dev' }; assert.equal(emit(iterm, 'link', args), '\u001B]8;;https://x.dev\u0007Docs\u001B]8;;\u0007'); assert.equal(emit(pipe, 'link', args), 'Docs (https://x.dev)'); assert.doesNotMatch(emit(pipe, 'notify', { title: 'Done' }), /[\u0000-\u001F]/u); console.log('ok'); ``` ```text title="node escapes-check.mjs" ok ``` The second runtime is iTerm2 with its output piped — a user redirecting to a file — and it gets the projection, because `when: { tty: true }` refuses a pipe whatever the terminal program is. --- # Why paratext > paratext against ansi-escapes, terminal-link and term-img, one capability per row, every cell linked to the test, grade or source that proves it. Source: https://paratext.interlace.tools/docs/why-paratext ansi-escapes, terminal-link and term-img each write the bytes a terminal needs, and paratext writes the same bytes — its three drop-in paths are graded by each one's own test suite. What none of them has is the other half: a text form for **every** capability, used whenever the terminal cannot be shown to understand the sequence, so a pipe, a log and an agent read text instead of escape bytes. terminal-link comes closest, for links alone. The table below is the whole comparison. Every mark links to its evidence: a test in this repository for ours, and for theirs the source file of the exact version compat-oracle grades, or that package's own test suite. `scripts/capabilities-lock.test.ts` fails the build when a cited test no longer contains the title it is cited for, when a source no longer contains the line it is quoted for, or when a source we say lacks something has gained it. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Off a terminal | Capability | **paratext** | ansi-escapes | terminal-link | term-img | | :-- | :-- | :-- | :-- | :-- | | **A hyperlink on a pipe is its text and its URL** — Where OSC 8 is not understood, a link is written as `Docs (https://x.dev)`, so a log, an agent or a redirected file reads the address instead of escape bytes. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/link.test.ts) | [✗ writes the OSC 8 bytes on every stream; it never checks](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/base.js) | [✓ the text, a space and the URL](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/index.js) | [— draws images, not links](https://cdn.jsdelivr.net/npm/term-img@7.1.0/index.js) | | **Every OSC capability has a text form** — An image becomes its caption, a notification a printed line and a link its text and URL whenever support is absent or unknown, so no OSC byte reaches a pipe. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/paratext.test.ts) | [✗ every OSC helper returns its bytes, whatever the stream](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/base.js) | [◐ a text form for links only; it has no other capability](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/index.js) | [◐ an unsupported terminal throws UnsupportedTerminalError unless the caller passes a fallback function](https://cdn.jsdelivr.net/npm/term-img@7.1.0/index.js) | ### Knowing the terminal | Capability | **paratext** | ansi-escapes | terminal-link | term-img | | :-- | :-- | :-- | :-- | :-- | | **Hyperlink support detected as supports-hyperlinks detects it** — `paratext/terminal-link` links on exactly the terminals supports-hyperlinks 4.5.0 says understand OSC 8, and asks about stderr when the link is bound for stderr. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/hyperlinks.test.ts) | [✗ detects nothing; the caller decides](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/base.js) | [✓](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/index.js) | [— draws images, not links](https://cdn.jsdelivr.net/npm/term-img@7.1.0/index.js) | | **Inline-image support detected by terminal** — `paratext/term-img` draws on iTerm2, WezTerm, Konsole, Rio and VS Code at the versions term-img names, read from the environment alone. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/term-img.test.ts) | [✗ encodes the image for any terminal it is given](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/base.js) | [— links only](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/index.js) | [✓ the same table](https://cdn.jsdelivr.net/npm/term-img@7.1.0/index.js) | ### Capabilities as data | Capability | **paratext** | ansi-escapes | terminal-link | term-img | | :-- | :-- | :-- | :-- | :-- | | **A registry of capabilities, extensible by plain objects** — A capability the package never heard of — a Kitty image, a WezTerm user variable — is registered as an object with an encoding and a fallback, survives a round trip through JSON, and emits through the same call as the built-ins. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/paratext.test.ts) | [✗ a fixed set of functions](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/base.js) | [✗ one function](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/index.js) | [✗ one function](https://cdn.jsdelivr.net/npm/term-img@7.1.0/index.js) | | **A capability without a text form is refused** — A capability with no static projection is refused at registration, with the family's code and a fix, so nothing registered can put raw bytes into a pipe. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/plugin.test.ts) | [✗ no helper has a text form to require](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/base.js) | [◐ its one link has a text form, and a caller can switch it off](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/index.js) | [✗ the fallback is optional, and the default is to throw](https://cdn.jsdelivr.net/npm/term-img@7.1.0/index.js) | | **A checker for capability plugins before they ship** — `npx paratext check ./kitty.mjs` validates a plugin against the family schema without running its author's code, and exits 1 with a code and a fix when it is refused. | [✓](https://github.com/ofri-peretz/burgee/blob/main/scripts/plugin-check-lock.test.ts) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/package.json) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/package.json) | [— takes no plugins, so has nothing to check](https://cdn.jsdelivr.net/npm/term-img@7.1.0/package.json) | ### Weight | Capability | **paratext** | ansi-escapes | terminal-link | term-img | | :-- | :-- | :-- | :-- | :-- | | **No runtime dependencies** — The escape sequences, the hyperlink detection, the image table and the registry install as one package that depends on nothing. | [✓](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) | [✗ environment](https://cdn.jsdelivr.net/npm/ansi-escapes@7.3.0/package.json) | [✗ ansi-escapes and supports-hyperlinks](https://cdn.jsdelivr.net/npm/terminal-link@5.0.0/package.json) | [✗ ansi-escapes and iterm2-version](https://cdn.jsdelivr.net/npm/term-img@7.1.0/package.json) | ### Compatibility | Capability | **paratext** | ansi-escapes | terminal-link | term-img | | :-- | :-- | :-- | :-- | :-- | | **Passes ansi-escapes' own test suite** — paratext's root is graded by ansi-escapes 7.3.0's own tests, unedited, and its CSI half is byte-exact with it. | [✓ 4 / 4 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/ansi-escapes.json) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/ansi-escapes/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/terminal-link/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/term-img/test.js) | | **Passes terminal-link's own test suite** — `paratext/terminal-link` is graded by terminal-link 5.0.0's own tests, unedited. | [✓ 8 / 8 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/terminal-link.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/ansi-escapes/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/terminal-link/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/term-img/test.js) | | **Passes term-img's own test suite** — `paratext/term-img` is graded by term-img 7.1.0's own tests, unedited, and passes all 18, file paths included. | [✓ 18 / 18 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/term-img.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/ansi-escapes/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/terminal-link/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/term-img/test.js) | ## Reading it - **terminal-link already projects a link**, and its row says so. paratext's `text (url)` and terminal-link's `text url` are two spellings of the same idea; `paratext/terminal-link` keeps terminal-link's. - **term-img's cells link to term-img 7.1.0 on jsDelivr**: term-img is not installed in this repository, so there is no local copy for the lock to read. Each URL cell was checked against that file by hand; the compatibility row, whose source is term-img's vendored suite, is checked on every run. - **term-img is level, file paths included.** `paratext/term-img` reads a path with `node:fs`, as term-img does, and it is the only entry that touches the filesystem; the root `image()` takes bytes (D-20260930-paratext-term-img-path, [Coming from term-img](/docs/coming-from/term-img)). ## What is not in the table A row goes in only when every cell of it can be proved. These were left out: - **Clipboard, notifications, the window title and the working directory.** paratext ships them; ansi-escapes 7.3.0 has `setCwd` and none of the other three, and terminal-link and term-img have none. They are in the text-form row rather than a row each. - **Detection as a pure function of a runtime.** `supports()` and `emit()` read only the runtime they are handed; each incumbent reads `process` itself. It is how every example on this site runs, but no single test states the comparison. - **Weight in bytes.** The per-subpath figures are asserted by [`weight.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/paratext/src/weight.test.ts) and published on [Benchmarks](https://burgee.interlace.tools/docs/benchmarks).