termcut 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aman Varshney
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # tcut
2
+
3
+ [![CI](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml/badge.svg)](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/termcut)](https://www.npmjs.com/package/termcut)
5
+ [![license](https://img.shields.io/github/license/AmanVarshney01/tcut)](LICENSE)
6
+
7
+ Script terminal sessions in **TypeScript**, render them to **reproducible** MP4 / GIF / WebM / SVG / HTML / PNG.
8
+
9
+ Built on Bun 1.4 — `Bun.Terminal` for the PTY, `Bun.WebView` for pixels, `Bun.build` for the renderer,
10
+ `Bun.Image` for stills, `bun build --compile` for a single binary — plus [wterm](https://github.com/vercel-labs/wterm)'s
11
+ libghostty WASM core as the terminal emulator. Inspired by [VHS](https://github.com/charmbracelet/vhs); scripts are
12
+ code, and recording is separate from rendering.
13
+
14
+ ![tcut demo](docs/demo.gif)
15
+
16
+ <sub>Made by tcut from [`examples/readme.ts`](examples/readme.ts). Same cast as SVG: [docs/demo.svg](docs/demo.svg).</sub>
17
+
18
+ ```ts
19
+ // demo.video.ts
20
+ import { defineVideo } from "tcut";
21
+
22
+ export default defineVideo(
23
+ {
24
+ output: ["out/demo.mp4", "out/demo.gif", "out/demo.svg"],
25
+ theme: "catppuccin-mocha",
26
+ cols: 80, rows: 20,
27
+ typingSpeed: "40ms", typingJitter: 0.4, // jitter is seeded → reproducible
28
+ windowBar: "colorful", title: "tcut", margin: 32, borderRadius: 12,
29
+ },
30
+ async (t) => {
31
+ await t.hide(() => t.run("cd /tmp && mkdir -p demo && cd demo")); // happens, but is cut from the video
32
+
33
+ await t.run("echo 'Hello 👋'"); // type + Enter + wait for the prompt to come back
34
+ await t.run("ls -la");
35
+ await t.expect(/total \d+/); // assertion — the script doubles as an integration test
36
+ await t.screenshot("out/ls.png");
37
+ await t.sleep("1.5s");
38
+ },
39
+ );
40
+ ```
41
+
42
+ ## Install
43
+
44
+ **With Bun (recommended)** — Bun ≥ 1.4. The npm package is **`termcut`** (npm's typosquat filter blocks the
45
+ 4-letter name); the command it installs is `tcut`:
46
+
47
+ ```sh
48
+ bun add -g termcut # then: tcut demo.video.ts
49
+ bunx termcut init demo # or run it without installing
50
+ ```
51
+
52
+ **Standalone binary** — no Bun required; download from
53
+ [Releases](https://github.com/AmanVarshney01/tcut/releases) (`tcut-<version>-<platform>`, checksums in `SHA256SUMS`):
54
+
55
+ ```sh
56
+ curl -fsSL https://github.com/AmanVarshney01/tcut/releases/latest/download/tcut-0.1.0-darwin-arm64 -o tcut
57
+ chmod +x tcut && ./tcut init demo
58
+ ```
59
+
60
+ Scripts `import { defineVideo } from "tcut"` — the CLI (binary or global install) resolves that import itself,
61
+ so no `node_modules` is needed next to your script. If you add `termcut` to a project, `import … from "termcut"`
62
+ also works and gives your editor the types.
63
+
64
+ **From source**
65
+
66
+ ```sh
67
+ git clone https://github.com/AmanVarshney01/tcut && cd tcut && bun install
68
+ bun src/cli.ts examples/demo.ts
69
+ ```
70
+
71
+ ## Requirements
72
+
73
+ | To… | You need |
74
+ |---|---|
75
+ | run tcut | **Bun ≥ 1.4** (`bun add -g termcut`), or the standalone binary (Bun is embedded) |
76
+ | record / `tcut test` | a shell — `bash` (default), `zsh`, `fish` or `sh` — plus whatever CLI tools your script runs. No browser, no ffmpeg |
77
+ | render `.svg` / `.html` | nothing else — pure Bun |
78
+ | render `.png` / `.jpg` / `frames/` | a WebView: **macOS → nothing** (system WebKit). **Linux / Windows → Chrome, Chromium, Edge or Brave** installed (Windows ships Edge) |
79
+ | render `.mp4` / `.gif` / `.webm` | the WebView above **+ ffmpeg** — `brew install ffmpeg` · `apt install ffmpeg` · `winget install ffmpeg` |
80
+ | render `.webp` | an ffmpeg with libwebp — Homebrew: `brew install ffmpeg-full` (tcut finds it automatically; see below) |
81
+ | your chosen font | `font.family` must be installed (default stack: JetBrains Mono → Menlo → monospace). SVG output uses the *viewer's* fonts |
82
+
83
+ Notes
84
+ - tcut looks for ffmpeg in this order: `$TCUT_FFMPEG`, `ffmpeg` on PATH, Homebrew's keg-only `ffmpeg-full`. It picks the first one that has the encoder a given output needs.
85
+ - Verified on macOS (Apple Silicon, CI on GitHub's macOS runners). Linux and Windows binaries are cross-compiled but not yet exercised in CI; recording should work everywhere, rendering depends on the WebView backend above.
86
+ - Rendering throughput is ~30 output frames/s; idle stretches are free (unchanged frames are reused). A 60 fps, 10 s clip renders in a few seconds.
87
+
88
+ ## Why not VHS?
89
+
90
+ | | VHS | tcut |
91
+ |---|---|---|
92
+ | Script format | `.tape` DSL | TypeScript: loops, imports, shared scenes, assertions |
93
+ | Wait for output | `Wait` regex on raw bytes | `wait()` / `expect()` / `run()` read the **rendered screen** (headless Ghostty) |
94
+ | Determinism | live screenshots, machine-speed dependent | record once to `.cast`, render on a virtual clock → identical frames anywhere |
95
+ | Re-theme | re-run everything | `tcut render demo.cast --theme dracula` — no shell is spawned |
96
+ | Outputs | mp4 / gif / webm / png frames | + **animated SVG**, **single-file HTML player**, PNG/JPG stills — SVG/HTML need no ffmpeg or browser |
97
+ | As tests | — | `tcut test` runs scripts in fast mode, exit code reflects `expect()` |
98
+ | Stack | ttyd + Chrome + ffmpeg | Bun + wterm (JS/WASM) + ffmpeg (only for video containers) |
99
+
100
+ ## How it works
101
+
102
+ ```
103
+ script.ts ─▶ record (Bun.Terminal PTY) ─▶ demo.cast ─▶ render (virtual clock) ─▶ mp4 / gif / webm / png
104
+ clean shell; every chunk asciicast v2 ├─ Bun.WebView + @wterm/dom → ffmpeg
105
+ also feeds a headless ├─ headless grid → animated SVG
106
+ Ghostty core for wait/expect └─ cast + lite core → self-contained HTML
107
+ ```
108
+
109
+ - **Record** drives a clean shell (no rc files, fixed prompt, `TERM=xterm-256color`) in a PTY. Output is
110
+ timestamped into an asciicast v2 file *and* parsed by a headless Ghostty terminal, so `run()` knows when the
111
+ prompt is back and `expect()` sees what a human would see. Terminal queries (e.g. from vim) are answered.
112
+ - **Render** replays the cast: frame *N* is the screen at *N / fps*. Hidden sections are cut, playback speed is
113
+ applied, cursor blink is driven by the render clock. Unchanged frames are reused, so idle time is free.
114
+ - **Cache**: re-running an unchanged script reuses the cast (`--force` to re-record). `quantize: true` snaps
115
+ timestamps to the frame grid for byte-stable casts.
116
+
117
+ ## CLI
118
+
119
+ ```
120
+ tcut <script.ts> [options] record + render
121
+ tcut record <script.ts> record only (writes the .cast)
122
+ tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
123
+ tcut test <path...> run scripts in fast mode as tests (no video)
124
+ tcut init [name] [--template t] scaffold a script: basic | tour | test
125
+ tcut themes list built-in themes
126
+
127
+ -o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or a directory/ for PNG frames (repeatable)
128
+ --theme <name> catppuccin-mocha | dracula | github-dark | tokyo-night | one-dark
129
+ --font <family> --font-size <px> --line-height <x> --letter-spacing <px>
130
+ --fps <n> --speed <x>
131
+ --padding <px> --margin <px> --margin-fill <color> --radius <px>
132
+ --window-bar <none|colorful|colorfulRight|rings|ringsRight> --title <text> --no-blink
133
+ --core <ghostty|lite> --cast <path> --record-only --force -q, --quiet
134
+ ```
135
+
136
+ ## Script API
137
+
138
+ `defineVideo(config, async (t) => { … })`
139
+
140
+ | Config | Default | |
141
+ |---|---|---|
142
+ | `output` | — | string or string[]; extension selects the format |
143
+ | `shell` | `"bash"` | `"bash" \| "zsh" \| "fish" \| "sh"` or a full `string[]` command |
144
+ | `prompt` / `promptPattern` | `"> "` | prompt for the clean shell; regex used by `run()` / `wait()` |
145
+ | `cols` / `rows` / `fps` | 80 / 24 / 60 | |
146
+ | `typingSpeed` / `typingJitter` / `seed` | `"50ms"` / 0 / 1 | |
147
+ | `playbackSpeed` | 1 | applied at render time |
148
+ | `waitTimeout` / `endPause` | `"15s"` / `"1s"` | |
149
+ | `quantize` / `cache` / `core` | false / true / `"ghostty"` | `core: "lite"` = wterm's Zig core (faster, no query replies) |
150
+ | `font` | JetBrains Mono 20px, lh 1.2 | `{ family, size, lineHeight, letterSpacing }` |
151
+ | `theme` | `"catppuccin-mocha"` | name or a full `Theme` object |
152
+ | `cursor` | `{ blink: true, period: 1000 }` | |
153
+ | `padding` / `margin` / `marginFill` / `borderRadius` / `windowBar` / `title` | 24 / 0 / bg / 0 / `"none"` / `""` | window chrome |
154
+ | `cast` | next to the first output | where the recording is saved |
155
+
156
+ `t` (a `TerminalSession`):
157
+
158
+ - Input: `type(text, {speed})`, `run(cmd, {wait, timeout})`, `paste(text)`, `enter() tab() backspace() delete()
159
+ escape() space() up() down() left() right() home() end() pageUp() pageDown()` (repeat count), `ctrl("c")`,
160
+ `alt("b")`, `key("f5")`, `raw(bytes)`
161
+ - Timing: `sleep("500ms")`, `wait(/re/, { scope: "line" | "screen", timeout })`
162
+ - Assertions: `expect(/re/, { scope })` — throws `ExpectationError` with a screen dump
163
+ - Structure: `hide(async () => …)`, `screenshot(path)`, `marker(name)`, `resize(cols, rows)`, `clear()`
164
+ - Introspection: `screen()`, `line()`, `cursor()`, `cols`, `rows`, `config`
165
+
166
+ Durations accept milliseconds or `"500ms" | "1.5s" | "2m"`.
167
+
168
+ Programmatic use: `const v = defineVideo(...); await v.record(); await v.render(undefined, { overrides: { theme: "dracula" } })`,
169
+ `renderCast(file, { output })`, `buildSvg(rec, config)`, `runScriptTests(paths)`.
170
+
171
+ ## Development
172
+
173
+ ```sh
174
+ bun test # recorder, renderer, exporters, CLI (spawns real shells + WebView)
175
+ bun run typecheck
176
+ bun src/cli.ts examples/demo.ts
177
+ bun run build # dist/tcut single binary with embedded renderer assets
178
+ bun run build:all # cross-compile all platforms into dist/ + SHA256SUMS
179
+ ```
180
+
181
+ Releasing: bump `version` in `package.json`, commit, `git tag v<version> && git push --tags`. The release
182
+ workflow builds the binaries, publishes the GitHub Release and, when an `NPM_TOKEN` secret is configured,
183
+ publishes to npm.
184
+
185
+ Specs and tasks are tracked with [OpenSpec](https://github.com/Fission-AI/OpenSpec) under `openspec/`; the roadmap
186
+ and measurements are in `PLAN.md`.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "termcut",
3
+ "version": "0.1.0",
4
+ "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
+ "license": "MIT",
6
+ "author": "Aman Varshney",
7
+ "homepage": "https://github.com/AmanVarshney01/tcut#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/AmanVarshney01/tcut.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/AmanVarshney01/tcut/issues"
14
+ },
15
+ "keywords": [
16
+ "terminal",
17
+ "recording",
18
+ "gif",
19
+ "mp4",
20
+ "svg",
21
+ "asciicast",
22
+ "asciinema",
23
+ "vhs",
24
+ "pty",
25
+ "demo",
26
+ "screencast",
27
+ "bun"
28
+ ],
29
+ "type": "module",
30
+ "module": "src/index.ts",
31
+ "exports": {
32
+ ".": "./src/index.ts"
33
+ },
34
+ "bin": {
35
+ "tcut": "./src/cli.ts"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "scripts",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "engines": {
44
+ "bun": ">=1.4.0"
45
+ },
46
+ "scripts": {
47
+ "demo": "bun src/cli.ts examples/demo.ts",
48
+ "typecheck": "bunx tsc --noEmit",
49
+ "test": "bun test",
50
+ "build:assets": "bun scripts/build-assets.ts",
51
+ "build": "bun run build:assets && bun build --compile --minify src/cli.ts --outfile dist/tcut",
52
+ "build:all": "bun run build:assets && bun scripts/build-binaries.ts"
53
+ },
54
+ "dependencies": {
55
+ "@wterm/core": "^0.3.4",
56
+ "@wterm/dom": "^0.3.4",
57
+ "@wterm/ghostty": "^0.3.4"
58
+ },
59
+ "devDependencies": {
60
+ "@types/bun": "latest"
61
+ },
62
+ "peerDependencies": {
63
+ "typescript": "^5"
64
+ }
65
+ }
@@ -0,0 +1,45 @@
1
+ // Builds the browser bundles and copies static assets into src/renderer/generated/ so that
2
+ // `bun build --compile` can embed them. Run via `bun run build:assets`.
3
+ import { mkdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const root = path.resolve(import.meta.dir, "..");
7
+ const rendererDir = path.join(root, "src", "renderer");
8
+ const outDir = path.join(rendererDir, "generated");
9
+
10
+ function packageRoot(specifier: string): string {
11
+ const entry = Bun.resolveSync(specifier, root);
12
+ let dir = path.dirname(entry);
13
+ for (let i = 0; i < 5; i++) {
14
+ if (path.basename(path.dirname(dir)) === "@wterm") return dir;
15
+ dir = path.dirname(dir);
16
+ }
17
+ throw new Error(`Could not locate package root for ${specifier}`);
18
+ }
19
+
20
+ await mkdir(outDir, { recursive: true });
21
+
22
+ for (const [entry, out] of [
23
+ ["page-entry.ts", "page.js"],
24
+ ["player-entry.ts", "player.js"],
25
+ ] as const) {
26
+ const result = await Bun.build({
27
+ entrypoints: [path.join(rendererDir, entry)],
28
+ target: "browser",
29
+ format: "esm",
30
+ minify: true,
31
+ });
32
+ if (!result.success) {
33
+ console.error(result.logs.map((l) => l.message).join("\n"));
34
+ process.exit(1);
35
+ }
36
+ await Bun.write(path.join(outDir, out), await result.outputs[0]!.text());
37
+ }
38
+
39
+ await Bun.write(path.join(outDir, "terminal.css"), Bun.file(path.join(packageRoot("@wterm/dom"), "src", "terminal.css")));
40
+ await Bun.write(path.join(outDir, "ghostty-vt.wasm"), Bun.file(path.join(packageRoot("@wterm/ghostty"), "wasm", "ghostty-vt.wasm")));
41
+
42
+ const sizes = await Promise.all(
43
+ ["page.js", "player.js", "terminal.css", "ghostty-vt.wasm"].map(async (f) => `${f} ${(Bun.file(path.join(outDir, f)).size / 1024).toFixed(0)} KB`),
44
+ );
45
+ console.log(`built src/renderer/generated/: ${sizes.join(", ")}`);
@@ -0,0 +1,41 @@
1
+ // Cross-compile standalone binaries for every supported platform into dist/ and write SHA-256 checksums.
2
+ // Run `bun run build:assets` first (done by `bun run build:all`).
3
+ import { mkdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const root = path.resolve(import.meta.dir, "..");
7
+ const dist = path.join(root, "dist");
8
+ const version = (await Bun.file(path.join(root, "package.json")).json()).version as string;
9
+
10
+ const targets = [
11
+ "bun-darwin-arm64",
12
+ "bun-darwin-x64",
13
+ "bun-linux-x64",
14
+ "bun-linux-arm64",
15
+ "bun-windows-x64",
16
+ ] as const;
17
+
18
+ await mkdir(dist, { recursive: true });
19
+ const checksums: string[] = [];
20
+
21
+ for (const target of targets) {
22
+ const platform = target.replace(/^bun-/, "");
23
+ const ext = target.includes("windows") ? ".exe" : "";
24
+ const outfile = path.join(dist, `tcut-${version}-${platform}${ext}`);
25
+ const proc = Bun.spawn(
26
+ ["bun", "build", "--compile", "--minify", `--target=${target}`, path.join(root, "src", "cli.ts"), "--outfile", outfile],
27
+ { cwd: root, stdout: "ignore", stderr: "pipe" },
28
+ );
29
+ const code = await proc.exited;
30
+ if (code !== 0) {
31
+ console.error(await new Response(proc.stderr).text());
32
+ process.exit(code);
33
+ }
34
+ const bytes = await Bun.file(outfile).arrayBuffer();
35
+ const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
36
+ checksums.push(`${hash} ${path.basename(outfile)}`);
37
+ console.log(`${path.basename(outfile)} ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB`);
38
+ }
39
+
40
+ await Bun.write(path.join(dist, "SHA256SUMS"), checksums.join("\n") + "\n");
41
+ console.log("wrote dist/SHA256SUMS");
package/src/cast.ts ADDED
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import type { CastEvent, CastHeader, Recording } from "./types";
3
+
4
+ /** Serialise to asciicast v2 (newline-delimited JSON). Playable with `asciinema play`. */
5
+ export function serializeCast(rec: Recording): string {
6
+ const lines = [JSON.stringify(rec.header)];
7
+ for (const event of rec.events) lines.push(JSON.stringify(event));
8
+ return lines.join("\n") + "\n";
9
+ }
10
+
11
+ export function parseCast(text: string): Recording {
12
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
13
+ if (lines.length === 0) throw new Error("Empty cast file");
14
+ const header = JSON.parse(lines[0]!) as CastHeader;
15
+ if (header.version !== 2) throw new Error(`Unsupported cast version ${String(header.version)} (expected 2)`);
16
+ const events: CastEvent[] = [];
17
+ for (let i = 1; i < lines.length; i++) {
18
+ const parsed = JSON.parse(lines[i]!) as unknown;
19
+ if (!Array.isArray(parsed) || parsed.length < 3) throw new Error(`Malformed cast event on line ${i + 1}`);
20
+ events.push([Number(parsed[0]), parsed[1] as CastEvent[1], String(parsed[2])]);
21
+ }
22
+ return { header, events };
23
+ }
24
+
25
+ export async function writeCast(file: string, rec: Recording): Promise<void> {
26
+ await Bun.write(file, serializeCast(rec));
27
+ }
28
+
29
+ export async function readCast(file: string): Promise<Recording> {
30
+ const f = Bun.file(path.resolve(file));
31
+ if (!(await f.exists())) throw new Error(`Cast file not found: ${file}`);
32
+ return parseCast(await f.text());
33
+ }
34
+
35
+ export const MARKER = {
36
+ hide: "hide",
37
+ show: "show",
38
+ screenshot: "screenshot:",
39
+ end: "end",
40
+ } as const;