@tacone/prosey 0.2.3 → 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/README.md CHANGED
@@ -4,20 +4,43 @@
4
4
 
5
5
  You can read, skim, search, copy, and manipulate the text using the tools you love the most.
6
6
 
7
- ## Usage
7
+ ## Features
8
+
9
+ | Feature | Description |
10
+ | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
11
+ | **📋 Transcript download** | Fetch YouTube transcripts as plain text or JSON |
12
+ | **🤖 AI summarization** | Pipe transcripts to any AI agent (opencode, Claude, Copilot, Codex) |
13
+ | **🎨 Smart pager** | Automatic syntax highlighting if bat, glow or mdcat are installed (or configure your own pager) |
14
+ | **🛠️ Customization** | Use `prosey config` to access and edit your configuration |
15
+ | **💾 Disk caching** | Transcripts and summaries cached for instant repeat |
16
+ | **📄 Supported formats** | Plain text and JSON |
17
+
18
+ ## Quickstart
19
+
20
+ Try it out immediately with npx:
8
21
 
9
22
  ```bash
10
23
  npx @tacone/prosey 771PQEDeRmw
11
24
  npx @tacone/prosey https://youtu.be/771PQEDeRmw --lang es -o transcript.txt
12
- npx @tacone/prosey info 771PQEDeRmw
13
25
  ```
14
26
 
15
27
  ## Install
16
28
 
17
- ### Global install (npm)
29
+ Install globally with your favorite package manager:
18
30
 
19
31
  ```bash
20
32
  npm install -g @tacone/prosey
33
+ # or
34
+ yarn global add @tacone/prosey
35
+ # or
36
+ pnpm add -g @tacone/prosey
37
+ # or
38
+ bun install -g @tacone/prosey
39
+ ```
40
+
41
+ Then use it anywhere:
42
+
43
+ ```bash
21
44
  prosey 771PQEDeRmw
22
45
  ```
23
46
 
@@ -43,6 +66,7 @@ Grab a compiled binary from the `dist/` directory (requires no runtime).
43
66
  prosey [options] <video-url-or-id>
44
67
  prosey info [options] <video-url-or-id>
45
68
  prosey summarize [options] <video-url-or-id>
69
+ prosey config
46
70
  ```
47
71
 
48
72
  Pass a full YouTube URL or a bare video ID. The transcript is printed to
@@ -51,35 +75,41 @@ stdout by default, with video details prepended.
51
75
  The `summarize` command fetches a transcript, prepends the prompt from the
52
76
  `[summarize]` config section, and pipes the result to the configured command.
53
77
 
78
+ The `config` command opens your config file in `$EDITOR` for editing. If
79
+ `$EDITOR` is not set, the config file path is printed.
80
+
54
81
  ### Examples
55
82
 
56
83
  ```bash
57
84
  # Basic — plain text with details
58
- npx @tacone/prosey 771PQEDeRmw
85
+ prosey 771PQEDeRmw
59
86
 
60
87
  # Specify language
61
- npx @tacone/prosey https://youtu.be/771PQEDeRmw --lang es
88
+ prosey https://youtu.be/771PQEDeRmw --lang es
62
89
 
63
90
  # Include timestamps
64
- npx @tacone/prosey 771PQEDeRmw -t
91
+ prosey 771PQEDeRmw -t
65
92
 
66
93
  # Save to file
67
- npx @tacone/prosey 771PQEDeRmw -o transcript.txt
94
+ prosey 771PQEDeRmw -o transcript.txt
68
95
 
69
96
  # JSON output (timestamps always included)
70
- npx @tacone/prosey 771PQEDeRmw --json
97
+ prosey 771PQEDeRmw --json
71
98
 
72
99
  # Transcript only, no video details
73
- npx @tacone/prosey 771PQEDeRmw --no-details
100
+ prosey 771PQEDeRmw --no-details
74
101
 
75
102
  # List available transcript languages
76
- npx @tacone/prosey 771PQEDeRmw --list
103
+ prosey 771PQEDeRmw --list
77
104
 
78
105
  # Show video metadata
79
- npx @tacone/prosey info 771PQEDeRmw
106
+ prosey info 771PQEDeRmw
80
107
 
81
108
  # Summarize via the configured command
82
- npx @tacone/prosey summarize 771PQEDeRmw
109
+ prosey summarize 771PQEDeRmw
110
+
111
+ # Edit config in $EDITOR
112
+ prosey config
83
113
  ```
84
114
 
85
115
  ## Options
@@ -95,6 +125,13 @@ npx @tacone/prosey summarize 771PQEDeRmw
95
125
  | `--details` | Prepend video details (title, channel, duration, views, description) to the transcript (default). |
96
126
  | `--no-details` | Suppress video details, transcript only. |
97
127
  | `--no-decode-entities` | Preserve raw HTML entities (e.g. `&#39;`). Decoded by default in text mode. |
