@tacone/prosey 0.2.2 → 0.2.4
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 +113235 -6858
- package/package.json +3 -3
- package/src/config-command.test.ts +50 -0
- package/src/config.test.ts +3 -3
- package/src/config.ts +12 -0
- package/src/debug.test.ts +87 -0
- package/src/debug.ts +47 -5
- package/src/default-config.toml +24 -1
- package/src/format.test.ts +17 -1
- package/src/index.ts +130 -27
- package/src/pager.test.ts +65 -0
- package/src/pager.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tacone/prosey",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Download YouTube video transcripts from the CLI",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -51,14 +51,14 @@
|
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/bun": "latest",
|
|
53
53
|
"husky": "^9.1.7",
|
|
54
|
-
"lint-staged": "^17.0.7"
|
|
55
|
-
"prettier": "^3.8.4"
|
|
54
|
+
"lint-staged": "^17.0.7"
|
|
56
55
|
},
|
|
57
56
|
"peerDependencies": {
|
|
58
57
|
"typescript": "^5"
|
|
59
58
|
},
|
|
60
59
|
"dependencies": {
|
|
61
60
|
"js-toml": "^1.1.2",
|
|
61
|
+
"prettier": "^3.8.4",
|
|
62
62
|
"youtube-transcript-plus": "^2.0.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test, afterEach } from "bun:test";
|
|
2
|
+
import { $ } from "bun";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const testConfigPath = "/tmp/prosey-test-config.toml";
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
try {
|
|
10
|
+
await rm(testConfigPath);
|
|
11
|
+
} catch {}
|
|
12
|
+
delete process.env.PROSEY_CONFIG_PATH;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("config subcommand", () => {
|
|
16
|
+
test("prints config path when EDITOR is not set", async () => {
|
|
17
|
+
delete process.env.EDITOR;
|
|
18
|
+
process.env.PROSEY_CONFIG_PATH = testConfigPath;
|
|
19
|
+
|
|
20
|
+
const { stdout, exitCode } = await $`bin/prosey config`.quiet();
|
|
21
|
+
|
|
22
|
+
expect(exitCode).toBe(0);
|
|
23
|
+
expect(stdout.toString().trim()).toBe(`Config file: ${testConfigPath}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("creates config file when missing", async () => {
|
|
27
|
+
delete process.env.EDITOR;
|
|
28
|
+
process.env.PROSEY_CONFIG_PATH = testConfigPath;
|
|
29
|
+
|
|
30
|
+
expect(existsSync(testConfigPath)).toBe(false);
|
|
31
|
+
await $`bin/prosey config`.quiet();
|
|
32
|
+
expect(existsSync(testConfigPath)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not require a video ID", async () => {
|
|
36
|
+
delete process.env.EDITOR;
|
|
37
|
+
process.env.PROSEY_CONFIG_PATH = testConfigPath;
|
|
38
|
+
|
|
39
|
+
const { exitCode } = await $`bin/prosey config`.quiet();
|
|
40
|
+
expect(exitCode).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("exits with code 0 after spawning editor", async () => {
|
|
44
|
+
process.env.EDITOR = "cat";
|
|
45
|
+
process.env.PROSEY_CONFIG_PATH = testConfigPath;
|
|
46
|
+
|
|
47
|
+
const { exitCode } = await $`bin/prosey config`.quiet();
|
|
48
|
+
expect(exitCode).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/config.test.ts
CHANGED
|
@@ -54,7 +54,7 @@ describe("loadConfig", () => {
|
|
|
54
54
|
|
|
55
55
|
const content = await readFile(tmpConfig, "utf8");
|
|
56
56
|
expect(content).toContain("[summarize]");
|
|
57
|
-
expect(content).toContain(
|
|
57
|
+
expect(content).toContain("command = ");
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
test("reads existing config file", async () => {
|
|
@@ -63,7 +63,7 @@ describe("loadConfig", () => {
|
|
|
63
63
|
await loadConfig();
|
|
64
64
|
const config = await loadConfig();
|
|
65
65
|
expect(config.summarize?.prompt).toBeString();
|
|
66
|
-
expect(config.summarize?.command).
|
|
66
|
+
expect(config.summarize?.command).toBeString();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
test("handles invalid TOML gracefully", async () => {
|
|
@@ -93,6 +93,6 @@ describe("resetConfig", () => {
|
|
|
93
93
|
|
|
94
94
|
const content = await readFile(tmpConfig, "utf8");
|
|
95
95
|
expect(content).toContain("[summarize]");
|
|
96
|
-
expect(content).toContain(
|
|
96
|
+
expect(content).toContain("command = ");
|
|
97
97
|
});
|
|
98
98
|
});
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { load } from "js-toml";
|
|
7
7
|
|
|
8
8
|
export interface ProseyConfig {
|
|
9
|
+
pager?: string;
|
|
10
|
+
hints?: boolean;
|
|
9
11
|
summarize?: {
|
|
10
12
|
prompt?: string;
|
|
11
13
|
command?: string;
|
|
@@ -15,6 +17,16 @@ export interface ProseyConfig {
|
|
|
15
17
|
const FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
16
18
|
# Created automatically on first run. Edit as needed.
|
|
17
19
|
|
|
20
|
+
# Pager command for transcript and summary output.
|
|
21
|
+
# Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
|
|
22
|
+
# Set to a custom command (e.g. "less -R") to override.
|
|
23
|
+
# Can also be set via the PROSEY_PAGER env var (takes precedence).
|
|
24
|
+
pager = "auto"
|
|
25
|
+
|
|
26
|
+
# Show hints for missing tools (e.g. markdown highlighter).
|
|
27
|
+
# Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
|
|
28
|
+
hints = true
|
|
29
|
+
|
|
18
30
|
[summarize]
|
|
19
31
|
# Prompt sent to the command via stdin.
|
|
20
32
|
# Customize this to change how transcripts are summarized.
|
|
@@ -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 → glow → 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 for missing tools (e.g. markdown highlighter).
|
|
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.
|
|
@@ -12,5 +22,18 @@ Write a comprehensive summary of the following transcription.
|
|
|
12
22
|
|
|
13
23
|
# Command to execute with the prompt and transcript piped via stdin.
|
|
14
24
|
# The transcript is appended to the prompt automatically.
|
|
15
|
-
|
|
25
|
+
#
|
|
26
|
+
# Available options:
|
|
27
|
+
#
|
|
28
|
+
# opencode run — full access (default)
|
|
29
|
+
# opencode run --permissions read — read-only (view files, no edits)
|
|
30
|
+
#
|
|
31
|
+
# claude -p "" --print — full access (--print for clean output)
|
|
32
|
+
# claude --permission-mode plan -p "" --print — read-only (plan/read only)
|
|
33
|
+
#
|
|
34
|
+
# copilot -sp "" — full access (-s = silent, -p = prompt)
|
|
35
|
+
# copilot -sp "" --deny-all-tools — read-only (no shell/write access)
|
|
36
|
+
#
|
|
37
|
+
# codex --sandbox default -p "" — full access
|
|
38
|
+
# codex --sandbox read-only -p "" — read-only
|
|
16
39
|
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,8 +12,8 @@ import { loadConfig, resetConfig, configPath } from "./config";
|
|
|
8
12
|
import type { ProseyConfig } from "./config";
|
|
9
13
|
import { summarize } from "./summarize";
|
|
10
14
|
import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
|
|
11
|
-
import { enableDebug, debug } from "./debug";
|
|
12
15
|
import pkg from "../package.json";
|
|
16
|
+
import prettier from "prettier";
|
|
13
17
|
|
|
14
18
|
const NAME = "prosey";
|
|
15
19
|
const VERSION = pkg.version;
|
|
@@ -20,12 +24,14 @@ function help(): string {
|
|
|
20
24
|
Usage: ${NAME} [options] <video-url-or-id>
|
|
21
25
|
${NAME} info [options] <video-url-or-id>
|
|
22
26
|
${NAME} summarize [options] <video-url-or-id>
|
|
27
|
+
${NAME} config
|
|
23
28
|
|
|
24
29
|
Download a YouTube video transcript or show video details.
|
|
25
30
|
|
|
26
31
|
Commands:
|
|
27
32
|
info Show video metadata (title, channel, duration, etc.)
|
|
28
33
|
summarize Pipe transcript to the command configured in [summarize]
|
|
34
|
+
config Open config file in \$EDITOR
|
|
29
35
|
|
|
30
36
|
Arguments:
|
|
31
37
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -42,7 +48,13 @@ Options:
|
|
|
42
48
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
43
49
|
--reset-config Reset config file to defaults and exit.
|
|
44
50
|
--no-cache Skip cache and overwrite cache files.
|
|
45
|
-
--
|
|
51
|
+
--no-format Skip prettier formatting.
|
|
52
|
+
--no-pager Disable pager for stdout output.
|
|
53
|
+
--pager Use pager for stdout output (default).
|
|
54
|
+
--no-hints Disable hints.
|
|
55
|
+
--hints Show hints (default).
|
|
56
|
+
-q, --quiet Suppress all stderr logging.
|
|
57
|
+
-v, --verbose Print debug information to stderr.
|
|
46
58
|
--help Show this help message.
|
|
47
59
|
--version Show version.
|
|
48
60
|
|
|
@@ -120,6 +132,50 @@ function printLanguages(languages: CaptionTrackInfo[]): void {
|
|
|
120
132
|
console.log(`Available transcripts (${languages.length}):\n${rows.join("\n")}`);
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
async function formatMd(text: string): Promise<string> {
|
|
136
|
+
try {
|
|
137
|
+
return await prettier.format(text, { parser: "markdown" });
|
|
138
|
+
} catch {
|
|
139
|
+
return text;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function outputText(text: string): Promise<void> {
|
|
144
|
+
if (outputPath) {
|
|
145
|
+
await writeFile(outputPath, text, "utf8");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!pagerCmd || !process.stdout.isTTY) {
|
|
150
|
+
process.stdout.write(text);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parts = pagerCmd.split(/\s+/);
|
|
155
|
+
const proc = spawn(parts[0]!, parts.slice(1), {
|
|
156
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
157
|
+
}) as import("node:child_process").ChildProcess;
|
|
158
|
+
|
|
159
|
+
await new Promise<void>((resolve) => {
|
|
160
|
+
let done = false;
|
|
161
|
+
proc.on("error", () => {
|
|
162
|
+
if (done) return;
|
|
163
|
+
done = true;
|
|
164
|
+
process.stdout.write(text);
|
|
165
|
+
resolve();
|
|
166
|
+
});
|
|
167
|
+
proc.on("exit", () => {
|
|
168
|
+
if (done) return;
|
|
169
|
+
done = true;
|
|
170
|
+
resolve();
|
|
171
|
+
});
|
|
172
|
+
proc.stdin!.write(text);
|
|
173
|
+
proc.stdin!.end();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let pagerCmd: string | null = null;
|
|
178
|
+
|
|
123
179
|
const args = process.argv.slice(2);
|
|
124
180
|
|
|
125
181
|
if (args.length === 0 || args.includes("--help")) {
|
|
@@ -141,7 +197,7 @@ if (args.includes("--reset-config")) {
|
|
|
141
197
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
142
198
|
|
|
143
199
|
let mode = "transcript";
|
|
144
|
-
const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
|
|
200
|
+
const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize" || a === "config");
|
|
145
201
|
if (subcmdIndex !== -1) {
|
|
146
202
|
mode = args[subcmdIndex]!;
|
|
147
203
|
args.splice(subcmdIndex, 1);
|
|
@@ -156,7 +212,10 @@ let outputJson = false;
|
|
|
156
212
|
let noDecode = false;
|
|
157
213
|
let showDetails = true;
|
|
158
214
|
let noCache = false;
|
|
159
|
-
let
|
|
215
|
+
let noFormat = false;
|
|
216
|
+
let usePager = true;
|
|
217
|
+
let useHints = true;
|
|
218
|
+
let logLevel: LogLevel = "normal";
|
|
160
219
|
|
|
161
220
|
for (let i = 0; i < args.length; i++) {
|
|
162
221
|
const arg = args[i];
|
|
@@ -187,8 +246,20 @@ for (let i = 0; i < args.length; i++) {
|
|
|
187
246
|
showDetails = false;
|
|
188
247
|
} else if (arg === "--no-cache") {
|
|
189
248
|
noCache = true;
|
|
190
|
-
} else if (arg === "--
|
|
191
|
-
|
|
249
|
+
} else if (arg === "--no-format") {
|
|
250
|
+
noFormat = true;
|
|
251
|
+
} else if (arg === "--no-pager") {
|
|
252
|
+
usePager = false;
|
|
253
|
+
} else if (arg === "--pager") {
|
|
254
|
+
usePager = true;
|
|
255
|
+
} else if (arg === "--no-hints") {
|
|
256
|
+
useHints = false;
|
|
257
|
+
} else if (arg === "--hints") {
|
|
258
|
+
useHints = true;
|
|
259
|
+
} else if (arg === "--quiet" || arg === "-q") {
|
|
260
|
+
logLevel = "quiet";
|
|
261
|
+
} else if (arg === "--verbose" || arg === "-v") {
|
|
262
|
+
logLevel = "verbose";
|
|
192
263
|
} else if (arg === "--no-decode-entities") {
|
|
193
264
|
noDecode = true;
|
|
194
265
|
} else if (arg.startsWith("-")) {
|
|
@@ -199,6 +270,21 @@ for (let i = 0; i < args.length; i++) {
|
|
|
199
270
|
}
|
|
200
271
|
}
|
|
201
272
|
|
|
273
|
+
if (mode === "config") {
|
|
274
|
+
const path = configPath();
|
|
275
|
+
const editor = process.env.EDITOR;
|
|
276
|
+
if (editor) {
|
|
277
|
+
await new Promise<void>((resolve) => {
|
|
278
|
+
const proc = spawn(editor, [path], { stdio: "inherit" });
|
|
279
|
+
proc.on("exit", () => resolve());
|
|
280
|
+
proc.on("error", () => resolve());
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
console.log(`Config file: ${path}`);
|
|
284
|
+
}
|
|
285
|
+
process.exit(0);
|
|
286
|
+
}
|
|
287
|
+
|
|
202
288
|
if (!videoId) {
|
|
203
289
|
console.error("Error: missing video URL or ID");
|
|
204
290
|
console.log(help());
|
|
@@ -214,7 +300,28 @@ if (!extracted) {
|
|
|
214
300
|
|
|
215
301
|
videoId = extracted;
|
|
216
302
|
|
|
217
|
-
|
|
303
|
+
setLevel(logLevel);
|
|
304
|
+
resetTimer();
|
|
305
|
+
pagerCmd = usePager ? detectPager(config.pager) : null;
|
|
306
|
+
debug("Pager:", pagerCmd ?? "none");
|
|
307
|
+
|
|
308
|
+
{
|
|
309
|
+
const envHints = process.env.PROSEY_HINTS;
|
|
310
|
+
if (envHints !== undefined) {
|
|
311
|
+
useHints = envHints === "yes" || envHints === "1" || envHints === "true";
|
|
312
|
+
} else if (config.hints !== undefined) {
|
|
313
|
+
useHints = config.hints;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (useHints) {
|
|
318
|
+
const hasMarkdownPager =
|
|
319
|
+
pagerCmd === "bat -lmd" || pagerCmd === "glow" || pagerCmd === "mdcat -l -p";
|
|
320
|
+
if (!hasMarkdownPager) {
|
|
321
|
+
hint("Tip: install a markdown highlighter for better output (e.g. bat, glow, mdcat)");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
218
325
|
debug("Config file:", configPath());
|
|
219
326
|
debug("Video ID:", videoId);
|
|
220
327
|
debug("Mode:", mode);
|
|
@@ -242,10 +349,13 @@ try {
|
|
|
242
349
|
let segments: TranscriptSegment[] | null = null;
|
|
243
350
|
let summary: string | null = null;
|
|
244
351
|
|
|
352
|
+
startTimer();
|
|
353
|
+
|
|
245
354
|
if (!noCache) {
|
|
246
355
|
const cachedSegments = await readCache(dir, "transcript.json");
|
|
247
356
|
const cachedSummary = await readCache(dir, "summary.md");
|
|
248
357
|
if (cachedSegments && cachedSummary) {
|
|
358
|
+
info("Transcript cached");
|
|
249
359
|
debug("Cache hit:", dir);
|
|
250
360
|
segments = JSON.parse(cachedSegments);
|
|
251
361
|
summary = cachedSummary;
|
|
@@ -257,9 +367,9 @@ try {
|
|
|
257
367
|
}
|
|
258
368
|
|
|
259
369
|
if (!segments) {
|
|
260
|
-
|
|
370
|
+
info("Fetching transcript...");
|
|
261
371
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
262
|
-
|
|
372
|
+
info(`Transcript: ${segments.length} segments`);
|
|
263
373
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
264
374
|
debug("Cache written: transcript.json");
|
|
265
375
|
}
|
|
@@ -268,23 +378,20 @@ try {
|
|
|
268
378
|
const transcriptText = toText(segments, !noDecode);
|
|
269
379
|
|
|
270
380
|
if (!summary) {
|
|
271
|
-
|
|
381
|
+
info(`Summarizing...`);
|
|
272
382
|
summary = await summarize({
|
|
273
383
|
prompt,
|
|
274
384
|
command: config.summarize.command,
|
|
275
385
|
transcript: transcriptText,
|
|
276
386
|
cwd: dir,
|
|
277
387
|
});
|
|
278
|
-
|
|
388
|
+
info("Summary ready");
|
|
279
389
|
await writeCache(dir, "summary.md", summary);
|
|
280
390
|
debug("Cache written: summary.md");
|
|
281
391
|
}
|
|
282
392
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
} else {
|
|
286
|
-
console.log(summary);
|
|
287
|
-
}
|
|
393
|
+
const formatted = noFormat ? summary : await formatMd(summary);
|
|
394
|
+
await outputText(formatted + "\n");
|
|
288
395
|
process.exit(0);
|
|
289
396
|
} else if (listOnly) {
|
|
290
397
|
const languages = await listLanguages(videoId);
|
|
@@ -298,9 +405,12 @@ try {
|
|
|
298
405
|
let segments: TranscriptSegment[] | null = null;
|
|
299
406
|
let videoDetailsCache: VideoDetails | null = null;
|
|
300
407
|
|
|
408
|
+
startTimer();
|
|
409
|
+
|
|
301
410
|
if (!noCache) {
|
|
302
411
|
const cached = await readCache(dir, "transcript.json");
|
|
303
412
|
if (cached) {
|
|
413
|
+
info("Transcript cached");
|
|
304
414
|
debug("Cache hit:", dir);
|
|
305
415
|
segments = JSON.parse(cached);
|
|
306
416
|
} else {
|
|
@@ -311,7 +421,7 @@ try {
|
|
|
311
421
|
}
|
|
312
422
|
|
|
313
423
|
if (!segments) {
|
|
314
|
-
|
|
424
|
+
info("Fetching transcript...");
|
|
315
425
|
if (showDetails && !outputJson) {
|
|
316
426
|
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
317
427
|
const result = (await fetchTranscript(videoId, opts)) as {
|
|
@@ -323,6 +433,7 @@ try {
|
|
|
323
433
|
} else {
|
|
324
434
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
325
435
|
}
|
|
436
|
+
info(`Transcript: ${segments.length} segments`);
|
|
326
437
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
327
438
|
}
|
|
328
439
|
|
|
@@ -342,11 +453,7 @@ try {
|
|
|
342
453
|
: toText(segments, decode);
|
|
343
454
|
const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
|
|
344
455
|
|
|
345
|
-
|
|
346
|
-
await writeFile(outputPath, output, "utf8");
|
|
347
|
-
} else {
|
|
348
|
-
console.log(output);
|
|
349
|
-
}
|
|
456
|
+
await outputText(output);
|
|
350
457
|
} else {
|
|
351
458
|
const output = outputJson
|
|
352
459
|
? toJSON(segments, decode) + "\n"
|
|
@@ -354,11 +461,7 @@ try {
|
|
|
354
461
|
? formatWithTimestamps(segments, decode) + "\n"
|
|
355
462
|
: toText(segments, decode) + "\n";
|
|
356
463
|
|
|
357
|
-
|
|
358
|
-
await writeFile(outputPath, output, "utf8");
|
|
359
|
-
} else {
|
|
360
|
-
console.log(output);
|
|
361
|
-
}
|
|
464
|
+
await outputText(output);
|
|
362
465
|
}
|
|
363
466
|
} catch (err: unknown) {
|
|
364
467
|
const message = err instanceof Error ? err.message : String(err);
|