@tacone/prosey 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Download YouTube video transcripts from the CLI",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "bin": {
8
- "prosey": "bin/prosey.js"
8
+ "prosey": "bin/prosey"
9
9
  },
10
10
  "files": [
11
11
  "bin/",
@@ -16,12 +16,11 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "start": "bun run src/index.ts",
19
- "build": "bun build src/index.ts --compile --outfile dist/prosey",
20
- "build:node": "bun build src/index.ts --target node --outfile bin/prosey.js",
19
+ "build": "bun build src/index.ts --target node --outfile bin/prosey",
21
20
  "test": "bun test",
22
21
  "typecheck": "tsc --noEmit",
23
22
  "prettier": "prettier --write .",
24
- "prepack": "bun run build:node",
23
+ "prepack": "bun run build",
25
24
  "prepare": "husky || true"
26
25
  },
27
26
  "lint-staged": {
@@ -59,6 +58,7 @@
59
58
  "typescript": "^5"
60
59
  },
61
60
  "dependencies": {
61
+ "js-toml": "^1.1.2",
62
62
  "youtube-transcript-plus": "^2.0.0"
63
63
  }
64
64
  }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { rm, readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { cacheKey, cacheDir, readCache, writeCache, extractVideoId } from "./cache";
6
+
7
+ const testDir = "/tmp/prosey/test-cache-spec";
8
+
9
+ afterEach(async () => {
10
+ await rm(testDir, { recursive: true, force: true });
11
+ });
12
+
13
+ describe("cacheKey", () => {
14
+ test("includes video ID and hash", () => {
15
+ const key = cacheKey("dQw4w9WgXcQ", {});
16
+ expect(key).toStartWith("dQw4w9WgXcQ_");
17
+ expect(key.length).toBe(20); // 11 + 1 + 8
18
+ });
19
+
20
+ test("different lang produces different key", () => {
21
+ const a = cacheKey("dQw4w9WgXcQ", { lang: "en" });
22
+ const b = cacheKey("dQw4w9WgXcQ", { lang: "fr" });
23
+ expect(a).not.toBe(b);
24
+ });
25
+
26
+ test("different flags produce different key", () => {
27
+ const a = cacheKey("dQw4w9WgXcQ", {});
28
+ const b = cacheKey("dQw4w9WgXcQ", { timestamps: true });
29
+ expect(a).not.toBe(b);
30
+ });
31
+
32
+ test("mode distinguishes transcript from summarize", () => {
33
+ const a = cacheKey("dQw4w9WgXcQ", {});
34
+ const b = cacheKey("dQw4w9WgXcQ", { mode: "summarize" });
35
+ expect(a).not.toBe(b);
36
+ });
37
+ });
38
+
39
+ describe("cacheDir", () => {
40
+ test("returns path under /tmp/prosey with cache key", () => {
41
+ const key = cacheKey("abc123def45", {});
42
+ const dir = cacheDir("abc123def45", {});
43
+ expect(dir).toBe(`/tmp/prosey/${key}`);
44
+ });
45
+ });
46
+
47
+ describe("extractVideoId", () => {
48
+ test("bare ID passes through", () => {
49
+ expect(extractVideoId("dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
50
+ });
51
+
52
+ test("full watch URL", () => {
53
+ expect(extractVideoId("https://www.youtube.com/watch?v=jNAAG3Ma5K8")).toBe("jNAAG3Ma5K8");
54
+ });
55
+
56
+ test("watch URL with extra query params", () => {
57
+ expect(
58
+ extractVideoId("https://www.youtube.com/watch?v=jNAAG3Ma5K8&pp=ygUKc3BhY2V4IGlwbw%3D%3D"),
59
+ ).toBe("jNAAG3Ma5K8");
60
+ });
61
+
62
+ test("short youtu.be URL", () => {
63
+ expect(extractVideoId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
64
+ });
65
+
66
+ test("embed URL", () => {
67
+ expect(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
68
+ });
69
+
70
+ test("invalid URL without video ID returns null", () => {
71
+ expect(extractVideoId("https://example.com/search?q=hello")).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe("readCache / writeCache", () => {
76
+ test("writes and reads a file", async () => {
77
+ await writeCache(testDir, "test.txt", "hello world");
78
+ const content = await readCache(testDir, "test.txt");
79
+ expect(content).toBe("hello world");
80
+ });
81
+
82
+ test("returns null for missing file", async () => {
83
+ const content = await readCache(testDir, "nonexistent.txt");
84
+ expect(content).toBeNull();
85
+ });
86
+
87
+ test("creates directory on write", async () => {
88
+ expect(existsSync(testDir)).toBe(false);
89
+ await writeCache(testDir, "createdir.txt", "data");
90
+ expect(existsSync(testDir)).toBe(true);
91
+ });
92
+ });
package/src/cache.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const RE_YOUTUBE =
7
+ /(?:v=|\/|v\/|embed\/|watch\?.*v=|youtu\.be\/|\/v\/|e\/|watch\?.*vi?=|\/embed\/|\/v\/|vi?\/|watch\?.*vi?=|youtu\.be\/|\/vi?\/|\/e\/)([a-zA-Z0-9_-]{11})/i;
8
+ const RE_BARE_ID = /^[a-zA-Z0-9_-]{11}$/;
9
+
10
+ export function extractVideoId(input: string): string | null {
11
+ if (RE_BARE_ID.test(input)) return input;
12
+ const match = input.match(RE_YOUTUBE);
13
+ if (match) return match[1] || null;
14
+ return null;
15
+ }
16
+
17
+ export interface CacheOptions {
18
+ lang?: string;
19
+ timestamps?: boolean;
20
+ json?: boolean;
21
+ noDecode?: boolean;
22
+ mode?: string;
23
+ }
24
+
25
+ function hashOptions(opts: CacheOptions): string {
26
+ return createHash("sha256").update(JSON.stringify(opts)).digest("hex").slice(0, 8);
27
+ }
28
+
29
+ export function cacheKey(videoId: string, opts: CacheOptions): string {
30
+ return `${videoId}_${hashOptions(opts)}`;
31
+ }
32
+
33
+ export function cacheDir(videoId: string, opts: CacheOptions): string {
34
+ return join("/tmp", "prosey", cacheKey(videoId, opts));
35
+ }
36
+
37
+ export async function readCache(dir: string, filename: string): Promise<string | null> {
38
+ try {
39
+ return await readFile(join(dir, filename), "utf8");
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export async function writeCache(dir: string, filename: string, data: string): Promise<void> {
46
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
47
+ await writeFile(join(dir, filename), data, "utf8");
48
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { rm, readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { configPath, loadConfig, resetConfig } from "./config";
7
+
8
+ const ORIGINAL_XDG = process.env.XDG_CONFIG_HOME;
9
+ const ORIGINAL_PROSEY_CONFIG = process.env.PROSEY_CONFIG_PATH;
10
+
11
+ afterEach(() => {
12
+ if (ORIGINAL_XDG) process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
13
+ else delete process.env.XDG_CONFIG_HOME;
14
+ if (ORIGINAL_PROSEY_CONFIG) process.env.PROSEY_CONFIG_PATH = ORIGINAL_PROSEY_CONFIG;
15
+ else delete process.env.PROSEY_CONFIG_PATH;
16
+ });
17
+
18
+ describe("configPath", () => {
19
+ test("default path uses XDG_CONFIG_HOME when set", () => {
20
+ process.env.XDG_CONFIG_HOME = "/custom/xdg";
21
+ delete process.env.PROSEY_CONFIG_PATH;
22
+ expect(configPath()).toBe("/custom/xdg/prosey/config.toml");
23
+ });
24
+
25
+ test("default path falls back to ~/.config", () => {
26
+ delete process.env.XDG_CONFIG_HOME;
27
+ delete process.env.PROSEY_CONFIG_PATH;
28
+ expect(configPath()).toBe(join(homedir(), ".config", "prosey", "config.toml"));
29
+ });
30
+
31
+ test("PROSEY_CONFIG_PATH overrides everything", () => {
32
+ process.env.XDG_CONFIG_HOME = "/custom/xdg";
33
+ process.env.PROSEY_CONFIG_PATH = "/override/path/config.toml";
34
+ expect(configPath()).toBe("/override/path/config.toml");
35
+ });
36
+ });
37
+
38
+ describe("loadConfig", () => {
39
+ const tmpConfig = join(import.meta.dir, "..", "tmp-test-config.toml");
40
+
41
+ afterEach(async () => {
42
+ try {
43
+ await rm(tmpConfig);
44
+ } catch {}
45
+ });
46
+
47
+ test("auto-creates config file when missing", async () => {
48
+ process.env.PROSEY_CONFIG_PATH = tmpConfig;
49
+ expect(existsSync(tmpConfig)).toBe(false);
50
+
51
+ const config = await loadConfig();
52
+ expect(config).toEqual({});
53
+ expect(existsSync(tmpConfig)).toBe(true);
54
+
55
+ const content = await readFile(tmpConfig, "utf8");
56
+ expect(content).toContain("[summarize]");
57
+ expect(content).toContain('command = "opencode run"');
58
+ });
59
+
60
+ test("reads existing config file", async () => {
61
+ process.env.PROSEY_CONFIG_PATH = tmpConfig;
62
+
63
+ await loadConfig();
64
+ const config = await loadConfig();
65
+ expect(config.summarize?.prompt).toBeString();
66
+ expect(config.summarize?.command).toBe("opencode run");
67
+ });
68
+
69
+ test("handles invalid TOML gracefully", async () => {
70
+ process.env.PROSEY_CONFIG_PATH = tmpConfig;
71
+ await Bun.write(tmpConfig, "invalid [[\ntoml{{{");
72
+
73
+ const config = await loadConfig();
74
+ expect(config).toEqual({});
75
+ });
76
+ });
77
+
78
+ describe("resetConfig", () => {
79
+ const tmpConfig = join(import.meta.dir, "..", "tmp-reset-config.toml");
80
+
81
+ afterEach(async () => {
82
+ try {
83
+ await rm(tmpConfig);
84
+ } catch {}
85
+ });
86
+
87
+ test("overwrites existing file with defaults", async () => {
88
+ process.env.PROSEY_CONFIG_PATH = tmpConfig;
89
+
90
+ await Bun.write(tmpConfig, "# garbage");
91
+ const path = await resetConfig();
92
+ expect(path).toBe(tmpConfig);
93
+
94
+ const content = await readFile(tmpConfig, "utf8");
95
+ expect(content).toContain("[summarize]");
96
+ expect(content).toContain('command = "opencode run"');
97
+ });
98
+ });
package/src/config.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { load } from "js-toml";
7
+
8
+ export interface ProseyConfig {
9
+ summarize?: {
10
+ prompt?: string;
11
+ command?: string;
12
+ };
13
+ }
14
+
15
+ const FALLBACK_CONFIG_TOML = `# Default prosey configuration
16
+ # Created automatically on first run. Edit as needed.
17
+
18
+ [summarize]
19
+ # Prompt sent to the command via stdin.
20
+ # Customize this to change how transcripts are summarized.
21
+ prompt = """
22
+ Write a comprehensive summary of the following transcription.
23
+ """
24
+
25
+ # Command to execute with the prompt and transcript piped via stdin.
26
+ # The transcript is appended to the prompt automatically.
27
+ #
28
+ # Available options:
29
+ #
30
+ # opencode run — full access (default)
31
+ # opencode run --permissions read — read-only (view files, no edits)
32
+ #
33
+ # claude -p "" --print — full access (--print for clean output)
34
+ # claude --permission-mode plan -p "" --print — read-only (plan/read only)
35
+ #
36
+ # copilot -sp "" — full access (-s = silent, -p = prompt)
37
+ # copilot -sp "" --deny-all-tools — read-only (no shell/write access)
38
+ #
39
+ # codex --sandbox default -p "" — full access
40
+ # codex --sandbox read-only -p "" — read-only
41
+ command = "opencode run"
42
+ `;
43
+
44
+ async function readDefaultConfig(): Promise<string> {
45
+ const paths = [
46
+ join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
47
+ join(dirname(fileURLToPath(import.meta.url)), "..", "src", "default-config.toml"),
48
+ ];
49
+ for (const p of paths) {
50
+ if (existsSync(p)) return readFile(p, "utf8");
51
+ }
52
+ return FALLBACK_CONFIG_TOML;
53
+ }
54
+
55
+ function configDir(): string {
56
+ const env = process.env.XDG_CONFIG_HOME;
57
+ if (env) return join(env, "prosey");
58
+ return join(homedir(), ".config", "prosey");
59
+ }
60
+
61
+ export function configPath(): string {
62
+ return process.env.PROSEY_CONFIG_PATH ?? join(configDir(), "config.toml");
63
+ }
64
+
65
+ function ensureDir(path: string): Promise<void> {
66
+ return mkdir(path, { recursive: true }) as Promise<void>;
67
+ }
68
+
69
+ export async function loadConfig(): Promise<ProseyConfig> {
70
+ const path = configPath();
71
+
72
+ if (!existsSync(path)) {
73
+ const dir = configDir();
74
+ await ensureDir(dir);
75
+ await writeFile(path, await readDefaultConfig(), "utf8");
76
+ return {};
77
+ }
78
+
79
+ const raw = await readFile(path, "utf8");
80
+ try {
81
+ return load(raw) as ProseyConfig;
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ export async function resetConfig(): Promise<string> {
88
+ const path = configPath();
89
+ const dir = configDir();
90
+ await ensureDir(dir);
91
+ await writeFile(path, await readDefaultConfig(), "utf8");
92
+ return path;
93
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,13 @@
1
+ const GRAY = "\x1b[90m";
2
+ const RESET = "\x1b[0m";
3
+
4
+ let enabled = false;
5
+
6
+ export function enableDebug(): void {
7
+ enabled = true;
8
+ }
9
+
10
+ export function debug(...args: unknown[]): void {
11
+ if (!enabled) return;
12
+ console.error(GRAY, ...args, RESET);
13
+ }
@@ -0,0 +1,16 @@
1
+ # Default prosey configuration
2
+ # Created automatically on first run. Edit as needed.
3
+
4
+ [summarize]
5
+
6
+ # Prompt sent to the command via stdin.
7
+ # Customize this to change how transcripts are summarized.
8
+
9
+ prompt = """
10
+ Write a comprehensive summary of the following transcription.
11
+ """
12
+
13
+ # Command to execute with the prompt and transcript piped via stdin.
14
+ # The transcript is appended to the prompt automatically.
15
+
16
+ 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/index.ts CHANGED
@@ -2,8 +2,13 @@
2
2
 
3
3
  import { writeFile } from "node:fs/promises";
4
4
  import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
5
- import type { CaptionTrackInfo, VideoDetails } from "youtube-transcript-plus";
5
+ import type { CaptionTrackInfo, VideoDetails, TranscriptSegment } from "youtube-transcript-plus";
6
6
  import { formatWithTimestamps, toText, toJSON, formatDuration, decodeEntities } from "./format";
7
+ import { loadConfig, resetConfig, configPath } from "./config";
8
+ import type { ProseyConfig } from "./config";
9
+ import { summarize } from "./summarize";
10
+ import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
11
+ import { enableDebug, debug } from "./debug";
7
12
 
8
13
  const NAME = "prosey";
9
14
  const VERSION = "0.1.0";
@@ -13,11 +18,13 @@ function help(): string {
13
18
 
14
19
  Usage: ${NAME} [options] <video-url-or-id>
15
20
  ${NAME} info [options] <video-url-or-id>
21
+ ${NAME} summarize [options] <video-url-or-id>
16
22
 
17
23
  Download a YouTube video transcript or show video details.
18
24
 
19
25
  Commands:
20
26
  info Show video metadata (title, channel, duration, etc.)
27
+ summarize Pipe transcript to the command configured in [summarize]
21
28
 
22
29
  Arguments:
23
30
  video-url-or-id YouTube URL (full or short) or bare video ID
@@ -32,6 +39,9 @@ Options:
32
39
  --details Prepend video details to transcript (default, text only).
33
40
  --no-details Suppress video details, transcript only.
34
41
  --no-decode-entities Preserve HTML entities (decoded by default).
42
+ --reset-config Reset config file to defaults and exit.
43
+ --no-cache Skip cache and overwrite cache files.
44
+ --debug Print debug information to stderr.
35
45
  --help Show this help message.
36
46
  --version Show version.
37
47
 
@@ -121,10 +131,19 @@ if (args.includes("--version")) {
121
131
  process.exit(0);
122
132
  }
123
133
 
134
+ if (args.includes("--reset-config")) {
135
+ const path = await resetConfig();
136
+ console.log(`Config reset to defaults: ${path}`);
137
+ process.exit(0);
138
+ }
139
+
140
+ const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
141
+
124
142
  let mode = "transcript";
125
- if (args[0] === "info") {
126
- mode = "info";
127
- args.splice(0, 1);
143
+ const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
144
+ if (subcmdIndex !== -1) {
145
+ mode = args[subcmdIndex]!;
146
+ args.splice(subcmdIndex, 1);
128
147
  }
129
148
 
130
149
  let videoId = "";
@@ -135,6 +154,8 @@ let outputPath: string | undefined;
135
154
  let outputJson = false;
136
155
  let noDecode = false;
137
156
  let showDetails = true;
157
+ let noCache = false;
158
+ let debugMode = false;
138
159
 
139
160
  for (let i = 0; i < args.length; i++) {
140
161
  const arg = args[i];
@@ -163,6 +184,10 @@ for (let i = 0; i < args.length; i++) {
163
184
  showDetails = true;
164
185
  } else if (arg === "--no-details") {
165
186
  showDetails = false;
187
+ } else if (arg === "--no-cache") {
188
+ noCache = true;
189
+ } else if (arg === "--debug") {
190
+ debugMode = true;
166
191
  } else if (arg === "--no-decode-entities") {
167
192
  noDecode = true;
168
193
  } else if (arg.startsWith("-")) {
@@ -179,6 +204,21 @@ if (!videoId) {
179
204
  process.exit(1);
180
205
  }
181
206
 
207
+ const extracted = extractVideoId(videoId);
208
+
209
+ if (!extracted) {
210
+ console.error("Error: invalid YouTube video URL or ID");
211
+ process.exit(65);
212
+ }
213
+
214
+ videoId = extracted;
215
+
216
+ if (debugMode) enableDebug();
217
+ debug("Config file:", configPath());
218
+ debug("Video ID:", videoId);
219
+ debug("Mode:", mode);
220
+ if (lang) debug("Language:", lang);
221
+
182
222
  try {
183
223
  if (mode === "info") {
184
224
  const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
@@ -190,25 +230,116 @@ try {
190
230
  process.exit(0);
191
231
  }
192
232
 
193
- if (listOnly) {
233
+ if (mode === "summarize") {
234
+ if (!config.summarize?.command) {
235
+ console.error("Error: [summarize] section with a command is required in config");
236
+ process.exit(1);
237
+ }
238
+
239
+ const cacheOpts = { lang, mode: "summarize", noDecode };
240
+ const dir = cacheDir(videoId, cacheOpts);
241
+ let segments: TranscriptSegment[] | null = null;
242
+ let summary: string | null = null;
243
+
244
+ if (!noCache) {
245
+ const cachedSegments = await readCache(dir, "transcript.json");
246
+ const cachedSummary = await readCache(dir, "summary.md");
247
+ if (cachedSegments && cachedSummary) {
248
+ debug("Cache hit:", dir);
249
+ segments = JSON.parse(cachedSegments);
250
+ summary = cachedSummary;
251
+ } else {
252
+ debug("Cache miss:", dir);
253
+ }
254
+ } else {
255
+ debug("Cache skipped (--no-cache)");
256
+ }
257
+
258
+ if (!segments) {
259
+ debug("Fetching transcript...");
260
+ segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
261
+ debug(`Transcript fetched: ${segments.length} segments`);
262
+ await writeCache(dir, "transcript.json", JSON.stringify(segments));
263
+ debug("Cache written: transcript.json");
264
+ }
265
+
266
+ const prompt = config.summarize.prompt ?? "";
267
+ const transcriptText = toText(segments, !noDecode);
268
+
269
+ if (!summary) {
270
+ debug("Running command:", config.summarize.command);
271
+ summary = await summarize({
272
+ prompt,
273
+ command: config.summarize.command,
274
+ transcript: transcriptText,
275
+ cwd: dir,
276
+ });
277
+ debug("Command exit: 0");
278
+ await writeCache(dir, "summary.md", summary);
279
+ debug("Cache written: summary.md");
280
+ }
281
+
282
+ if (outputPath) {
283
+ await writeFile(outputPath, summary, "utf8");
284
+ } else {
285
+ console.log(summary);
286
+ }
287
+ process.exit(0);
288
+ } else if (listOnly) {
194
289
  const languages = await listLanguages(videoId);
195
290
  printLanguages(languages);
196
291
  process.exit(0);
197
292
  }
198
293
 
199
294
  const decode = !noDecode;
295
+ const cacheOpts = { lang, timestamps, json: outputJson, noDecode };
296
+ const dir = cacheDir(videoId, cacheOpts);
297
+ let segments: TranscriptSegment[] | null = null;
298
+ let videoDetailsCache: VideoDetails | null = null;
299
+
300
+ if (!noCache) {
301
+ const cached = await readCache(dir, "transcript.json");
302
+ if (cached) {
303
+ debug("Cache hit:", dir);
304
+ segments = JSON.parse(cached);
305
+ } else {
306
+ debug("Cache miss:", dir);
307
+ }
308
+ } else {
309
+ debug("Cache skipped (--no-cache)");
310
+ }
311
+
312
+ if (!segments) {
313
+ debug("Fetching transcript...");
314
+ if (showDetails && !outputJson) {
315
+ const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
316
+ const result = (await fetchTranscript(videoId, opts)) as {
317
+ videoDetails: VideoDetails;
318
+ segments: TranscriptSegment[];
319
+ };
320
+ segments = result.segments;
321
+ videoDetailsCache = result.videoDetails;
322
+ } else {
323
+ segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
324
+ }
325
+ await writeCache(dir, "transcript.json", JSON.stringify(segments));
326
+ }
200
327
 
201
328
  if (showDetails && !outputJson) {
202
- const config = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
203
- const result = (await fetchTranscript(videoId, config)) as {
204
- videoDetails: VideoDetails;
205
- segments: { text: string; offset: number; duration: number; lang: string }[];
206
- };
207
- const detailsBlock = formatDetailsBlock(result.videoDetails);
208
- const transcript = timestamps
209
- ? formatWithTimestamps(result.segments, decode)
210
- : toText(result.segments, decode);
211
- const output = detailsBlock + "\n\n\n" + transcript + "\n";
329
+ if (!videoDetailsCache) {
330
+ const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
331
+ const result = (await fetchTranscript(videoId, opts)) as {
332
+ videoDetails: VideoDetails;
333
+ segments: TranscriptSegment[];
334
+ };
335
+ videoDetailsCache = result.videoDetails;
336
+ }
337
+
338
+ const detailsBlock = formatDetailsBlock(videoDetailsCache);
339
+ const transcriptText = timestamps
340
+ ? formatWithTimestamps(segments, decode)
341
+ : toText(segments, decode);
342
+ const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
212
343
 
213
344
  if (outputPath) {
214
345
  await writeFile(outputPath, output, "utf8");
@@ -216,10 +347,6 @@ try {
216
347
  console.log(output);
217
348
  }
218
349
  } else {
219
- const segments = lang
220
- ? await fetchTranscript(videoId, { lang })
221
- : await fetchTranscript(videoId);
222
-
223
350
  const output = outputJson
224
351
  ? toJSON(segments, decode) + "\n"
225
352
  : timestamps
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { summarize } from "./summarize";
3
+
4
+ describe("summarize", () => {
5
+ test("rejects when command only echoes input", async () => {
6
+ await expect(
7
+ summarize({
8
+ prompt: "Summarize this:",
9
+ command: "cat",
10
+ transcript: "Hello world. This is the transcript.",
11
+ }),
12
+ ).rejects.toThrow("Summarization command returned no meaningful output");
13
+ });
14
+
15
+ test("rejects when command only echoes input with empty prompt", async () => {
16
+ await expect(
17
+ summarize({
18
+ prompt: "",
19
+ command: "cat",
20
+ transcript: "Just the transcript.",
21
+ }),
22
+ ).rejects.toThrow("Summarization command returned no meaningful output");
23
+ });
24
+
25
+ test("preserves response text beyond the input", async () => {
26
+ const cmd = "sh -c 'cat -; echo \"RESPONSE\"'";
27
+ const result = await summarize({
28
+ prompt: "Summarize:",
29
+ command: cmd,
30
+ transcript: "Transcript text.",
31
+ });
32
+ expect(result).toBe("RESPONSE");
33
+ });
34
+
35
+ test("rejects on non-zero exit code", async () => {
36
+ await expect(
37
+ summarize({
38
+ prompt: "test",
39
+ command: "false",
40
+ transcript: "whatever",
41
+ }),
42
+ ).rejects.toThrow(/exited with code 1/);
43
+ });
44
+ });