128
+ | `--no-cache` | Skip cache reads and force a fresh fetch. |
129
+ | `--no-format` | Skip prettier markdown formatting on summarize output. |
130
+ | `-q`, `--quiet` | Suppress all stderr logging. |
131
+ | `-v`, `--verbose` | Print debug information to stderr. |
132
+ | `--no-pager` | Disable pager for stdout output (auto-detected by default). |
133
+ | `--pager` | Use pager for stdout output (default). |
134
+ | `--reset-config` | Reset config file to defaults and exit. |
98
135
  | `--help` | Show help message and exit. |
99
136
  | `--version` | Show version number and exit. |
100
137
 
@@ -140,6 +177,15 @@ environment variable to use a custom path.
140
177
  The config file is created automatically on first run with default values.
141
178
  Use `--reset-config` to restore the defaults at any time.
142
179
 
180
+ ### `pager`
181
+
182
+ Pager command for transcript and summary output. Defaults to `"auto"`, which
183
+ detects the first available of: `bat -lmd` → `glow` → `mdcat -l -p` → `less`.
184
+ Set to a custom command (e.g. `"less -R"`) to override. The `PROSEY_PAGER`
185
+ environment variable takes precedence over this setting.
186
+
187
+ ### `[summarize]`
188
+
143
189
  The `[summarize]` section configures the `summarize` command:
144
190
 
145
191
  | Key | Description |
@@ -147,6 +193,11 @@ The `[summarize]` section configures the `summarize` command:
147
193
  | `prompt` | Instruction prepended to the transcript |
148
194
  | `command` | Shell command that receives the prompt via stdin |
149
195
 
196
+ ### `PROSEY_PAGER`
197
+
198
+ Environment variable to set the pager command. Takes precedence over the
199
+ `pager` config value. Set to `"auto"` or empty to use auto-detection.
200
+
150
201
  ## Cache
151
202
 
152
203
  Transcripts and summaries are cached to `/tmp/prosey/`. Repeated invocations
