@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.
- package/README.md +63 -12
- package/bin/prosey +248 -66
- package/package.json +1 -1
- package/src/config-command.test.ts +50 -0
- package/src/config.ts +12 -0
- package/src/debug.test.ts +87 -0
- package/src/debug.ts +47 -5
- package/src/default-config.toml +19 -2
- package/src/format.test.ts +17 -1
- package/src/index.ts +151 -44
- package/src/pager.test.ts +65 -0
- package/src/pager.ts +23 -0
- package/src/version-check.ts +32 -0
|
@@ -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
|
-
|
|
4
|
+
export type LogLevel = "quiet" | "normal" | "verbose";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
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 (
|
|
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
|
}
|
package/src/default-config.toml
CHANGED
|
@@ -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
|
|
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"
|
package/src/format.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { TranscriptSegment } from "youtube-transcript-plus";
|
|
3
|
-
import {
|
|
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("'")).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 {
|
|
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
|
-
--
|
|
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
|
-
|
|
198
|
+
exitProcess(0);
|
|
138
199
|
}
|
|
139
200
|
|
|
140
201
|
if (args.includes("--version")) {
|
|
141
202
|
console.log(VERSION);
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "--
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
info("Fetching transcript...");
|
|
274
389
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
275
|
-
|
|
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
|
|
395
|
+
const prompt = config.summarize!.prompt ?? "";
|
|
281
396
|
const transcriptText = toText(segments, !noDecode);
|
|
282
397
|
|
|
283
398
|
if (!summary) {
|
|
284
|
-
|
|
399
|
+
info(`Summarizing...`);
|
|
285
400
|
summary = await summarize({
|
|
286
401
|
prompt,
|
|
287
|
-
command: config.summarize
|
|
402
|
+
command: config.summarize!.command!,
|
|
288
403
|
transcript: transcriptText,
|
|
289
404
|
cwd: dir,
|
|
290
405
|
});
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|