yt-briefing 0.6.0 → 0.7.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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: yt-search
3
3
  description: Search WITHIN one YouTube channel by intent — name a channel and what you're after; the engine lists that channel's uploads, ranks them against your intent (metadata only, no transcript yet), then lazily yields ONE matching video at a time with a rich summary. You keep or skip each; at the end it synthesizes a comparison from everything you kept. Channel-scoped, not whole-YouTube. Same transcript engine + proxy as /yt; lazy on purpose (no transcript bursts → no IP block). Summaries and prompts use the language chosen at onboarding.
4
- argument-hint: A channel (@handle or URL) and a descriptive intent, e.g. "@t3dotgg which terminal for AI coding". Optional --max N, --scan N, --since YYYY-MM-DD.
4
+ argument-hint: A channel (@handle or URL) and a descriptive intent, e.g. "@t3dotgg which terminal for AI coding". Optional --top N (default 10), --scan N, --since YYYY-MM-DD.
5
5
  ---
6
6
 
7
7
  ## How it works
package/README.md CHANGED
@@ -79,24 +79,15 @@ npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to
79
79
 
80
80
  ## Search within a channel
81
81
 
82
- Following a creator and want to mine *their* videos for something specific — and get an actual
83
- comparison, not a pile of links? Run `/yt-search`, name a **channel** and your **intent**, e.g.
84
- `@t3dotgg which terminal for AI coding`. It lists that channel's uploads, ranks them against your
85
- intent (titles/descriptions — no transcripts yet, matching is **descriptive, not exact keywords**),
86
- then hands you **one matching video at a time** with a rich summary; you **Keep** or **Skip** each.
87
- At the end it synthesizes a **comparison** from everything you kept.
82
+ Mine one channel's videos for a topic and get a comparison. Run `/yt-search` with a channel and
83
+ an intent — e.g. `/yt-search @betterstack which terminal for AI coding`.
88
84
 
89
- It's channel-scoped on purpose (you choose where to look) and lazy — one transcript per step, never
90
- a burst (a burst gets your IP blocked, same as `/yt`). Summaries and the comparison use the
91
- language you chose at setup.
85
+ It covers the channel's **whole history** (not just recent uploads), re-ranks every upload against
86
+ your intent, then lazily yields one matching video at a time to keep or skip — and synthesizes a
87
+ comparison from everything you kept.
92
88
 
93
- ```bash
94
- yt-briefing search "<intent>" --channel <@handle|url> [--max N] [--scan N] [--since YYYY-MM-DD]
95
- ```
96
-
97
- > Listing a channel's uploads is cheap (`playlistItems`, ~1 quota unit per 50 videos — not the
98
- > 100-unit `search.list`). `--scan` (default 50) caps how many recent uploads are considered;
99
- > `--since` widens by date.
89
+ The one flag is `--top N` — how many of the top re-ranked matches to triage (**default 10**). Raise
90
+ it to go deeper, lower it for a quicker pass: `/yt-search @betterstack which terminal --top 20`.
100
91
 
101
92
  ## Run it
102
93
 
@@ -121,6 +112,23 @@ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
121
112
  Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
