@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/README.md +61 -31
- package/bin/prosey +14317 -0
- package/package.json +5 -5
- package/src/cache.test.ts +92 -0
- package/src/cache.ts +48 -0
- package/src/config.test.ts +98 -0
- package/src/config.ts +93 -0
- package/src/debug.ts +13 -0
- package/src/default-config.toml +16 -0
- package/src/index.ts +146 -19
- package/src/summarize.test.ts +44 -0
- package/src/summarize.ts +51 -0
- package/bin/prosey.js +0 -604
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tacone/prosey",
|
|
3
|
-
"version": "0.1
|
|
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
|
|
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 --
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
args
|
|
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 (
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
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
|
+
});
|