yt-briefing 0.5.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,15 +1,19 @@
1
1
  ---
2
2
  name: yt-search
3
- description: Research a topic across YouTube — describe an intent, the engine expands it into search queries, ranks results against your intent, then lazily yields ONE video at a time with a rich summary. You keep or skip each; at the end it synthesizes a comparison from everything you kept. 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 descriptive intent in quotes, e.g. "which terminal for coding with Claude Code". Optional --max N, --since YYYY-MM-DD, --queries 1..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 --top N (default 10), --scan N, --since YYYY-MM-DD.
5
5
  ---
6
6
 
7
7
  ## How it works
8
8
 
9
- `src/yt-search.ts` is the whole engine: intent → query expansion (LLM) → `search.list` → re-rank against the intent on metadata only (no transcript) → **lazy** one-candidate-at-a-time yield with a rich summary → record keep/skip → on demand synthesize a comparison from everything kept. Matching is **descriptive, not exact-keyword** — YouTube ranks by relevance and the LLM bridges intent→query and filters noise. This skill is a thin loop: paste the summary, collect keep/skip, show the final comparison. It runs no filters and formats nothing itself.
9
+ `src/yt-search.ts` is the whole engine: list one channel's uploads (cheap — `playlistItems`, ~1 quota unit/page; NOT `search.list`) → re-rank them against your intent on metadata only (title/description, no transcript) → **lazy** one-candidate-at-a-time yield with a rich summary → record keep/skip → on demand synthesize a comparison from everything kept. **Channel-scoped on purpose** — you choose where to look; it does NOT search all of YouTube. Matching is descriptive: the LLM filters the channel's videos by intent. This skill is a thin loop — paste the summary, collect keep/skip, show the final comparison.
10
10
 
11
11
  **Lazy on purpose:** one transcript per step, never a burst — a burst looks like scraping and gets the IP blocked (same reason `/yt` is lazy). Run the engine bare — stdout is a single JSON line, stderr empty; never redirect.
12
12
 
13
+ ## Inputs
14
+
15
+ The user gives a **channel** (`@handle` or a channel URL) and an **intent** (what to look for). Pass the channel via `--channel` and the intent as the quoted positional. If the user names a channel but no clear intent (or vice versa), ask for the missing half before running.
16
+
13
17
  ## Language
14
18
 
15
19
  Read `data/config.json` → `output_lang` once at the start. Phrase the question text and option descriptions in that language. The two button labels stay the short English words `Keep` / `Skip`. Summaries and the final comparison are already written in `output_lang` by the engine — paste them verbatim.
@@ -17,11 +21,11 @@ Read `data/config.json` → `output_lang` once at the start. Phrase the question
17
21
  ## Loop
18
22
 
