@tacone/prosey 0.1.0 → 0.2.0

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.0",
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,64 @@
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 } 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("readCache / writeCache", () => {
48
+ test("writes and reads a file", async () => {
49
+ await writeCache(testDir, "test.txt", "hello world");
50
+ const content = await readCache(testDir, "test.txt");
51
+ expect(content).toBe("hello world");
52
+ });
53
+
54
+ test("returns null for missing file", async () => {
55
+ const content = await readCache(testDir, "nonexistent.txt");
56
+ expect(content).toBeNull();
57
+ });
58
+
59
+ test("creates directory on write", async () => {
60
+ expect(existsSync(testDir)).toBe(false);
61
+ await writeCache(testDir, "createdir.txt", "data");
62
+ expect(existsSync(testDir)).toBe(true);
63
+ });
64
+ });
package/src/cache.ts ADDED
@@ -0,0 +1,37 @@
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
+ export interface CacheOptions {
7
+ lang?: string;
8
+ timestamps?: boolean;
9
+ json?: boolean;
10
+ noDecode?: boolean;
11
+ mode?: string;
12
+ }
13
+
14
+ function hashOptions(opts: CacheOptions): string {
15
+ return createHash("sha256").update(JSON.stringify(opts)).digest("hex").slice(0, 8);
16
+ }
17
+
18
+ export function cacheKey(videoId: string, opts: CacheOptions): string {
19
+ return `${videoId}_${hashOptions(opts)}`;
20
+ }
21
+
22
+ export function cacheDir(videoId: string, opts: CacheOptions): string {
23
+ return join("/tmp", "prosey", cacheKey(videoId, opts));
24
+ }
25
+
26
+ export async function readCache(dir: string, filename: string): Promise<string | null> {
27
+ try {
28
+ return await readFile(join(dir, filename), "utf8");
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export async function writeCache(dir: string, filename: string, data: string): Promise<void> {
35
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
36
+ await writeFile(join(dir, filename), data, "utf8");
37
+ }
@@ -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,79 @@
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
+ Summarize the following transcript.
23
+ Focus on the key points and main arguments.
24
+ """
25
+
26
+ # Command to execute with the prompt piped via stdin.
27
+ command = "opencode run"
28
+ `;
29
+
30
+ async function readDefaultConfig(): Promise<string> {
31
+ const paths = [
32
+ join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
33
+ join(dirname(fileURLToPath(import.meta.url)), "..", "src", "default-config.toml"),
34
+ ];
35
+ for (const p of paths) {
36
+ if (existsSync(p)) return readFile(p, "utf8");
37
+ }
38
+ return FALLBACK_CONFIG_TOML;
39
+ }
40
+
41
+ function configDir(): string {
42
+ const env = process.env.XDG_CONFIG_HOME;
43
+ if (env) return join(env, "prosey");
44
+ return join(homedir(), ".config", "prosey");
45
+ }
46
+
47
+ export function configPath(): string {
48
+ return process.env.PROSEY_CONFIG_PATH ?? join(configDir(), "config.toml");
49
+ }
50
+
51
+ function ensureDir(path: string): Promise<void> {
52
+ return mkdir(path, { recursive: true }) as Promise<void>;
53
+ }
54
+
55
+ export async function loadConfig(): Promise<ProseyConfig> {
56
+ const path = configPath();
57
+
58
+ if (!existsSync(path)) {
59
+ const dir = configDir();
60
+ await ensureDir(dir);
61
+ await writeFile(path, await readDefaultConfig(), "utf8");
62
+ return {};
63
+ }
64
+
65
+ const raw = await readFile(path, "utf8");
66
+ try {
67
+ return load(raw) as ProseyConfig;
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ export async function resetConfig(): Promise<string> {
74
+ const path = configPath();
75
+ const dir = configDir();
76
+ await ensureDir(dir);
77
+ await writeFile(path, await readDefaultConfig(), "utf8");
78
+ return path;
79
+ }
@@ -0,0 +1,12 @@
1
+ # Default prosey configuration
2
+ # Created automatically on first run. Edit as needed.
3
+
4
+ [summarize]
5
+ # Prompt sent to the command via stdin.
6
+ # Customize this to change how transcripts are summarized.
7
+ prompt = """
8
+ Write a comprehensive summary of the following transcription.
9
+ """
10
+
11
+ # Command to execute with the prompt piped via stdin.
12
+ command = "opencode run"
package/src/index.ts CHANGED
@@ -2,8 +2,12 @@
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 } from "./config";
8
+ import type { ProseyConfig } from "./config";
9
+ import { summarize } from "./summarize";
10
+ import { cacheDir, readCache, writeCache } from "./cache";
7
11
 
8
12
  const NAME = "prosey";
9
13
  const VERSION = "0.1.0";
@@ -13,11 +17,13 @@ function help(): string {
13
17
 
14
18
  Usage: ${NAME} [options] <video-url-or-id>
15
19
  ${NAME} info [options] <video-url-or-id>
20
+ ${NAME} summarize [options] <video-url-or-id>
16
21
 
17
22
  Download a YouTube video transcript or show video details.
18
23
 
19
24
  Commands:
20
25
  info Show video metadata (title, channel, duration, etc.)
26
+ summarize Pipe transcript to the command configured in [summarize]
21
27
 
22
28
  Arguments:
23
29
  video-url-or-id YouTube URL (full or short) or bare video ID
@@ -32,6 +38,8 @@ Options:
32
38
  --details Prepend video details to transcript (default, text only).
33
39
  --no-details Suppress video details, transcript only.
34
40
  --no-decode-entities Preserve HTML entities (decoded by default).
41
+ --reset-config Reset config file to defaults and exit.
42
+ --no-cache Skip cache and overwrite cache files.
35
43
  --help Show this help message.
36
44
  --version Show version.
37
45
 
@@ -121,10 +129,21 @@ if (args.includes("--version")) {
121
129
  process.exit(0);
122
130
  }
123
131
 
132
+ if (args.includes("--reset-config")) {
133
+ const path = await resetConfig();
134
+ console.log(`Config reset to defaults: ${path}`);
135
+ process.exit(0);
136
+ }
137
+
138
+ const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
139
+
124
140
  let mode = "transcript";
125
141
  if (args[0] === "info") {
126
142
  mode = "info";
127
143
  args.splice(0, 1);
144
+ } else if (args[0] === "summarize") {
145
+ mode = "summarize";
146
+ args.splice(0, 1);
128
147
  }
129
148
 
130
149
  let videoId = "";
@@ -135,6 +154,7 @@ let outputPath: string | undefined;
135
154
  let outputJson = false;
136
155
  let noDecode = false;
137
156
  let showDetails = true;
157
+ let noCache = false;
138
158
 
139
159
  for (let i = 0; i < args.length; i++) {
140
160
  const arg = args[i];
@@ -163,6 +183,8 @@ for (let i = 0; i < args.length; i++) {
163
183
  showDetails = true;
164
184
  } else if (arg === "--no-details") {
165
185
  showDetails = false;
186
+ } else if (arg === "--no-cache") {
187
+ noCache = true;
166
188
  } else if (arg === "--no-decode-entities") {
167
189
  noDecode = true;
168
190
  } else if (arg.startsWith("-")) {
@@ -190,25 +212,97 @@ try {
190
212
  process.exit(0);
191
213
  }
192
214
 
193
- if (listOnly) {
215
+ if (mode === "summarize") {
216
+ if (!config.summarize?.command) {
217
+ console.error("Error: [summarize] section with a command is required in config");
218
+ process.exit(1);
219
+ }
220
+
221
+ const cacheOpts = { lang, mode: "summarize", noDecode };
222
+ const dir = cacheDir(videoId, cacheOpts);
223
+ let segments: TranscriptSegment[] | null = null;
224
+ let summary: string | null = null;
225
+
226
+ if (!noCache) {
227
+ const cachedSegments = await readCache(dir, "transcript.json");
228
+ const cachedSummary = await readCache(dir, "summary.md");
229
+ if (cachedSegments && cachedSummary) {
230
+ segments = JSON.parse(cachedSegments);
231
+ summary = cachedSummary;
232
+ }
233
+ }
234
+
235
+ if (!segments) {
236
+ segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
237
+ await writeCache(dir, "transcript.json", JSON.stringify(segments));
238
+ }
239
+
240
+ const prompt = config.summarize.prompt ?? "";
241
+ const transcriptText = toText(segments, !noDecode);
242
+
243
+ if (!summary) {
244
+ summary = await summarize({
245
+ prompt,
246
+ command: config.summarize.command,
247
+ transcript: transcriptText,
248
+ cwd: dir,
249
+ });
250
+ await writeCache(dir, "summary.md", summary);
251
+ }
252
+
253
+ if (outputPath) {
254
+ await writeFile(outputPath, summary, "utf8");
255
+ } else {
256
+ console.log(summary);
257
+ }
258
+ process.exit(0);
259
+ } else if (listOnly) {
194
260
  const languages = await listLanguages(videoId);
195
261
  printLanguages(languages);
196
262
  process.exit(0);
197
263
  }
198
264
 
199
265
  const decode = !noDecode;
266
+ const cacheOpts = { lang, timestamps, json: outputJson, noDecode };
267
+ const dir = cacheDir(videoId, cacheOpts);
268
+ let segments: TranscriptSegment[] | null = null;
269
+ let videoDetailsCache: VideoDetails | null = null;
270
+
271
+ if (!noCache) {
272
+ const cached = await readCache(dir, "transcript.json");
273
+ if (cached) segments = JSON.parse(cached);
274
+ }
275
+
276
+ if (!segments) {
277
+ if (showDetails && !outputJson) {
278
+ const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
279
+ const result = (await fetchTranscript(videoId, opts)) as {
280
+ videoDetails: VideoDetails;
281
+ segments: TranscriptSegment[];
282
+ };
283
+ segments = result.segments;
284
+ videoDetailsCache = result.videoDetails;
285
+ } else {
286
+ segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
287
+ }
288
+ await writeCache(dir, "transcript.json", JSON.stringify(segments));
289
+ }
200
290
 
201
291
  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";
292
+ if (!videoDetailsCache) {
293
+ const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
294
+ const result = (await fetchTranscript(videoId, opts)) as {
295
+ videoDetails: VideoDetails;
296
+ segments: TranscriptSegment[];
297
+ };
298
+ videoDetailsCache = result.videoDetails;
299
+ }
300
+
301
+ const detailsBlock = formatDetailsBlock(videoDetailsCache);
302
+ const transcriptText = timestamps
303
+ ? formatWithTimestamps(segments, decode)
304
+ : toText(segments, decode);
305
+ const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
212
306
 
213
307
  if (outputPath) {
214
308
  await writeFile(outputPath, output, "utf8");
@@ -216,10 +310,6 @@ try {
216
310
  console.log(output);
217
311
  }
218
312
  } else {
219
- const segments = lang
220
- ? await fetchTranscript(videoId, { lang })
221
- : await fetchTranscript(videoId);
222
-
223
313
  const output = outputJson
224
314
  ? toJSON(segments, decode) + "\n"
225
315
  : timestamps
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { summarize } from "./summarize";
3
+
4
+ describe("summarize", () => {
5
+ test("strips input text from output", async () => {
6
+ const result = await summarize({
7
+ prompt: "Summarize this:",
8
+ command: "cat",
9
+ transcript: "Hello world. This is the transcript.",
10
+ });
11
+ expect(result).toBe("");
12
+ });
13
+
14
+ test("strips input text from output with empty prompt", async () => {
15
+ const result = await summarize({
16
+ prompt: "",
17
+ command: "cat",
18
+ transcript: "Just the transcript.",
19
+ });
20
+ expect(result).toBe("");
21
+ });
22
+
23
+ test("preserves response text beyond the input", async () => {
24
+ const cmd = "sh -c 'cat -; echo \"RESPONSE\"'";
25
+ const result = await summarize({
26
+ prompt: "Summarize:",
27
+ command: cmd,
28
+ transcript: "Transcript text.",
29
+ });
30
+ expect(result).toBe("RESPONSE");
31
+ });
32
+
33
+ test("rejects on non-zero exit code", async () => {
34
+ await expect(
35
+ summarize({
36
+ prompt: "test",
37
+ command: "false",
38
+ transcript: "whatever",
39
+ }),
40
+ ).rejects.toThrow(/exited with code 1/);
41
+ });
42
+ });
@@ -0,0 +1,42 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface SummarizeOptions {
4
+ prompt: string;
5
+ command: string;
6
+ transcript: string;
7
+ cwd?: string;
8
+ }
9
+
10
+ function executeCommand(command: string, input: string, cwd?: string): Promise<string> {
11
+ return new Promise((resolve, reject) => {
12
+ const proc = spawn(command, [], { shell: true, stdio: "pipe", cwd });
13
+
14
+ let stdout = "";
15
+ let stderr = "";
16
+
17
+ proc.stdout!.on("data", (data: Buffer) => {
18
+ stdout += data.toString();
19
+ });
20
+
21
+ proc.stderr!.on("data", (data: Buffer) => {
22
+ stderr += data.toString();
23
+ });
24
+
25
+ proc.on("close", (code: number | null) => {
26
+ if (code === 0) resolve(stdout);
27
+ else reject(new Error(`Command exited with code ${code}: ${stderr}`));
28
+ });
29
+
30
+ proc.on("error", (err: Error) => reject(err));
31
+
32
+ proc.stdin!.write(input);
33
+ proc.stdin!.end();
34
+ });
35
+ }
36
+
37
+ export async function summarize(options: SummarizeOptions): Promise<string> {
38
+ const { prompt, command, transcript, cwd } = options;
39
+ const fullPrompt = `${prompt}\n\n${transcript}`;
40
+ const output = await executeCommand(command, fullPrompt, cwd);
41
+ return output.replace(fullPrompt, "").replace(/\n+$/, "");
42
+ }