@tacone/prosey 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Download YouTube video transcripts from the CLI",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -51,14 +51,14 @@
51
51
  "devDependencies": {
52
52
  "@types/bun": "latest",
53
53
  "husky": "^9.1.7",
54
- "lint-staged": "^17.0.7",
55
- "prettier": "^3.8.4"
54
+ "lint-staged": "^17.0.7"
56
55
  },
57
56
  "peerDependencies": {
58
57
  "typescript": "^5"
59
58
  },
60
59
  "dependencies": {
61
60
  "js-toml": "^1.1.2",
61
+ "prettier": "^3.8.4",
62
62
  "youtube-transcript-plus": "^2.0.0"
63
63
  }
64
64
  }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { $ } from "bun";
3
+ import { rm } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+
6
+ const testConfigPath = "/tmp/prosey-test-config.toml";
7
+
8
+ afterEach(async () => {
9
+ try {
10
+ await rm(testConfigPath);
11
+ } catch {}
12
+ delete process.env.PROSEY_CONFIG_PATH;
13
+ });
14
+
15
+ describe("config subcommand", () => {
16
+ test("prints config path when EDITOR is not set", async () => {
17
+ delete process.env.EDITOR;
18
+ process.env.PROSEY_CONFIG_PATH = testConfigPath;
19
+
20
+ const { stdout, exitCode } = await $`bin/prosey config`.quiet();
21
+
22
+ expect(exitCode).toBe(0);
23
+ expect(stdout.toString().trim()).toBe(`Config file: ${testConfigPath}`);
24
+ });
25
+
26
+ test("creates config file when missing", async () => {
27
+ delete process.env.EDITOR;
28
+ process.env.PROSEY_CONFIG_PATH = testConfigPath;
29
+
30
+ expect(existsSync(testConfigPath)).toBe(false);
31
+ await $`bin/prosey config`.quiet();
32
+ expect(existsSync(testConfigPath)).toBe(true);
33
+ });
34
+
35
+ test("does not require a video ID", async () => {
36
+ delete process.env.EDITOR;
37
+ process.env.PROSEY_CONFIG_PATH = testConfigPath;
38
+
39
+ const { exitCode } = await $`bin/prosey config`.quiet();
40
+ expect(exitCode).toBe(0);
41
+ });
42
+
43
+ test("exits with code 0 after spawning editor", async () => {
44
+ process.env.EDITOR = "cat";
45
+ process.env.PROSEY_CONFIG_PATH = testConfigPath;
46
+
47
+ const { exitCode } = await $`bin/prosey config`.quiet();
48
+ expect(exitCode).toBe(0);
49
+ });
50
+ });
@@ -54,7 +54,7 @@ describe("loadConfig", () => {
54
54
 
55
55
  const content = await readFile(tmpConfig, "utf8");
56
56
  expect(content).toContain("[summarize]");
57
- expect(content).toContain('command = "opencode run"');
57
+ expect(content).toContain("command = ");
58
58
  });
59
59
 