19
23
  ```
20
- out = JSON.parse(`bun run src/yt-search.ts "<intent from the user>" --reset`) // first call
24
+ out = JSON.parse(`bun run src/yt-search.ts "<intent>" --channel <@handle|url> --reset`) // first call
21
25
  while true:
22
26
  out.status:
23
27
  "error" → show out.error verbatim, stop
24
- "no_results" → tell the user nothing relevant was found, stop
28
+ "no_results" → tell the user nothing in that channel matched, stop
25
29
  "rate_limited" → transcript fetch blocked (datacenter IP) — tell the user, stop; recovery in README.md → Running on a VPS
26
30
  "decision_needed" → steps A–C
27
31
  "done" → step D
@@ -38,16 +42,16 @@ while true:
38
42
  - Keep → `bun run src/yt-search.ts --keep`
39
43
  - Skip → `bun run src/yt-search.ts --skip`
40
44
  - stop/dismissed → `bun run src/yt-search.ts --compare` (skip straight to D)
41
- - The script reads the pending candidate from cache; pass only `--keep` / `--skip` / `--compare`. Its JSON becomes the next `out` — back to the top of the loop.
45
+ - The script reads the pending candidate from cache; pass only `--keep` / `--skip` / `--compare` (no channel/intent again). Its JSON becomes the next `out` — back to the top of the loop.
42
46
 
43
47
  **On `done` (step D):**
44
48
 
45
- - If `out.kept > 0` → run `out = JSON.parse(\`bun run src/yt-search.ts --compare\`)`; when it returns `status:"compare"`, paste `out.comparison` **verbatim** as your chat text (it's the artifact — a decision-grade comparison in `output_lang`). Stop.
49
+ - If `out.kept > 0` → run `out = JSON.parse(\`bun run src/yt-search.ts --compare\`)`; when it returns `status:"compare"`, paste `out.comparison` **verbatim** as your chat text (the artifact — a decision-grade comparison in `output_lang`). Stop.
46
50
  - If `out.kept == 0` → tell the user nothing was kept, so there's nothing to compare. Stop.
47
51
 
48
52
  ## Rules
49
53
 
50
54
  - **Verbatim:** paste `summary` and `comparison` exactly as returned; never paste a raw transcript.
51
55
  - **Language:** question text + option descriptions follow `output_lang`; button labels stay `Keep` / `Skip`.
52
- - **Cost awareness:** each search runs `search.list` (100 quota units/query, up to `--queries`). Don't silently re-run `--reset` in a loop. A bare resume (no `--reset`) continues the same ranked queue without new searches.
53
- - **Stateless triage:** this is independent of `/yt` (no channels, no ratings). For the recurring channel briefing use `/yt`; for one video use `/yt-transcribe`.
56
+ - **Scope:** one channel per search. Listing is cheap; the cost is the lazy transcript fetches, so let the user keep/skip rather than pulling everything. A bare resume (no `--reset`) continues the same ranked queue. `--scan N` (default 50) caps how many recent uploads are considered; `--since` widens by date.
57
+ - **Stateless triage:** independent of `/yt` (no channel profiles, no ratings written). For the recurring multi-channel briefing use `/yt`; for one known video use `/yt-transcribe`.
package/README.md CHANGED
@@ -77,24 +77,17 @@ The skill is installed alongside `/yt` by `init` / `install-skill`. From the pla
77
77
  npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to stdout
78
78
  ```
79
79
 
80
- ## Research a topic across YouTube
80
+ ## Search within a channel
81
81
 
82
- Want to know what YouTube says about something — and get an actual comparison, not a pile of
83
- links? Run `/yt-search` and describe the intent, e.g. `which terminal for coding with Claude
84
- Code`. The matching is **descriptive, not exact keywords**: an LLM turns your intent into search
85
- queries, then ranks the results against what you actually meant (on titles/descriptions — no
86
- transcripts yet). Then it hands you **one video at a time** with a rich summary; you **Keep** or
87
- **Skip** each. 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 lazy on purpose — one transcript per step, never a burst (a burst gets your IP blocked, same
90
- as `/yt`). Summaries and the comparison use the 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.
91
88
 
92
- ```bash
93
- yt-briefing search "<intent>" [--max N] [--queries 1..3] [--since YYYY-MM-DD] # JSON status line
94
- ```
95
-
96
- > Each search calls the YouTube `search.list` endpoint, which costs **100 quota units per query**
97
- > (plain reads cost 1; the daily free quota is 10,000). `--queries` (default 2) caps how many.
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`.
98
91
 
99
92
  ## Run it
100
93
 
@@ -119,6 +112,23 @@ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
119
112
  Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
120
113
  OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`).
121
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
+
122
132
  ## Why an API, not the agent's native model
123
133
 
124
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}`);
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * yt-briefing sweep [--reset] advance one step; prints a JSON status line
13
13
  * yt-briefing rate --rating 0|1 [...] record a rating for the pending video
14
14
  * yt-briefing transcribe <url|id> print a single video's transcript
15
- * yt-briefing search "<intent>" [...] topic search → lazy triage → compare (JSON status line)
15
+ * yt-briefing search "<intent>" --channel <@handle|url> search within one channel → triage → compare
16
16
  */
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { script } from "./lib/paths.js";
@@ -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) {
@@ -58,7 +58,7 @@ async function listUploads(playlistId, since, maxCount) {
58
58
  hitOld = true;
59
59
  break;
60
60
  }
61
- videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt });
61
+ videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt, description: item.snippet.description ?? '' });
62
62
  if (maxCount !== null && videos.length >= maxCount)
63
63
  return videos;
64
64
  }
@@ -110,35 +110,6 @@ async function enrichWithTypes(videos) {
110
110
  }
111
111
  return out;
112
112
  }
