termcut 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,176 +1,62 @@
1
1
  # tcut
2
2
 
3
- **Turn a terminal session into a video.** Record it live or script it in TypeScript; render it to MP4, GIF, WebM, SVG, HTML or PNG — identical every time.
4
-
5
- [![npm](https://img.shields.io/npm/v/termcut)](https://www.npmjs.com/package/termcut)
6
- [![CI](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml/badge.svg)](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
7
- [![license](https://img.shields.io/github/license/AmanVarshney01/tcut)](LICENSE)
3
+ Turn a terminal session into a video. Record it live or script it in TypeScript; render to MP4, GIF, SVG, HTML — identical every time.
8
4
 
9
5
  ![tcut demo](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/demo.gif)
10
6
 
11
- Website: **[tcut.amanv.dev](https://tcut.amanv.dev)**
12
-
13
7
  ## Install
14
8
 
15
9
  ```sh
16
- bun add -g termcut # installs the `tcut` command (needs Bun ≥ 1.4)
17
- bunx termcut --help # or run it without installing
18
- ```
19
-
20
- No Bun? Grab a standalone binary from [Releases](https://github.com/AmanVarshney01/tcut/releases) (macOS, Linux, Windows):
21
-
22
- ```sh
23
- curl -fsSL https://github.com/AmanVarshney01/tcut/releases/latest/download/tcut-0.2.0-darwin-arm64 -o tcut && chmod +x tcut
10
+ bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
24
11
  ```
25
12
 
26
- For `.mp4` / `.gif` / `.webm` you also need `ffmpeg` (`brew install ffmpeg`, `apt install ffmpeg`). SVG, HTML and PNG need nothing else.
13
+ Standalone binaries: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
27
14
 
28
- ## Record a session
15
+ ## Use
29
16
 
30
- ### Live just do it, tcut records it
17
+ Record what you do:
31
18
 
32
19
  ```sh
33
- tcut rec -o demo.gif
20
+ tcut rec -o demo.gif # opens a shell, records until you `exit`
21
+ tcut rec -o demo.mp4 -- npm create vite # or just one command
34
22
  ```
35
23
 
36
- A clean shell opens in your terminal. Type whatever you want to show; when you `exit`, tcut renders `demo.gif`.
37
- Everything is captured — output, timing, colours, arrow keys in TUIs, window resizes.
24
+ You get `demo.gif`, the exact recording (`demo.cast`) and an editable script (`demo.video.ts`) of what you typed.
38
25
 
39
- ```sh
40
- tcut rec -o demo.mp4 -- bun create better-t-stack # record just one command (you still drive it)
41
- tcut rec -o demo.svg -o demo.gif # several formats from one session
42
- ```
43
-
44
- ### Scripted — write it once, re-record forever
26
+ Or script it:
45
27
 
46
28
  ```ts
47
29
  // demo.video.ts
48
30
  import { defineVideo } from "tcut";
49
31
 
50
- export default defineVideo(
51
- { output: ["demo.mp4", "demo.gif"], theme: "catppuccin-mocha", cols: 80, rows: 20 },
52
- async (t) => {
53
- await t.run("bun --version"); // types it, presses Enter, waits for the prompt
54
- await t.run("ls -la");
55
- await t.expect(/package\.json/); // assert on the screen — the demo is also a test
56
- await t.sleep("1.5s");
57
- },
58
- );
32
+ export default defineVideo({ output: "demo.gif" }, async (t) => {
33
+ await t.run("bun --version"); // type, Enter, wait for the prompt
34
+ await t.expect(/1\.\d+/); // assert on the screen
35
+ await t.sleep("1s");
36
+ });
59
37
  ```
60
38
 
61
39
  ```sh
62
- tcut demo.video.ts # record + render
63
- tcut init demo # scaffold a script to start from
40
+ tcut demo.video.ts
64
41
  ```
65
42
 
66
- Scripts are plain TypeScript: loops, helpers, imports, whatever you need. All output in the video comes from the
67
- real programs; the script only provides the key presses a person would make.
68
-
69
- ## Render again, differently
70
-
71
- Recording and rendering are separate. Every recording is saved as a standard [asciicast](https://docs.asciinema.org/manual/asciicast/v2/) (`demo.cast`) and frames are computed on a virtual clock, so the same cast renders to the same pixels on any machine — and you can re-render without re-running anything:
43
+ Re-render any recording without re-running it ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
72
44
 
73
45
  ```sh
74
- tcut render demo.cast --theme dracula -o demo.gif # new theme
75
- tcut render demo.cast -o demo.svg -o demo.html # animated SVG for a README, single-file HTML player
76
- tcut render demo.cast --font-size 24 --speed 1.5 -o demo.mp4
46
+ tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg
77
47
  ```
78
48
 
79
- | Output | Needs | |
80
- |---|---|---|
81
- | `.mp4` `.webm` | ffmpeg | H.264 / VP9 |
82
- | `.gif` `.webp` | ffmpeg | animated, palette-optimised |
83
- | `.svg` | nothing | animated vector — crisp at any size, ~20 KB, renders on GitHub |
84
- | `.html` | nothing | self-contained player with play / pause / loop |
85
- | `.png` `.jpg` | — | the final frame (`t.screenshot()` for any moment) |
86
- | `frames/` | — | one PNG per frame |
87
-
88
- ## Examples
89
-
90
- | | |
91
- |---|---|
92
- | **Driving an interactive TUI** — answers `bun create better-t-stack` with arrow keys, picks options by reading the screen. [`better-t-stack.ts`](packages/tcut/examples/better-t-stack.ts) | ![better-t-stack](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/examples/better-t-stack.gif) |
93
- | **Recording AI agents** — `claude -p` explains a file, `codex exec` edits it. [`ai-agents.ts`](packages/tcut/examples/ai-agents.ts) | ![claude and codex](https://raw.githubusercontent.com/AmanVarshney01/tcut/main/packages/tcut/docs/examples/ai-agents.gif) |
94
- | **README media** — the GIF at the top of this page. [`readme.ts`](packages/tcut/examples/readme.ts) | |
95
-
96
- More in [`packages/tcut/examples/`](packages/tcut/examples).
97
-
98
- ## Use scripts as tests
49
+ Share it:
99
50
 
100
51
  ```sh
101
- tcut test examples/
52
+ tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
53
+ tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
102
54
  ```
103
55
 
104
- Runs every script in fast mode (no typing delay, no sleeps), renders nothing, and exits non-zero if any
105
- `expect()` fails — so the demo in your README is also the integration test for your CLI.
106
-
107
- ## CLI
108
-
109
- ```
110
- tcut <script.ts> record + render
111
- tcut rec [-- command…] record a live session, then render
112
- tcut record <script.ts> record only (.cast)
113
- tcut render <file.cast> render a cast (tcut's or asciinema's)
114
- tcut test <paths…> run scripts as tests
115
- tcut init [name] [--template basic|tour|test]
116
- tcut themes
117
-
118
- -o, --output <path> repeatable: .mp4 .webm .gif .webp .svg .html .png .jpg or a directory/
119
- --theme <name> catppuccin-mocha · dracula · github-dark · tokyo-night · one-dark
120
- --font <family> --font-size <px> --line-height <x> --letter-spacing <px>
121
- --fps <n> --speed <x> --padding <px> --margin <px> --margin-fill <color> --radius <px>
122
- --window-bar <none|colorful|colorfulRight|rings|ringsRight> --title <text> --no-blink
123
- --core <ghostty|lite> --cast <path> --record-only --force -q
124
- ```
125
-
126
- ## Script reference
127
-
128
- `defineVideo(config, async (t) => { … })`
129
-
130
- **Config** (all optional except `output`)
131
-
132
- | | default | |
133
- |---|---|---|
134
- | `output` | — | string or array; extension picks the format |
135
- | `shell` | `"bash"` | `bash` · `zsh` · `fish` · `sh` · or a `string[]` command |
136
- | `prompt` | `"> "` | prompt of the clean shell; `run()` waits for it |
137
- | `cols` · `rows` · `fps` | 80 · 24 · 60 | |
138
- | `typingSpeed` · `typingJitter` · `seed` | `"50ms"` · 0 · 1 | jitter is seeded, so it's reproducible |
139
- | `theme` | `"catppuccin-mocha"` | a name or a full theme object |
140
- | `font` | JetBrains Mono 20 px | `{ family, size, lineHeight, letterSpacing }` |
141
- | `windowBar` · `title` · `padding` · `margin` · `marginFill` · `borderRadius` | `"none"` · `""` · 24 · 0 · bg · 0 | window chrome |
142
- | `cursor` | `{ blink: true, period: 1000 }` | |
143
- | `playbackSpeed` · `waitTimeout` · `endPause` | 1 · `"15s"` · `"1s"` | |
144
- | `cache` · `quantize` · `core` | true · false · `"ghostty"` | skip re-recording when unchanged · frame-grid timestamps · emulator |
145
-
146
- **`t`**
147
-
148
- - Type: `run(cmd)` · `type(text)` · `paste(text)` · `enter()` `tab()` `backspace()` `escape()` `space()` `up()` `down()` `left()` `right()` `home()` `end()` `pageUp()` `pageDown()` (all take a count) · `ctrl("c")` · `alt("b")` · `key("f5")` · `raw(bytes)`
149
- - Wait: `sleep("500ms")` · `wait(/re/, { scope: "line" | "screen" })` — default waits for the prompt
150
- - Assert: `expect(/re/)` — throws with a screen dump
151
- - Shape the video: `hide(async () => …)` cuts a section · `screenshot("x.png")` · `marker("name")` · `resize(cols, rows)` · `clear()`
152
- - Look: `screen()` · `line()` · `cursor()` · `cols` · `rows`
153
-
154
- Durations accept `500`, `"500ms"`, `"1.5s"`, `"2m"`.
155
-
156
- ## Requirements
157
-
158
- | To… | You need |
159
- |---|---|
160
- | run tcut | Bun ≥ 1.4, or the standalone binary |
161
- | record (`rec`, scripts, `test`) | a shell — nothing else |
162
- | render `.svg` / `.html` | nothing else |
163
- | render `.png` / `frames/` | macOS: nothing (built-in WebKit) · Linux / Windows: Chrome, Chromium, Edge or Brave |
164
- | render `.mp4` / `.gif` / `.webm` | the above + ffmpeg |
165
- | render `.webp` | ffmpeg with libwebp (`brew install ffmpeg-full`; found automatically) |
166
-
167
- Verified on macOS. Linux and Windows binaries are cross-compiled and not yet exercised in CI.
168
-
169
- ## How it works, briefly
56
+ ## More
170
57
 
171
- `Bun.Terminal` runs your shell in a PTY. Output is timestamped into the cast and also fed to a headless
172
- [Ghostty](https://ghostty.org) terminal (via [wterm](https://github.com/vercel-labs/wterm)), which is how `run()` knows the prompt is back and
173
- `expect()` sees what you see. Rendering replays the cast into that terminal inside `Bun.WebView` one frame at a time and hands the
174
- frames to ffmpeg; SVG and HTML are built straight from the terminal grid. Inspired by [VHS](https://github.com/charmbracelet/vhs).
58
+ - [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) driving an interactive TUI, recording Claude Code / Codex
59
+ - [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) every CLI flag and script option
60
+ - [tcut.amanv.dev](https://tcut.amanv.dev)
175
61
 
176
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md). MIT.
62
+ MIT
package/bin/tcut.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ // npm only accepts .js/.mjs bins; Bun imports the TypeScript CLI directly from here.
3
+ import "../src/cli.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
@@ -33,9 +33,10 @@
33
33
  ".": "./src/index.ts"
34
34
  },
35
35
  "bin": {
36
- "tcut": "./src/cli.ts"
36
+ "tcut": "bin/tcut.mjs"
37
37
  },
38
38
  "files": [
39
+ "bin",
39
40
  "src",
40
41
  "scripts",
41
42
  "README.md",
@@ -0,0 +1,86 @@
1
+ // Generates src/themes.generated.json from the Ghostty-format themes in mbadolato/iTerm2-Color-Schemes (MIT) —
2
+ // the same collection Ghostty bundles. Run: `bun scripts/build-themes.ts` (network). Output is committed.
3
+ import path from "node:path";
4
+ import { mkdtemp, readdir, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+
7
+ const SOURCE = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/refs/heads/master.tar.gz";
8
+ const out = path.resolve(import.meta.dir, "..", "src", "themes.generated.json");
9
+
10
+ const KEYS = [
11
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
12
+ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
13
+ ] as const;
14
+
15
+ const slug = (name: string) =>
16
+ name
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+
21
+ const hex = (v: string) => {
22
+ const h = v.trim().replace(/^#/, "");
23
+ return /^[0-9a-f]{6}$/i.test(h) ? `#${h.toLowerCase()}` : null;
24
+ };
25
+
26
+ function parse(name: string, text: string): Record<string, string> | null {
27
+ const palette: Record<number, string> = {};
28
+ const props: Record<string, string> = {};
29
+ for (const raw of text.split("\n")) {
30
+ const line = raw.trim();
31
+ if (!line || line.startsWith("#")) continue;
32
+ const eq = line.indexOf("=");
33
+ if (eq < 0) continue;
34
+ const key = line.slice(0, eq).trim();
35
+ const value = line.slice(eq + 1).trim();
36
+ if (key === "palette") {
37
+ const m = /^(\d+)\s*=\s*(.+)$/.exec(value);
38
+ if (m) {
39
+ const c = hex(m[2]!);
40
+ if (c) palette[Number(m[1])] = c;
41
+ }
42
+ } else {
43
+ props[key] = value;
44
+ }
45
+ }
46
+ const background = hex(props.background ?? "");
47
+ const foreground = hex(props.foreground ?? "");
48
+ if (!background || !foreground) return null;
49
+ for (let i = 0; i < 16; i++) if (!palette[i]) return null;
50
+ const theme: Record<string, string> = { name, background, foreground };
51
+ const cursor = hex(props["cursor-color"] ?? "");
52
+ if (cursor) theme.cursor = cursor;
53
+ const cursorText = hex(props["cursor-text"] ?? "");
54
+ if (cursorText) theme.cursorAccent = cursorText;
55
+ const selection = hex(props["selection-background"] ?? "");
56
+ if (selection) theme.selectionBackground = selection;
57
+ KEYS.forEach((k, i) => (theme[k] = palette[i]!));
58
+ return theme;
59
+ }
60
+
61
+ const tmp = await mkdtemp(path.join(tmpdir(), "tcut-themes-"));
62
+ const tgz = path.join(tmp, "schemes.tgz");
63
+ const res = await fetch(SOURCE);
64
+ if (!res.ok) throw new Error(`download failed: ${res.status}`);
65
+ await Bun.write(tgz, await res.arrayBuffer());
66
+ const untar = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "pipe" });
67
+ if ((await untar.exited) !== 0) throw new Error(await new Response(untar.stderr).text());
68
+ const root = (await readdir(tmp)).find((d) => d.startsWith("iTerm2-Color-Schemes"));
69
+ if (!root) throw new Error("unexpected archive layout");
70
+ const dir = path.join(tmp, root, "ghostty");
71
+
72
+ const themes: Record<string, Record<string, string>> = {};
73
+ let skipped = 0;
74
+ for (const file of (await readdir(dir)).sort()) {
75
+ const text = await Bun.file(path.join(dir, file)).text();
76
+ const theme = parse(file, text);
77
+ if (!theme) {
78
+ skipped++;
79
+ continue;
80
+ }
81
+ themes[slug(file)] = theme;
82
+ }
83
+ await rm(tmp, { recursive: true, force: true });
84
+
85
+ await Bun.write(out, JSON.stringify(themes) + "\n");
86
+ console.log(`wrote ${path.relative(process.cwd(), out)}: ${Object.keys(themes).length} themes (${skipped} skipped), ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
package/src/cli.ts CHANGED
@@ -6,9 +6,11 @@ import { writeCast } from "./cast";
6
6
  import { resolveConfig } from "./config";
7
7
  import * as api from "./index";
8
8
  import { recordLive } from "./live";
9
+ import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig } from "./publish";
9
10
  import { renderOutputs } from "./render";
11
+ import { generateScript } from "./scriptgen";
10
12
  import { runScriptTests } from "./testing";
11
- import { themeNames } from "./themes";
13
+ import { findThemes, themeNames } from "./themes";
12
14
  import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
13
15
  import { Video, isVideo, renderCast } from "./video";
14
16
 
@@ -32,8 +34,10 @@ Usage:
32
34
  tcut record <script.ts> [options] record only (writes the .cast)
33
35
  tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
34
36
  tcut test <path...> run scripts in fast mode as tests (no video)
37
+ tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
38
+ tcut publish --setup configure the bucket (RustFS, MinIO, R2, S3 …) — once
35
39
  tcut init [name] [--template t] scaffold a new script (basic | tour | test)
36
- tcut themes list built-in themes
40
+ tcut themes [query] list the ~600 bundled themes (Ghostty collection)
37
41
 
38
42
  Options (override the script's config):
39
43
  -o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or dir/ for PNG frames — repeatable
@@ -44,9 +48,14 @@ Options (override the script's config):
44
48
  --window-bar <type> none | colorful | colorfulRight | rings | ringsRight
45
49
  --title <text> --no-blink
46
50
  --core <name> ghostty | lite
51
+ --cols <n> --rows <n> terminal size (rec: defaults to your terminal's size)
47
52
  --cast <path> where to read/write the .cast
48
53
  --record-only stop after writing the cast
54
+ --no-script rec: don't write the editable <name>.video.ts next to the cast
49
55
  --force ignore the cast cache and re-record
56
+ --open publish: open the first link in the browser
57
+ --name <file> publish: object name (default: the file's basename)
58
+ --endpoint --bucket --access-key --secret-key --public-url --region publish --setup values
50
59
  --template <name> for init: basic | tour | test
51
60
  -q, --quiet
52
61
  -h, --help
@@ -72,9 +81,21 @@ const { values, positionals } = parseArgs({
72
81
  title: { type: "string" },
73
82
  "no-blink": { type: "boolean" },
74
83
  core: { type: "string" },
84
+ cols: { type: "string" },
85
+ rows: { type: "string" },
75
86
  cast: { type: "string" },
76
87
  "record-only": { type: "boolean" },
88
+ "no-script": { type: "boolean" },
77
89
  force: { type: "boolean" },
90
+ setup: { type: "boolean" },
91
+ open: { type: "boolean" },
92
+ name: { type: "string" },
93
+ endpoint: { type: "string" },
94
+ bucket: { type: "string" },
95
+ "access-key": { type: "string" },
96
+ "secret-key": { type: "string" },
97
+ "public-url": { type: "string" },
98
+ region: { type: "string" },
78
99
  template: { type: "string" },
79
100
  quiet: { type: "boolean", short: "q" },
80
101
  help: { type: "boolean", short: "h" },
@@ -82,12 +103,19 @@ const { values, positionals } = parseArgs({
82
103
  });
83
104
 
84
105
  const quiet = values.quiet === true;
106
+ const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
107
+ const paint = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
108
+ const green = paint("32");
109
+ const dim = paint("2");
110
+ const red = paint("31");
111
+
112
+ // Status goes to stdout (plain informational output); only real errors go to stderr.
85
113
  const log = (msg: string) => {
86
- if (!quiet) console.error(msg);
114
+ if (!quiet) process.stdout.write(`${msg}\n`);
87
115
  };
88
116
 
89
117
  function fail(message: string): never {
90
- console.error(`error: ${message}`);
118
+ console.error(`${red("error:")} ${message}`);
91
119
  process.exit(1);
92
120
  }
93
121
 
@@ -123,6 +151,8 @@ function overridesFromFlags(): Partial<VideoConfig> {
123
151
  o.core = values.core as CoreName;
124
152
  }
125
153
  if (values.cast) o.cast = values.cast;
154
+ if (values.cols !== undefined) o.cols = num("cols");
155
+ if (values.rows !== undefined) o.rows = num("rows");
126
156
  return o;
127
157
  }
128
158
 
@@ -152,14 +182,14 @@ async function loadVideo(file: string): Promise<Video> {
152
182
  }
153
183
 
154
184
  function progressReporter(): (p: { frame: number; total: number }) => void {
155
- if (quiet || !process.stderr.isTTY) return () => {};
185
+ if (quiet || !process.stdout.isTTY) return () => {};
156
186
  let last = -1;
157
187
  return ({ frame, total }) => {
158
188
  const pct = Math.floor((frame / total) * 100);
159
189
  if (pct === last && frame !== total) return;
160
190
  last = pct;
161
- process.stderr.write(`\r rendering ${frame}/${total} frames (${pct}%)`);
162
- if (frame === total) process.stderr.write("\n");
191
+ process.stdout.write(`\r${dim(` rendering ${frame}/${total} frames (${pct}%)`)}`);
192
+ if (frame === total) process.stdout.write("\n");
163
193
  };
164
194
  }
165
195
 
@@ -171,9 +201,11 @@ async function fileSize(file: string): Promise<string> {
171
201
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
172
202
  }
173
203
 
204
+ const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
205
+
174
206
  async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
175
- for (const out of outputs) log(`✔ wrote ${out} ${await fileSize(out)}`);
176
- for (const shot of screenshots) log(`✔ screenshot ${shot}`);
207
+ for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
208
+ for (const shot of screenshots) ok(`screenshot ${shot}`);
177
209
  }
178
210
 
179
211
  const TEMPLATES: Record<string, (name: string) => string> = {
@@ -267,7 +299,47 @@ async function main(): Promise<void> {
267
299
 
268
300
  switch (first) {
269
301
  case "themes": {
270
- for (const name of themeNames) console.log(name);
302
+ const names = rest[0] ? findThemes(rest[0]) : themeNames;
303
+ if (names.length === 0) fail(`No theme matches "${rest[0]}"`);
304
+ for (const name of names) console.log(name);
305
+ if (!rest[0]) log(dim(`${names.length} themes · use any name with --theme, e.g. --theme "Gruvbox Dark"`));
306
+ return;
307
+ }
308
+ case "publish": {
309
+ if (values.setup) {
310
+ const ask = async (label: string, flag: string | undefined, fallback: string, secret = false): Promise<string> => {
311
+ if (flag) return flag;
312
+ if (!process.stdin.isTTY) return fallback;
313
+ const answer = prompt(`${label}${fallback ? ` [${fallback}]` : ""}:`) ?? "";
314
+ return answer.trim() || fallback;
315
+ };
316
+ const existing = await loadPublishConfig().catch(() => null);
317
+ const cfg: PublishConfig = {
318
+ endpoint: await ask("S3 endpoint", values.endpoint, existing?.endpoint ?? "https://s3.amanv.cloud"),
319
+ bucket: await ask("Bucket", values.bucket, existing?.bucket ?? "tcut"),
320
+ accessKeyId: await ask("Access key", values["access-key"], existing?.accessKeyId ?? ""),
321
+ secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? "", true),
322
+ region: values.region ?? existing?.region ?? "us-east-1",
323
+ ...(values["public-url"] || existing?.publicUrl ? { publicUrl: values["public-url"] ?? existing?.publicUrl } : {}),
324
+ };
325
+ if (!cfg.accessKeyId || !cfg.secretAccessKey) fail("publish --setup needs --access-key and --secret-key (or run it in a terminal to be prompted)");
326
+ const result = await ensurePublicBucket(cfg, log);
327
+ const file = await savePublishConfig(cfg);
328
+ ok(`saved ${file}`, "mode 600");
329
+ ok(`bucket ${cfg.bucket} on ${cfg.endpoint}`, result.bucketCreated ? "created" : "exists");
330
+ if (result.publicReadOk) ok("public read verified", `links will look like ${publicUrlFor(cfg, "x").replace(/\/x$/, "/<hash>/demo.gif")}`);
331
+ else log(`${red("✘")} anonymous read failed — set a public-read policy on the bucket or pass --public-url for a CDN/proxy in front of it`);
332
+ return;
333
+ }
334
+ if (rest.length === 0) fail("publish needs at least one file (or --setup)");
335
+ const cfg = await loadPublishConfig();
336
+ if (!cfg) fail("publish is not configured yet — run `tcut publish --setup` (or set TCUT_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)");
337
+ const published = await publishFiles(rest, cfg, { name: values.name, log });
338
+ for (const p of published) ok(p.url, dim(path.basename(p.file)));
339
+ if (values.open && published[0]) {
340
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
341
+ Bun.spawn([opener, published[published.length - 1]!.url], { stdout: "ignore", stderr: "ignore" });
342
+ }
271
343
  return;
272
344
  }
273
345
  case "init": {
@@ -285,31 +357,39 @@ async function main(): Promise<void> {
285
357
  case "rec": {
286
358
  // Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
287
359
  const overrides = overridesFromFlags();
288
- const outputs = overrides.output ?? ["rec.mp4"];
360
+ const rawOutputs = overrides.output ?? ["rec.mp4"];
361
+ const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
289
362
  const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
290
363
  const command = rest.length > 0 ? rest : undefined;
291
- const recording = await recordLive(config, { command, log });
364
+ // Size: --cols/--rows if given, else the terminal tcut runs in.
365
+ const recording = await recordLive(config, { command, log, cols: overrides.cols, rows: overrides.rows });
292
366
  await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
293
367
  await writeCast(config.cast, recording);
294
- log(`\n✔ wrote ${config.cast} (${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s)`);
368
+ log("");
369
+ ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
370
+ if (!values["no-script"]) {
371
+ const scriptPath = config.cast.replace(/\.cast$/, "") + ".video.ts";
372
+ await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
373
+ ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
374
+ }
295
375
  if (values["record-only"]) return;
296
376
  const result = await renderOutputs(recording, config, progressReporter());
297
377
  await reportOutputs(result.outputs, result.screenshots);
298
- log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
378
+ log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
299
379
  return;
300
380
  }
301
381
  case "record": {
302
382
  if (!rest[0]) fail("record needs a script file");
303
383
  const video = await loadVideo(rest[0]);
304
384
  const rec = await video.record({ log, force: values.force });
305
- log(`✔ ${rec.cached ? "reused" : "wrote"} ${video.config.cast} (${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s) in ${elapsed()}`);
385
+ ok(`${rec.cached ? "reused" : "wrote"} ${video.config.cast}`, `${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s, ${elapsed()}`);
306
386
  return;
307
387
  }
308
388
  case "render": {
309
389
  if (!rest[0]) fail("render needs a .cast file");
310
390
  const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
311
391
  await reportOutputs(result.outputs, result.screenshots);
312
- log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
392
+ log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
313
393
  return;
314
394
  }
315
395
  case "test": {
@@ -321,9 +401,9 @@ async function main(): Promise<void> {
321
401
  default: {
322
402
  const video = await loadVideo(first!);
323
403
  const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
324
- log(`✔ ${result.cached ? "reused" : "wrote"} ${result.cast}`);
404
+ ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
325
405
  await reportOutputs(result.outputs, result.screenshots);
326
- if (!values["record-only"]) log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
406
+ if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
327
407
  }
328
408
  }
329
409
  }
package/src/index.ts CHANGED
@@ -9,7 +9,11 @@ export { replayFrames } from "./export/frames";
9
9
  export type { GridFrame, GridCell, GridReplay } from "./export/frames";
10
10
  export { runScriptTests, discoverScripts } from "./testing";
11
11
  export type { TestResult, TestSummary } from "./testing";
12
- export { themes, themeNames, resolveTheme } from "./themes";
12
+ export { themes, themeNames, resolveTheme, findThemes, themeSlug, builtinThemes } from "./themes";
13
+ export { generateScript, eventsToOps, tokenize } from "./scriptgen";
14
+ export type { ScriptGenOptions } from "./scriptgen";
15
+ export { publishFiles, loadPublishConfig, savePublishConfig, ensurePublicBucket, publicUrlFor, keyFor } from "./publish";
16
+ export type { PublishConfig, Published, PublishOptions } from "./publish";
13
17
  export { readCast, writeCast, parseCast, serializeCast } from "./cast";
14
18
  export { buildTimeline } from "./timeline";
15
19
  export { resolveConfig } from "./config";
package/src/live.ts CHANGED
@@ -92,7 +92,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
92
92
  stdin.on("data", onData);
93
93
  }
94
94
  process.on("SIGWINCH", onResize);
95
- log(`recording ${setup.cmd.join(" ")} at ${cols}x${rows} — exit the shell to stop`);
95
+ log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
96
96
 
97
97
  try {
98
98
  await Promise.race([exitedPromise, proc.exited]);