60
60
  test("reads existing config file", async () => {
@@ -63,7 +63,7 @@ describe("loadConfig", () => {
63
63
  await loadConfig();
64
64
  const config = await loadConfig();
65
65
  expect(config.summarize?.prompt).toBeString();
66
- expect(config.summarize?.command).toBe("opencode run");
66
+ expect(config.summarize?.command).toBeString();
67
67
  });
68
68
 
69
69
  test("handles invalid TOML gracefully", async () => {
@@ -93,6 +93,6 @@ describe("resetConfig", () => {
93
93
 
94
94
  const content = await readFile(tmpConfig, "utf8");
95
95
  expect(content).toContain("[summarize]");
96
- expect(content).toContain('command = "opencode run"');
96
+ expect(content).toContain("command = ");
97
97
  });
98
98
  });
package/src/config.ts CHANGED
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
6
6
  import { load } from "js-toml";
7
7
 
8
8
  export interface ProseyConfig {
9
+ pager?: string;
10
+ hints?: boolean;
9
11
  summarize?: {
10
12
  prompt?: string;
11
13
  command?: string;
@@ -15,6 +17,16 @@ export interface ProseyConfig {
15
17
  const FALLBACK_CONFIG_TOML = `# Default prosey configuration
16
18
  # Created automatically on first run. Edit as needed.
17
19
 
20
+ # Pager command for transcript and summary output.
21
+ # Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
22
+ # Set to a custom command (e.g. "less -R") to override.
23
+ # Can also be set via the PROSEY_PAGER env var (takes precedence).
24
+ pager = "auto"
25
+
26
+ # Show hints for missing tools (e.g. markdown highlighter).
27
+ # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
28
+ hints = true
29
+
18
30
  [summarize]
19
31
  # Prompt sent to the command via stdin.
20
32
  # Customize this to change how transcripts are summarized.
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test, beforeEach, spyOn } from "bun:test";
2
+ import { setLevel, info, debug, startTimer, resetTimer } from "./debug";
3
+ import type { LogLevel } from "./debug";
4
+
5
+ beforeEach(() => {
6
+ setLevel("normal");
7
+ resetTimer();
8
+ });
9
+
10
+ describe("setLevel", () => {
11
+ test("info outputs at normal level", () => {
12
+ const spy = spyOn(console, "error").mockImplementation(() => {});
13
+ setLevel("normal");
14
+ info("hello");
15
+ expect(spy).toHaveBeenCalled();
16
+ spy.mockRestore();
17
+ });
18
+
19
+ test("info suppressed at quiet level", () => {
20
+ const spy = spyOn(console, "error").mockImplementation(() => {});
21
+ setLevel("quiet");
22
+ info("hello");
23
+ expect(spy).not.toHaveBeenCalled();
24
+ spy.mockRestore();
25
+ });
26
+
27
+ test("info outputs at verbose level", () => {
28
+ const spy = spyOn(console, "error").mockImplementation(() => {});
29
+ setLevel("verbose");
30
+ info("hello");
31
+ expect(spy).toHaveBeenCalled();
32
+ spy.mockRestore();
33
+ });
34
+
35
+ test("debug suppressed at normal level", () => {
36
+ const spy = spyOn(console, "error").mockImplementation(() => {});
37
+ setLevel("normal");
38
+ debug("detail");
39
+ expect(spy).not.toHaveBeenCalled();
40
+ spy.mockRestore();
41
+ });
42
+
43
+ test("debug outputs at verbose level", () => {
44
+ const spy = spyOn(console, "error").mockImplementation(() => {});
45
+ setLevel("verbose");
46
+ debug("detail");
47
+ expect(spy).toHaveBeenCalled();
48
+ spy.mockRestore();
49
+ });
50
+
51
+ test("debug suppressed at quiet level", () => {
52
+ const spy = spyOn(console, "error").mockImplementation(() => {});
53
+ setLevel("quiet");
54
+ debug("detail");
55
+ expect(spy).not.toHaveBeenCalled();
56
+ spy.mockRestore();
57
+ });
58
+ });
59
+
60
+ describe("info format", () => {
61
+ test("includes timestamp and message", () => {
62
+ const spy = spyOn(console, "error").mockImplementation(() => {});
63
+ info("test message");
64
+ expect(spy.mock.calls[0]?.length).toBeGreaterThanOrEqual(3);
65
+ spy.mockRestore();
66
+ });
67
+ });
68
+
69
+ describe("startTimer", () => {
70
+ test("resets elapsed time on next info call", () => {
71
+ const spy = spyOn(console, "error").mockImplementation(() => {});
72
+ startTimer();
73
+ info("after reset");
74
+ expect(spy).toHaveBeenCalled();
75
+ spy.mockRestore();
76
+ });
77
+ });
78
+
79
+ describe("resetTimer", () => {
80
+ test("resets elapsed time on next info call", () => {
81
+ const spy = spyOn(console, "error").mockImplementation(() => {});
82
+ resetTimer();
83
+ info("after reset");
84
+ expect(spy).toHaveBeenCalled();
85
+ spy.mockRestore();
86
+ });
87
+ });
package/src/debug.ts CHANGED
@@ -1,13 +1,55 @@
1
1
  const GRAY = "\x1b[90m";
2
2
  const RESET = "\x1b[0m";
3
3
 
4
- let enabled = false;
4
+ export type LogLevel = "quiet" | "normal" | "verbose";
5
5
 
6
- export function enableDebug(): void {
7
- enabled = true;
6
+ let level: LogLevel = "normal";
7
+
8
+ export function setLevel(l: LogLevel): void {
9
+ level = l;
10
+ }
11
+
12
+ let lastTime = performance.now();
13
+ let resumed = false;
14
+
15
+ function stamp(): string {
16
+ const now = performance.now();
17
+ if (resumed) {
18
+ lastTime = now;
19
+ resumed = false;
20
+ }
21
+ const elapsed = now - lastTime;
22
+ lastTime = now;
23
+ const text = elapsed < 1000 ? `${elapsed.toFixed(0)}ms` : `${(elapsed / 1000).toFixed(1)}s`;
24
+ return text.padStart(5);
25
+ }
26
+
27
+ const INFO_BEFORE = "\x1b[0m\x1b[2m\x1b[1m";
28
+ const INFO_AFTER = "\x1b[0m";
29
+
30
+ export function info(...args: unknown[]): void {
31
+ if (level === "quiet") return;
32
+ console.error(INFO_BEFORE, `[${stamp()}]`, ...args, INFO_AFTER);
8
33
  }
9
34
 
10
35
  export function debug(...args: unknown[]): void {
11
- if (!enabled) return;
12
- console.error(GRAY, ...args, RESET);
36
+ if (level !== "verbose") return;
37
+ console.error(GRAY, `[${stamp()}]`, ...args, RESET);
38
+ }
39
+
40
+ export function startTimer(): void {
41
+ resumed = true;
42
+ }
43
+
44
+ export function resetTimer(): void {
45
+ lastTime = performance.now();
46
+ resumed = false;
47
+ }
48
+
49
+ const YELLOW = "\x1b[33m";
50
+
51
+ export function hint(message: string): void {
52
+ console.error(
53
+ YELLOW + message + RESET + " " + GRAY + "[use prosey config to disable hints]" + RESET,
54
+ );
13
55
  }
@@ -1,6 +1,16 @@
1
1
  # Default prosey configuration
2
2
  # Created automatically on first run. Edit as needed.
3
3
 
4
+ # Pager command for transcript and summary output.
5
+ # Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
6
+ # Set to a custom command (e.g. "less -R") to override.
7
+ # Can also be set via the PROSEY_PAGER env var (takes precedence).
8
+ pager = "auto"
9
+
10
+ # Show hints for missing tools (e.g. markdown highlighter).
11
+ # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
12
+ hints = true
13
+
4
14
  [summarize]
5
15
 
6
16
  # Prompt sent to the command via stdin.
@@ -12,5 +22,18 @@ Write a comprehensive summary of the following transcription.
12
22
 
13
23
  # Command to execute with the prompt and transcript piped via stdin.
14
24
  # The transcript is appended to the prompt automatically.
15
-
25
+ #
26
+ # Available options:
27
+ #
28
+ # opencode run — full access (default)
29
+ # opencode run --permissions read — read-only (view files, no edits)
30
+ #
31
+ # claude -p "" --print — full access (--print for clean output)
32
+ # claude --permission-mode plan -p "" --print — read-only (plan/read only)
33
+ #
34
+ # copilot -sp "" — full access (-s = silent, -p = prompt)
35
+ # copilot -sp "" --deny-all-tools — read-only (no shell/write access)
36
+ #
37
+ # codex --sandbox default -p "" — full access
38
+ # codex --sandbox read-only -p "" — read-only
16
39
  command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":\"deny\",\"bash\":\"deny\",\"glob\":\"deny\",\"grep\":\"deny\",\"webfetch\":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny\",\"lsp\":\"deny\"}' opencode run --pure"
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { TranscriptSegment } from "youtube-transcript-plus";
3
- import { formatTime, decodeEntities, formatWithTimestamps, toText, toJSON } from "./format";
3
+ import {
4
+ formatTime,
5
+ formatDuration,
6
+ decodeEntities,
7
+ formatWithTimestamps,
8
+ toText,
9
+ toJSON,
10
+ } from "./format";
4
11
 
5
12
  const segments: TranscriptSegment[] = [
6
13
  { text: "Hello world", offset: 1.5, duration: 2.0, lang: "en" },
@@ -17,6 +24,15 @@ describe("formatTime", () => {
17
24
  test("fractional truncated", () => expect(formatTime(90.7)).toBe("01:30"));
18
25
  });
19
26
 
27
+ describe("formatDuration", () => {
28
+ test("zero", () => expect(formatDuration(0)).toBe("0:00"));
29
+ test("seconds only", () => expect(formatDuration(45)).toBe("0:45"));
30
+ test("minute boundary", () => expect(formatDuration(60)).toBe("1:00"));
31
+ test("minutes only", () => expect(formatDuration(185)).toBe("3:05"));
32
+ test("hour boundary", () => expect(formatDuration(3600)).toBe("1:00:00"));
33
+ test("hours and minutes", () => expect(formatDuration(3661)).toBe("1:01:01"));
34
+ });
35
+
20
36
  describe("decodeEntities", () => {
21
37
  test("plain text unchanged", () => expect(decodeEntities("hello")).toBe("hello"));
22
38
  test("apostrophe", () => expect(decodeEntities("&#39;")).toBe("'"));
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { setLevel, info, debug, startTimer, resetTimer, hint } from "./debug";
4
+ import type { LogLevel } from "./debug";
5
+ import { spawn } from "node:child_process";
3
6
  import { writeFile } from "node:fs/promises";
7
+ import { detectPager } from "./pager";
4
8
  import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
5
9
  import type { CaptionTrackInfo, VideoDetails, TranscriptSegment } from "youtube-transcript-plus";
6
10
  import { formatWithTimestamps, toText, toJSON, formatDuration, decodeEntities } from "./format";
@@ -8,8 +12,8 @@ import { loadConfig, resetConfig, configPath } from "./config";
8
12
  import type { ProseyConfig } from "./config";
9
13
  import { summarize } from "./summarize";
10
14
  import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
11
- import { enableDebug, debug } from "./debug";
12
15
  import pkg from "../package.json";
16
+ import prettier from "prettier";
13
17
 
14
18
  const NAME = "prosey";
15
19
  const VERSION = pkg.version;
@@ -20,12 +24,14 @@ function help(): string {
20
24
  Usage: ${NAME} [options] <video-url-or-id>
21
25
  ${NAME} info [options] <video-url-or-id>
22
26
  ${NAME} summarize [options] <video-url-or-id>
27
+ ${NAME} config
23
28
 
24
29
  Download a YouTube video transcript or show video details.
25
30
 
26
31
  Commands:
27
32
  info Show video metadata (title, channel, duration, etc.)
28
33
  summarize Pipe transcript to the command configured in [summarize]
34
+ config Open config file in \$EDITOR
29
35
 
30
36
  Arguments:
31
37
  video-url-or-id YouTube URL (full or short) or bare video ID
@@ -42,7 +48,13 @@ Options:
42
48
  --no-decode-entities Preserve HTML entities (decoded by default).
43
49
  --reset-config Reset config file to defaults and exit.
44
50
  --no-cache Skip cache and overwrite cache files.
45
- --debug Print debug information to stderr.
51
+ --no-format Skip prettier formatting.
52
+ --no-pager Disable pager for stdout output.
53
+ --pager Use pager for stdout output (default).
54
+ --no-hints Disable hints.
55
+ --hints Show hints (default).
56
+ -q, --quiet Suppress all stderr logging.
57
+ -v, --verbose Print debug information to stderr.
46
58
  --help Show this help message.
47
59
  --version Show version.
48
60
 
@@ -120,6 +132,50 @@ function printLanguages(languages: CaptionTrackInfo[]): void {
120
132
  console.log(`Available transcripts (${languages.length}):\n${rows.join("\n")}`);
121
133
  }
122
134
 
135
+ async function formatMd(text: string): Promise<string> {
136
+ try {
137
+ return await prettier.format(text, { parser: "markdown" });
138
+ } catch {
139
+ return text;
140
+ }
141
+ }
142
+
143
+ async function outputText(text: string): Promise<void> {
144
+ if (outputPath) {
145
+ await writeFile(outputPath, text, "utf8");
146
+ return;
147
+ }
148
+
149
+ if (!pagerCmd || !process.stdout.isTTY) {
150
+ process.stdout.write(text);
151
+ return;
152
+ }
153
+
154
+ const parts = pagerCmd.split(/\s+/);
155
+ const proc = spawn(parts[0]!, parts.slice(1), {
156
+ stdio: ["pipe", "inherit", "inherit"],
157
+ }) as import("node:child_process").ChildProcess;
158
+
159
+ await new Promise<void>((resolve) => {
160
+ let done = false;
161
+ proc.on("error", () => {
162
+ if (done) return;
163
+ done = true;
164
+ process.stdout.write(text);
165
+ resolve();
166
+ });
167
+ proc.on("exit", () => {
168
+ if (done) return;
169
+ done = true;
170
+ resolve();
171
+ });
172
+ proc.stdin!.write(text);
173
+ proc.stdin!.end();
174
+ });
175
+ }
176
+
177
+ let pagerCmd: string | null = null;
178
+
123
179
  const args = process.argv.slice(2);
124
180
 
125
181
  if (args.length === 0 || args.includes("--help")) {
@@ -141,7 +197,7 @@ if (args.includes("--reset-config")) {
141
197
  const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
142
198
 
143
199
  let mode = "transcript";
144
- const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
200
+ const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize" || a === "config");
145
201
  if (subcmdIndex !== -1) {
146
202
  mode = args[subcmdIndex]!;
147
203
  args.splice(subcmdIndex, 1);
@@ -156,7 +212,10 @@ let outputJson = false;
156
212
  let noDecode = false;
157
213
  let showDetails = true;
158
214
  let noCache = false;
159
- let debugMode = false;
215
+ let noFormat = false;
216
+ let usePager = true;
217
+ let useHints = true;
218
+ let logLevel: LogLevel = "normal";
160
219
 
161
220
  for (let i = 0; i < args.length; i++) {
162
221
  const arg = args[i];
@@ -187,8 +246,20 @@ for (let i = 0; i < args.length; i++) {
187
246
  showDetails = false;
188
247
  } else if (arg === "--no-cache") {
189
248
  noCache = true;
190
- } else if (arg === "--debug") {
191
- debugMode = true;
249
+ } else if (arg === "--no-format") {
250
+ noFormat = true;
251
+ } else if (arg === "--no-pager") {
252
+ usePager = false;
253
+ } else if (arg === "--pager") {
254
+ usePager = true;
255
+ } else if (arg === "--no-hints") {
256
+ useHints = false;
257
+ } else if (arg === "--hints") {
258
+ useHints = true;
259
+ } else if (arg === "--quiet" || arg === "-q") {
260
+ logLevel = "quiet";
261
+ } else if (arg === "--verbose" || arg === "-v") {
262
+ logLevel = "verbose";
192
263
  } else if (arg === "--no-decode-entities") {
193
264
  noDecode = true;
194
265
  } else if (arg.startsWith("-")) {
@@ -199,6 +270,21 @@ for (let i = 0; i < args.length; i++) {
199
270
  }
200
271
  }
201
272
 
273
+ if (mode === "config") {
274
+ const path = configPath();
275
+ const editor = process.env.EDITOR;
276
+ if (editor) {
277
+ await new Promise<void>((resolve) => {
278
+ const proc = spawn(editor, [path], { stdio: "inherit" });
279
+ proc.on("exit", () => resolve());
280
+ proc.on("error", () => resolve());
281
+ });
282
+ } else {
283
+ console.log(`Config file: ${path}`);
284
+ }
285
+ process.exit(0);
286
+ }
287
+
202
288
  if (!videoId) {
203
289
  console.error("Error: missing video URL or ID");
204
290
  console.log(help());
@@ -214,7 +300,28 @@ if (!extracted) {
214
300
 
215
301
  videoId = extracted;
216
302
 
217
- if (debugMode) enableDebug();
303
+ setLevel(logLevel);
304
+ resetTimer();
305
+ pagerCmd = usePager ? detectPager(config.pager) : null;
306
+ debug("Pager:", pagerCmd ?? "none");
307
+
308
+ {
309
+ const envHints = process.env.PROSEY_HINTS;
310
+ if (envHints !== undefined) {
311
+ useHints = envHints === "yes" || envHints === "1" || envHints === "true";
312
+ } else if (config.hints !== undefined) {
313
+ useHints = config.hints;
314
+ }
315
+ }
316
+
317
+ if (useHints) {
318
+ const hasMarkdownPager =
319
+ pagerCmd === "bat -lmd" || pagerCmd === "glow" || pagerCmd === "mdcat -l -p";
320
+ if (!hasMarkdownPager) {
321
+ hint("Tip: install a markdown highlighter for better output (e.g. bat, glow, mdcat)");
322
+ }
323
+ }
324
+
218
325
  debug("Config file:", configPath());
219
326
  debug("Video ID:", videoId);
220
327
  debug("Mode:", mode);
@@ -242,10 +349,13 @@ try {
242
349
  let segments: TranscriptSegment[] | null = null;
243
350
  let summary: string | null = null;
244
351
 
352
+ startTimer();
353
+
245
354
  if (!noCache) {
246
355
  const cachedSegments = await readCache(dir, "transcript.json");
247
356
  const cachedSummary = await readCache(dir, "summary.md");
248
357
  if (cachedSegments && cachedSummary) {
358
+ info("Transcript cached");
249
359
  debug("Cache hit:", dir);
250
360
  segments = JSON.parse(cachedSegments);
251
361
  summary = cachedSummary;
@@ -257,9 +367,9 @@ try {
257
367
  }
258
368
 
259
369
  if (!segments) {
260
- debug("Fetching transcript...");
370
+ info("Fetching transcript...");
261
371
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
262
- debug(`Transcript fetched: ${segments.length} segments`);
372
+ info(`Transcript: ${segments.length} segments`);
263
373
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
264
374
  debug("Cache written: transcript.json");
265
375
  }
@@ -268,23 +378,20 @@ try {
268
378
  const transcriptText = toText(segments, !noDecode);
269
379
 
270
380
  if (!summary) {
271
- debug("Running command:", config.summarize.command);
381
+ info(`Summarizing...`);
272
382
  summary = await summarize({
273
383
  prompt,
274
384
  command: config.summarize.command,
275
385
  transcript: transcriptText,
276
386
  cwd: dir,
277
387
  });
278
- debug("Command exit: 0");
388
+ info("Summary ready");
279
389
  await writeCache(dir, "summary.md", summary);
280
390
  debug("Cache written: summary.md");
281
391
  }
282
392
 
283
- if (outputPath) {
284
- await writeFile(outputPath, summary, "utf8");
285
- } else {
286
- console.log(summary);
287
- }
393
+ const formatted = noFormat ? summary : await formatMd(summary);
394
+ await outputText(formatted + "\n");
288
395
  process.exit(0);
289
396
  } else if (listOnly) {
290
397
  const languages = await listLanguages(videoId);
@@ -298,9 +405,12 @@ try {
298
405
  let segments: TranscriptSegment[] | null = null;
299
406
  let videoDetailsCache: VideoDetails | null = null;
300
407
 
408
+ startTimer();
409
+
301
410
  if (!noCache) {
302
411
  const cached = await readCache(dir, "transcript.json");
303
412
  if (cached) {
413
+ info("Transcript cached");
304
414
  debug("Cache hit:", dir);
305
415
  segments = JSON.parse(cached);
306
416
  } else {
@@ -311,7 +421,7 @@ try {
311
421
  }
312
422
 
313
423
  if (!segments) {
314
- debug("Fetching transcript...");
424
+ info("Fetching transcript...");
315
425
  if (showDetails && !outputJson) {
316
426
  const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
317
427
  const result = (await fetchTranscript(videoId, opts)) as {
@@ -323,6 +433,7 @@ try {
323
433
  } else {
324
434
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
325
435
  }
436
+ info(`Transcript: ${segments.length} segments`);
326
437
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
327
438
  }
328
439
 
@@ -342,11 +453,7 @@ try {
342
453
  : toText(segments, decode);
343
454
  const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
344
455
 
345
- if (outputPath) {
346
- await writeFile(outputPath, output, "utf8");
347
- } else {
348
- console.log(output);
349
- }
456
+ await outputText(output);
350
457
  } else {
351
458
  const output = outputJson
352
459
  ? toJSON(segments, decode) + "\n"
@@ -354,11 +461,7 @@ try {
354
461
  ? formatWithTimestamps(segments, decode) + "\n"
355
462
  : toText(segments, decode) + "\n";
356
463
 
357
- if (outputPath) {
358
- await writeFile(outputPath, output, "utf8");
359
- } else {
360
- console.log(output);
361
- }
464
+ await outputText(output);
362
465
  }
363
466
  } catch (err: unknown) {
364
467
  const message = err instanceof Error ? err.message : String(err);