113
- /**
114
- * Free-text video search via `search.list`. YouTube ranks by relevance, so the query can be
115
- * descriptive — no exact keyword match required. NOTE: search.list costs 100 quota units per
116
- * call (plain reads cost 1), so callers should keep the number of queries small.
117
- *
118
- * `since` (ISO date) maps to publishedAfter — useful to cut stale results on fast-moving topics.
119
- */
120
- export async function searchVideos(query, opts = {}) {
121
- const { maxResults = 10, since = null } = opts;
122
- const params = {
123
- part: 'snippet',
124
- q: query,
125
- type: 'video',
126
- order: 'relevance',
127
- maxResults: String(Math.min(Math.max(maxResults, 1), 50)),
128
- };
129
- if (since)
130
- params.publishedAfter = new Date(since).toISOString();
131
- const data = await get('search', params);
132
- return (data.items || [])
133
- .filter((it) => it.id?.videoId)
134
- .map((it) => ({
135
- videoId: it.id.videoId,
136
- title: it.snippet?.title ?? '',
137
- channelTitle: it.snippet?.channelTitle ?? '',
138
- publishedAt: it.snippet?.publishedAt ?? '',
139
- description: it.snippet?.description ?? '',
140
- }));
141
- }
142
113
  /**
143
114
  * List a channel's uploads (newest first). With `enrich` (default true) each video is
144
115
  * typed (short/live/longform) and current/upcoming live broadcasts are filtered out.
@@ -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
@@ -1,23 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * yt-search.ts — ad-hoc topic search → lazy triage → comparison. The third yt-briefing mode,
4
- * sibling to the channel briefing (yt-sweep) and one-shot transcribe (yt-transcript).
3
+ * yt-search.ts — search WITHIN one channel by intent, then lazy triage → comparison. The third
4
+ * yt-briefing mode, sibling to the channel briefing (yt-sweep) and one-shot transcribe.
5
5
  *
6
- * You describe an intent ("which terminal for coding with Claude Code"); the engine:
7
- * 1. expands it into a few YouTube search queries (LLM),
8
- * 2. runs search.list and merges candidates,
9
- * 3. re-ranks candidates against your intent on metadata only — title/channel/description,
10
- * NO transcript yet (cheap; protects the expensive/rate-limited transcript step),
11
- * 4. yields ONE candidate at a time with a rich summary, lazily — never a burst of transcript
12
- * fetches (a burst looks like scraping and gets the IP blocked, same reason yt-sweep is lazy),
13
- * 5. records your keep/skip decision; kept summaries accumulate in a cache,
14
- * 6. on demand synthesizes a comparison across everything you kept.
6
+ * You point it at a channel and describe what you're after ("which terminal does he recommend
7
+ * for AI coding"); the engine:
8
+ * 1. lists that channel's uploads (cheap — playlistItems, 1 quota unit/page; NOT search.list),
9
+ * 2. re-ranks them against your intent on metadata only — title/description, NO transcript yet,
10
+ * 3. yields ONE matching video at a time with a rich summary, lazily — never a burst of
11
+ * transcript fetches (a burst looks like scraping and gets the IP blocked),
12
+ * 4. records your keep/skip decision; kept summaries accumulate in a cache,
13
+ * 5. on demand synthesizes a comparison across everything you kept.
15
14
  *
16
- * Matching is descriptive, not exact-keyword: search.list already ranks by relevance, and the
17
- * LLM bridges intent→query (step 1) and filters noise (step 3).
15
+ * Channel-scoped on purpose: you choose where to look. Matching is descriptive — the LLM filters
16
+ * the channel's videos by intent (no exact-keyword needed).
18
17
  *
19
18
  * Usage (the skill / CLI drives these; one JSON line per call):
20
- * yt-search "<intent>" [--reset] [--max N] [--queries N] [--since DATE] [--lang auto]
19
+ * yt-search "<intent>" --channel <@handle|url> [--reset] [--top N] [--scan N] [--since DATE] [--lang auto]
21
20
  * yt-search --keep record the pending candidate, advance, yield next
22
21
  * yt-search --skip drop the pending candidate, advance, yield next
23
22
  * yt-search --compare synthesize a comparison from everything kept
@@ -26,39 +25,55 @@
26
25
  * {"status":"decision_needed","summary":"<md>","pending":{videoId,title,channelTitle,publishedAt,position,total}}
27
26
  * {"status":"done","kept":N} queue exhausted — caller runs --compare if kept>0
28
27
  * {"status":"compare","comparison":"<md>"}
29
- * {"status":"no_results"} search returned nothing for the intent
28
+ * {"status":"no_results"} the channel has no videos matching the intent
30
29
  * {"status":"rate_limited"} transcript fetch blocked (datacenter IP — see docs/warp-proxy.md)
31
- * {"status":"error","error":"<msg>"} setup/config problem (missing key, etc.)
30
+ * {"status":"error","error":"<msg>"} setup/config problem (missing key, missing channel, …)
32
31
  *
33
32
  * Cache (all throwaway, under DATA_DIR/.cache): search-queue.json (ranked candidates + cursor),
34
33
  * search-pending.json (current candidate), search-kept.json (kept summaries = compare corpus).
35
34
  */
