yt-liked 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { emitKeypressEvents } from 'node:readline';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { stdin as processStdin, stdout as processStdout } from 'node:process';
5
+ import { geminiEnvLocalPath, loadEnv, writeGeminiApiKeyToEnvLocal } from './config.js';
6
+ const RESET = '\x1b[0m';
7
+ const DIM = '\x1b[2m';
8
+ const BOLD = '\x1b[1m';
9
+ const WHITE = '\x1b[97m';
10
+ const RED = '\x1b[38;5;196m';
11
+ const RED_SOFT = '\x1b[38;5;203m';
12
+ const GREEN = '\x1b[32m';
13
+ const GOLD = '\x1b[33m';
14
+ const PROFILE_FOOTER = `${DIM}Tip:${RESET} pass ${BOLD}--engine${RESET}, ${BOLD}--model${RESET}, ${BOLD}--batch-size${RESET}, or ${BOLD}--concurrency${RESET} to skip the guided setup.`;
15
+ function isInteractiveTerminal() {
16
+ return Boolean(processStdin.isTTY && processStdout.isTTY);
17
+ }
18
+ function commandExists(command) {
19
+ try {
20
+ execFileSync('which', [command], { stdio: 'ignore' });
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function getGeminiApiKey() {
28
+ loadEnv();
29
+ return process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? null;
30
+ }
31
+ function buildProfiles(defaultGeminiModel) {
32
+ return [
33
+ {
34
+ label: 'Rocket',
35
+ description: 'YouTube-redline speed. Best for large archives and fast broad labeling.',
36
+ model: defaultGeminiModel,
37
+ batchSize: 50,
38
+ concurrency: 10,
39
+ recommended: true,
40
+ },
41
+ {
42
+ label: 'Balanced',
43
+ description: 'Same model, gentler burst rate. Better if you want steadier throughput.',
44
+ model: defaultGeminiModel,
45
+ batchSize: 50,
46
+ concurrency: 6,
47
+ },
48
+ {
49
+ label: 'Careful',
50
+ description: 'Smaller batches and fewer workers for the quietest run.',
51
+ model: defaultGeminiModel,
52
+ batchSize: 25,
53
+ concurrency: 3,
54
+ },
55
+ ];
56
+ }
57
+ function resolveRecommendedEngine() {
58
+ if (getGeminiApiKey())
59
+ return 'gemini';
60
+ if (commandExists('claude'))
61
+ return 'claude';
62
+ if (commandExists('codex'))
63
+ return 'codex';
64
+ return null;
65
+ }
66
+ function buildEngineChoices() {
67
+ const geminiAvailable = Boolean(getGeminiApiKey());
68
+ const claudeAvailable = commandExists('claude');
69
+ const codexAvailable = commandExists('codex');
70
+ const recommended = resolveRecommendedEngine();
71
+ return [
72
+ {
73
+ engine: 'gemini',
74
+ label: geminiAvailable ? 'Gemini API' : 'Gemini API (set up key)',
75
+ description: 'Fastest path. Uses your own Gemini API key and supports concurrent batches.',
76
+ available: geminiAvailable,
77
+ recommended: recommended === 'gemini',
78
+ needsSetup: !geminiAvailable,
79
+ },
80
+ {
81
+ engine: 'claude',
82
+ label: 'Claude CLI',
83
+ description: 'Uses your local Claude Code login through the Claude CLI.',
84
+ available: claudeAvailable,
85
+ recommended: recommended === 'claude',
86
+ },
87
+ {
88
+ engine: 'codex',
89
+ label: 'Codex CLI',
90
+ description: 'Uses your local Codex login through the Codex CLI.',
91
+ available: codexAvailable,
92
+ recommended: recommended === 'codex',
93
+ },
94
+ ];
95
+ }
96
+ async function promptForSecretInput(label) {
97
+ if (!isInteractiveTerminal() || typeof processStdin.setRawMode !== 'function') {
98
+ const rl = createInterface({ input: processStdin, output: processStdout });
99
+ try {
100
+ const value = (await rl.question(`${label}: `)).trim();
101
+ return value || null;
102
+ }
103
+ finally {
104
+ rl.close();
105
+ }
106
+ }
107
+ emitKeypressEvents(processStdin);
108
+ processStdin.resume();
109
+ processStdin.setRawMode(true);
110
+ return new Promise((resolve) => {
111
+ let value = '';
112
+ const render = () => {
113
+ const masked = value.length === 0
114
+ ? ''
115
+ : `${'•'.repeat(Math.min(value.length, 24))}${value.length > 24 ? ` (${value.length} chars)` : ''}`;
116
+ processStdout.write(`\r\x1b[2K${label}: ${masked}`);
117
+ };
118
+ const cleanup = (result) => {
119
+ processStdin.off('keypress', onKeypress);
120
+ processStdin.setRawMode(false);
121
+ processStdout.write('\r\x1b[2K');
122
+ resolve(result);
123
+ };
124
+ const onKeypress = (str, key) => {
125
+ if (key?.name === 'return' || key?.name === 'enter') {
126
+ processStdout.write('\n');
127
+ cleanup(value.trim() || null);
128
+ return;
129
+ }
130
+ if (key?.name === 'escape' || (key?.ctrl && key?.name === 'c')) {
131
+ processStdout.write('\n');
132
+ cleanup(null);
133
+ return;
134
+ }
135
+ if (key?.name === 'backspace') {
136
+ value = value.slice(0, -1);
137
+ render();
138
+ return;
139
+ }
140
+ if (typeof str === 'string' && str.length > 0 && !key?.ctrl && !key?.meta) {
141
+ value += str;
142
+ render();
143
+ }
144
+ };
145
+ processStdin.on('keypress', onKeypress);
146
+ render();
147
+ });
148
+ }
149
+ async function maybePromptForGeminiKey() {
150
+ if (!isInteractiveTerminal()) {
151
+ return false;
152
+ }
153
+ processStdout.write(`\n${BOLD}${WHITE}Gemini API setup${RESET}\n`);
154
+ processStdout.write(`${RED_SOFT}Paste${RESET} your ${BOLD}GEMINI_API_KEY${RESET} and press Enter.\n`);
155
+ processStdout.write(`It will be saved to ${geminiEnvLocalPath()}.\n`);
156
+ processStdout.write('Press Esc or submit an empty value to cancel.\n\n');
157
+ const apiKey = await promptForSecretInput('GEMINI_API_KEY');
158
+ if (!apiKey) {
159
+ processStdout.write('Setup cancelled.\n');
160
+ return false;
161
+ }
162
+ const envPath = writeGeminiApiKeyToEnvLocal(apiKey);
163
+ process.env.GEMINI_API_KEY = apiKey;
164
+ processStdout.write(`${GREEN}Gemini ready.${RESET} Saved key to ${envPath}\n`);
165
+ return true;
166
+ }
167
+ function renderEngineMenu(choices, selectedIndex, linesRendered) {
168
+ const lines = [
169
+ '',
170
+ `${BOLD}${WHITE}Choose a classify engine${RESET}`,
171
+ `${RED_SOFT}Gemini${RESET} is fastest. ${WHITE}Claude${RESET} and ${WHITE}Codex${RESET} reuse the CLIs you already log into.`,
172
+ 'Use Up/Down or j/k, then press Enter. Press Esc to cancel.',
173
+ '',
174
+ ];
175
+ for (const [index, choice] of choices.entries()) {
176
+ const selected = index === selectedIndex;
177
+ const marker = selected ? `${RED}>${RESET}` : ' ';
178
+ const label = selected ? `${RED}${choice.label}${RESET}` : choice.label;
179
+ const tags = [
180
+ choice.recommended ? `${GOLD}recommended${RESET}` : null,
181
+ !choice.available && choice.engine !== 'gemini' ? `${DIM}not installed${RESET}` : null,
182
+ choice.needsSetup ? `${DIM}setup required${RESET}` : null,
183
+ ].filter(Boolean).join(` ${DIM}•${RESET} `);
184
+ lines.push(` ${marker} ${label}${tags ? ` ${tags}` : ''}`);
185
+ lines.push(` ${choice.description}`);
186
+ lines.push('');
187
+ }
188
+ const output = `${lines.join('\n')}\n`;
189
+ if (linesRendered > 0) {
190
+ processStdout.write(`\x1b[${linesRendered}A`);
191
+ }
192
+ processStdout.write('\x1b[J');
193
+ processStdout.write(output);
194
+ return lines.length + 1;
195
+ }
196
+ async function maybePromptForEngineChoice() {
197
+ const choices = buildEngineChoices().filter((choice) => choice.available || choice.engine === 'gemini');
198
+ if (!isInteractiveTerminal() || choices.length === 0) {
199
+ return null;
200
+ }
201
+ if (typeof processStdin.setRawMode !== 'function') {
202
+ return choices.find((choice) => choice.recommended) ?? choices[0] ?? null;
203
+ }
204
+ let selectedIndex = Math.max(0, choices.findIndex((choice) => choice.recommended));
205
+ let linesRendered = 0;
206
+ emitKeypressEvents(processStdin);
207
+ processStdin.resume();
208
+ processStdout.write('\x1b[?25l');
209
+ processStdin.setRawMode(true);
210
+ return new Promise((resolve) => {
211
+ const cleanup = (result, summary) => {
212
+ processStdin.off('keypress', onKeypress);
213
+ processStdin.setRawMode(false);
214
+ processStdout.write('\x1b[?25h');
215
+ if (linesRendered > 0) {
216
+ processStdout.write(`\x1b[${linesRendered}A`);
217
+ }
218
+ processStdout.write('\x1b[J');
219
+ if (summary) {
220
+ processStdout.write(`${summary}\n`);
221
+ }
222
+ resolve(result);
223
+ };
224
+ const onKeypress = (_str, key) => {
225
+ if (key?.name === 'up' || key?.name === 'k') {
226
+ selectedIndex = (selectedIndex - 1 + choices.length) % choices.length;
227
+ linesRendered = renderEngineMenu(choices, selectedIndex, linesRendered);
228
+ return;
229
+ }
230
+ if (key?.name === 'down' || key?.name === 'j') {
231
+ selectedIndex = (selectedIndex + 1) % choices.length;
232
+ linesRendered = renderEngineMenu(choices, selectedIndex, linesRendered);
233
+ return;
234
+ }
235
+ if (key?.name === 'return' || key?.name === 'enter') {
236
+ const choice = choices[selectedIndex];
237
+ cleanup(choice, `Selected engine: ${choice.label}`);
238
+ return;
239
+ }
240
+ if (key?.name === 'escape' || (key?.ctrl && key?.name === 'c')) {
241
+ cleanup(null, 'Classification setup cancelled.');
242
+ }
243
+ };
244
+ processStdin.on('keypress', onKeypress);
245
+ linesRendered = renderEngineMenu(choices, selectedIndex, linesRendered);
246
+ });
247
+ }
248
+ function renderProfileMenu(profiles, selectedIndex, linesRendered) {
249
+ const lines = [
250
+ '',
251
+ `${BOLD}${WHITE}Choose a Gemini launch profile${RESET}`,
252
+ `${RED_SOFT}YouTube-tuned defaults${RESET} for big archives and noisy real-world metadata.`,
253
+ 'Use Up/Down or j/k, then press Enter. Press Esc to cancel.',
254
+ '',
255
+ ];
256
+ for (const [index, profile] of profiles.entries()) {
257
+ const selected = index === selectedIndex;
258
+ const marker = selected ? `${RED}>${RESET}` : ' ';
259
+ const label = selected ? `${RED}${profile.label}${RESET}` : profile.label;
260
+ const meta = `${DIM}${profile.model}${RESET} ${DIM}•${RESET} ${profile.batchSize} batch ${DIM}•${RESET} ${profile.concurrency} workers`;
261
+ lines.push(` ${marker} ${label}${profile.recommended ? ` ${GOLD}recommended${RESET}` : ''}`);
262
+ lines.push(` ${profile.description}`);
263
+ lines.push(` ${meta}`);
264
+ lines.push('');
265
+ }
266
+ lines.push(PROFILE_FOOTER);
267
+ const output = `${lines.join('\n')}\n`;
268
+ if (linesRendered > 0) {
269
+ processStdout.write(`\x1b[${linesRendered}A`);
270
+ }
271
+ processStdout.write('\x1b[J');
272
+ processStdout.write(output);
273
+ return lines.length + 1;
274
+ }
275
+ async function maybePromptForProfile(profiles) {
276
+ if (!isInteractiveTerminal()) {
277
+ return null;
278
+ }
279
+ if (typeof processStdin.setRawMode !== 'function') {
280
+ return profiles.find((profile) => profile.recommended) ?? profiles[0] ?? null;
281
+ }
282
+ let selectedIndex = Math.max(0, profiles.findIndex((profile) => profile.recommended));
283
+ let linesRendered = 0;
284
+ emitKeypressEvents(processStdin);
285
+ processStdin.resume();
286
+ processStdout.write('\x1b[?25l');
287
+ processStdin.setRawMode(true);
288
+ return new Promise((resolve) => {
289
+ const cleanup = (result, summary) => {
290
+ processStdin.off('keypress', onKeypress);
291
+ processStdin.setRawMode(false);
292
+ processStdout.write('\x1b[?25h');
293
+ if (linesRendered > 0) {
294
+ processStdout.write(`\x1b[${linesRendered}A`);
295
+ }
296
+ processStdout.write('\x1b[J');
297
+ if (summary) {
298
+ processStdout.write(`${summary}\n`);
299
+ }
300
+ resolve(result);
301
+ };
302
+ const onKeypress = (_str, key) => {
303
+ if (key?.name === 'up' || key?.name === 'k') {
304
+ selectedIndex = (selectedIndex - 1 + profiles.length) % profiles.length;
305
+ linesRendered = renderProfileMenu(profiles, selectedIndex, linesRendered);
306
+ return;
307
+ }
308
+ if (key?.name === 'down' || key?.name === 'j') {
309
+ selectedIndex = (selectedIndex + 1) % profiles.length;
310
+ linesRendered = renderProfileMenu(profiles, selectedIndex, linesRendered);
311
+ return;
312
+ }
313
+ if (key?.name === 'return' || key?.name === 'enter') {
314
+ const selected = profiles[selectedIndex];
315
+ cleanup(selected, `Selected profile: ${selected.label}`);
316
+ return;
317
+ }
318
+ if (key?.name === 'escape' || (key?.ctrl && key?.name === 'c')) {
319
+ cleanup(null, 'Classification setup cancelled.');
320
+ }
321
+ };
322
+ processStdin.on('keypress', onKeypress);
323
+ linesRendered = renderProfileMenu(profiles, selectedIndex, linesRendered);
324
+ });
325
+ }
326
+ function resolveEngine(preferredEngine) {
327
+ if (preferredEngine === 'gemini')
328
+ return getGeminiApiKey() ? 'gemini' : null;
329
+ if (preferredEngine === 'claude')
330
+ return commandExists('claude') ? 'claude' : null;
331
+ if (preferredEngine === 'codex')
332
+ return commandExists('codex') ? 'codex' : null;
333
+ return resolveRecommendedEngine();
334
+ }
335
+ function formatMissingEngineMessage(preferredEngine) {
336
+ if (preferredEngine === 'gemini') {
337
+ return `Set GEMINI_API_KEY or GOOGLE_API_KEY in your shell, or save GEMINI_API_KEY to ${geminiEnvLocalPath()} before running classification.`;
338
+ }
339
+ if (preferredEngine === 'claude') {
340
+ return 'Claude CLI was requested, but `claude` was not found in PATH.';
341
+ }
342
+ if (preferredEngine === 'codex') {
343
+ return 'Codex CLI was requested, but `codex` was not found in PATH.';
344
+ }
345
+ return `No supported classification engine found. Set GEMINI_API_KEY/GOOGLE_API_KEY, or install ${BOLD}claude${RESET} or ${BOLD}codex${RESET}.`;
346
+ }
347
+ export async function resolveClassifySetup(input) {
348
+ loadEnv();
349
+ let engine = resolveEngine(input.engine);
350
+ if (!input.engine && isInteractiveTerminal()) {
351
+ const choice = await maybePromptForEngineChoice();
352
+ if (!choice) {
353
+ return null;
354
+ }
355
+ engine = choice.engine;
356
+ if (choice.engine === 'gemini' && choice.needsSetup) {
357
+ const saved = await maybePromptForGeminiKey();
358
+ if (!saved) {
359
+ return null;
360
+ }
361
+ engine = 'gemini';
362
+ }
363
+ }
364
+ else if (input.engine === 'gemini' && !engine) {
365
+ const saved = await maybePromptForGeminiKey();
366
+ if (!saved) {
367
+ throw new Error(formatMissingEngineMessage('gemini'));
368
+ }
369
+ engine = 'gemini';
370
+ }
371
+ if (!engine) {
372
+ throw new Error(formatMissingEngineMessage(input.engine));
373
+ }
374
+ if (engine === 'gemini') {
375
+ const hasCustomSettings = input.model != null || input.batchSize != null || input.concurrency != null;
376
+ const keySource = 'env';
377
+ if (!hasCustomSettings && isInteractiveTerminal()) {
378
+ const selected = await maybePromptForProfile(buildProfiles(input.defaultGeminiModel));
379
+ if (!selected) {
380
+ return null;
381
+ }
382
+ return {
383
+ engine,
384
+ model: selected.model,
385
+ batchSize: selected.batchSize,
386
+ concurrency: selected.concurrency,
387
+ limit: input.limit,
388
+ profileLabel: selected.label,
389
+ keySource,
390
+ };
391
+ }
392
+ return {
393
+ engine,
394
+ model: input.model ?? input.defaultGeminiModel,
395
+ batchSize: Math.max(1, input.batchSize ?? input.defaultBatchSize),
396
+ concurrency: Math.max(1, input.concurrency ?? input.defaultConcurrency),
397
+ limit: input.limit,
398
+ profileLabel: hasCustomSettings ? 'Custom Gemini' : 'Rocket',
399
+ keySource,
400
+ };
401
+ }
402
+ return {
403
+ engine,
404
+ batchSize: Math.max(1, input.batchSize ?? input.defaultBatchSize),
405
+ concurrency: 1,
406
+ limit: input.limit,
407
+ profileLabel: engine === 'claude' ? 'Claude CLI' : 'Codex CLI',
408
+ };
409
+ }