termcut 1.0.1 → 1.0.2
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 +95 -38
- package/package.json +1 -1
- package/src/cli.ts +21 -1
- package/src/live.ts +8 -2
- package/src/scriptgen.ts +21 -2
- package/src/usershell.ts +69 -0
package/README.md
CHANGED
|
@@ -1,38 +1,45 @@
|
|
|
1
1
|
# tcut
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Terminal videos, written in TypeScript. Record a session live or script it, then render it to MP4, GIF, WebM, SVG or HTML — the same recording gives the same pixels every time.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
[tcut.amanv.dev](https://tcut.amanv.dev) · [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) · [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) · [llms.txt](https://tcut.amanv.dev/llms.txt)
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```sh
|
|
10
|
-
bun add -g termcut
|
|
12
|
+
bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
Or a standalone binary for macOS, Linux or Windows from [Releases](https://github.com/AmanVarshney01/tcut/releases) — all three are tested in CI.
|
|
16
|
+
|
|
17
|
+
- MP4, GIF, WebM, WebP need `ffmpeg` on the PATH. SVG, HTML and text outputs need nothing.
|
|
18
|
+
- Linux and Windows render pixels through Chrome or Chromium (`BUN_CHROME_PATH` to point at one).
|
|
14
19
|
|
|
15
|
-
##
|
|
20
|
+
## Record
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
**Live.** Your shell opens — your prompt, config and aliases; type; `exit`. You get the video, the exact recording (`demo.cast`) and an editable script of what you typed (`demo.video.ts`). `--clean` opens a plain shell with a `>` prompt instead.
|
|
18
23
|
|
|
19
24
|
```sh
|
|
20
|
-
tcut rec -o demo.gif
|
|
21
|
-
tcut rec -o demo.mp4 -- npm create vite
|
|
25
|
+
tcut rec -o demo.gif
|
|
26
|
+
tcut rec -o demo.mp4 -- npm create vite # one command, ends when it exits
|
|
22
27
|
```
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
`-- command` runs through that same shell, so `tcut rec -- ls` records what *your* `ls` shows — aliases, functions and colours included. `--raw` runs the binary directly.
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
**Scripted.** Plain TypeScript, so loops, helpers and imports work, and the script lives in the repo next to the code it shows.
|
|
27
32
|
|
|
28
33
|
```ts
|
|
29
34
|
// demo.video.ts
|
|
30
35
|
import { defineVideo } from "tcut";
|
|
31
36
|
|
|
32
|
-
export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
33
|
-
await t.run("bun --version");
|
|
34
|
-
await t.
|
|
35
|
-
await t.
|
|
37
|
+
export default defineVideo({ output: ["demo.mp4", "demo.gif"] }, async (t) => {
|
|
38
|
+
await t.run("bun --version"); // types it, presses Enter, waits for the prompt
|
|
39
|
+
await t.run("ls -la");
|
|
40
|
+
await t.expect(/package\.json/); // asserts on the screen — the demo is a test
|
|
41
|
+
await t.snapshot("files.png"); // a still of this exact moment
|
|
42
|
+
await t.sleep("1.5s");
|
|
36
43
|
});
|
|
37
44
|
```
|
|
38
45
|
|
|
@@ -40,57 +47,107 @@ export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
|
40
47
|
tcut demo.video.ts
|
|
41
48
|
```
|
|
42
49
|
|
|
43
|
-
|
|
50
|
+
What a script can do, one line each:
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `run(cmd)` | waits for your prompt to come back, not for a timer |
|
|
55
|
+
| `wait(/re/)` · `expect(/re/)` | observe or assert the screen — including lines that already scrolled away (`{ scope: "scrollback" }`) |
|
|
56
|
+
| `type` · `enter` · arrows · `ctrl("c")` · `key("f5")` | keys, sent the way the running program asked for them |
|
|
57
|
+
| `hide(fn)` | runs setup off-camera; the state stays |
|
|
58
|
+
| `snapshot("x.png" \| "x.svg")` | a pixel or vector still of that exact moment, written on every render |
|
|
59
|
+
| `chapter(name)` | mp4 chapters, and cut points for `--chapters` / `--split-chapters` |
|
|
60
|
+
| `print(markdown)` · `title(text)` | captions rendered into the terminal, nothing typed |
|
|
61
|
+
| `zoom({ rows, cols })` | magnifies a region; `keys: true` shows what was pressed |
|
|
62
|
+
| `timelapse(fn, { speed })` | fast-forwards an install or a build, not just the silence |
|
|
63
|
+
| `browser` | a real browser window beside or over the terminal (below) |
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
The full surface is in the [reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md).
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
## Render again
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
Recording and rendering are separate. A recording is an asciicast; frames are computed on a virtual clock. So a new theme, size or format never re-runs a shell — and cuts, joins and chapter splits happen on the recording, which is why they work for SVG as well as MP4.
|
|
61
70
|
|
|
62
71
|
```sh
|
|
63
|
-
tcut render demo.cast --
|
|
64
|
-
tcut render demo.cast --
|
|
72
|
+
tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg -o demo.html
|
|
73
|
+
tcut render demo.cast --width 1280 --height 720 --speed 1.5 -o demo.mp4
|
|
74
|
+
tcut render demo.cast --from 2s --to 10s --shadow --watermark "@you" -o clip.gif
|
|
75
|
+
tcut render demo.cast --split-chapters --margin-fill transparent -o demo.webm
|
|
65
76
|
tcut concat intro.cast demo.cast --gap 500ms -o launch.mp4
|
|
77
|
+
tcut themes # ~600 themes, Ghostty's collection
|
|
66
78
|
```
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
Outputs by extension: `.mp4` `.gif` `.webm` `.webp` · `.svg` (animated, real text) · `.html` (single-file player) · `.png` `.jpg` (final frame) · `.txt` (final screen) · `.log` (full transcript) · `dir/` (PNG frames).
|
|
81
|
+
|
|
82
|
+
## Faithful to the terminal
|
|
83
|
+
|
|
84
|
+
The emulator is Ghostty's core, so what tcut sees is what your terminal would show — and what it records is what the program actually received.
|
|
85
|
+
|
|
86
|
+
- Arrow keys and pastes arrive exactly as the running program asked: application cursor mode, bracketed paste.
|
|
87
|
+
- Links printed with OSC 8 — including Markdown links in `print()` captions — stay clickable in SVG and HTML.
|
|
88
|
+
- Frames are never torn: synchronized-output blocks are captured whole.
|
|
89
|
+
- Symbols the font lacks (progress blocks, Nerd Font icons) stay on their cell, so status bars never drift.
|
|
90
|
+
- `tcut doctor demo.cast` explains what a recording used, and what cannot be rendered (inline images).
|
|
91
|
+
|
|
92
|
+
## Test it
|
|
69
93
|
|
|
70
94
|
```sh
|
|
71
|
-
tcut
|
|
95
|
+
tcut test demo.video.ts # runs the script with no delays — just the assertions
|
|
96
|
+
tcut diff a.cast b.cast # catches output changes between two recordings
|
|
72
97
|
```
|
|
73
98
|
|
|
74
|
-
|
|
99
|
+
`expect()` makes a demo a test. `tcut test` runs it fast, and exits non-zero when the screen does not match — so the same script that renders your README video can guard it in CI.
|
|
100
|
+
|
|
101
|
+
## A browser next to the terminal
|
|
102
|
+
|
|
103
|
+
For dev-server demos: the page is recorded on the same clock and composited beside or over the terminal.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t) => {
|
|
107
|
+
await t.run("bun run dev </dev/null >/tmp/dev.log 2>&1 &");
|
|
108
|
+
await t.browser.goto("http://localhost:5173");
|
|
109
|
+
await t.run("sed -i '' 's/Hello/Hi/' src/App.tsx"); // HMR updates the page
|
|
110
|
+
await t.focus("browser");
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Share it
|
|
75
115
|
|
|
76
116
|
```sh
|
|
77
|
-
tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
|
|
117
|
+
tcut publish --setup # once: your own S3-compatible bucket (RustFS, MinIO, R2, S3)
|
|
78
118
|
tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
|
|
79
119
|
```
|
|
80
120
|
|
|
81
|
-
|
|
121
|
+
There is no hosted service; you bring the bucket.
|
|
122
|
+
|
|
123
|
+
## For agents
|
|
82
124
|
|
|
83
125
|
```sh
|
|
84
|
-
npx skills add AmanVarshney01/tcut #
|
|
126
|
+
npx skills add AmanVarshney01/tcut # skills for Claude Code, Cursor, Codex, …
|
|
85
127
|
```
|
|
86
128
|
|
|
87
|
-
Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into launch
|
|
129
|
+
Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into a launch video with [Remotion](https://remotion.dev)). Every command has `--json`, exit codes and no prompts; [llms.txt](https://tcut.amanv.dev/llms.txt) is the condensed guide.
|
|
130
|
+
|
|
131
|
+
## Compared with VHS
|
|
132
|
+
|
|
133
|
+
[VHS](https://github.com/charmbracelet/vhs) is the reference point and the inspiration. Where tcut differs:
|
|
134
|
+
|
|
135
|
+
- **Scripts are TypeScript** — loops, imports, shared scenes, autocomplete — instead of a `.tape` DSL.
|
|
136
|
+
- **Waits on the screen** — `run()` returns when your prompt is back; VHS sleeps for a guessed duration.
|
|
137
|
+
- **Rendering never re-runs the shell** — a new theme, size or format is computed from the recording. VHS screenshots Chrome live, so output depends on machine speed.
|
|
138
|
+
- **Demos are tests** — `expect()` asserts on the screen; `tcut test` runs them in CI.
|
|
139
|
+
- **Same emulator as your terminal** — Ghostty's core in WASM, its themes, plus SVG and HTML outputs that need no ffmpeg or browser.
|
|
140
|
+
|
|
141
|
+
## How it works
|
|
142
|
+
|
|
143
|
+
1. **Record.** `Bun.Terminal` runs your shell in a PTY. Every byte is timestamped into a `.cast`.
|
|
144
|
+
2. **Watch.** The same bytes feed a headless [Ghostty](https://ghostty.org) (via [wterm](https://github.com/vercel-labs/wterm)). That is how `run()` knows the prompt is back and `expect()` sees what you see.
|
|
145
|
+
3. **Render.** The cast replays into the same terminal inside `Bun.WebView`, one frame per tick, straight to ffmpeg. SVG and HTML are built from the terminal grid, no browser involved.
|
|
88
146
|
|
|
89
147
|
## More
|
|
90
148
|
|
|
91
|
-
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code
|
|
149
|
+
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code and Codex
|
|
92
150
|
- [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
|
|
93
|
-
- [llms.txt](https://tcut.amanv.dev/llms.txt) — the same, condensed for coding agents (`--json` gives machine-readable results)
|
|
94
151
|
- [tcut.amanv.dev](https://tcut.amanv.dev)
|
|
95
152
|
|
|
96
153
|
MIT
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readCast, writeCast } from "./cast";
|
|
|
6
6
|
import { applyOverrides, resolveConfig } from "./config";
|
|
7
7
|
import * as api from "./index";
|
|
8
8
|
import { recordLive } from "./live";
|
|
9
|
+
import { throughShell, userShell } from "./usershell";
|
|
9
10
|
import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
|
|
10
11
|
import { diffCasts, type DiffResult } from "./diff";
|
|
11
12
|
import { diagnoseCast, formatDoctorReport, type DoctorReport } from "./doctor";
|
|
@@ -70,6 +71,8 @@ Options (override the script's config):
|
|
|
70
71
|
--gap <dur> concat: still time between parts
|
|
71
72
|
--preset <name> readme | x | youtube | square
|
|
72
73
|
--browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
|
|
74
|
+
--raw rec: run the command directly instead of through your shell (no aliases/functions)
|
|
75
|
+
--clean rec: open tcut's clean shell (plain > prompt, no personal config) instead of yours
|
|
73
76
|
--at <seconds> diff: compare the screen at this time instead of the end
|
|
74
77
|
--images <dir> diff: also write a.png / b.png
|
|
75
78
|
--cast <path> where to read/write the .cast
|
|
@@ -130,6 +133,8 @@ const { values, positionals } = parseArgs({
|
|
|
130
133
|
cast: { type: "string" },
|
|
131
134
|
"record-only": { type: "boolean" },
|
|
132
135
|
"no-script": { type: "boolean" },
|
|
136
|
+
raw: { type: "boolean" },
|
|
137
|
+
clean: { type: "boolean" },
|
|
133
138
|
force: { type: "boolean" },
|
|
134
139
|
setup: { type: "boolean" },
|
|
135
140
|
open: { type: "boolean" },
|
|
@@ -471,11 +476,26 @@ async function main(): Promise<void> {
|
|
|
471
476
|
const rawOutputs = overrides.output ?? ["rec.mp4"];
|
|
472
477
|
const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
|
|
473
478
|
const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
|
|
474
|
-
|
|
479
|
+
// The shell tcut was typed into: `-- cmd` runs through it (aliases, functions, colours) unless --raw;
|
|
480
|
+
// with no command it IS the session — your prompt and config — unless --clean or an explicit --shell.
|
|
481
|
+
const clean = Boolean(values.clean) || overrides.shell !== undefined;
|
|
482
|
+
const shell = (rest.length > 0 ? !values.raw : !clean) ? userShell() : null;
|
|
483
|
+
let command: string[] | undefined;
|
|
484
|
+
let interactive = false;
|
|
485
|
+
if (rest.length > 0) {
|
|
486
|
+
command = shell ? throughShell(rest, shell) : rest;
|
|
487
|
+
if (shell) log(dim(` via ${shell.name} — your aliases and functions apply (--raw to run the binary directly)`));
|
|
488
|
+
} else if (shell) {
|
|
489
|
+
command = [shell.path, "-il"];
|
|
490
|
+
interactive = true;
|
|
491
|
+
log(dim(` your ${shell.name}, with its config (--clean for a plain shell with a > prompt)`));
|
|
492
|
+
}
|
|
475
493
|
// Size: --cols/--rows if given, else derived from --width/--height, else the terminal tcut runs in.
|
|
476
494
|
const sized = overrides.width !== undefined || overrides.height !== undefined;
|
|
477
495
|
const recording = await recordLive(config, {
|
|
478
496
|
command,
|
|
497
|
+
interactive,
|
|
498
|
+
describe: rest.length > 0 ? rest.join(" ") : shell ? `your ${shell.name}` : undefined,
|
|
479
499
|
log,
|
|
480
500
|
cols: overrides.cols ?? (sized ? config.cols : undefined),
|
|
481
501
|
rows: overrides.rows ?? (sized ? config.rows : undefined),
|
package/src/live.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface LiveOptions {
|
|
|
24
24
|
/** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
|
|
25
25
|
stdin?: LiveStdin | null;
|
|
26
26
|
log?: (message: string) => void;
|
|
27
|
+
/** How to name the command in status lines (defaults to the argv). */
|
|
28
|
+
describe?: string;
|
|
29
|
+
/** The command is a shell the user types into (ends on `exit`), not a program that runs and exits. */
|
|
30
|
+
interactive?: boolean;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/**
|
|
@@ -104,11 +108,13 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
104
108
|
stdin.on("data", onData);
|
|
105
109
|
}
|
|
106
110
|
process.on("SIGWINCH", onResize);
|
|
107
|
-
log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
|
|
111
|
+
log(`recording ${opts.describe ?? setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command && !opts.interactive ? "ends when the command exits" : "type exit to stop"}`);
|
|
108
112
|
|
|
109
113
|
try {
|
|
110
114
|
await Promise.race([exitedPromise, proc.exited]);
|
|
111
|
-
|
|
115
|
+
// Hold the final screen for `endPause`, like a scripted recording does — otherwise a command that exits in
|
|
116
|
+
// 20 ms (`tcut rec -- ls`) becomes a three-frame video. The timeline is virtual: stamp it, don't sleep.
|
|
117
|
+
events.push([Number((stamp() + config.endPause / 1000).toFixed(6)), "m", MARKER.end]);
|
|
112
118
|
} finally {
|
|
113
119
|
await browser?.stop();
|
|
114
120
|
const emitter: NodeJS.EventEmitter = process; // @types/bun's process.off() lacks the signal overload; the generic emitter has it
|
package/src/scriptgen.ts
CHANGED
|
@@ -128,6 +128,25 @@ function formatMs(ms: number): string {
|
|
|
128
128
|
|
|
129
129
|
const q = (s: string) => JSON.stringify(s);
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Answers the terminal sent to the program's queries (device attributes, kitty keyboard, colours via OSC,
|
|
133
|
+
* XTGETTCAP via DCS, cursor position). They arrive on stdin and get recorded as input, but they are not
|
|
134
|
+
* keystrokes: when a script replays, tcut answers the queries itself.
|
|
135
|
+
*/
|
|
136
|
+
const ESC = String.fromCharCode(0x1b);
|
|
137
|
+
const BEL = String.fromCharCode(0x07);
|
|
138
|
+
const TERMINAL_REPLIES = new RegExp(
|
|
139
|
+
[
|
|
140
|
+
`${ESC}\\[\\?[\\d;]*[cu]`, // primary DA, kitty keyboard flags
|
|
141
|
+
`${ESC}\\[>[\\d;]*c`, // secondary DA
|
|
142
|
+
`${ESC}\\][\\d;]*[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, // OSC (colour queries)
|
|
143
|
+
`${ESC}P[^${ESC}]*${ESC}\\\\`, // DCS (XTGETTCAP)
|
|
144
|
+
`${ESC}\\[\\d+;\\d+R`, // cursor position report
|
|
145
|
+
].join("|"),
|
|
146
|
+
"g",
|
|
147
|
+
);
|
|
148
|
+
export const stripTerminalReplies = (input: string): string => input.replace(TERMINAL_REPLIES, "");
|
|
149
|
+
|
|
131
150
|
/** Turn the `i` (input) events of a recording into a list of script operations. */
|
|
132
151
|
export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
|
|
133
152
|
const threshold = opts.pauseThresholdMs ?? 400;
|
|
@@ -163,7 +182,7 @@ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
|
|
|
163
182
|
}
|
|
164
183
|
lastTime = time;
|
|
165
184
|
|
|
166
|
-
for (const token of tokenize(data)) {
|
|
185
|
+
for (const token of tokenize(stripTerminalReplies(data))) {
|
|
167
186
|
if (token.length > 1 && token[0]! >= " ") {
|
|
168
187
|
pendingText += token;
|
|
169
188
|
continue;
|
|
@@ -251,7 +270,7 @@ export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
|
|
|
251
270
|
const body = ops.length ? ops.map((op) => ` ${opToLine(op)}`).join("\n") : " // (no input was recorded)";
|
|
252
271
|
const castNote = opts.castPath ? ` The exact recording is in ${path.basename(opts.castPath)}.` : "";
|
|
253
272
|
const modeNote = opts.command
|
|
254
|
-
? "It
|
|
273
|
+
? "It opens the same program and replays your keys; waits are the pauses you took, so adjust them if it is slower elsewhere."
|
|
255
274
|
: "Typed commands became run(), which waits for the prompt instead of guessing.";
|
|
256
275
|
|
|
257
276
|
return `import { defineVideo } from "tcut";
|
package/src/usershell.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
// `tcut rec -- ls` should record what the user sees when they type `ls`: their shell's alias, function or
|
|
4
|
+
// abbreviation, with their colour settings. Running the bare binary gives none of that ("Executable not
|
|
5
|
+
// found" for an alias, plain output for a coloured one). So the command goes through the shell tcut was
|
|
6
|
+
// launched from, interactively, the way the user's own terminal runs it.
|
|
7
|
+
|
|
8
|
+
export interface UserShell {
|
|
9
|
+
path: string;
|
|
10
|
+
/** bash | zsh | fish — the shells whose `-ic <command>` form loads the interactive config. */
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const INTERACTIVE_SHELLS = new Set(["bash", "zsh", "fish"]);
|
|
15
|
+
|
|
16
|
+
/** The process that launched tcut, by executable name (macOS/Linux `ps`). Null when that cannot be read. */
|
|
17
|
+
function parentCommand(): string | null {
|
|
18
|
+
try {
|
|
19
|
+
const r = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(process.ppid)]);
|
|
20
|
+
const comm = r.stdout.toString().trim().replace(/^-/, ""); // login shells report as "-fish"
|
|
21
|
+
return comm || null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The shell the user typed tcut into, falling back to $SHELL. Null on Windows or when neither is a known shell. */
|
|
28
|
+
export function userShell(): UserShell | null {
|
|
29
|
+
if (process.platform === "win32") return null;
|
|
30
|
+
const candidates = [parentCommand(), process.env.SHELL].filter((c): c is string => Boolean(c));
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
const name = path.basename(candidate);
|
|
33
|
+
if (!INTERACTIVE_SHELLS.has(name)) continue;
|
|
34
|
+
const resolved = candidate.includes("/") ? candidate : Bun.which(candidate);
|
|
35
|
+
if (resolved) return { path: resolved, name };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Join argv into one command line; single quotes are understood the same way by bash, zsh and fish. */
|
|
41
|
+
export function shellQuote(args: string[]): string {
|
|
42
|
+
return args.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(" ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** fish abbreviations expand only at the prompt, not in `fish -c`; expand a leading one ourselves. */
|
|
46
|
+
export function fishAbbreviation(shell: UserShell, word: string): string | null {
|
|
47
|
+
if (shell.name !== "fish") return null;
|
|
48
|
+
try {
|
|
49
|
+
const r = Bun.spawnSync([shell.path, "-c", "abbr --show"]);
|
|
50
|
+
for (const line of r.stdout.toString().split("\n")) {
|
|
51
|
+
// `abbr -a -- lzg lazygit` (options may precede `--`; the expansion may be quoted)
|
|
52
|
+
const m = /^abbr .*-- (\S+) (.+)$/.exec(line.trim());
|
|
53
|
+
if (!m || m[1] !== word) continue;
|
|
54
|
+
const expansion = m[2]!;
|
|
55
|
+
return expansion.startsWith("'") && expansion.endsWith("'") ? expansion.slice(1, -1).replace(/\\'/g, "'") : expansion;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
/* no abbreviations available */
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `args` as the user's shell would run them when typed: interactive, so aliases, functions and abbreviations apply. */
|
|
64
|
+
export function throughShell(args: string[], shell: UserShell): string[] {
|
|
65
|
+
const [first, ...rest] = args;
|
|
66
|
+
const expanded = first ? fishAbbreviation(shell, first) : null;
|
|
67
|
+
const line = expanded ? `${expanded} ${shellQuote(rest)}`.trim() : shellQuote(args);
|
|
68
|
+
return [shell.path, "-ic", line];
|
|
69
|
+
}
|