36
35
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
37
36
  import { spawn } from 'node:child_process';
38
- import dotenv from 'dotenv';
37
+ import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
39
38
  import { chat, getModel } from "./lib/llm.js";
40
39
  import { outputLang } from "./lib/config.js";
41
- import { searchVideos } from "./lib/yt-api.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 });
40
+ import { fetchChannelVideos } from "./lib/yt-api.js";
41
+ import { normalizeHandle } from "./lib/channels.js";
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', '--top', '--scan', '--since', '--lang']);
48
49
  const has = (f) => argv.includes(f);
49
50
  const flagVal = (f) => {
50
51
  const i = argv.indexOf(f);
51
- return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
52
+ return i !== -1 && argv[i + 1] != null ? argv[i + 1] : null;
52
53
  };
53
- // First non-flag token is the intent (only on the initial / --reset call).
54
- const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--reset' && argv[i - 1] !== '--keep' && argv[i - 1] !== '--skip' && argv[i - 1] !== '--compare'));
55
- const intentArg = positional[0] ?? null;
54
+ // First token that is neither a flag nor a flag's value is the intent.
55
+ function positionalIntent() {
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i];
58
+ if (a.startsWith('--')) {
59
+ if (VALUE_FLAGS.has(a))
60
+ i++;
61
+ continue;
62
+ }
63
+ if (i > 0 && VALUE_FLAGS.has(argv[i - 1]))
64
+ continue;
65
+ return a;
66
+ }
67
+ return null;
68
+ }
69
+ const intentArg = positionalIntent();
56
70
  const RESET = has('--reset');
57
71
  const KEEP = has('--keep');
58
72
  const SKIP = has('--skip');
59
73
  const COMPARE = has('--compare');
60
- const MAX = Math.max(1, parseInt(flagVal('--max') || '8', 10));
61
- const QUERIES = Math.max(1, Math.min(3, parseInt(flagVal('--queries') || '2', 10)));
74
+ const CHANNEL = flagVal('--channel');
75
+ const TOP = Math.max(1, parseInt(flagVal('--top') || '10', 10));
76
+ const SCAN = Math.max(1, parseInt(flagVal('--scan') || '50', 10)); // recent uploads to consider when no --since
62
77
  const SINCE = flagVal('--since');
63
78
  const LANGTRACK = flagVal('--lang') || 'auto';
64
79
  function emit(obj) {
@@ -89,7 +104,7 @@ function fetchTranscript(videoId) {
89
104
  p.on('error', reject);
90
105
  });
91
106
  }
92
- /** Strip ```fences``` and slice the outermost JSON array from an LLM reply. */
107
+ /** Slice the outermost JSON array from an LLM reply (tolerates stray prose / fences). */
93
108
  function parseJsonArray(out) {
94
109
  const start = out.indexOf('['), end = out.lastIndexOf(']');
95
110
  if (start === -1 || end === -1 || end < start)
@@ -101,44 +116,27 @@ function parseJsonArray(out) {
101
116
  return null;
102
117
  }
103
118
  }
