@tacone/prosey 0.2.3 → 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 +198 -53
- package/package.json +1 -1
- package/src/config-command.test.ts +50 -0
- package/src/config.ts +12 -0
- package/src/debug.test.ts +87 -0
- package/src/debug.ts +47 -5
- package/src/default-config.toml +24 -1
- package/src/format.test.ts +17 -1
- package/src/index.ts +116 -28
- package/src/pager.test.ts +65 -0
- package/src/pager.ts +23 -0
package/README.md
CHANGED
|
@@ -4,20 +4,43 @@
|
|
|
4
4
|
|
|
5
5
|
You can read, skim, search, copy, and manipulate the text using the tools you love the most.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
| Feature | Description |
|
|
10
|
+
| ------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
|
11
|
+
| **📋 Transcript download** | Fetch YouTube transcripts as plain text or JSON |
|
|
12
|
+
| **🤖 AI summarization** | Pipe transcripts to any AI agent (opencode, Claude, Copilot, Codex) |
|
|
13
|
+
| **🎨 Smart pager** | Automatic syntax highlighting if bat, glow or mdcat are installed (or configure your own pager) |
|
|
14
|
+
| **🛠️ Customization** | Use `prosey config` to access and edit your configuration |
|
|
15
|
+
| **💾 Disk caching** | Transcripts and summaries cached for instant repeat |
|
|
16
|
+
| **📄 Supported formats** | Plain text and JSON |
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
Try it out immediately with npx:
|
|
8
21
|
|
|
9
22
|
```bash
|
|
10
23
|
npx @tacone/prosey 771PQEDeRmw
|
|
11
24
|
npx @tacone/prosey https://youtu.be/771PQEDeRmw --lang es -o transcript.txt
|
|
12
|
-
npx @tacone/prosey info 771PQEDeRmw
|
|
13
25
|
```
|
|
14
26
|
|
|
15
27
|
## Install
|
|
16
28
|
|
|
17
|
-
|
|
29
|
+
Install globally with your favorite package manager:
|
|
18
30
|
|
|
19
31
|
```bash
|
|
20
32
|
npm install -g @tacone/prosey
|
|
33
|
+
# or
|
|
34
|
+
yarn global add @tacone/prosey
|
|
35
|
+
# or
|
|
36
|
+
pnpm add -g @tacone/prosey
|
|
37
|
+
# or
|
|
38
|
+
bun install -g @tacone/prosey
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then use it anywhere:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
21
44
|
prosey 771PQEDeRmw
|
|
22
45
|
```
|
|
23
46
|
|
|
@@ -43,6 +66,7 @@ Grab a compiled binary from the `dist/` directory (requires no runtime).
|
|
|
43
66
|
prosey [options] <video-url-or-id>
|
|
44
67
|
prosey info [options] <video-url-or-id>
|
|
45
68
|
prosey summarize [options] <video-url-or-id>
|
|
69
|
+
prosey config
|
|
46
70
|
```
|
|
47
71
|
|
|
48
72
|
Pass a full YouTube URL or a bare video ID. The transcript is printed to
|
|
@@ -51,35 +75,41 @@ stdout by default, with video details prepended.
|
|
|
51
75
|
The `summarize` command fetches a transcript, prepends the prompt from the
|
|
52
76
|
`[summarize]` config section, and pipes the result to the configured command.
|
|
53
77
|
|
|
78
|
+
The `config` command opens your config file in `$EDITOR` for editing. If
|
|
79
|
+
`$EDITOR` is not set, the config file path is printed.
|
|
80
|
+
|
|
54
81
|
### Examples
|
|
55
82
|
|
|
56
83
|
```bash
|
|
57
84
|
# Basic — plain text with details
|
|
58
|
-
|
|
85
|
+
prosey 771PQEDeRmw
|
|
59
86
|
|
|
60
87
|
# Specify language
|
|
61
|
-
|
|
88
|
+
prosey https://youtu.be/771PQEDeRmw --lang es
|
|
62
89
|
|
|
63
90
|
# Include timestamps
|
|
64
|
-
|
|
91
|
+
prosey 771PQEDeRmw -t
|
|
65
92
|
|
|
66
93
|
# Save to file
|
|
67
|
-
|
|
94
|
+
prosey 771PQEDeRmw -o transcript.txt
|
|
68
95
|
|
|
69
96
|
# JSON output (timestamps always included)
|
|
70
|
-
|
|
97
|
+
prosey 771PQEDeRmw --json
|
|
71
98
|
|
|
72
99
|
# Transcript only, no video details
|
|
73
|
-
|
|
100
|
+
prosey 771PQEDeRmw --no-details
|
|
74
101
|
|
|
75
102
|
# List available transcript languages
|
|
76
|
-
|
|
103
|
+
prosey 771PQEDeRmw --list
|
|
77
104
|
|
|
78
105
|
# Show video metadata
|
|
79
|
-
|
|
106
|
+
prosey info 771PQEDeRmw
|
|
80
107
|
|
|
81
108
|
# Summarize via the configured command
|
|
82
|
-
|
|
109
|
+
prosey summarize 771PQEDeRmw
|
|
110
|
+
|
|
111
|
+
# Edit config in $EDITOR
|
|
112
|
+
prosey config
|
|
83
113
|
```
|
|
84
114
|
|
|
85
115
|
## Options
|
|
@@ -95,6 +125,13 @@ npx @tacone/prosey summarize 771PQEDeRmw
|
|
|
95
125
|
| `--details` | Prepend video details (title, channel, duration, views, description) to the transcript (default). |
|
|
96
126
|
| `--no-details` | Suppress video details, transcript only. |
|
|
97
127
|
| `--no-decode-entities` | Preserve raw HTML entities (e.g. `'`). Decoded by default in text mode. |
|
|
128
|
+
| `--no-cache` | Skip cache reads and force a fresh fetch. |
|
|
129
|
+
| `--no-format` | Skip prettier markdown formatting on summarize output. |
|
|
130
|
+
| `-q`, `--quiet` | Suppress all stderr logging. |
|
|
131
|
+
| `-v`, `--verbose` | Print debug information to stderr. |
|
|
132
|
+
| `--no-pager` | Disable pager for stdout output (auto-detected by default). |
|
|
133
|
+
| `--pager` | Use pager for stdout output (default). |
|
|
134
|
+
| `--reset-config` | Reset config file to defaults and exit. |
|
|
98
135
|
| `--help` | Show help message and exit. |
|
|
99
136
|
| `--version` | Show version number and exit. |
|
|
100
137
|
|
|
@@ -140,6 +177,15 @@ environment variable to use a custom path.
|
|
|
140
177
|
The config file is created automatically on first run with default values.
|
|
141
178
|
Use `--reset-config` to restore the defaults at any time.
|
|
142
179
|
|
|
180
|
+
### `pager`
|
|
181
|
+
|
|
182
|
+
Pager command for transcript and summary output. Defaults to `"auto"`, which
|
|
183
|
+
detects the first available of: `bat -lmd` → `glow` → `mdcat -l -p` → `less`.
|
|
184
|
+
Set to a custom command (e.g. `"less -R"`) to override. The `PROSEY_PAGER`
|
|
185
|
+
environment variable takes precedence over this setting.
|
|
186
|
+
|
|
187
|
+
### `[summarize]`
|
|
188
|
+
|
|
143
189
|
The `[summarize]` section configures the `summarize` command:
|
|
144
190
|
|
|
145
191
|
| Key | Description |
|
|
@@ -147,6 +193,11 @@ The `[summarize]` section configures the `summarize` command:
|
|
|
147
193
|
| `prompt` | Instruction prepended to the transcript |
|
|
148
194
|
| `command` | Shell command that receives the prompt via stdin |
|
|
149
195
|
|
|
196
|
+
### `PROSEY_PAGER`
|
|
197
|
+
|
|
198
|
+
Environment variable to set the pager command. Takes precedence over the
|
|
199
|
+
`pager` config value. Set to `"auto"` or empty to use auto-detection.
|
|
200
|
+
|
|
150
201
|
## Cache
|
|
151
202
|
|
|
152
203
|
Transcripts and summaries are cached to `/tmp/prosey/`. Repeated invocations
|
package/bin/prosey
CHANGED
|
@@ -2114,7 +2114,7 @@ var require_symbol_define_to_primitive = __commonJS((exports, module) => {
|
|
|
2114
2114
|
var valueOf = SymbolPrototype && SymbolPrototype.valueOf;
|
|
2115
2115
|
var TO_PRIMITIVE = wellKnownSymbol("toPrimitive");
|
|
2116
2116
|
if (SymbolPrototype && !SymbolPrototype[TO_PRIMITIVE]) {
|
|
2117
|
-
defineBuiltIn(SymbolPrototype, TO_PRIMITIVE, function(
|
|
2117
|
+
defineBuiltIn(SymbolPrototype, TO_PRIMITIVE, function(hint2) {
|
|
2118
2118
|
return call(valueOf, this);
|
|
2119
2119
|
}, { arity: 1 });
|
|
2120
2120
|
}
|
|
@@ -4397,9 +4397,9 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
4397
4397
|
return global2 ? result || [] : result && result[0];
|
|
4398
4398
|
};
|
|
4399
4399
|
XRegExp.matchChain = function(str, chain) {
|
|
4400
|
-
return function recurseChain(values,
|
|
4401
|
-
var item = chain[
|
|
4402
|
-
regex: chain[
|
|
4400
|
+
return function recurseChain(values, level2) {
|
|
4401
|
+
var item = chain[level2].regex ? chain[level2] : {
|
|
4402
|
+
regex: chain[level2]
|
|
4403
4403
|
};
|
|
4404
4404
|
var matches = [];
|
|
4405
4405
|
function addMatch(match) {
|
|
@@ -4430,7 +4430,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
4430
4430
|
} finally {
|
|
4431
4431
|
_iterator3.f();
|
|
4432
4432
|
}
|
|
4433
|
-
return
|
|
4433
|
+
return level2 === chain.length - 1 || !matches.length ? matches : recurseChain(matches, level2 + 1);
|
|
4434
4434
|
}([str], 0);
|
|
4435
4435
|
};
|
|
4436
4436
|
XRegExp.replace = function(str, search, replacement, scope) {
|
|
@@ -94145,9 +94145,81 @@ ${Me13.join(`
|
|
|
94145
94145
|
Y42 = k0(G42);
|
|
94146
94146
|
});
|
|
94147
94147
|
|
|
94148
|
+
// src/debug.ts
|
|
94149
|
+
var GRAY = "\x1B[90m";
|
|
94150
|
+
var RESET = "\x1B[0m";
|
|
94151
|
+
var level = "normal";
|
|
94152
|
+
function setLevel(l) {
|
|
94153
|
+
level = l;
|
|
94154
|
+
}
|
|
94155
|
+
var lastTime = performance.now();
|
|
94156
|
+
var resumed = false;
|
|
94157
|
+
function stamp() {
|
|
94158
|
+
const now = performance.now();
|
|
94159
|
+
if (resumed) {
|
|
94160
|
+
lastTime = now;
|
|
94161
|
+
resumed = false;
|
|
94162
|
+
}
|
|
94163
|
+
const elapsed = now - lastTime;
|
|
94164
|
+
lastTime = now;
|
|
94165
|
+
const text = elapsed < 1000 ? `${elapsed.toFixed(0)}ms` : `${(elapsed / 1000).toFixed(1)}s`;
|
|
94166
|
+
return text.padStart(5);
|
|
94167
|
+
}
|
|
94168
|
+
var INFO_BEFORE = "\x1B[0m\x1B[2m\x1B[1m";
|
|
94169
|
+
var INFO_AFTER = "\x1B[0m";
|
|
94170
|
+
function info(...args) {
|
|
94171
|
+
if (level === "quiet")
|
|
94172
|
+
return;
|
|
94173
|
+
console.error(INFO_BEFORE, `[${stamp()}]`, ...args, INFO_AFTER);
|
|
94174
|
+
}
|
|
94175
|
+
function debug(...args) {
|
|
94176
|
+
if (level !== "verbose")
|
|
94177
|
+
return;
|
|
94178
|
+
console.error(GRAY, `[${stamp()}]`, ...args, RESET);
|
|
94179
|
+
}
|
|
94180
|
+
function startTimer() {
|
|
94181
|
+
resumed = true;
|
|
94182
|
+
}
|
|
94183
|
+
function resetTimer() {
|
|
94184
|
+
lastTime = performance.now();
|
|
94185
|
+
resumed = false;
|
|
94186
|
+
}
|
|
94187
|
+
var YELLOW = "\x1B[33m";
|
|
94188
|
+
function hint(message) {
|
|
94189
|
+
console.error(YELLOW + message + RESET + " " + GRAY + "[use prosey config to disable hints]" + RESET);
|
|
94190
|
+
}
|
|
94191
|
+
|
|
94148
94192
|
// src/index.ts
|
|
94193
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
94149
94194
|
import { writeFile as writeFile3 } from "node:fs/promises";
|
|
94150
94195
|
|
|
94196
|
+
// src/pager.ts
|
|
94197
|
+
import { execSync } from "node:child_process";
|
|
94198
|
+
function hasCommand(cmd) {
|
|
94199
|
+
try {
|
|
94200
|
+
execSync(`command -v ${cmd} 2>/dev/null`, { stdio: "ignore" });
|
|
94201
|
+
return true;
|
|
94202
|
+
} catch {
|
|
94203
|
+
return false;
|
|
94204
|
+
}
|
|
94205
|
+
}
|
|
94206
|
+
function detectPager(cfgPager) {
|
|
94207
|
+
const env = process.env.PROSEY_PAGER;
|
|
94208
|
+
if (env !== undefined && env !== "" && env !== "auto")
|
|
94209
|
+
return env;
|
|
94210
|
+
if (cfgPager !== undefined && cfgPager !== "" && cfgPager !== "auto")
|
|
94211
|
+
return cfgPager;
|
|
94212
|
+
if (hasCommand("bat"))
|
|
94213
|
+
return "bat -lmd";
|
|
94214
|
+
if (hasCommand("glow"))
|
|
94215
|
+
return "glow";
|
|
94216
|
+
if (hasCommand("mdcat"))
|
|
94217
|
+
return "mdcat -l -p";
|
|
94218
|
+
if (hasCommand("less"))
|
|
94219
|
+
return "less";
|
|
94220
|
+
return null;
|
|
94221
|
+
}
|
|
94222
|
+
|
|
94151
94223
|
// node_modules/youtube-transcript-plus/dist/youtube-transcript-plus.mjs
|
|
94152
94224
|
function __awaiter(thisArg, _arguments, P, generator) {
|
|
94153
94225
|
function adopt(value) {
|
|
@@ -101530,6 +101602,16 @@ var lt = (r) => {
|
|
|
101530
101602
|
var FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
101531
101603
|
# Created automatically on first run. Edit as needed.
|
|
101532
101604
|
|
|
101605
|
+
# Pager command for transcript and summary output.
|
|
101606
|
+
# Defaults to "auto": bat -lmd → glow → mdcat -l -p → less
|
|
101607
|
+
# Set to a custom command (e.g. "less -R") to override.
|
|
101608
|
+
# Can also be set via the PROSEY_PAGER env var (takes precedence).
|
|
101609
|
+
pager = "auto"
|
|
101610
|
+
|
|
101611
|
+
# Show hints for missing tools (e.g. markdown highlighter).
|
|
101612
|
+
# Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
|
|
101613
|
+
hints = true
|
|
101614
|
+
|
|
101533
101615
|
[summarize]
|
|
101534
101616
|
# Prompt sent to the command via stdin.
|
|
101535
101617
|
# Customize this to change how transcripts are summarized.
|
|
@@ -101674,23 +101756,10 @@ async function writeCache(dir, filename, data) {
|
|
|
101674
101756
|
await mkdir2(dir, { recursive: true });
|
|
101675
101757
|
await writeFile2(join2(dir, filename), data, "utf8");
|
|
101676
101758
|
}
|
|
101677
|
-
|
|
101678
|
-
// src/debug.ts
|
|
101679
|
-
var GRAY = "\x1B[90m";
|
|
101680
|
-
var RESET = "\x1B[0m";
|
|
101681
|
-
var enabled = false;
|
|
101682
|
-
function enableDebug() {
|
|
101683
|
-
enabled = true;
|
|
101684
|
-
}
|
|
101685
|
-
function debug(...args) {
|
|
101686
|
-
if (!enabled)
|
|
101687
|
-
return;
|
|
101688
|
-
console.error(GRAY, ...args, RESET);
|
|
101689
|
-
}
|
|
101690
101759
|
// package.json
|
|
101691
101760
|
var package_default = {
|
|
101692
101761
|
name: "@tacone/prosey",
|
|
101693
|
-
version: "0.2.
|
|
101762
|
+
version: "0.2.4",
|
|
101694
101763
|
description: "Download YouTube video transcripts from the CLI",
|
|
101695
101764
|
module: "src/index.ts",
|
|
101696
101765
|
type: "module",
|
|
@@ -102266,7 +102335,7 @@ function addAlignmentToDoc(doc, size, tabWidth) {
|
|
|
102266
102335
|
assertDoc(doc);
|
|
102267
102336
|
let aligned = doc;
|
|
102268
102337
|
if (size > 0) {
|
|
102269
|
-
for (let
|
|
102338
|
+
for (let level2 = 0;level2 < Math.floor(size / tabWidth); ++level2) {
|
|
102270
102339
|
aligned = indent(aligned);
|
|
102271
102340
|
}
|
|
102272
102341
|
aligned = align(size % tabWidth, aligned);
|
|
@@ -107697,7 +107766,7 @@ var require_partial = __commonJS2({
|
|
|
107697
107766
|
match(filepath) {
|
|
107698
107767
|
const parts = filepath.split("/");
|
|
107699
107768
|
const levels = parts.length;
|
|
107700
|
-
const patterns = this._storage.filter((
|
|
107769
|
+
const patterns = this._storage.filter((info2) => !info2.complete || info2.segments.length > levels);
|
|
107701
107770
|
for (const pattern of patterns) {
|
|
107702
107771
|
const section = pattern.sections[0];
|
|
107703
107772
|
if (!pattern.complete && levels > section.length) {
|
|
@@ -108269,10 +108338,10 @@ var require_picocolors = __commonJS2({
|
|
|
108269
108338
|
} while (~index);
|
|
108270
108339
|
return result + string.substring(cursor2);
|
|
108271
108340
|
};
|
|
108272
|
-
var createColors2 = (
|
|
108273
|
-
let f7 =
|
|
108341
|
+
var createColors2 = (enabled = isColorSupported2) => {
|
|
108342
|
+
let f7 = enabled ? formatter : () => String;
|
|
108274
108343
|
return {
|
|
108275
|
-
isColorSupported:
|
|
108344
|
+
isColorSupported: enabled,
|
|
108276
108345
|
reset: f7("\x1B[0m", "\x1B[0m"),
|
|
108277
108346
|
bold: f7("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
|
|
108278
108347
|
dim: f7("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
|
|
@@ -113965,8 +114034,8 @@ function buildDefs(colors) {
|
|
|
113965
114034
|
}
|
|
113966
114035
|
var defsOn = buildDefs((0, import_picocolors4.createColors)(true));
|
|
113967
114036
|
var defsOff = buildDefs((0, import_picocolors4.createColors)(false));
|
|
113968
|
-
function getDefs(
|
|
113969
|
-
return
|
|
114037
|
+
function getDefs(enabled) {
|
|
114038
|
+
return enabled ? defsOn : defsOff;
|
|
113970
114039
|
}
|
|
113971
114040
|
var sometimesKeywords = /* @__PURE__ */ new Set(["as", "async", "from", "get", "of", "set"]);
|
|
113972
114041
|
var NEWLINE$1 = /\r\n|[\n\r\u2028\u2029]/;
|
|
@@ -120299,12 +120368,14 @@ function help() {
|
|
|
120299
120368
|
Usage: ${NAME2} [options] <video-url-or-id>
|
|
120300
120369
|
${NAME2} info [options] <video-url-or-id>
|
|
120301
120370
|
${NAME2} summarize [options] <video-url-or-id>
|
|
120371
|
+
${NAME2} config
|
|
120302
120372
|
|
|
120303
120373
|
Download a YouTube video transcript or show video details.
|
|
120304
120374
|
|
|
120305
120375
|
Commands:
|
|
120306
120376
|
info Show video metadata (title, channel, duration, etc.)
|
|
120307
120377
|
summarize Pipe transcript to the command configured in [summarize]
|
|
120378
|
+
config Open config file in $EDITOR
|
|
120308
120379
|
|
|
120309
120380
|
Arguments:
|
|
120310
120381
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -120322,7 +120393,12 @@ Options:
|
|
|
120322
120393
|
--reset-config Reset config file to defaults and exit.
|
|
120323
120394
|
--no-cache Skip cache and overwrite cache files.
|
|
120324
120395
|
--no-format Skip prettier formatting.
|
|
120325
|
-
--
|
|
120396
|
+
--no-pager Disable pager for stdout output.
|
|
120397
|
+
--pager Use pager for stdout output (default).
|
|
120398
|
+
--no-hints Disable hints.
|
|
120399
|
+
--hints Show hints (default).
|
|
120400
|
+
-q, --quiet Suppress all stderr logging.
|
|
120401
|
+
-v, --verbose Print debug information to stderr.
|
|
120326
120402
|
--help Show this help message.
|
|
120327
120403
|
--version Show version.
|
|
120328
120404
|
|
|
@@ -120392,6 +120468,39 @@ async function formatMd(text) {
|
|
|
120392
120468
|
return text;
|
|
120393
120469
|
}
|
|
120394
120470
|
}
|
|
120471
|
+
async function outputText(text) {
|
|
120472
|
+
if (outputPath) {
|
|
120473
|
+
await writeFile3(outputPath, text, "utf8");
|
|
120474
|
+
return;
|
|
120475
|
+
}
|
|
120476
|
+
if (!pagerCmd || !process.stdout.isTTY) {
|
|
120477
|
+
process.stdout.write(text);
|
|
120478
|
+
return;
|
|
120479
|
+
}
|
|
120480
|
+
const parts = pagerCmd.split(/\s+/);
|
|
120481
|
+
const proc = spawn2(parts[0], parts.slice(1), {
|
|
120482
|
+
stdio: ["pipe", "inherit", "inherit"]
|
|
120483
|
+
});
|
|
120484
|
+
await new Promise((resolve3) => {
|
|
120485
|
+
let done = false;
|
|
120486
|
+
proc.on("error", () => {
|
|
120487
|
+
if (done)
|
|
120488
|
+
return;
|
|
120489
|
+
done = true;
|
|
120490
|
+
process.stdout.write(text);
|
|
120491
|
+
resolve3();
|
|
120492
|
+
});
|
|
120493
|
+
proc.on("exit", () => {
|
|
120494
|
+
if (done)
|
|
120495
|
+
return;
|
|
120496
|
+
done = true;
|
|
120497
|
+
resolve3();
|
|
120498
|
+
});
|
|
120499
|
+
proc.stdin.write(text);
|
|
120500
|
+
proc.stdin.end();
|
|
120501
|
+
});
|
|
120502
|
+
}
|
|
120503
|
+
var pagerCmd = null;
|
|
120395
120504
|
var args = process.argv.slice(2);
|
|
120396
120505
|
if (args.length === 0 || args.includes("--help")) {
|
|
120397
120506
|
console.log(help());
|
|
@@ -120408,7 +120517,7 @@ if (args.includes("--reset-config")) {
|
|
|
120408
120517
|
}
|
|
120409
120518
|
var config = await loadConfig().catch(() => ({}));
|
|
120410
120519
|
var mode = "transcript";
|
|
120411
|
-
var subcmdIndex = args.findIndex((a5) => a5 === "info" || a5 === "summarize");
|
|
120520
|
+
var subcmdIndex = args.findIndex((a5) => a5 === "info" || a5 === "summarize" || a5 === "config");
|
|
120412
120521
|
if (subcmdIndex !== -1) {
|
|
120413
120522
|
mode = args[subcmdIndex];
|
|
120414
120523
|
args.splice(subcmdIndex, 1);
|
|
@@ -120423,7 +120532,9 @@ var noDecode = false;
|
|
|
120423
120532
|
var showDetails = true;
|
|
120424
120533
|
var noCache = false;
|
|
120425
120534
|
var noFormat = false;
|
|
120426
|
-
var
|
|
120535
|
+
var usePager = true;
|
|
120536
|
+
var useHints = true;
|
|
120537
|
+
var logLevel = "normal";
|
|
120427
120538
|
for (let i = 0;i < args.length; i++) {
|
|
120428
120539
|
const arg = args[i];
|
|
120429
120540
|
if (!arg)
|
|
@@ -120456,8 +120567,18 @@ for (let i = 0;i < args.length; i++) {
|
|
|
120456
120567
|
noCache = true;
|
|
120457
120568
|
} else if (arg === "--no-format") {
|
|
120458
120569
|
noFormat = true;
|
|
120459
|
-
} else if (arg === "--
|
|
120460
|
-
|
|
120570
|
+
} else if (arg === "--no-pager") {
|
|
120571
|
+
usePager = false;
|
|
120572
|
+
} else if (arg === "--pager") {
|
|
120573
|
+
usePager = true;
|
|
120574
|
+
} else if (arg === "--no-hints") {
|
|
120575
|
+
useHints = false;
|
|
120576
|
+
} else if (arg === "--hints") {
|
|
120577
|
+
useHints = true;
|
|
120578
|
+
} else if (arg === "--quiet" || arg === "-q") {
|
|
120579
|
+
logLevel = "quiet";
|
|
120580
|
+
} else if (arg === "--verbose" || arg === "-v") {
|
|
120581
|
+
logLevel = "verbose";
|
|
120461
120582
|
} else if (arg === "--no-decode-entities") {
|
|
120462
120583
|
noDecode = true;
|
|
120463
120584
|
} else if (arg.startsWith("-")) {
|
|
@@ -120467,6 +120588,20 @@ for (let i = 0;i < args.length; i++) {
|
|
|
120467
120588
|
videoId = arg;
|
|
120468
120589
|
}
|
|
120469
120590
|
}
|
|
120591
|
+
if (mode === "config") {
|
|
120592
|
+
const path15 = configPath();
|
|
120593
|
+
const editor = process.env.EDITOR;
|
|
120594
|
+
if (editor) {
|
|
120595
|
+
await new Promise((resolve3) => {
|
|
120596
|
+
const proc = spawn2(editor, [path15], { stdio: "inherit" });
|
|
120597
|
+
proc.on("exit", () => resolve3());
|
|
120598
|
+
proc.on("error", () => resolve3());
|
|
120599
|
+
});
|
|
120600
|
+
} else {
|
|
120601
|
+
console.log(`Config file: ${path15}`);
|
|
120602
|
+
}
|
|
120603
|
+
process.exit(0);
|
|
120604
|
+
}
|
|
120470
120605
|
if (!videoId) {
|
|
120471
120606
|
console.error("Error: missing video URL or ID");
|
|
120472
120607
|
console.log(help());
|
|
@@ -120478,8 +120613,24 @@ if (!extracted) {
|
|
|
120478
120613
|
process.exit(65);
|
|
120479
120614
|
}
|
|
120480
120615
|
videoId = extracted;
|
|
120481
|
-
|
|
120482
|
-
|
|
120616
|
+
setLevel(logLevel);
|
|
120617
|
+
resetTimer();
|
|
120618
|
+
pagerCmd = usePager ? detectPager(config.pager) : null;
|
|
120619
|
+
debug("Pager:", pagerCmd ?? "none");
|
|
120620
|
+
{
|
|
120621
|
+
const envHints = process.env.PROSEY_HINTS;
|
|
120622
|
+
if (envHints !== undefined) {
|
|
120623
|
+
useHints = envHints === "yes" || envHints === "1" || envHints === "true";
|
|
120624
|
+
} else if (config.hints !== undefined) {
|
|
120625
|
+
useHints = config.hints;
|
|
120626
|
+
}
|
|
120627
|
+
}
|
|
120628
|
+
if (useHints) {
|
|
120629
|
+
const hasMarkdownPager = pagerCmd === "bat -lmd" || pagerCmd === "glow" || pagerCmd === "mdcat -l -p";
|
|
120630
|
+
if (!hasMarkdownPager) {
|
|
120631
|
+
hint("Tip: install a markdown highlighter for better output (e.g. bat, glow, mdcat)");
|
|
120632
|
+
}
|
|
120633
|
+
}
|
|
120483
120634
|
debug("Config file:", configPath());
|
|
120484
120635
|
debug("Video ID:", videoId);
|
|
120485
120636
|
debug("Mode:", mode);
|
|
@@ -120504,10 +120655,12 @@ try {
|
|
|
120504
120655
|
const dir2 = cacheDir(videoId, cacheOpts2);
|
|
120505
120656
|
let segments2 = null;
|
|
120506
120657
|
let summary = null;
|
|
120658
|
+
startTimer();
|
|
120507
120659
|
if (!noCache) {
|
|
120508
120660
|
const cachedSegments = await readCache(dir2, "transcript.json");
|
|
120509
120661
|
const cachedSummary = await readCache(dir2, "summary.md");
|
|
120510
120662
|
if (cachedSegments && cachedSummary) {
|
|
120663
|
+
info("Transcript cached");
|
|
120511
120664
|
debug("Cache hit:", dir2);
|
|
120512
120665
|
segments2 = JSON.parse(cachedSegments);
|
|
120513
120666
|
summary = cachedSummary;
|
|
@@ -120518,32 +120671,29 @@ try {
|
|
|
120518
120671
|
debug("Cache skipped (--no-cache)");
|
|
120519
120672
|
}
|
|
120520
120673
|
if (!segments2) {
|
|
120521
|
-
|
|
120674
|
+
info("Fetching transcript...");
|
|
120522
120675
|
segments2 = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
120523
|
-
|
|
120676
|
+
info(`Transcript: ${segments2.length} segments`);
|
|
120524
120677
|
await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
|
|
120525
120678
|
debug("Cache written: transcript.json");
|
|
120526
120679
|
}
|
|
120527
120680
|
const prompt = config.summarize.prompt ?? "";
|
|
120528
120681
|
const transcriptText = toText(segments2, !noDecode);
|
|
120529
120682
|
if (!summary) {
|
|
120530
|
-
|
|
120683
|
+
info(`Summarizing...`);
|
|
120531
120684
|
summary = await summarize({
|
|
120532
120685
|
prompt,
|
|
120533
120686
|
command: config.summarize.command,
|
|
120534
120687
|
transcript: transcriptText,
|
|
120535
120688
|
cwd: dir2
|
|
120536
120689
|
});
|
|
120537
|
-
|
|
120690
|
+
info("Summary ready");
|
|
120538
120691
|
await writeCache(dir2, "summary.md", summary);
|
|
120539
120692
|
debug("Cache written: summary.md");
|
|
120540
120693
|
}
|
|
120541
120694
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
120542
|
-
|
|
120543
|
-
|
|
120544
|
-
} else {
|
|
120545
|
-
console.log(formatted);
|
|
120546
|
-
}
|
|
120695
|
+
await outputText(formatted + `
|
|
120696
|
+
`);
|
|
120547
120697
|
process.exit(0);
|
|
120548
120698
|
} else if (listOnly) {
|
|
120549
120699
|
const languages2 = await listLanguages(videoId);
|
|
@@ -120555,9 +120705,11 @@ try {
|
|
|
120555
120705
|
const dir = cacheDir(videoId, cacheOpts);
|
|
120556
120706
|
let segments = null;
|
|
120557
120707
|
let videoDetailsCache = null;
|
|
120708
|
+
startTimer();
|
|
120558
120709
|
if (!noCache) {
|
|
120559
120710
|
const cached = await readCache(dir, "transcript.json");
|
|
120560
120711
|
if (cached) {
|
|
120712
|
+
info("Transcript cached");
|
|
120561
120713
|
debug("Cache hit:", dir);
|
|
120562
120714
|
segments = JSON.parse(cached);
|
|
120563
120715
|
} else {
|
|
@@ -120567,7 +120719,7 @@ try {
|
|
|
120567
120719
|
debug("Cache skipped (--no-cache)");
|
|
120568
120720
|
}
|
|
120569
120721
|
if (!segments) {
|
|
120570
|
-
|
|
120722
|
+
info("Fetching transcript...");
|
|
120571
120723
|
if (showDetails && !outputJson) {
|
|
120572
120724
|
const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
120573
120725
|
const result = await fetchTranscript(videoId, opts);
|
|
@@ -120576,6 +120728,7 @@ try {
|
|
|
120576
120728
|
} else {
|
|
120577
120729
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
120578
120730
|
}
|
|
120731
|
+
info(`Transcript: ${segments.length} segments`);
|
|
120579
120732
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
120580
120733
|
}
|
|
120581
120734
|
if (showDetails && !outputJson) {
|
|
@@ -120591,21 +120744,13 @@ try {
|
|
|
120591
120744
|
|
|
120592
120745
|
` + transcriptText + `
|
|
120593
120746
|
`;
|
|
120594
|
-
|
|
120595
|
-
await writeFile3(outputPath, output, "utf8");
|
|
120596
|
-
} else {
|
|
120597
|
-
console.log(output);
|
|
120598
|
-
}
|
|
120747
|
+
await outputText(output);
|
|
120599
120748
|
} else {
|
|
120600
120749
|
const output = outputJson ? toJSON(segments, decode) + `
|
|
120601
120750
|
` : timestamps ? formatWithTimestamps(segments, decode) + `
|
|
120602
120751
|
` : toText(segments, decode) + `
|
|
120603
120752
|
`;
|
|
120604
|
-
|
|
120605
|
-
await writeFile3(outputPath, output, "utf8");
|
|
120606
|
-
} else {
|
|
120607
|
-
console.log(output);
|
|
120608
|
-
}
|
|
120753
|
+
await outputText(output);
|
|
120609
120754
|
}
|
|
120610
120755
|
} catch (err) {
|
|
120611
120756
|
const message = err instanceof Error ? err.message : String(err);
|
package/package.json
CHANGED
|
@@ -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.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,7 +12,6 @@ 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";
|
|
13
16
|
import prettier from "prettier";
|
|
14
17
|
|
|
@@ -21,12 +24,14 @@ function help(): string {
|
|
|
21
24
|
Usage: ${NAME} [options] <video-url-or-id>
|
|
22
25
|
${NAME} info [options] <video-url-or-id>
|
|
23
26
|
${NAME} summarize [options] <video-url-or-id>
|
|
27
|
+
${NAME} config
|
|
24
28
|
|
|
25
29
|
Download a YouTube video transcript or show video details.
|
|
26
30
|
|
|
27
31
|
Commands:
|
|
28
32
|
info Show video metadata (title, channel, duration, etc.)
|
|
29
33
|
summarize Pipe transcript to the command configured in [summarize]
|
|
34
|
+
config Open config file in \$EDITOR
|
|
30
35
|
|
|
31
36
|
Arguments:
|
|
32
37
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -44,7 +49,12 @@ Options:
|
|
|
44
49
|
--reset-config Reset config file to defaults and exit.
|
|
45
50
|
--no-cache Skip cache and overwrite cache files.
|
|
46
51
|
--no-format Skip prettier formatting.
|
|
47
|
-
--
|
|
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.
|
|
48
58
|
--help Show this help message.
|
|
49
59
|
--version Show version.
|
|
50
60
|
|
|
@@ -130,6 +140,42 @@ async function formatMd(text: string): Promise<string> {
|
|
|
130
140
|
}
|
|
131
141
|
}
|
|
132
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
|
+
|
|
133
179
|
const args = process.argv.slice(2);
|
|
134
180
|
|
|
135
181
|
if (args.length === 0 || args.includes("--help")) {
|
|
@@ -151,7 +197,7 @@ if (args.includes("--reset-config")) {
|
|
|
151
197
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
152
198
|
|
|
153
199
|
let mode = "transcript";
|
|
154
|
-
const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
|
|
200
|
+
const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize" || a === "config");
|
|
155
201
|
if (subcmdIndex !== -1) {
|
|
156
202
|
mode = args[subcmdIndex]!;
|
|
157
203
|
args.splice(subcmdIndex, 1);
|
|
@@ -167,7 +213,9 @@ let noDecode = false;
|
|
|
167
213
|
let showDetails = true;
|
|
168
214
|
let noCache = false;
|
|
169
215
|
let noFormat = false;
|
|
170
|
-
let
|
|
216
|
+
let usePager = true;
|
|
217
|
+
let useHints = true;
|
|
218
|
+
let logLevel: LogLevel = "normal";
|
|
171
219
|
|
|
172
220
|
for (let i = 0; i < args.length; i++) {
|
|
173
221
|
const arg = args[i];
|
|
@@ -200,8 +248,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
200
248
|
noCache = true;
|
|
201
249
|
} else if (arg === "--no-format") {
|
|
202
250
|
noFormat = true;
|
|
203
|
-
} else if (arg === "--
|
|
204
|
-
|
|
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";
|
|
205
263
|
} else if (arg === "--no-decode-entities") {
|
|
206
264
|
noDecode = true;
|
|
207
265
|
} else if (arg.startsWith("-")) {
|
|
@@ -212,6 +270,21 @@ for (let i = 0; i < args.length; i++) {
|
|
|
212
270
|
}
|
|
213
271
|
}
|
|
214
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
|
+
|
|
215
288
|
if (!videoId) {
|
|
216
289
|
console.error("Error: missing video URL or ID");
|
|
217
290
|
console.log(help());
|
|
@@ -227,7 +300,28 @@ if (!extracted) {
|
|
|
227
300
|
|
|
228
301
|
videoId = extracted;
|
|
229
302
|
|
|
230
|
-
|
|
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
|
+
|
|
231
325
|
debug("Config file:", configPath());
|
|
232
326
|
debug("Video ID:", videoId);
|
|
233
327
|
debug("Mode:", mode);
|
|
@@ -255,10 +349,13 @@ try {
|
|
|
255
349
|
let segments: TranscriptSegment[] | null = null;
|
|
256
350
|
let summary: string | null = null;
|
|
257
351
|
|
|
352
|
+
startTimer();
|
|
353
|
+
|
|
258
354
|
if (!noCache) {
|
|
259
355
|
const cachedSegments = await readCache(dir, "transcript.json");
|
|
260
356
|
const cachedSummary = await readCache(dir, "summary.md");
|
|
261
357
|
if (cachedSegments && cachedSummary) {
|
|
358
|
+
info("Transcript cached");
|
|
262
359
|
debug("Cache hit:", dir);
|
|
263
360
|
segments = JSON.parse(cachedSegments);
|
|
264
361
|
summary = cachedSummary;
|
|
@@ -270,9 +367,9 @@ try {
|
|
|
270
367
|
}
|
|
271
368
|
|
|
272
369
|
if (!segments) {
|
|
273
|
-
|
|
370
|
+
info("Fetching transcript...");
|
|
274
371
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
275
|
-
|
|
372
|
+
info(`Transcript: ${segments.length} segments`);
|
|
276
373
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
277
374
|
debug("Cache written: transcript.json");
|
|
278
375
|
}
|
|
@@ -281,25 +378,20 @@ try {
|
|
|
281
378
|
const transcriptText = toText(segments, !noDecode);
|
|
282
379
|
|
|
283
380
|
if (!summary) {
|
|
284
|
-
|
|
381
|
+
info(`Summarizing...`);
|
|
285
382
|
summary = await summarize({
|
|
286
383
|
prompt,
|
|
287
384
|
command: config.summarize.command,
|
|
288
385
|
transcript: transcriptText,
|
|
289
386
|
cwd: dir,
|
|
290
387
|
});
|
|
291
|
-
|
|
388
|
+
info("Summary ready");
|
|
292
389
|
await writeCache(dir, "summary.md", summary);
|
|
293
390
|
debug("Cache written: summary.md");
|
|
294
391
|
}
|
|
295
392
|
|
|
296
393
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
297
|
-
|
|
298
|
-
if (outputPath) {
|
|
299
|
-
await writeFile(outputPath, formatted, "utf8");
|
|
300
|
-
} else {
|
|
301
|
-
console.log(formatted);
|
|
302
|
-
}
|
|
394
|
+
await outputText(formatted + "\n");
|
|
303
395
|
process.exit(0);
|
|
304
396
|
} else if (listOnly) {
|
|
305
397
|
const languages = await listLanguages(videoId);
|
|
@@ -313,9 +405,12 @@ try {
|
|
|
313
405
|
let segments: TranscriptSegment[] | null = null;
|
|
314
406
|
let videoDetailsCache: VideoDetails | null = null;
|
|
315
407
|
|
|
408
|
+
startTimer();
|
|
409
|
+
|
|
316
410
|
if (!noCache) {
|
|
317
411
|
const cached = await readCache(dir, "transcript.json");
|
|
318
412
|
if (cached) {
|
|
413
|
+
info("Transcript cached");
|
|
319
414
|
debug("Cache hit:", dir);
|
|
320
415
|
segments = JSON.parse(cached);
|
|
321
416
|
} else {
|
|
@@ -326,7 +421,7 @@ try {
|
|
|
326
421
|
}
|
|
327
422
|
|
|
328
423
|
if (!segments) {
|
|
329
|
-
|
|
424
|
+
info("Fetching transcript...");
|
|
330
425
|
if (showDetails && !outputJson) {
|
|
331
426
|
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
332
427
|
const result = (await fetchTranscript(videoId, opts)) as {
|
|
@@ -338,6 +433,7 @@ try {
|
|
|
338
433
|
} else {
|
|
339
434
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
340
435
|
}
|
|
436
|
+
info(`Transcript: ${segments.length} segments`);
|
|
341
437
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
342
438
|
}
|
|
343
439
|
|
|
@@ -357,11 +453,7 @@ try {
|
|
|
357
453
|
: toText(segments, decode);
|
|
358
454
|
const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
|
|
359
455
|
|
|
360
|
-
|
|
361
|
-
await writeFile(outputPath, output, "utf8");
|
|
362
|
-
} else {
|
|
363
|
-
console.log(output);
|
|
364
|
-
}
|
|
456
|
+
await outputText(output);
|
|
365
457
|
} else {
|
|
366
458
|
const output = outputJson
|
|
367
459
|
? toJSON(segments, decode) + "\n"
|
|
@@ -369,11 +461,7 @@ try {
|
|
|
369
461
|
? formatWithTimestamps(segments, decode) + "\n"
|
|
370
462
|
: toText(segments, decode) + "\n";
|
|
371
463
|
|
|
372
|
-
|
|
373
|
-
await writeFile(outputPath, output, "utf8");
|
|
374
|
-
} else {
|
|
375
|
-
console.log(output);
|
|
376
|
-
}
|
|
464
|
+
await outputText(output);
|
|
377
465
|
}
|
|
378
466
|
} catch (err: unknown) {
|
|
379
467
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { hasCommand, detectPager } from "./pager";
|
|
3
|
+
|
|
4
|
+
const ORIGINAL_PROSEY_PAGER = process.env.PROSEY_PAGER;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete process.env.PROSEY_PAGER;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (ORIGINAL_PROSEY_PAGER !== undefined) {
|
|
12
|
+
process.env.PROSEY_PAGER = ORIGINAL_PROSEY_PAGER;
|
|
13
|
+
} else {
|
|
14
|
+
delete process.env.PROSEY_PAGER;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("hasCommand", () => {
|
|
19
|
+
test("returns true for existing command", () => {
|
|
20
|
+
expect(hasCommand("sh")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns false for nonexistent command", () => {
|
|
24
|
+
expect(hasCommand("nonexistent-cmd-12345")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("detectPager", () => {
|
|
29
|
+
test("uses PROSEY_PAGER env var", () => {
|
|
30
|
+
process.env.PROSEY_PAGER = "custom-pager";
|
|
31
|
+
expect(detectPager()).toBe("custom-pager");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("env var takes precedence over config pager", () => {
|
|
35
|
+
process.env.PROSEY_PAGER = "env-pager";
|
|
36
|
+
expect(detectPager("cfg-pager")).toBe("env-pager");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('"auto" env var falls through to config', () => {
|
|
40
|
+
process.env.PROSEY_PAGER = "auto";
|
|
41
|
+
expect(detectPager("cfg-pager")).toBe("cfg-pager");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("empty env var falls through to config", () => {
|
|
45
|
+
process.env.PROSEY_PAGER = "";
|
|
46
|
+
expect(detectPager("cfg-pager")).toBe("cfg-pager");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('"auto" config falls through to auto-detection', () => {
|
|
50
|
+
process.env.PROSEY_PAGER = "auto";
|
|
51
|
+
const pager = detectPager("auto");
|
|
52
|
+
// Falls through to auto-detect: one of bat/glow/mdcat/less or null
|
|
53
|
+
expect(pager === null || typeof pager === "string").toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns null when no pager is configured or available", () => {
|
|
57
|
+
const pager = detectPager();
|
|
58
|
+
// In CI or minimal environments, no pagers may be installed
|
|
59
|
+
// If a pager IS found, it should be one of the expected ones
|
|
60
|
+
if (pager !== null) {
|
|
61
|
+
const known = ["bat -lmd", "glow", "mdcat -l -p", "less"];
|
|
62
|
+
expect(known).toContain(pager);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/pager.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function hasCommand(cmd: string): boolean {
|
|
4
|
+
try {
|
|
5
|
+
execSync(`command -v ${cmd} 2>/dev/null`, { stdio: "ignore" });
|
|
6
|
+
return true;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function detectPager(cfgPager?: string): string | null {
|
|
13
|
+
const env = process.env.PROSEY_PAGER;
|
|
14
|
+
if (env !== undefined && env !== "" && env !== "auto") return env;
|
|
15
|
+
|
|
16
|
+
if (cfgPager !== undefined && cfgPager !== "" && cfgPager !== "auto") return cfgPager;
|
|
17
|
+
|
|
18
|
+
if (hasCommand("bat")) return "bat -lmd";
|
|
19
|
+
if (hasCommand("glow")) return "glow";
|
|
20
|
+
if (hasCommand("mdcat")) return "mdcat -l -p";
|
|
21
|
+
if (hasCommand("less")) return "less";
|
|
22
|
+
return null;
|
|
23
|
+
}
|