package/bin/prosey CHANGED
@@ -2114,7 +2114,7 @@ var require_symbol_define_to_primitive = __commonJS((exports, module) => {
2114
2114
  var valueOf = SymbolPrototype && SymbolPrototype.valueOf;
2115
2115
  var TO_PRIMITIVE = wellKnownSymbol("toPrimitive");
2116
2116
  if (SymbolPrototype && !SymbolPrototype[TO_PRIMITIVE]) {
2117
- defineBuiltIn(SymbolPrototype, TO_PRIMITIVE, function(hint) {
2117
+ defineBuiltIn(SymbolPrototype, TO_PRIMITIVE, function(hint2) {
2118
2118
  return call(valueOf, this);
2119
2119
  }, { arity: 1 });
2120
2120
  }
@@ -4397,9 +4397,9 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
4397
4397
  return global2 ? result || [] : result && result[0];
4398
4398
  };
4399
4399
  XRegExp.matchChain = function(str, chain) {
4400
- return function recurseChain(values, level) {
4401
- var item = chain[level].regex ? chain[level] : {
4402
- regex: chain[level]
4400
+ return function recurseChain(values, level2) {
4401
+ var item = chain[level2].regex ? chain[level2] : {
4402
+ regex: chain[level2]
4403
4403
  };
4404
4404
  var matches = [];
4405
4405
  function addMatch(match) {
@@ -4430,7 +4430,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
4430
4430
  } finally {
4431
4431
  _iterator3.f();
4432
4432
  }
4433
- return level === chain.length - 1 || !matches.length ? matches : recurseChain(matches, level + 1);
4433
+ return level2 === chain.length - 1 || !matches.length ? matches : recurseChain(matches, level2 + 1);
4434
4434
  }([str], 0);
4435
4435
  };
4436
4436
  XRegExp.replace = function(str, search, replacement, scope) {
@@ -94145,9 +94145,81 @@ ${Me13.join(`
94145
94145
  Y42 = k0(G42);
94146
94146
  });
94147
94147
 
94148
+ // src/debug.ts
94149
+ var GRAY = "\x1B[90m";
94150
+ var RESET = "\x1B[0m";
94151
+ var level = "normal";
94152
+ function setLevel(l) {
94153
+ level = l;
94154
+ }
94155
+ var lastTime = performance.now();
94156
+ var resumed = false;
94157
+ function stamp() {
94158
+ const now = performance.now();
94159
+ if (resumed) {
94160
+ lastTime = now;
94161
+ resumed = false;
94162
+ }
94163
+ const elapsed = now - lastTime;
94164
+ lastTime = now;
94165
+ const text = elapsed < 1000 ? `${elapsed.toFixed(0)}ms` : `${(elapsed / 1000).toFixed(1)}s`;
94166
+ return text.padStart(5);
94167
+ }
94168
+ var INFO_BEFORE = "\x1B[0m\x1B[2m\x1B[1m";
94169
+ var INFO_AFTER = "\x1B[0m";
94170
+ function info(...args) {
94171
+ if (level === "quiet")
94172
+ return;
94173
+ console.error(INFO_BEFORE, `[${stamp()}]`, ...args, INFO_AFTER);
94174
+ }
94175
+ function debug(...args) {
94176
+ if (level !== "verbose")
94177
+ return;
94178
+ console.error(GRAY, `[${stamp()}]`, ...args, RESET);
94179
+ }
94180
+ function startTimer() {
94181
+ resumed = true;
94182
+ }
94183
+ function resetTimer() {
94184
+ lastTime = performance.now();
94185
+ resumed = false;
94186
+ }
94187
+ var YELLOW = "\x1B[33m";
94188
+ function hint(message) {
94189
+ console.error(YELLOW + message + RESET + " " + GRAY + "[use prosey config to disable hints]" + RESET);
94190
+ }
94191
+
94148
94192
  // src/index.ts
94193
+ import { spawn as spawn2 } from "node:child_process";
94149
94194
  import { writeFile as writeFile3 } from "node:fs/promises";
94150
94195
 
94196
+ // src/pager.ts
94197
+ import { execSync } from "node:child_process";
94198
+ function hasCommand(cmd) {
94199
+ try {
94200
+ execSync(`command -v ${cmd} 2>/dev/null`, { stdio: "ignore" });
94201
+ return true;
94202
+ } catch {
94203
+ return false;
94204
+ }
94205
+ }
94206
+ function detectPager(cfgPager) {
94207
+ const env = process.env.PROSEY_PAGER;
94208
+ if (env !== undefined && env !== "" && env !== "auto")
94209
+ return env;
94210
+ if (cfgPager !== undefined && cfgPager !== "" && cfgPager !== "auto")
94211
+ return cfgPager;
94212
+ if (hasCommand("bat"))
94213
+ return "bat -lmd";
94214
+ if (hasCommand("glow"))
94215
+ return "glow";
94216
+ if (hasCommand("mdcat"))
94217
+ return "mdcat -l -p";
94218
+ if (hasCommand("less"))
94219
+ return "less";
94220
+ return null;
94221
+ }
94222
+
94151
94223
  // node_modules/youtube-transcript-plus/dist/youtube-transcript-plus.mjs
94152
94224
  function __awaiter(thisArg, _arguments, P, generator) {
94153
94225
  function adopt(value) {
@@ -101530,6 +101602,16 @@ var lt = (r) => {
101530
101602
  var FALLBACK_CONFIG_TOML = `# Default prosey configuration
101531
101603
  # Created automatically on first run. Edit as needed.
101532
101604
 
101605
+ # Pager command for transcript and summary output.
101606
+ # Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
101607
+ # Set to a custom command (e.g. "less -R") to override.
101608
+ # Can also be set via the PROSEY_PAGER env var (takes precedence).
101609
+ pager = "auto"
101610
+
101611
+ # Show hints for missing tools (e.g. markdown highlighter).
101612
+ # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
101613
+ hints = true
101614
+
101533
101615
  [summarize]
101534
101616
  # Prompt sent to the command via stdin.
101535
101617
  # Customize this to change how transcripts are summarized.
@@ -101674,23 +101756,10 @@ async function writeCache(dir, filename, data) {
101674
101756
  await mkdir2(dir, { recursive: true });
101675
101757
  await writeFile2(join2(dir, filename), data, "utf8");
101676
101758
  }
101677
-
101678
- // src/debug.ts
101679
- var GRAY = "\x1B[90m";
101680
- var RESET = "\x1B[0m";
101681
- var enabled = false;
101682
- function enableDebug() {
101683
- enabled = true;
101684
- }
101685
- function debug(...args) {
101686
- if (!enabled)
101687
- return;
101688
- console.error(GRAY, ...args, RESET);
101689
- }
101690
101759
  // package.json
101691
101760
  var package_default = {
101692
101761
  name: "@tacone/prosey",
101693
- version: "0.2.3",
101762
+ version: "0.2.4",
101694
101763
  description: "Download YouTube video transcripts from the CLI",
101695
101764
  module: "src/index.ts",
101696
101765
  type: "module",
@@ -102266,7 +102335,7 @@ function addAlignmentToDoc(doc, size, tabWidth) {
102266
102335
  assertDoc(doc);
102267
102336
  let aligned = doc;
102268
102337
  if (size > 0) {
102269
- for (let level = 0;level < Math.floor(size / tabWidth); ++level) {
102338
+ for (let level2 = 0;level2 < Math.floor(size / tabWidth); ++level2) {
102270
102339
  aligned = indent(aligned);
102271
102340
  }
102272
102341
  aligned = align(size % tabWidth, aligned);
@@ -107697,7 +107766,7 @@ var require_partial = __commonJS2({
107697
107766
  match(filepath) {
107698
107767
  const parts = filepath.split("/");
107699
107768
  const levels = parts.length;
107700
- const patterns = this._storage.filter((info) => !info.complete || info.segments.length > levels);
107769
+ const patterns = this._storage.filter((info2) => !info2.complete || info2.segments.length > levels);
107701
107770
  for (const pattern of patterns) {
107702
107771
  const section = pattern.sections[0];
107703
107772
  if (!pattern.complete && levels > section.length) {
@@ -108269,10 +108338,10 @@ var require_picocolors = __commonJS2({
108269
108338
  } while (~index);
108270
108339
  return result + string.substring(cursor2);
108271
108340
  };
108272
- var createColors2 = (enabled2 = isColorSupported2) => {
108273
- let f7 = enabled2 ? formatter : () => String;
108341
+ var createColors2 = (enabled = isColorSupported2) => {
108342
+ let f7 = enabled ? formatter : () => String;
108274
108343
  return {
108275
- isColorSupported: enabled2,
108344
+ isColorSupported: enabled,
108276
108345
  reset: f7("\x1B[0m", "\x1B[0m"),
108277
108346
  bold: f7("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
108278
108347
  dim: f7("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
@@ -113965,8 +114034,8 @@ function buildDefs(colors) {
113965
114034
  }
113966
114035
  var defsOn = buildDefs((0, import_picocolors4.createColors)(true));
113967
114036
  var defsOff = buildDefs((0, import_picocolors4.createColors)(false));
113968
- function getDefs(enabled2) {
113969
- return enabled2 ? defsOn : defsOff;
114037
+ function getDefs(enabled) {
114038
+ return enabled ? defsOn : defsOff;
113970
114039
  }
113971
114040
  var sometimesKeywords = /* @__PURE__ */ new Set(["as", "async", "from", "get", "of", "set"]);
113972
114041
  var NEWLINE$1 = /\r\n|[\n\r\u2028\u2029]/;
@@ -120299,12 +120368,14 @@ function help() {
120299
120368
  Usage: ${NAME2} [options] <video-url-or-id>
120300
120369
  ${NAME2} info [options] <video-url-or-id>
120301
120370
  ${NAME2} summarize [options] <video-url-or-id>
120371
+ ${NAME2} config
120302
120372
 
120303
120373
  Download a YouTube video transcript or show video details.
120304
120374
 
120305
120375
  Commands:
120306
120376
  info Show video metadata (title, channel, duration, etc.)
120307
120377
  summarize Pipe transcript to the command configured in [summarize]
120378
+ config Open config file in $EDITOR
120308
120379
 
120309
120380
  Arguments:
120310
120381
  video-url-or-id YouTube URL (full or short) or bare video ID
@@ -120322,7 +120393,12 @@ Options:
120322
120393
  --reset-config Reset config file to defaults and exit.
120323
120394
  --no-cache Skip cache and overwrite cache files.
120324
120395
  --no-format Skip prettier formatting.
120325
- --debug Print debug information to stderr.
120396
+ --no-pager Disable pager for stdout output.
120397
+ --pager Use pager for stdout output (default).
120398
+ --no-hints Disable hints.
120399
+ --hints Show hints (default).
120400
+ -q, --quiet Suppress all stderr logging.
120401
+ -v, --verbose Print debug information to stderr.
120326
120402
  --help Show this help message.
120327
120403
  --version Show version.
120328
120404
 
@@ -120392,6 +120468,39 @@ async function formatMd(text) {
120392
120468
  return text;
120393
120469
  }
120394
120470
  }
120471
+ async function outputText(text) {
120472
+ if (outputPath) {
120473
+ await writeFile3(outputPath, text, "utf8");
120474
+ return;
120475
+ }
120476
+ if (!pagerCmd || !process.stdout.isTTY) {
120477
+ process.stdout.write(text);
120478
+ return;
120479
+ }
120480
+ const parts = pagerCmd.split(/\s+/);
120481
+ const proc = spawn2(parts[0], parts.slice(1), {
120482
+ stdio: ["pipe", "inherit", "inherit"]
120483
+ });
120484
+ await new Promise((resolve3) => {
120485
+ let done = false;
120486
+ proc.on("error", () => {
120487
+ if (done)
120488
+ return;
120489
+ done = true;
120490
+ process.stdout.write(text);
120491
+ resolve3();
120492
+ });
120493
+ proc.on("exit", () => {
120494
+ if (done)
120495
+ return;
120496
+ done = true;
120497
+ resolve3();
120498
+ });
120499
+ proc.stdin.write(text);
120500
+ proc.stdin.end();
120501
+ });
120502
+ }
120503
+ var pagerCmd = null;
120395
120504
  var args = process.argv.slice(2);
120396
120505
  if (args.length === 0 || args.includes("--help")) {
120397
120506
  console.log(help());
@@ -120408,7 +120517,7 @@ if (args.includes("--reset-config")) {
120408
120517
  }
120409
120518
  var config = await loadConfig().catch(() => ({}));
120410
120519
  var mode = "transcript";
120411
- var subcmdIndex = args.findIndex((a5) => a5 === "info" || a5 === "summarize");
120520
+ var subcmdIndex = args.findIndex((a5) => a5 === "info" || a5 === "summarize" || a5 === "config");
120412
120521
  if (subcmdIndex !== -1) {
120413
120522
  mode = args[subcmdIndex];
120414
120523
  args.splice(subcmdIndex, 1);
@@ -120423,7 +120532,9 @@ var noDecode = false;
120423
120532
  var showDetails = true;
120424
120533
  var noCache = false;
120425
120534
  var noFormat = false;
120426
- var debugMode = false;
120535
+ var usePager = true;
120536
+ var useHints = true;
120537
+ var logLevel = "normal";
120427
120538
  for (let i = 0;i < args.length; i++) {
120428
120539
  const arg = args[i];
120429
120540
  if (!arg)
@@ -120456,8 +120567,18 @@ for (let i = 0;i < args.length; i++) {
120456
120567
  noCache = true;
120457
120568
  } else if (arg === "--no-format") {
120458
120569
  noFormat = true;
120459
- } else if (arg === "--debug") {
120460
- debugMode = true;
120570
+ } else if (arg === "--no-pager") {
120571
+ usePager = false;
120572
+ } else if (arg === "--pager") {
120573
+ usePager = true;
120574
+ } else if (arg === "--no-hints") {
120575
+ useHints = false;
120576
+ } else if (arg === "--hints") {
120577
+ useHints = true;
120578
+ } else if (arg === "--quiet" || arg === "-q") {
120579
+ logLevel = "quiet";
120580
+ } else if (arg === "--verbose" || arg === "-v") {
120581
+ logLevel = "verbose";
120461
120582
  } else if (arg === "--no-decode-entities") {
120462
120583
  noDecode = true;
120463
120584
  } else if (arg.startsWith("-")) {
@@ -120467,6 +120588,20 @@ for (let i = 0;i < args.length; i++) {
120467
120588
  videoId = arg;
120468
120589
  }
120469
120590
  }
120591
+ if (mode === "config") {
120592
+ const path15 = configPath();
120593
+ const editor = process.env.EDITOR;
120594
+ if (editor) {
120595
+ await new Promise((resolve3) => {
120596
+ const proc = spawn2(editor, [path15], { stdio: "inherit" });
120597
+ proc.on("exit", () => resolve3());
120598
+ proc.on("error", () => resolve3());
120599
+ });
120600
+ } else {
120601
+ console.log(`Config file: ${path15}`);
120602
+ }
120603
+ process.exit(0);
120604
+ }
120470
120605
  if (!videoId) {
120471
120606
  console.error("Error: missing video URL or ID");
120472
120607
  console.log(help());
@@ -120478,8 +120613,24 @@ if (!extracted) {
120478
120613
  process.exit(65);
120479
120614
  }
120480
120615
  videoId = extracted;
120481
- if (debugMode)
120482
- enableDebug();
120616
+ setLevel(logLevel);
120617
+ resetTimer();
120618
+ pagerCmd = usePager ? detectPager(config.pager) : null;
120619
+ debug("Pager:", pagerCmd ?? "none");
120620
+ {
120621
+ const envHints = process.env.PROSEY_HINTS;
120622
+ if (envHints !== undefined) {
120623
+ useHints = envHints === "yes" || envHints === "1" || envHints === "true";
120624
+ } else if (config.hints !== undefined) {
120625
+ useHints = config.hints;
120626
+ }
120627
+ }
120628
+ if (useHints) {
120629
+ const hasMarkdownPager = pagerCmd === "bat -lmd" || pagerCmd === "glow" || pagerCmd === "mdcat -l -p";
120630
+ if (!hasMarkdownPager) {
120631
+ hint("Tip: install a markdown highlighter for better output (e.g. bat, glow, mdcat)");
120632
+ }
120633
+ }
120483
120634
  debug("Config file:", configPath());
120484
120635
  debug("Video ID:", videoId);
120485
120636
  debug("Mode:", mode);
@@ -120504,10 +120655,12 @@ try {
120504
120655
  const dir2 = cacheDir(videoId, cacheOpts2);
120505
120656
  let segments2 = null;
120506
120657
  let summary = null;
120658
+ startTimer();
120507
120659
  if (!noCache) {
120508
120660
  const cachedSegments = await readCache(dir2, "transcript.json");
120509
120661
  const cachedSummary = await readCache(dir2, "summary.md");
120510
120662
  if (cachedSegments && cachedSummary) {
120663
+ info("Transcript cached");
120511
120664
  debug("Cache hit:", dir2);
120512
120665
  segments2 = JSON.parse(cachedSegments);
120513
120666
  summary = cachedSummary;
@@ -120518,32 +120671,29 @@ try {
120518
120671
  debug("Cache skipped (--no-cache)");
120519
120672
  }
120520
120673
  if (!segments2) {
120521
- debug("Fetching transcript...");
120674
+ info("Fetching transcript...");
120522
120675
  segments2 = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
120523
- debug(`Transcript fetched: ${segments2.length} segments`);
120676
+ info(`Transcript: ${segments2.length} segments`);
120524
120677
  await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
120525
120678
  debug("Cache written: transcript.json");
120526
120679
  }
120527
120680
  const prompt = config.summarize.prompt ?? "";
120528
120681
  const transcriptText = toText(segments2, !noDecode);
120529
120682
  if (!summary) {
120530
- debug("Running command:", config.summarize.command);
120683
+ info(`Summarizing...`);
120531
120684
  summary = await summarize({
120532
120685
  prompt,
120533
120686
  command: config.summarize.command,
120534
120687
  transcript: transcriptText,
120535
120688
  cwd: dir2
120536
120689
  });
120537
- debug("Command exit: 0");
120690
+ info("Summary ready");
120538
120691
  await writeCache(dir2, "summary.md", summary);
120539
120692
  debug("Cache written: summary.md");
120540
120693
  }
120541
120694
  const formatted = noFormat ? summary : await formatMd(summary);
120542
- if (outputPath) {
120543
- await writeFile3(outputPath, formatted, "utf8");
120544
- } else {
120545
- console.log(formatted);
120546
- }
120695
+ await outputText(formatted + `
120696
+ `);
120547
120697
  process.exit(0);
120548
120698
  } else if (listOnly) {
120549
120699
  const languages2 = await listLanguages(videoId);
@@ -120555,9 +120705,11 @@ try {
120555
120705
  const dir = cacheDir(videoId, cacheOpts);
120556
120706
  let segments = null;
120557
120707
  let videoDetailsCache = null;
120708
+ startTimer();
120558
120709
  if (!noCache) {
120559
120710
  const cached = await readCache(dir, "transcript.json");
120560
120711
  if (cached) {
120712
+ info("Transcript cached");
120561
120713
  debug("Cache hit:", dir);
120562
120714
  segments = JSON.parse(cached);
120563
120715
  } else {
@@ -120567,7 +120719,7 @@ try {
120567
120719
  debug("Cache skipped (--no-cache)");
120568
120720
  }
120569
120721
  if (!segments) {
120570
- debug("Fetching transcript...");
120722
+ info("Fetching transcript...");
120571
120723
  if (showDetails && !outputJson) {
120572
120724
  const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
120573
120725
  const result = await fetchTranscript(videoId, opts);
@@ -120576,6 +120728,7 @@ try {
120576
120728
  } else {
120577
120729
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
120578
120730
  }
120731
+ info(`Transcript: ${segments.length} segments`);
120579
120732
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
120580
120733
  }
120581
120734
  if (showDetails && !outputJson) {
@@ -120591,21 +120744,13 @@ try {
120591
120744
 
120592
120745
  ` + transcriptText + `
120593
120746
  `;
120594
- if (outputPath) {
120595
- await writeFile3(outputPath, output, "utf8");
120596
- } else {
120597
- console.log(output);
120598
- }
120747
+ await outputText(output);
120599
120748
  } else {
120600
120749
  const output = outputJson ? toJSON(segments, decode) + `
120601
120750
  ` : timestamps ? formatWithTimestamps(segments, decode) + `
120602
120751
  ` : toText(segments, decode) + `
120603
120752
  `;
120604
- if (outputPath) {
120605
- await writeFile3(outputPath, output, "utf8");
120606
- } else {
120607
- console.log(output);
120608
- }
120753
+ await outputText(output);
120609
120754
  }
120610
120755
  } catch (err) {
120611
120756
  const message = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.2.3",
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",
@@ -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
+ });
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,7 +12,6 @@ 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";
13
16
  import prettier from "prettier";
14
17
 
@@ -21,12 +24,14 @@ function help(): string {
21
24
  Usage: ${NAME} [options] <video-url-or-id>
22
25
  ${NAME} info [options] <video-url-or-id>
23
26
  ${NAME} summarize [options] <video-url-or-id>
27
+ ${NAME} config
24
28
 
25
29
  Download a YouTube video transcript or show video details.
26
30
 
27
31
  Commands:
28
32
  info Show video metadata (title, channel, duration, etc.)
29
33
  summarize Pipe transcript to the command configured in [summarize]
34
+ config Open config file in \$EDITOR
30
35
 
31
36
  Arguments:
32
37
  video-url-or-id YouTube URL (full or short) or bare video ID
@@ -44,7 +49,12 @@ Options:
44
49
  --reset-config Reset config file to defaults and exit.
45
50
  --no-cache Skip cache and overwrite cache files.
46
51
  --no-format Skip prettier formatting.
47
- --debug Print debug information to stderr.
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.
48
58
  --help Show this help message.
49
59
  --version Show version.
50
60
 
@@ -130,6 +140,42 @@ async function formatMd(text: string): Promise<string> {
130
140
  }
131
141
  }
132
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
+
133
179
  const args = process.argv.slice(2);
134
180
 
135
181
  if (args.length === 0 || args.includes("--help")) {
@@ -151,7 +197,7 @@ if (args.includes("--reset-config")) {
151
197
  const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
152
198
 
153
199
  let mode = "transcript";
154
- const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
200
+ const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize" || a === "config");
155
201
  if (subcmdIndex !== -1) {
156
202
  mode = args[subcmdIndex]!;
157
203
  args.splice(subcmdIndex, 1);
@@ -167,7 +213,9 @@ let noDecode = false;
167
213
  let showDetails = true;
168
214
  let noCache = false;
169
215
  let noFormat = false;
170
- let debugMode = false;
216
+ let usePager = true;
217
+ let useHints = true;
218
+ let logLevel: LogLevel = "normal";
171
219
 
172
220
  for (let i = 0; i < args.length; i++) {
173
221
  const arg = args[i];
@@ -200,8 +248,18 @@ for (let i = 0; i < args.length; i++) {
200
248
  noCache = true;
201
249
  } else if (arg === "--no-format") {
202
250
  noFormat = true;
203
- } else if (arg === "--debug") {
204
- debugMode = 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";
205
263
  } else if (arg === "--no-decode-entities") {
206
264
  noDecode = true;
207
265
  } else if (arg.startsWith("-")) {
@@ -212,6 +270,21 @@ for (let i = 0; i < args.length; i++) {
212
270
  }
213
271
  }
214
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
+
215
288
  if (!videoId) {
216
289
  console.error("Error: missing video URL or ID");
217
290
  console.log(help());
@@ -227,7 +300,28 @@ if (!extracted) {
227
300
 
228
301
  videoId = extracted;
229
302
 
230
- 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
+
231
325
  debug("Config file:", configPath());
232
326
  debug("Video ID:", videoId);
233
327
  debug("Mode:", mode);
@@ -255,10 +349,13 @@ try {
255
349
  let segments: TranscriptSegment[] | null = null;
256
350
  let summary: string | null = null;
257
351
 
352
+ startTimer();
353
+
258
354
  if (!noCache) {
259
355
  const cachedSegments = await readCache(dir, "transcript.json");
260
356
  const cachedSummary = await readCache(dir, "summary.md");
261
357
  if (cachedSegments && cachedSummary) {
358
+ info("Transcript cached");
262
359
  debug("Cache hit:", dir);
263
360
  segments = JSON.parse(cachedSegments);
264
361
  summary = cachedSummary;
@@ -270,9 +367,9 @@ try {
270
367
  }
271
368
 
272
369
  if (!segments) {
273
- debug("Fetching transcript...");
370
+ info("Fetching transcript...");
274
371
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
275
- debug(`Transcript fetched: ${segments.length} segments`);
372
+ info(`Transcript: ${segments.length} segments`);
276
373
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
277
374
  debug("Cache written: transcript.json");
278
375
  }
@@ -281,25 +378,20 @@ try {
281
378
  const transcriptText = toText(segments, !noDecode);
282
379
 
283
380
  if (!summary) {
284
- debug("Running command:", config.summarize.command);
381
+ info(`Summarizing...`);
285
382
  summary = await summarize({
286
383
  prompt,
287
384
  command: config.summarize.command,
288
385
  transcript: transcriptText,
289
386
  cwd: dir,
290
387
  });
291
- debug("Command exit: 0");
388
+ info("Summary ready");
292
389
  await writeCache(dir, "summary.md", summary);
293
390
  debug("Cache written: summary.md");
294
391
  }
295
392
 
296
393
  const formatted = noFormat ? summary : await formatMd(summary);
297
-
298
- if (outputPath) {
299
- await writeFile(outputPath, formatted, "utf8");
300
- } else {
301
- console.log(formatted);
302
- }
394
+ await outputText(formatted + "\n");
303
395
  process.exit(0);
304
396
  } else if (listOnly) {
305
397
  const languages = await listLanguages(videoId);
@@ -313,9 +405,12 @@ try {
313
405
  let segments: TranscriptSegment[] | null = null;
314
406
  let videoDetailsCache: VideoDetails | null = null;
315
407
 
408
+ startTimer();
409
+
316
410
  if (!noCache) {
317
411
  const cached = await readCache(dir, "transcript.json");
318
412
  if (cached) {
413
+ info("Transcript cached");
319
414
  debug("Cache hit:", dir);
320
415
  segments = JSON.parse(cached);
321
416
  } else {
@@ -326,7 +421,7 @@ try {
326
421
  }
327
422
 
328
423
  if (!segments) {
329
- debug("Fetching transcript...");
424
+ info("Fetching transcript...");
330
425
  if (showDetails && !outputJson) {
331
426
  const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
332
427
  const result = (await fetchTranscript(videoId, opts)) as {
@@ -338,6 +433,7 @@ try {
338
433
  } else {
339
434
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
340
435
  }
436
+ info(`Transcript: ${segments.length} segments`);
341
437
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
342
438
  }
343
439
 
@@ -357,11 +453,7 @@ try {
357
453
  : toText(segments, decode);
358
454
  const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
359
455
 
360
- if (outputPath) {
361
- await writeFile(outputPath, output, "utf8");
362
- } else {
363
- console.log(output);
364
- }
456
+ await outputText(output);
365
457
  } else {
366
458
  const output = outputJson
367
459
  ? toJSON(segments, decode) + "\n"
@@ -369,11 +461,7 @@ try {
369
461
  ? formatWithTimestamps(segments, decode) + "\n"
370
462
  : toText(segments, decode) + "\n";
371
463
 
372
- if (outputPath) {
373
- await writeFile(outputPath, output, "utf8");
374
- } else {
375
- console.log(output);
376
- }
464
+ await outputText(output);
377
465
  }
378
466
  } catch (err: unknown) {
379
467
  const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { hasCommand, detectPager } from "./pager";
3
+
4
+ const ORIGINAL_PROSEY_PAGER = process.env.PROSEY_PAGER;
5
+
6
+ beforeEach(() => {
7
+ delete process.env.PROSEY_PAGER;
8
+ });
9
+
10
+ afterEach(() => {
11
+ if (ORIGINAL_PROSEY_PAGER !== undefined) {
12
+ process.env.PROSEY_PAGER = ORIGINAL_PROSEY_PAGER;
13
+ } else {
14
+ delete process.env.PROSEY_PAGER;
15
+ }
16
+ });
17
+
18
+ describe("hasCommand", () => {
19
+ test("returns true for existing command", () => {
20
+ expect(hasCommand("sh")).toBe(true);
21
+ });
22
+
23
+ test("returns false for nonexistent command", () => {
24
+ expect(hasCommand("nonexistent-cmd-12345")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("detectPager", () => {
29
+ test("uses PROSEY_PAGER env var", () => {
30
+ process.env.PROSEY_PAGER = "custom-pager";
31
+ expect(detectPager()).toBe("custom-pager");
32
+ });
33
+
34
+ test("env var takes precedence over config pager", () => {
35
+ process.env.PROSEY_PAGER = "env-pager";
36
+ expect(detectPager("cfg-pager")).toBe("env-pager");
37
+ });
38
+
39
+ test('"auto" env var falls through to config', () => {
40
+ process.env.PROSEY_PAGER = "auto";
41
+ expect(detectPager("cfg-pager")).toBe("cfg-pager");
42
+ });
43
+
44
+ test("empty env var falls through to config", () => {
45
+ process.env.PROSEY_PAGER = "";
46
+ expect(detectPager("cfg-pager")).toBe("cfg-pager");
47
+ });
48
+
49
+ test('"auto" config falls through to auto-detection', () => {
50
+ process.env.PROSEY_PAGER = "auto";
51
+ const pager = detectPager("auto");
52
+ // Falls through to auto-detect: one of bat/glow/mdcat/less or null
53
+ expect(pager === null || typeof pager === "string").toBe(true);
54
+ });
55
+
56
+ test("returns null when no pager is configured or available", () => {
57
+ const pager = detectPager();
58
+ // In CI or minimal environments, no pagers may be installed
59
+ // If a pager IS found, it should be one of the expected ones
60
+ if (pager !== null) {
61
+ const known = ["bat -lmd", "glow", "mdcat -l -p", "less"];
62
+ expect(known).toContain(pager);
63
+ }
64
+ });
65
+ });
package/src/pager.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ export function hasCommand(cmd: string): boolean {
4
+ try {
5
+ execSync(`command -v ${cmd} 2>/dev/null`, { stdio: "ignore" });
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ export function detectPager(cfgPager?: string): string | null {
13
+ const env = process.env.PROSEY_PAGER;
14
+ if (env !== undefined && env !== "" && env !== "auto") return env;
15
+
16
+ if (cfgPager !== undefined && cfgPager !== "" && cfgPager !== "auto") return cfgPager;
17
+
18
+ if (hasCommand("bat")) return "bat -lmd";
19
+ if (hasCommand("glow")) return "glow";
20
+ if (hasCommand("mdcat")) return "mdcat -l -p";
21
+ if (hasCommand("less")) return "less";
22
+ return null;
23
+ }