104
- // ---------- stage 1: intent → search queries ----------
105
- async function expandQueries(intent) {
106
- const prompt = `A user wants to research a topic on YouTube. Turn their intent into up to ${QUERIES} effective YouTube search queries (short, keyword-rich, the way people actually search). Cover slightly different angles if useful. Use the language the topic is most discussed in (usually English for tech).
107
-
108
- Intent: "${intent}"
109
-
110
- Output ONLY a raw JSON array of strings, no fences, no commentary. Example: ["query one","query two"]`;
111
- try {
112
- const out = await chat(prompt, { system: 'You output ONLY a raw JSON array of search-query strings.', temperature: 0.4 });
113
- const arr = parseJsonArray(out);
114
- const qs = (arr ?? []).filter((s) => typeof s === 'string' && s.trim().length > 0).slice(0, QUERIES);
115
- return qs.length ? qs : [intent];
116
- }
117
- catch {
118
- return [intent]; // expansion is best-effort; fall back to the raw intent
119
- }
120
- }
121
- // ---------- stage 3: re-rank candidates against intent (metadata only) ----------
122
- async function rerank(intent, hits) {
123
- if (hits.length === 0)
119
+ /** Re-rank a channel's videos against the intent (metadata only — no transcript). */
120
+ async function rerank(intent, items) {
121
+ if (items.length === 0)
124
122
  return [];
125
- const compact = hits.map(h => ({ id: h.videoId, title: h.title, channel: h.channelTitle, published: h.publishedAt, desc: (h.description || '').slice(0, 280) }));
126
- const prompt = `Rank these YouTube videos by how well they serve the user's intent. Judge on title + channel + description only (no transcripts). Drop clearly off-topic, clickbait, or duplicate-angle results.
123
+ const compact = items.map(h => ({ id: h.videoId, title: h.title, published: h.publishedAt, desc: (h.description || '').slice(0, 280) }));
124
+ const prompt = `From this YouTube channel's videos, pick the ones that serve the user's intent and rank them. Judge on title + description only (no transcripts). Drop anything off-topic.
127
125
 
128
126
  Intent: "${intent}"
129
127
 
130
- Candidates:
128
+ Videos:
131
129
  ${JSON.stringify(compact)}
132
130
 
133
131
  Output ONLY a raw JSON array, best first, no fences:
134
132
  [{"id":"VIDEO_ID","keep":true,"score":0-100,"reason":"max 12 words"},...]
135
- Set keep=false for anything not worth the user's time.`;
133
+ Set keep=false for anything not relevant to the intent.`;
136
134
  try {
137
135
  const out = await chat(prompt, { system: 'You output ONLY a raw JSON array as instructed.', temperature: 0 });
138
136
  const arr = parseJsonArray(out);
139
137
  if (!arr)
140
- return hits.map(h => ({ ...h })); // fall back: keep all, original order
141
- const byId = new Map(hits.map(h => [h.videoId, h]));
138
+ return items; // fall back: keep all, original (newest-first) order
139
+ const byId = new Map(items.map(h => [h.videoId, h]));
142
140
  const ranked = [];
143
141
  for (const r of arr) {
144
142
  if (!r || r.keep === false)
@@ -147,13 +145,13 @@ Set keep=false for anything not worth the user's time.`;
147
145
  if (h)
148
146
  ranked.push({ ...h, score: typeof r.score === 'number' ? r.score : undefined, reason: r.reason });
149
147
  }
150
- return ranked.length ? ranked : hits.map(h => ({ ...h }));
148
+ return ranked.length ? ranked : items;
151
149
  }
152
150
  catch {
153
- return hits.map(h => ({ ...h }));
151
+ return items;
154
152
  }
155
153
  }
156
- // ---------- mega-summary for one candidate (the triage artifact) ----------
154
+ /** Rich, standalone summary for one candidate — the triage artifact + compare input. */
157
155
  async function megaSummary(c, transcript, intent) {
158
156
  const prompt = `Summarize this YouTube video in ${LANG} for a user researching: "${intent}". Make it a RICH, standalone summary they can decide on and that will later feed a cross-video comparison — OR return 'OFFTOPIC: <reason>' if the transcript clearly doesn't serve the intent.
159
157
 
@@ -181,7 +179,7 @@ Output ONLY the summary OR 'OFFTOPIC: <reason>'. No preamble.`;
181
179
  model: getModel(),
182
180
  });
183
181
  }
184
- // ---------- stage 6: comparison across kept summaries ----------
182
+ /** Synthesize a comparison across everything kept. */
185
183
  async function synthesizeComparison(intent, kept) {
186
184
  const corpus = kept.map((k, i) => `--- VIDEO ${i + 1}: ${k.channelTitle} — "${k.title}" (${k.publishedAt})\nhttps://youtube.com/watch?v=${k.videoId}\n${k.summary}`).join('\n\n');
187
185
  const prompt = `The user researched "${intent}" and kept ${kept.length} YouTube video summaries below. Synthesize a single comparison in ${LANG} that actually helps them decide.
@@ -197,7 +195,7 @@ Write:
197
195
  Language: natural ${LANG}; foreign words only for proper nouns or established technical terms. Cite videos as [1], [2]… matching the order above. Output only the comparison.`;
198
196
  return chat(prompt, { system: `You synthesize a decision-grade comparison in ${LANG}. No preamble.`, model: getModel() });
199
197
  }
