termcut 0.4.0 → 0.4.1
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 +1 -0
- package/package.json +1 -1
- package/src/cli.ts +29 -9
- package/src/recorder.ts +13 -7
- package/src/screen.ts +15 -0
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
|
|
|
57
57
|
|
|
58
58
|
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
|
|
59
59
|
- [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
|
|
60
|
+
- [llms.txt](https://tcut.amanv.dev/llms.txt) — the same, condensed for coding agents (`--json` gives machine-readable results)
|
|
60
61
|
- [tcut.amanv.dev](https://tcut.amanv.dev)
|
|
61
62
|
|
|
62
63
|
MIT
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -59,6 +59,7 @@ Options (override the script's config):
|
|
|
59
59
|
--name <file> publish: object name (default: the file's basename)
|
|
60
60
|
--endpoint --bucket --access-key --secret-key --public-url --region publish --setup values
|
|
61
61
|
--template <name> for init: basic | tour | test
|
|
62
|
+
--json machine-readable result (or { "error" }) on stdout, nothing else
|
|
62
63
|
-q, --quiet
|
|
63
64
|
-h, --help
|
|
64
65
|
`;
|
|
@@ -103,11 +104,17 @@ const { values, positionals } = parseArgs({
|
|
|
103
104
|
region: { type: "string" },
|
|
104
105
|
template: { type: "string" },
|
|
105
106
|
quiet: { type: "boolean", short: "q" },
|
|
107
|
+
json: { type: "boolean" },
|
|
106
108
|
help: { type: "boolean", short: "h" },
|
|
107
109
|
},
|
|
108
110
|
});
|
|
109
111
|
|
|
110
|
-
const
|
|
112
|
+
const json = values.json === true;
|
|
113
|
+
const quiet = values.quiet === true || json;
|
|
114
|
+
/** With --json, the only thing on stdout is one JSON document (results or { error }). */
|
|
115
|
+
const emit = (data: unknown) => {
|
|
116
|
+
if (json) process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
117
|
+
};
|
|
111
118
|
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
112
119
|
const paint = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
113
120
|
const green = paint("32");
|
|
@@ -120,7 +127,8 @@ const log = (msg: string) => {
|
|
|
120
127
|
};
|
|
121
128
|
|
|
122
129
|
function fail(message: string): never {
|
|
123
|
-
|
|
130
|
+
if (json) process.stdout.write(JSON.stringify({ error: message }) + "\n");
|
|
131
|
+
else console.error(`${red("error:")} ${message}`);
|
|
124
132
|
process.exit(1);
|
|
125
133
|
}
|
|
126
134
|
|
|
@@ -211,9 +219,10 @@ async function fileSize(file: string): Promise<string> {
|
|
|
211
219
|
|
|
212
220
|
const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
|
|
213
221
|
|
|
214
|
-
async function reportOutputs(outputs: string[], screenshots: string[]): Promise<
|
|
222
|
+
async function reportOutputs(outputs: string[], screenshots: string[]): Promise<Array<{ path: string; bytes: number }>> {
|
|
215
223
|
for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
|
|
216
224
|
for (const shot of screenshots) ok(`screenshot ${shot}`);
|
|
225
|
+
return Promise.all([...outputs, ...screenshots].map(async (p) => ({ path: p, bytes: (await Bun.file(p).exists()) ? Bun.file(p).size : 0 })));
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
const TEMPLATES: Record<string, (name: string) => string> = {
|
|
@@ -344,6 +353,7 @@ async function main(): Promise<void> {
|
|
|
344
353
|
if (!cfg) fail("publish is not configured yet — run `tcut publish --setup` (or set TCUT_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)");
|
|
345
354
|
const published = await publishFiles(rest, cfg, { name: values.name, log });
|
|
346
355
|
for (const p of published) ok(p.url, dim(path.basename(p.file)));
|
|
356
|
+
emit({ published });
|
|
347
357
|
if (values.open && published[0]) {
|
|
348
358
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
349
359
|
Bun.spawn([opener, published[published.length - 1]!.url], { stdout: "ignore", stderr: "ignore" });
|
|
@@ -386,10 +396,14 @@ async function main(): Promise<void> {
|
|
|
386
396
|
await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
|
|
387
397
|
ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
|
|
388
398
|
}
|
|
389
|
-
if (values["record-only"])
|
|
399
|
+
if (values["record-only"]) {
|
|
400
|
+
emit({ cast: config.cast, script: values["no-script"] ? null : config.cast.replace(/\.cast$/, "") + ".video.ts", events: recording.events.length, durationSeconds: recording.header.duration ?? 0 });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
390
403
|
const result = await renderOutputs(recording, config, progressReporter());
|
|
391
|
-
await reportOutputs(result.outputs, result.screenshots);
|
|
404
|
+
const files = await reportOutputs(result.outputs, result.screenshots);
|
|
392
405
|
log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
406
|
+
emit({ cast: config.cast, script: values["no-script"] ? null : config.cast.replace(/\.cast$/, "") + ".video.ts", outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
|
|
393
407
|
return;
|
|
394
408
|
}
|
|
395
409
|
case "record": {
|
|
@@ -397,18 +411,21 @@ async function main(): Promise<void> {
|
|
|
397
411
|
const video = await loadVideo(rest[0]);
|
|
398
412
|
const rec = await video.record({ log, force: values.force });
|
|
399
413
|
ok(`${rec.cached ? "reused" : "wrote"} ${video.config.cast}`, `${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s, ${elapsed()}`);
|
|
414
|
+
emit({ cast: video.config.cast, cached: rec.cached === true, events: rec.events.length, durationSeconds: rec.header.duration ?? 0 });
|
|
400
415
|
return;
|
|
401
416
|
}
|
|
402
417
|
case "render": {
|
|
403
418
|
if (!rest[0]) fail("render needs a .cast file");
|
|
404
419
|
const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
|
|
405
|
-
await reportOutputs(result.outputs, result.screenshots);
|
|
420
|
+
const files = await reportOutputs(result.outputs, result.screenshots);
|
|
406
421
|
log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
422
|
+
emit({ cast: rest[0], outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
|
|
407
423
|
return;
|
|
408
424
|
}
|
|
409
425
|
case "test": {
|
|
410
426
|
if (rest.length === 0) fail("test needs at least one script file or directory");
|
|
411
|
-
const summary = await runScriptTests(rest, (line) => console.log(line));
|
|
427
|
+
const summary = await runScriptTests(rest, json ? () => {} : (line) => console.log(line));
|
|
428
|
+
emit(summary);
|
|
412
429
|
process.exit(summary.failed > 0 ? 1 : 0);
|
|
413
430
|
}
|
|
414
431
|
// eslint-disable-next-line no-fallthrough -- process.exit above never returns
|
|
@@ -416,13 +433,16 @@ async function main(): Promise<void> {
|
|
|
416
433
|
const video = await loadVideo(first!);
|
|
417
434
|
const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
|
|
418
435
|
ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
|
|
419
|
-
await reportOutputs(result.outputs, result.screenshots);
|
|
436
|
+
const files = await reportOutputs(result.outputs, result.screenshots);
|
|
420
437
|
if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
438
|
+
emit({ cast: result.cast, cached: result.cached, outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
|
|
421
439
|
}
|
|
422
440
|
}
|
|
423
441
|
}
|
|
424
442
|
|
|
425
443
|
main().catch((err: unknown) => {
|
|
426
|
-
|
|
444
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
445
|
+
if (json) process.stdout.write(JSON.stringify({ error: message, type: err instanceof Error ? err.name : "Error" }) + "\n");
|
|
446
|
+
else console.error(`\n${red("error:")} ${message}`);
|
|
427
447
|
process.exit(1);
|
|
428
448
|
});
|
package/src/recorder.ts
CHANGED
|
@@ -230,18 +230,24 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
230
230
|
}
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
+
// The prompt is whatever sits left of the cursor; text to the right may be residue from a TUI that exited.
|
|
234
|
+
const promptVisible = (): boolean => promptPattern.test(screen.lineToCursor());
|
|
235
|
+
|
|
233
236
|
const wait = async (pattern?: RegExp | string, waitOpts: WaitOptions = {}): Promise<void> => {
|
|
234
|
-
const
|
|
237
|
+
const timeout = toMs(waitOpts.timeout, config.waitTimeout);
|
|
238
|
+
if (pattern === undefined) {
|
|
239
|
+
await waitFor(`prompt ${promptPattern}`, promptVisible, timeout);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const regex = toRegExp(pattern);
|
|
235
243
|
const scope = waitOpts.scope ?? "line";
|
|
236
|
-
await waitFor(`${regex} on ${scope}`, () => matches(regex, scope),
|
|
244
|
+
await waitFor(`${regex} on ${scope}`, () => matches(regex, scope), timeout);
|
|
237
245
|
};
|
|
238
246
|
|
|
239
247
|
const waitForPrompt = async (afterLine: number, echoLine: string, timeoutMs: number): Promise<void> => {
|
|
240
248
|
await waitFor(
|
|
241
249
|
`prompt ${promptPattern}`,
|
|
242
|
-
() =>
|
|
243
|
-
promptPattern.test(screen.line()) &&
|
|
244
|
-
(screen.absoluteCursorLine() !== afterLine || screen.line() !== echoLine),
|
|
250
|
+
() => promptVisible() && (screen.absoluteCursorLine() !== afterLine || screen.lineToCursor() !== echoLine),
|
|
245
251
|
timeoutMs,
|
|
246
252
|
);
|
|
247
253
|
};
|
|
@@ -250,7 +256,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
250
256
|
await type(command, runOpts);
|
|
251
257
|
await screen.settle();
|
|
252
258
|
const beforeLine = screen.absoluteCursorLine();
|
|
253
|
-
const echoLine = screen.
|
|
259
|
+
const echoLine = screen.lineToCursor();
|
|
254
260
|
await raw("\r");
|
|
255
261
|
const timeout = toMs(runOpts.timeout, config.waitTimeout);
|
|
256
262
|
if (runOpts.wait === false) return;
|
|
@@ -345,7 +351,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
345
351
|
log(`starting ${Array.isArray(config.shell) ? config.shell.join(" ") : config.shell}`);
|
|
346
352
|
// Named shells get a known prompt; for an arbitrary command there is nothing to wait for — start at once.
|
|
347
353
|
if (!Array.isArray(config.shell)) {
|
|
348
|
-
await waitFor(`initial prompt ${promptPattern}`,
|
|
354
|
+
await waitFor(`initial prompt ${promptPattern}`, promptVisible, config.waitTimeout);
|
|
349
355
|
}
|
|
350
356
|
startedAt = performance.now();
|
|
351
357
|
log("recording");
|
package/src/screen.ts
CHANGED
|
@@ -114,6 +114,21 @@ export class Screen {
|
|
|
114
114
|
return this.rowText(this.core.getCursor().row);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The cursor line up to the cursor column. Prompt detection uses this rather than the whole line: after a
|
|
119
|
+
* full-screen program exits, the primary screen may still hold stale text to the right of the cursor.
|
|
120
|
+
*/
|
|
121
|
+
lineToCursor(): string {
|
|
122
|
+
const { row, col } = this.core.getCursor();
|
|
123
|
+
let text = "";
|
|
124
|
+
for (let x = 0; x < col; x++) {
|
|
125
|
+
const cell = this.core.getCell(row, x);
|
|
126
|
+
if (cell.width === 0) continue;
|
|
127
|
+
text += cell.chars ?? (cell.char === 0 ? " " : String.fromCodePoint(cell.char));
|
|
128
|
+
}
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
117
132
|
screen(): string {
|
|
118
133
|
const rows: string[] = [];
|
|
119
134
|
for (let y = 0; y < this.core.getRows(); y++) rows.push(this.rowText(y));
|