@tacone/prosey 0.2.3 → 0.2.6

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.
@@ -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 --style plain → glow -p → 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 when available
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.
@@ -11,6 +21,13 @@ Write a comprehensive summary of the following transcription.
11
21
  """
12
22
 
13
23
  # Command to execute with the prompt and transcript piped via stdin.
14
- # The transcript is appended to the prompt automatically.
15
-
24
+ # The transcript will be appended to the prompt automatically.
25
+ #
26
+ # Examples:
27
+ #
28
+ # 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
29
+ # claude --permission-mode plan --print --read-only
30
+ # copilot -s --deny-all-tools --read-only
31
+ # codex --sandbox read-only --read-only
32
+ #
16
33
  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,25 +12,41 @@ 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";
15
+ import { checkVersion } from "./version-check";
12
16
  import pkg from "../package.json";
13
17
  import prettier from "prettier";
14
18
 
15
19
  const NAME = "prosey";
16
20
  const VERSION = pkg.version;
17
21
 
22
+ let latestVersion: string | null = null;
23
+ const versionCheck = checkVersion().then((v) => {
24
+ latestVersion = v;
25
+ });
26
+
27
+ function exitProcess(code: number): never {
28
+ if (useHints && code === 0 && latestVersion && latestVersion !== VERSION) {
29
+ hint(
30
+ `📦 New version available: ${latestVersion} — use npm/pnpm/bun -g i ${pkg.name} to upgrade`,
31
+ );
32
+ }
33
+ process.exit(code);
34
+ }
35
+
18
36
  function help(): string {
19
37
  return `${NAME} v${VERSION}
20
38
 
21
39
  Usage: ${NAME} [options] <video-url-or-id>
22
40
  ${NAME} info [options] <video-url-or-id>
23
41
  ${NAME} summarize [options] <video-url-or-id>
42
+ ${NAME} config
24
43
 
25
44
  Download a YouTube video transcript or show video details.
26
45
 
27
46
  Commands:
28
47
  info Show video metadata (title, channel, duration, etc.)
29
48
  summarize Pipe transcript to the command configured in [summarize]
49
+ config Open config file in \$EDITOR
30
50
 
31
51
  Arguments:
32
52
  video-url-or-id YouTube URL (full or short) or bare video ID
@@ -44,7 +64,12 @@ Options:
44
64
  --reset-config Reset config file to defaults and exit.
45
65
  --no-cache Skip cache and overwrite cache files.
46
66
  --no-format Skip prettier formatting.
47
- --debug Print debug information to stderr.
67
+ --no-pager Disable pager for stdout output.
68
+ --pager Use pager for stdout output (default).
69
+ --no-hints Disable hints.
70
+ --hints Show hints (default).
71
+ -q, --quiet Suppress all stderr logging.
72
+ -v, --verbose Print debug information to stderr.
48
73
  --help Show this help message.
49
74
  --version Show version.
50
75
 
@@ -130,28 +155,64 @@ async function formatMd(text: string): Promise<string> {
130
155
  }
131
156
  }
132
157
 
158
+ async function outputText(text: string): Promise<void> {
159
+ if (outputPath) {
160
+ await writeFile(outputPath, text, "utf8");
161
+ return;
162
+ }
163
+
164
+ if (!pagerCmd || !process.stdout.isTTY) {
165
+ process.stdout.write(text);
166
+ return;
167
+ }
168
+
169
+ const parts = pagerCmd.split(/\s+/);
170
+ const proc = spawn(parts[0]!, parts.slice(1), {
171
+ stdio: ["pipe", "inherit", "inherit"],
172
+ }) as import("node:child_process").ChildProcess;
173
+
174
+ await new Promise<void>((resolve) => {
175
+ let done = false;
176
+ proc.on("error", () => {
177
+ if (done) return;
178
+ done = true;
179
+ process.stdout.write(text);
180
+ resolve();
181
+ });
182
+ proc.on("exit", () => {
183
+ if (done) return;
184
+ done = true;
185
+ resolve();
186
+ });
187
+ proc.stdin!.write(text);
188
+ proc.stdin!.end();
189
+ });
190
+ }
191
+
192
+ let pagerCmd: string | null = null;
193
+
133
194
  const args = process.argv.slice(2);
134
195
 
135
196
  if (args.length === 0 || args.includes("--help")) {
136
197
  console.log(help());
137
- process.exit(0);
198
+ exitProcess(0);
138
199
  }
139
200
 
140
201
  if (args.includes("--version")) {
141
202
  console.log(VERSION);
142
- process.exit(0);
203
+ exitProcess(0);
143
204
  }
144
205
 
145
206
  if (args.includes("--reset-config")) {
146
207
  const path = await resetConfig();
147
208
  console.log(`Config reset to defaults: ${path}`);
148
- process.exit(0);
209
+ exitProcess(0);
149
210
  }
150
211
 
151
212
  const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
152
213
 
153
214
  let mode = "transcript";
154
- const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
215
+ const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize" || a === "config");
155
216
  if (subcmdIndex !== -1) {
156
217
  mode = args[subcmdIndex]!;
157
218
  args.splice(subcmdIndex, 1);
@@ -167,7 +228,9 @@ let noDecode = false;
167
228
  let showDetails = true;
168
229
  let noCache = false;
169
230
  let noFormat = false;
170
- let debugMode = false;
231
+ let usePager = true;
232
+ let useHints = true;
233
+ let logLevel: LogLevel = "normal";
171
234
 
172
235
  for (let i = 0; i < args.length; i++) {
173
236
  const arg = args[i];
@@ -176,7 +239,7 @@ for (let i = 0; i < args.length; i++) {
176
239
  lang = args[++i] ?? undefined;
177
240
  if (!lang) {
178
241
  console.error("Error: --lang requires a language code");
179
- process.exit(1);
242
+ exitProcess(1);
180
243
  }
181
244
  } else if (arg === "--timestamps" || arg === "-t") {
182
245
  timestamps = true;
@@ -186,7 +249,7 @@ for (let i = 0; i < args.length; i++) {
186
249
  outputPath = args[++i] ?? undefined;
187
250
  if (!outputPath) {
188
251
  console.error("Error: -o/--output requires a file path");
189
- process.exit(1);
252
+ exitProcess(1);
190
253
  }
191
254
  } else if (arg === "--json") {
192
255
  outputJson = true;
@@ -200,39 +263,88 @@ for (let i = 0; i < args.length; i++) {
200
263
  noCache = true;
201
264
  } else if (arg === "--no-format") {
202
265
  noFormat = true;
203
- } else if (arg === "--debug") {
204
- debugMode = true;
266
+ } else if (arg === "--no-pager") {
267
+ usePager = false;
268
+ } else if (arg === "--pager") {
269
+ usePager = true;
270
+ } else if (arg === "--no-hints") {
271
+ useHints = false;
272
+ } else if (arg === "--hints") {
273
+ useHints = true;
274
+ } else if (arg === "--quiet" || arg === "-q") {
275
+ logLevel = "quiet";
276
+ } else if (arg === "--verbose" || arg === "-v") {
277
+ logLevel = "verbose";
205
278
  } else if (arg === "--no-decode-entities") {
206
279
  noDecode = true;
207
280
  } else if (arg.startsWith("-")) {
208
281
  console.error(`Unknown option: ${arg}`);
209
- process.exit(1);
282
+ exitProcess(1);
210
283
  } else {
211
284
  videoId = arg;
212
285
  }
213
286
  }
214
287
 
288
+ if (mode === "config") {
289
+ const path = configPath();
290
+ const editor = process.env.EDITOR;
291
+ if (editor) {
292
+ await new Promise<void>((resolve) => {
293
+ const proc = spawn(editor, [path], { stdio: "inherit" });
294
+ proc.on("exit", () => resolve());
295
+ proc.on("error", () => resolve());
296
+ });
297
+ } else {
298
+ console.log(`Config file: ${path}`);
299
+ }
300
+ exitProcess(0);
301
+ }
302
+
215
303
  if (!videoId) {
216
304
  console.error("Error: missing video URL or ID");
217
305
  console.log(help());
218
- process.exit(1);
306
+ exitProcess(1);
219
307
  }
220
308
 
221
309
  const extracted = extractVideoId(videoId);
222
310
 
223
311
  if (!extracted) {
224
312
  console.error("Error: invalid YouTube video URL or ID");
225
- process.exit(65);
313
+ exitProcess(65);
226
314
  }
227
315
 
228
- videoId = extracted;
316
+ videoId = extracted!;
317
+
318
+ setLevel(logLevel);
319
+ resetTimer();
320
+ pagerCmd = usePager ? detectPager(config.pager) : null;
321
+ debug("Pager:", pagerCmd ?? "none");
322
+
323
+ {
324
+ const envHints = process.env.PROSEY_HINTS;
325
+ if (envHints !== undefined) {
326
+ useHints = envHints === "yes" || envHints === "1" || envHints === "true";
327
+ } else if (config.hints !== undefined) {
328
+ useHints = config.hints;
329
+ }
330
+ }
331
+
332
+ if (useHints) {
333
+ const hasMarkdownPager =
334
+ pagerCmd === "bat -lmd --style plain" || pagerCmd === "glow -p" || pagerCmd === "mdcat -l -p";
335
+ if (!hasMarkdownPager) {
336
+ hint("Tip: install a markdown highlighter for better output (e.g. bat, glow, mdcat)");
337
+ }
338
+ }
229
339
 
230
- if (debugMode) enableDebug();
231
340
  debug("Config file:", configPath());
232
341
  debug("Video ID:", videoId);
233
342
  debug("Mode:", mode);
234
343
  if (lang) debug("Language:", lang);
235
344
 
345
+ // Give the version check a moment to complete
346
+ await Promise.race([versionCheck, new Promise((r) => setTimeout(r, 1000))]);
347
+
236
348
  try {
237
349
  if (mode === "info") {
238
350
  const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
@@ -241,13 +353,13 @@ try {
241
353
  } else {
242
354
  printVideoInfo(result.videoDetails);
243
355
  }
244
- process.exit(0);
356
+ exitProcess(0);
245
357
  }
246
358
 
247
359
  if (mode === "summarize") {
248
360
  if (!config.summarize?.command) {
249
361
  console.error("Error: [summarize] section with a command is required in config");
250
- process.exit(1);
362
+ exitProcess(1);
251
363
  }
252
364
 
253
365
  const cacheOpts = { lang, mode: "summarize", noDecode };
@@ -255,10 +367,13 @@ try {
255
367
  let segments: TranscriptSegment[] | null = null;
256
368
  let summary: string | null = null;
257
369
 
370
+ startTimer();
371
+
258
372
  if (!noCache) {
259
373
  const cachedSegments = await readCache(dir, "transcript.json");
260
374
  const cachedSummary = await readCache(dir, "summary.md");
261
375
  if (cachedSegments && cachedSummary) {
376
+ info("Transcript cached");
262
377
  debug("Cache hit:", dir);
263
378
  segments = JSON.parse(cachedSegments);
264
379
  summary = cachedSummary;
@@ -270,41 +385,36 @@ try {
270
385
  }
271
386
 
272
387
  if (!segments) {
273
- debug("Fetching transcript...");
388
+ info("Fetching transcript...");
274
389
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
275
- debug(`Transcript fetched: ${segments.length} segments`);
390
+ info(`Transcript: ${segments.length} segments`);
276
391
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
277
392
  debug("Cache written: transcript.json");
278
393
  }
279
394
 
280
- const prompt = config.summarize.prompt ?? "";
395
+ const prompt = config.summarize!.prompt ?? "";
281
396
  const transcriptText = toText(segments, !noDecode);
282
397
 
283
398
  if (!summary) {
284
- debug("Running command:", config.summarize.command);
399
+ info(`Summarizing...`);
285
400
  summary = await summarize({
286
401
  prompt,
287
- command: config.summarize.command,
402
+ command: config.summarize!.command!,
288
403
  transcript: transcriptText,
289
404
  cwd: dir,
290
405
  });
291
- debug("Command exit: 0");
406
+ info("Summary ready");
292
407
  await writeCache(dir, "summary.md", summary);
293
408
  debug("Cache written: summary.md");
294
409
  }
295
410
 
296
411
  const formatted = noFormat ? summary : await formatMd(summary);
297
-
298
- if (outputPath) {
299
- await writeFile(outputPath, formatted, "utf8");
300
- } else {
301
- console.log(formatted);
302
- }
303
- process.exit(0);
412
+ await outputText(formatted + "\n");
413
+ exitProcess(0);
304
414
  } else if (listOnly) {
305
415
  const languages = await listLanguages(videoId);
306
416
  printLanguages(languages);
307
- process.exit(0);
417
+ exitProcess(0);
308
418
  }
309
419
 
310
420
  const decode = !noDecode;
@@ -313,9 +423,12 @@ try {
313
423
  let segments: TranscriptSegment[] | null = null;
314
424
  let videoDetailsCache: VideoDetails | null = null;
315
425
 
426
+ startTimer();
427
+
316
428
  if (!noCache) {
317
429
  const cached = await readCache(dir, "transcript.json");
318
430
  if (cached) {
431
+ info("Transcript cached");
319
432
  debug("Cache hit:", dir);
320
433
  segments = JSON.parse(cached);
321
434
  } else {
@@ -326,7 +439,7 @@ try {
326
439
  }
327
440
 
328
441
  if (!segments) {
329
- debug("Fetching transcript...");
442
+ info("Fetching transcript...");
330
443
  if (showDetails && !outputJson) {
331
444
  const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
332
445
  const result = (await fetchTranscript(videoId, opts)) as {
@@ -338,6 +451,7 @@ try {
338
451
  } else {
339
452
  segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
340
453
  }
454
+ info(`Transcript: ${segments.length} segments`);
341
455
  await writeCache(dir, "transcript.json", JSON.stringify(segments));
342
456
  }
343
457
 
@@ -357,11 +471,7 @@ try {
357
471
  : toText(segments, decode);
358
472
  const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
359
473
 
360
- if (outputPath) {
361
- await writeFile(outputPath, output, "utf8");
362
- } else {
363
- console.log(output);
364
- }
474
+ await outputText(output);
365
475
  } else {
366
476
  const output = outputJson
367
477
  ? toJSON(segments, decode) + "\n"
@@ -369,14 +479,11 @@ try {
369
479
  ? formatWithTimestamps(segments, decode) + "\n"
370
480
  : toText(segments, decode) + "\n";
371
481
 
372
- if (outputPath) {
373
- await writeFile(outputPath, output, "utf8");
374
- } else {
375
- console.log(output);
376
- }
482
+ await outputText(output);
377
483
  }
484
+ exitProcess(0);
378
485
  } catch (err: unknown) {
379
486
  const message = err instanceof Error ? err.message : String(err);
380
487
  console.error(`Error: ${message}`);
381
- process.exit(1);
488
+ exitProcess(1);
382
489
  }
@@ -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 --style plain", "glow -p", "mdcat -l -p", "less"];
62
+ expect(known).toContain(pager);
63
+ }
64
+ });
65
+ });