200
- // ---------- lazy yield: advance to the next candidate that has a transcript ----------
198
+ /** Lazy yield: advance to the next candidate that has a transcript, summarize, emit. */
201
199
  async function yieldNext(queue) {
202
200
  while (queue.cursor < queue.candidates.length) {
203
201
  const c = queue.candidates[queue.cursor];
@@ -225,10 +223,14 @@ async function yieldNext(queue) {
225
223
  }
226
224
  emit({ status: 'done', kept: loadKept().length });
227
225
  }
228
- // ---------- main ----------
229
226
  async function main() {
230
- if (!process.env.YT_BRIEFING_YOUTUBE_API_KEY && (RESET || (intentArg && !loadQueue()))) {
231
- 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).' });
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) });
232
234
  }
233
235
  // --compare: synthesize from kept summaries.
234
236
  if (COMPARE) {
@@ -243,7 +245,7 @@ async function main() {
243
245
  if (KEEP || SKIP) {
244
246
  const queue = loadQueue();
245
247
  if (!queue)
246
- emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>".' });
248
+ emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>" --channel <@handle>.' });
247
249
  if (KEEP) {
248
250
  const pending = readJSON(SEARCH_PENDING_FILE, null);
249
251
  if (pending) {
@@ -256,37 +258,38 @@ async function main() {
256
258
  writeJSON(SEARCH_QUEUE_FILE, queue);
257
259
  await yieldNext(queue);
258
260
  }
259
- // Resume an in-progress search (bare call, same intent) without rebuilding.
261
+ // Resume an in-progress search (bare call, same intent + channel) without rebuilding.
260
262
  const existing = loadQueue();
261
- if (existing && !RESET && (!intentArg || intentArg === existing.intent)) {
263
+ if (existing && !RESET && (!intentArg || intentArg === existing.intent) && (!CHANNEL || normalizeHandle(CHANNEL) === existing.channel)) {
262
264
  await yieldNext(existing);
263
265
  }
264
- // Fresh search (new intent or --reset).
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) });
271
+ }
265
272
  if (!intentArg)
266
- emit({ status: 'error', error: 'Provide an intent: yt-search "<what to research>".' });
267
- const queries = await expandQueries(intentArg);
268
- const seen = new Set();
269
- const hits = [];
270
- for (const q of queries) {
271
- let batch = [];
272
- try {
273
- batch = await searchVideos(q, { maxResults: 10, since: SINCE });
274
- }
275
- catch (e) {
276
- emit({ status: 'error', error: e.message });
277
- }
278
- for (const h of batch)
279
- if (!seen.has(h.videoId)) {
280
- seen.add(h.videoId);
281
- hits.push(h);
282
- }
273
+ emit({ status: 'error', error: 'Provide an intent: yt-search "<what to look for>" --channel <@handle|url>.' });
274
+ if (!CHANNEL)
275
+ emit({ status: 'error', error: 'Provide a channel: --channel <@handle|url>. /yt-search searches within one channel, not all of YouTube.' });
276
+ const handle = normalizeHandle(CHANNEL);
277
+ if (!handle)
278
+ emit({ status: 'error', error: `Could not read a channel handle from "${CHANNEL}" — use @name or the channel URL.` });
279
+ let videos;
280
+ try {
281
+ videos = await fetchChannelVideos(handle, { since: SINCE, limit: SINCE ? null : SCAN, enrich: false });
282
+ }
283
+ catch (e) {
284
+ emit({ status: 'error', error: e.message });
283
285
  }
284
- if (hits.length === 0)
286
+ if (videos.length === 0)
285
287
  emit({ status: 'no_results' });
286
- const ranked = (await rerank(intentArg, hits)).slice(0, MAX);
288
+ const pool = videos.map(v => ({ videoId: v.videoId, title: v.title, channelTitle: handle, publishedAt: v.publishedAt, description: v.description }));
289
+ const ranked = (await rerank(intentArg, pool)).slice(0, TOP);
287
290
  if (ranked.length === 0)
288
291
  emit({ status: 'no_results' });
289
- const queue = { built_at: new Date().toISOString(), intent: intentArg, candidates: ranked, cursor: 0 };
292
+ const queue = { built_at: new Date().toISOString(), intent: intentArg, channel: handle, candidates: ranked, cursor: 0 };
290
293
  writeJSON(SEARCH_QUEUE_FILE, queue);
291
294
  writeJSON(SEARCH_KEPT_FILE, []); // fresh search → fresh compare corpus
292
295
  await yieldNext(queue);
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.5.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": {