122
113
  OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`).
123
114
 
115
+ ### Where the keys live
116
+
117
+ Keys are read from your project's **root `.env`** only — there is no fallback file. Put them there
118
+ (or export them in the shell / CI — an exported var wins). `bun run init` writes them into your
119
+ root `.env`, merging without clobbering any other variables already in it.
120
+
121
+ These are **required**, and missing one fails fast naming exactly which (no silent defaults):
122
+
123
+ ```ini
124
+ YT_BRIEFING_LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
125
+ YT_BRIEFING_LLM_API_KEY=<your-key>
126
+ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
127
+ YT_BRIEFING_YOUTUBE_API_KEY=<your-key>
128
+ ```
129
+
130
+ `YT_BRIEFING_PROXY` and `YT_BRIEFING_DLP_PATH` are optional. Keep `.env` gitignored.
131
+
124
132
  ## Why an API, not the agent's native model
125
133
 
126
134
  The filtering and the summaries go through a plain OpenAI-compatible API call from the engine,
package/dist/bootstrap.js CHANGED
@@ -13,10 +13,11 @@
13
13
  * Re-running is safe: it warns before overwriting existing data and lets you bail.
14
14
  * Everything it writes is plain Markdown / JSON you can also edit by hand afterwards.
15
15
  */
16
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
- import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ENV_PATH, profilePath, } from "./lib/paths.js";
18
+ import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ROOT_ENV_PATH, profilePath, } from "./lib/paths.js";
19
19
  import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
20
+ import { loadEnv } from "./lib/env.js";
20
21
  import { question } from "./lib/prompt.js";
21
22
  import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
22
23
  const ask = (q, def = '') => {
@@ -29,8 +30,34 @@ const askYN = (q, def = true) => {
29
30
  return def;
30
31
  return a.startsWith('y');
31
32
  };
33
+ // Secrets: if already provided by the environment (root .env or an exported var), offer to keep it
34
+ // without echoing the value back to the terminal. Returns the kept/typed value.
35
+ const askSecret = (q, preset) => {
36
+ if (preset)
37
+ return question(`${q} [Enter = keep the value from your environment]:`).trim() || preset;
38
+ return ask(q);
39
+ };
40
+ // Merge vars into the project root .env without clobbering: only append keys not already present
41
+ // (so a value the user keeps from their existing root .env stays the single source). Skips empties.
42
+ function mergeRootEnv(vars) {
43
+ const existing = existsSync(ROOT_ENV_PATH) ? readFileSync(ROOT_ENV_PATH, 'utf8') : '';
44
+ const present = new Set(existing.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
45
+ .map(l => l.slice(0, l.indexOf('=')).trim()).filter(Boolean));
46
+ const additions = vars.filter(([k, v]) => v !== '' && !present.has(k)).map(([k, v]) => `${k}=${v}`);
47
+ if (additions.length) {
48
+ const sep = existing && !existing.endsWith('\n') ? '\n' : '';
49
+ writeFileSync(ROOT_ENV_PATH, existing + sep + additions.join('\n') + '\n', 'utf8');
50
+ }
51
+ return additions.map(l => l.slice(0, l.indexOf('=')));
52
+ }
32
53
  function main() {
33
54
  console.log('\n yt-briefing — onboarding\n ' + '─'.repeat(40) + '\n');
55
+ // See what the environment already provides (root .env / exported vars) so we can offer to keep
56
+ // those secrets instead of re-asking, and avoid baking a second copy into .yt-briefing/.env.
57
+ loadEnv();
58
+ const presetLlmKey = process.env.YT_BRIEFING_LLM_API_KEY ?? '';
59
+ const presetYtKey = process.env.YT_BRIEFING_YOUTUBE_API_KEY ?? '';
60
+ const presetProxy = process.env.YT_BRIEFING_PROXY ?? '';
34
61
  if (existsSync(CHANNELS_MD)) {
35
62
  console.log(` Existing data found at ${DATA_DIR}`);
36
63
  if (!askYN(' Overwrite it?', false)) {
@@ -61,7 +88,7 @@ function main() {
61
88
  const picked = PROVIDERS[ask(' Your choice', '1')];
62
89
  if (picked) {
63
90
  console.log(`\n → ${picked.name}. Get your key here: ${picked.keyUrl}`);
64
- llmKey = ask(' Paste your API key');
91
+ llmKey = askSecret(' Paste your API key', presetLlmKey);
65
92
  llmBaseUrl = picked.base;
66
93
  llmModel = ask(' Model (Enter to accept)', picked.model);
67
94
  }
@@ -69,13 +96,13 @@ function main() {
69
96
  console.log('\n → Custom / local endpoint (e.g. Ollama at http://localhost:11434/v1)');
70
97
  llmBaseUrl = ask(' YT_BRIEFING_LLM_BASE_URL', 'http://localhost:11434/v1');
71
98
  llmModel = ask(' YT_BRIEFING_LLM_MODEL', 'llama3.1');
72
- llmKey = ask(' YT_BRIEFING_LLM_API_KEY (blank for local)', '');
99
+ llmKey = askSecret(' YT_BRIEFING_LLM_API_KEY (blank for local)', presetLlmKey);
73
100
  }
74
101
  console.log('\n 3) YouTube Data API (needed to list channel uploads)');
75
102
  console.log(' Get a key: https://console.cloud.google.com → YouTube Data API v3');
76
- const ytKey = ask(' YT_BRIEFING_YOUTUBE_API_KEY');
103
+ const ytKey = askSecret(' YT_BRIEFING_YOUTUBE_API_KEY', presetYtKey);
77
104
  console.log('\n 4) Proxy (optional — only needed on datacenter/VPS IPs; see docs/warp-proxy.md)');
78
- const ytProxy = ask(' YT_BRIEFING_PROXY (blank = direct)', '');
105
+ const ytProxy = askSecret(' YT_BRIEFING_PROXY (blank = direct)', presetProxy);
79
106
  // 5. Channels ----------------------------------------------------------------
80
107
  // Just collect a flat list. No categories, no per-channel rules to define up front —
81
108
  // each channel's profile LEARNS what to skip as you rate it (## Skip titles / ## Notes).
@@ -118,21 +145,20 @@ function main() {
118
145
  const customDir = AGENTS[agentKey] ? '' : ask(' Skills folder to install into', customSkillsRootDefault());
119
146
  // 7. Write everything --------------------------------------------------------
120
147
  mkdirSync(CHANNELS_DIR, { recursive: true });
121
- // .env
122
- const envBody = [
123
- `YT_BRIEFING_LLM_BASE_URL=${llmBaseUrl}`,
124
- `YT_BRIEFING_LLM_API_KEY=${llmKey}`,
125
- `YT_BRIEFING_LLM_MODEL=${llmModel}`,
126
- `YT_BRIEFING_YOUTUBE_API_KEY=${ytKey}`,
127
- `YT_BRIEFING_PROXY=${ytProxy}`,
128
- '',
129
- ].join('\n');
130
- writeFileSync(ENV_PATH, envBody, 'utf8');
131
- // Secret-safety for the consume layout: drop a .gitignore inside .yt-briefing/ so .env never
132
- // gets committed regardless of the host project's own ignore rules. data/ stays versionable
133
- // (for sync). In a dev clone (BASE_DIR === PKG_ROOT) the repo's own .gitignore already covers it.
148
+ // Secrets live in the project ROOT .env (the only file the engine reads). Merge into it without
149
+ // clobbering — keys already there (e.g. kept from the environment) stay the single source. The
150
+ // engine has no fallback file, so a key not set here (or exported) is a hard error at run time.
151
+ const envAdded = mergeRootEnv([
152
+ ['YT_BRIEFING_LLM_BASE_URL', llmBaseUrl],
153
+ ['YT_BRIEFING_LLM_API_KEY', llmKey],
154
+ ['YT_BRIEFING_LLM_MODEL', llmModel],
155
+ ['YT_BRIEFING_YOUTUBE_API_KEY', ytKey],
156
+ ['YT_BRIEFING_PROXY', ytProxy],
157
+ ]);
158
+ // Keep the throwaway cache out of git for the consume layout. data/ stays versionable (for sync).
159
+ // No secrets live under .yt-briefing/ anymore, so nothing else needs ignoring here.
134
160
  if (BASE_DIR !== PKG_ROOT) {
135
- writeFileSync(join(BASE_DIR, '.gitignore'), '.env\ndata/.cache/\n', 'utf8');
161
+ writeFileSync(join(BASE_DIR, '.gitignore'), 'data/.cache/\n', 'utf8');
136
162
  }
137
163
  // config.json
138
164
  writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
@@ -144,7 +170,7 @@ function main() {
144
170
  writeFileSync(profilePath(c.slug), profileBody(c.handle, c.slug), 'utf8');
145
171
  console.log(' ' + '─'.repeat(40));
146
172
  console.log(` Done. Wrote:`);
147
- console.log(` ${ENV_PATH}`);
173
+ console.log(` ${ROOT_ENV_PATH} ${envAdded.length ? `(+${envAdded.join(', ')})` : '(no new keys — already set)'}`);
148
174
  console.log(` ${CONFIG_JSON}`);
149
175
  console.log(` ${CHANNELS_MD}`);
150
176
  console.log(` ${STATE_MD}`);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Env loader + required-variable preflight — the one place that reads secrets off disk.
3
+ *
4
+ * Keys come from the project's root `.env` ONLY. There is no fallback file: dotenv loads root
5
+ * `.env` into process.env (anything already exported wins, since dotenv never overrides). Missing
6
+ * a required variable is a hard error that names exactly which one — never a silent default.
7
+ *
8
+ * Call `loadEnv()` once at every entrypoint, then `requireEnv([...])` for what that command needs.
9
+ */
10
+ import dotenv from 'dotenv';
11
+ import { ROOT_ENV_PATH } from "./paths.js";
12
+ let done = false;
13
+ /** Load the project's root `.env` into process.env. The only file we read. */
14
+ export function loadEnv() {
15
+ if (done)
16
+ return;
17
+ done = true;
18
+ dotenv.config({ path: ROOT_ENV_PATH });
19
+ }
20
+ /** The required vars per capability — single source of truth for the preflight checks. */
21
+ export const REQUIRED_LLM = ['YT_BRIEFING_LLM_BASE_URL', 'YT_BRIEFING_LLM_API_KEY', 'YT_BRIEFING_LLM_MODEL'];
22
+ export const REQUIRED_YOUTUBE = ['YT_BRIEFING_YOUTUBE_API_KEY'];
23
+ /** Names from `names` that are missing or empty in the environment, in order. */
24
+ export function missingEnv(names) {
25
+ return names.filter(n => !process.env[n]);
26
+ }
27
+ /** Human-readable error for a set of missing vars — names each one and where to set it. */
28
+ export function missingEnvMessage(missing) {
29
+ const plural = missing.length > 1;
30
+ return `Missing required environment variable${plural ? 's' : ''}: ${missing.join(', ')}. ` +
31
+ `Set ${plural ? 'them' : 'it'} in your project root .env (see README → Providers → Where the keys live).`;
32
+ }
33
+ /** Throw a clear, named error if any required var is missing. */
34
+ export function requireEnv(names) {
35
+ const missing = missingEnv(names);
36
+ if (missing.length)
37
+ throw new Error(missingEnvMessage(missing));
38
+ }
package/dist/lib/llm.js CHANGED
@@ -10,21 +10,22 @@
10
10
  * the default — cheap and fast enough for the batch title filter, capable enough for
11
11
  * the summaries.
12
12
  *
13
- * Env (see .env.example):
14
- * YT_BRIEFING_LLM_BASE_URL default https://openrouter.ai/api/v1
13
+ * Env (see .env.example) — all required, no defaults:
14
+ * YT_BRIEFING_LLM_BASE_URL required (e.g. https://openrouter.ai/api/v1)
15
15
  * YT_BRIEFING_LLM_API_KEY required
16
- * YT_BRIEFING_LLM_MODEL default google/gemini-2.5-flash
16
+ * YT_BRIEFING_LLM_MODEL required (e.g. google/gemini-2.5-flash)
17
17
  */
18
- const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
19
- const DEFAULT_MODEL = "google/gemini-2.5-flash";
18
+ import { requireEnv, REQUIRED_LLM } from "./env.js";
20
19
  export function getModel() {
21
- return process.env.YT_BRIEFING_LLM_MODEL || DEFAULT_MODEL;
20
+ const model = process.env.YT_BRIEFING_LLM_MODEL;
21
+ if (!model)
22
+ throw new Error("Missing required environment variable: YT_BRIEFING_LLM_MODEL. Set it in your project root .env.");
23
+ return model;
22
24
  }
23
25
  export async function chat(prompt, opts = {}) {
24
- const baseUrl = (process.env.YT_BRIEFING_LLM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
26
+ requireEnv(REQUIRED_LLM);
27
+ const baseUrl = process.env.YT_BRIEFING_LLM_BASE_URL.replace(/\/+$/, "");
25
28
  const apiKey = process.env.YT_BRIEFING_LLM_API_KEY;
26
- if (!apiKey)
27
- throw new Error("YT_BRIEFING_LLM_API_KEY not set (see .env.example)");
28
29
  const model = opts.model || getModel();
29
30
  const messages = [];
30
31
  if (opts.system)
package/dist/lib/paths.js CHANGED
@@ -33,6 +33,13 @@ export const BASE_DIR = process.env.YT_BRIEFING_BASE_DIR
33
33
  : CONSUMED ? join(process.cwd(), '.yt-briefing') : PKG_ROOT;
34
34
  process.env.YT_BRIEFING_BASE_DIR = BASE_DIR; // pin for children (their cwd differs)
35
35
  export const ENV_PATH = join(BASE_DIR, '.env');
36
+ /**
37
+ * The project's root `.env` — the conventional, user-owned home for secrets (12-factor). When
38
+ * consumed, BASE_DIR is `<project>/.yt-briefing`, so the root is its parent; in a dev clone
39
+ * BASE_DIR === PKG_ROOT, so the root `.env` *is* ENV_PATH (one file, loaded once). Read-only:
40
+ * the loader never writes here, so it can't clobber the user's other variables. See lib/env.ts.
41
+ */
42
+ export const ROOT_ENV_PATH = CONSUMED ? join(dirname(BASE_DIR), '.env') : ENV_PATH;
36
43
  export const DATA_DIR = process.env.YT_BRIEFING_DATA_DIR
37
44
  ? resolve(process.env.YT_BRIEFING_DATA_DIR)
38
45
  : join(BASE_DIR, 'data');
@@ -5,14 +5,14 @@
5
5
  * cost ~7s to load from a cold FS cache on every fresh process. The Data API is a
6
6
  * trivial REST surface, so direct fetch keeps cold-start near the runtime's own startup.
7
7
  *
8
- * Auth: YT_BRIEFING_YOUTUBE_API_KEY — the entrypoint loads it (dotenv.config from ENV_PATH) before
8
+ * Auth: YT_BRIEFING_YOUTUBE_API_KEY — the entrypoint loads it (loadEnv() in lib/env.ts) before
9
9
  * calling; this module only reads process.env at call time.
10
10
  */
11
11
  const API = 'https://www.googleapis.com/youtube/v3';
12
12
  function apiKey() {
13
13
  const k = process.env.YT_BRIEFING_YOUTUBE_API_KEY;
14
14
  if (!k)
15
- throw new Error('YT_BRIEFING_YOUTUBE_API_KEY env var not set (see .env.example)');
15
+ throw new Error('Missing required environment variable: YT_BRIEFING_YOUTUBE_API_KEY. Set it in your project root .env (see README → Providers → Where the keys live).');
16
16
  return k;
17
17
  }
18
18
  async function get(path, params) {
@@ -12,11 +12,11 @@
12
12
  * Empty result is valid (channel has no new content).
13
13
  */
14
14
  import { readFileSync } from 'fs';
15
- import dotenv from 'dotenv';
15
+ import { loadEnv } from "./lib/env.js";
16
16
  import { parseState } from "./lib/yt-lib.js";
17
- import { STATE_MD, ENV_PATH } from "./lib/paths.js";
17
+ import { STATE_MD } from "./lib/paths.js";
18
18
  import { fetchChannelVideos } from "./lib/yt-api.js";
19
- dotenv.config({ path: ENV_PATH });
19
+ loadEnv();
20
20
  const handle = process.argv[2];
21
21
  if (!handle) {
22
22
  console.error('Usage: yt-channel-pending @HANDLE (internal helper)');
@@ -17,10 +17,9 @@
17
17
  *
18
18
  * Quota: ~1 unit per page (playlistItems.list) + 1 unit per 50 videos (videos.list).
19
19
  */
20
- import dotenv from 'dotenv';
21
- import { ENV_PATH } from "./lib/paths.js";
20
+ import { loadEnv } from "./lib/env.js";
22
21
  import { fetchChannelVideos } from "./lib/yt-api.js";
23
- dotenv.config({ path: ENV_PATH });
22
+ loadEnv();
24
23
  const args = process.argv.slice(2);
25
24
  const handleOrId = args[0];
26
25
  const sinceIdx = args.indexOf('--since');
package/dist/yt-rating.js CHANGED
@@ -17,10 +17,10 @@
17
17
  * bullets are de-duplicated; a state.md re-bump is a no-op.
18
18
  */
19
19
  import { readFileSync, writeFileSync, existsSync } from 'fs';
20
- import dotenv from 'dotenv';
20
+ import { loadEnv } from "./lib/env.js";
21
21
  import { parseChannels, appendSkipTitle, appendNote, bumpStatePointer } from "./lib/yt-lib.js";
22
- import { CHANNELS_MD, STATE_MD, PENDING_FILE, ENV_PATH, profilePath } from "./lib/paths.js";
23
- dotenv.config({ path: ENV_PATH });
22
+ import { CHANNELS_MD, STATE_MD, PENDING_FILE, profilePath } from "./lib/paths.js";
23
+ loadEnv();
24
24
  function getArg(args, name) {
25
25
  const idx = args.indexOf(name);
26
26
  return idx !== -1 && args[idx + 1] !== undefined ? args[idx + 1] : null;
package/dist/yt-search.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * the channel's videos by intent (no exact-keyword needed).
17
17
  *
18
18
  * Usage (the skill / CLI drives these; one JSON line per call):
19
- * yt-search "<intent>" --channel <@handle|url> [--reset] [--max N] [--scan N] [--since DATE] [--lang auto]
19
+ * yt-search "<intent>" --channel <@handle|url> [--reset] [--top N] [--scan N] [--since DATE] [--lang auto]
20
20
  * yt-search --keep record the pending candidate, advance, yield next
21
21
  * yt-search --skip drop the pending candidate, advance, yield next
22
22
  * yt-search --compare synthesize a comparison from everything kept
@@ -34,18 +34,18 @@
34
34
  */
35
35
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
36
36
  import { spawn } from 'node:child_process';
37
- import dotenv from 'dotenv';
37
+ import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
38
38
  import { chat, getModel } from "./lib/llm.js";
39
39
  import { outputLang } from "./lib/config.js";
40
40
  import { fetchChannelVideos } from "./lib/yt-api.js";
41
41
  import { normalizeHandle } from "./lib/channels.js";
42
- import { PKG_ROOT, ENV_PATH, CACHE_DIR, SEARCH_QUEUE_FILE, SEARCH_PENDING_FILE, SEARCH_KEPT_FILE, script, } from "./lib/paths.js";
43
- dotenv.config({ path: ENV_PATH });
42
+ import { PKG_ROOT, CACHE_DIR, SEARCH_QUEUE_FILE, SEARCH_PENDING_FILE, SEARCH_KEPT_FILE, script, } from "./lib/paths.js";
43
+ loadEnv();
44
44
  mkdirSync(CACHE_DIR, { recursive: true });
45
45
  const RUNTIME = process.execPath;
46
46
  const LANG = outputLang();
47
47
  const argv = process.argv.slice(2);
48
- const VALUE_FLAGS = new Set(['--channel', '--max', '--scan', '--since', '--lang']);
48
+ const VALUE_FLAGS = new Set(['--channel', '--top', '--scan', '--since', '--lang']);
49
49
  const has = (f) => argv.includes(f);
50
50
  const flagVal = (f) => {
51
51
  const i = argv.indexOf(f);
@@ -72,7 +72,7 @@ const KEEP = has('--keep');
72
72
  const SKIP = has('--skip');
73
73
  const COMPARE = has('--compare');
74
74
  const CHANNEL = flagVal('--channel');
75
- const MAX = Math.max(1, parseInt(flagVal('--max') || '8', 10));
75
+ const TOP = Math.max(1, parseInt(flagVal('--top') || '10', 10));
76
76
  const SCAN = Math.max(1, parseInt(flagVal('--scan') || '50', 10)); // recent uploads to consider when no --since
77
77
  const SINCE = flagVal('--since');
78
78
  const LANGTRACK = flagVal('--lang') || 'auto';
@@ -224,6 +224,14 @@ async function yieldNext(queue) {
224
224
  emit({ status: 'done', kept: loadKept().length });
225
225
  }
226
226
  async function main() {
227
+ // LLM is needed on every path (rerank, summaries, compare). Fail fast naming any missing var
228
+ // (the throw is turned into a status:"error" by the .catch below). YouTube is checked separately,
229
+ // only when building a fresh queue (the compare/keep/skip paths work off cache, no API).
230
+ {
231
+ const missing = missingEnv(REQUIRED_LLM);
232
+ if (missing.length)
233
+ emit({ status: 'error', error: missingEnvMessage(missing) });
234
+ }
227
235
  // --compare: synthesize from kept summaries.
228
236
  if (COMPARE) {
229
237
  const queue = loadQueue();
@@ -255,9 +263,11 @@ async function main() {
255
263
  if (existing && !RESET && (!intentArg || intentArg === existing.intent) && (!CHANNEL || normalizeHandle(CHANNEL) === existing.channel)) {
256
264
  await yieldNext(existing);
257
265
  }
258
- // Fresh search: needs an intent AND a channel.
259
- if (!process.env.YT_BRIEFING_YOUTUBE_API_KEY) {
260
- emit({ status: 'error', error: 'YT_BRIEFING_YOUTUBE_API_KEY is not set — add a YouTube Data API v3 key to .yt-briefing/.env (see README → setup / .env.example).' });
266
+ // Fresh search: needs an intent AND a channel AND the YouTube key (to list the channel).
267
+ {
268
+ const missing = missingEnv(REQUIRED_YOUTUBE);
269
+ if (missing.length)
270
+ emit({ status: 'error', error: missingEnvMessage(missing) });
261
271
  }
262
272
  if (!intentArg)
263
273
  emit({ status: 'error', error: 'Provide an intent: yt-search "<what to look for>" --channel <@handle|url>.' });
@@ -276,7 +286,7 @@ async function main() {
276
286
  if (videos.length === 0)
277
287
  emit({ status: 'no_results' });
278
288
  const pool = videos.map(v => ({ videoId: v.videoId, title: v.title, channelTitle: handle, publishedAt: v.publishedAt, description: v.description }));
279
- const ranked = (await rerank(intentArg, pool)).slice(0, MAX);
289
+ const ranked = (await rerank(intentArg, pool)).slice(0, TOP);
280
290
  if (ranked.length === 0)
281
291
  emit({ status: 'no_results' });
282
292
  const queue = { built_at: new Date().toISOString(), intent: intentArg, channel: handle, candidates: ranked, cursor: 0 };
package/dist/yt-sweep.js CHANGED
@@ -49,12 +49,12 @@
49
49
  */
50
50
  import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, renameSync, appendFileSync } from 'node:fs';
51
51
  import { spawn } from 'node:child_process';
52
- import dotenv from 'dotenv';
52
+ import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
53
53
  import { parseChannels, parseState, bumpStatePointer } from "./lib/yt-lib.js";
54
54
  import { chat, getModel } from "./lib/llm.js";
55
55
  import { outputLang } from "./lib/config.js";
56
- import { PKG_ROOT, ENV_PATH, CHANNELS_MD, STATE_MD, CACHE_DIR, QUEUE_FILE, REST_FILE, PENDING_FILE, PREFETCH_FILE, LOG_FILE, profilePath, script, } from "./lib/paths.js";
57
- dotenv.config({ path: ENV_PATH });
56
+ import { PKG_ROOT, CHANNELS_MD, STATE_MD, CACHE_DIR, QUEUE_FILE, REST_FILE, PENDING_FILE, PREFETCH_FILE, LOG_FILE, profilePath, script, } from "./lib/paths.js";
57
+ loadEnv();
58
58
  mkdirSync(CACHE_DIR, { recursive: true });
59
59
  // Re-invoke sibling scripts with the SAME runtime that launched us (bun/node/deno),
60
60
  // never a hardcoded binary — the tool must run wherever the user installed it.
@@ -542,12 +542,14 @@ if (reset) {
542
542
  clearRest();
543
543
  clearPrefetch();
544
544
  }
545
- // Fatal config check, foreground only (the detached --fill / --prefetch children already
546
- // exited above). A missing YouTube Data API key makes EVERY channel expansion throw the same
547
- // error, which the per-channel `catch` collapses to 0 items — surfacing as a misleading
548
- // `status:"done"` ("no new videos"). Fail fast with a clear, actionable error instead.
549
- if (!process.env.YT_BRIEFING_YOUTUBE_API_KEY) {
550
- emit({ status: 'error', error: 'YT_BRIEFING_YOUTUBE_API_KEY is not set — add a YouTube Data API v3 key to .yt-briefing/.env (see README → first run / .env.example).' });
545
+ // Fatal config preflight, foreground only (the detached --fill / --prefetch children already
546
+ // exited above). A missing key would otherwise surface as a misleading `status:"done"` ("no new
547
+ // videos") — the YouTube error is collapsed by the per-channel catch, and a missing LLM key is
548
+ // swallowed by the title-filter's keep-all fallback. Fail fast naming every missing var instead.
549
+ {
550
+ const missing = missingEnv([...REQUIRED_LLM, ...REQUIRED_YOUTUBE]);
551
+ if (missing.length)
552
+ emit({ status: 'error', error: missingEnvMessage(missing) });
551
553
  }
552
554
  const queue = loadQueue() ?? buildQueue();
553
555
  await advance(queue);
@@ -25,11 +25,11 @@ import { spawnSync } from 'node:child_process';
25
25
  import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
26
26
  import { join, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
- import dotenv from 'dotenv';
29
- import { CACHE_DIR, ENV_PATH } from "./lib/paths.js";
28
+ import { loadEnv } from "./lib/env.js";
29
+ import { CACHE_DIR } from "./lib/paths.js";
30
30
  // Load .env so YT_BRIEFING_PROXY is set when run standalone under Node (Bun auto-loads it; Node doesn't).
31
31
  // When spawned by yt-sweep, the parent already loaded it and the child inherits the env.
32
- dotenv.config({ path: ENV_PATH });
32
+ loadEnv();
33
33
  /** Resolve the yt-dlp binary: explicit env → project-local ./bin → PATH (Windows-aware). */
34
34
  function resolveYtDlp() {
35
35
  if (process.env.YT_BRIEFING_DLP_PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A self-learning YouTube briefing engine: it sweeps the channels you follow, filters noise in two stages (title, then transcript), summarizes the rest in your language, and adapts to your ratings — one video at a time.",
5
5
  "type": "module",
6
6
  "bin": {