yt-briefing 0.6.0 → 0.8.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
@@ -1,11 +1,11 @@
1
1
  # yt-briefing
2
2
 
3
- Save hours on YouTube. yt-briefing watches the channels you follow so you don't have to. It
4
- turns each new video into a short summary in your own language that keeps what matters and
5
- skips the filler. Reading it takes a fraction of the time the video would, so you stay on top
6
- of everything and only watch what's actually worth it.
3
+ Save hours on YouTube. yt-briefing watches the channels you follow so you don't have to. For
4
+ each new video it distills the essence in your own language — every point that matters, with
5
+ only the filler cut, so nothing important is lost. Reading it takes a fraction of the time the
6
+ video would, so you stay on top of everything and only watch what's actually worth it.
7
7
 
8
- It also gets better the more you use it. You give each summary a quick rating, worth my time
8
+ It also gets better the more you use it. You give each briefing a quick rating, worth my time
9
9
  or not, and from that it learns what to keep showing you and what to drop. Over time the queue
10
10
  becomes yours: less noise, more of what you care about.
11
11
 
@@ -46,13 +46,26 @@ yarn add yt-briefing
46
46
  bun add yt-briefing
47
47
  ```
48
48
 
49
- 3. Onboard:
49
+ 3. Put your keys in a `.env` at your project root — all four are required:
50
+
51
+ ```ini
52
+ YT_BRIEFING_LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
53
+ YT_BRIEFING_LLM_API_KEY=<key> # free at https://aistudio.google.com/apikey
54
+ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
55
+ YT_BRIEFING_YOUTUBE_API_KEY=<key> # console.cloud.google.com → enable "YouTube Data API v3"
56
+ ```
57
+
58
+ Any OpenAI-compatible endpoint works — see [Providers](#providers) to use OpenRouter, OpenAI, or a
59
+ local Ollama instead of Gemini. Miss a key and the engine tells you exactly which one. Keep `.env`
60
+ gitignored; `YT_BRIEFING_PROXY` (datacenter/VPS IPs) is the only optional extra.
61
+
62
+ 4. Onboard:
50
63
 
51
64
  ```bash
52
65
  npx yt-briefing init # or: bunx yt-briefing init
53
66
  ```
54
67
 
55
- `init` asks for your language, your keys, the channels to follow, and which tool runs `/yt`.
68
+ `init` asks for your language, the channels to follow, and which tool runs `/yt`.
56
69
 
57
70
  Add or remove channels anytime:
58
71
 
@@ -79,24 +92,15 @@ npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to
79
92
 
80
93
  ## Search within a channel
81
94
 
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.
88
-
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.
95
+ Mine one channel's videos for a topic and get a comparison. Run `/yt-search` with a channel and
96
+ an intent — e.g. `/yt-search @betterstack which terminal for AI coding`.
92
97
 
93
- ```bash
94
- yt-briefing search "<intent>" --channel <@handle|url> [--max N] [--scan N] [--since YYYY-MM-DD]
95
- ```
98
+ It covers the channel's **whole history** (not just recent uploads), re-ranks every upload against
99
+ your intent, then lazily yields one matching video at a time to keep or skip — and synthesizes a
100
+ comparison from everything you kept.
96
101
 
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.
102
+ The one flag is `--top N` — how many of the top re-ranked matches to triage (**default 10**). Raise
103
+ it to go deeper, lower it for a quicker pass: `/yt-search @betterstack which terminal --top 20`.
100
104
 
101
105
  ## Run it
102
106
 
@@ -119,7 +123,8 @@ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
119
123
  > or switch to a paid key (enable billing, same model) to avoid it.
120
124
 
121
125
  Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
122
- OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`).
126
+ OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`). Set
127
+ `YT_BRIEFING_LLM_BASE_URL`, `_API_KEY`, and `_MODEL` in your root `.env` (see [Setup](#setup)).
123
128
 
124
129
  ## Why an API, not the agent's native model
125
130
 
package/dist/bootstrap.js CHANGED
@@ -6,16 +6,17 @@
6
6
  *
7
7
  * Asks for, and writes:
8
8
  * 1. Output language for summaries + ratings → DATA_DIR/config.json
9
- * 2. LLM provider / model / key, YouTube key, optional proxy → .env
10
- * 3. The channels you follow — just a flat list of handles
9
+ * 2. The channels you follow — just a flat list of handles
11
10
  * → DATA_DIR/channels.md, DATA_DIR/state.md, DATA_DIR/channels/<slug>.md
11
+ * 3. Which agent runs /yt — installs the skill into its skills dir
12
12
  *
13
- * Re-running is safe: it warns before overwriting existing data and lets you bail.
13
+ * It does NOT touch keys: those live in your project root .env (see README → Setup); the engine
14
+ * reads them at run time. Re-running is safe: it warns before overwriting existing data and bails.
14
15
  * Everything it writes is plain Markdown / JSON you can also edit by hand afterwards.
15
16
  */
16
17
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
17
18
  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";
19
+ import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, profilePath, } from "./lib/paths.js";
19
20
  import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
20
21
  import { question } from "./lib/prompt.js";
21
22
  import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
@@ -39,47 +40,15 @@ function main() {
39
40
  }
40
41
  console.log('');
41
42
  }
43
+ // Keys are NOT asked here — they live in your project root .env (LLM + YouTube; see README
44
+ // → Setup). The engine reads them at run time and fails fast naming any that are missing.
42
45
  // 1. Language ----------------------------------------------------------------
43
46
  console.log(' 1) Language');
44
47
  const outputLang = ask(' Output language for summaries and ratings', 'English');
45
- // 2. LLM provider (.env) -----------------------------------------------------
46
- // Pick a provider → we prefill its endpoint + a sensible model and ask ONLY for the
47
- // key (with the exact link to get it). The recommended free path is the default, so
48
- // pressing Enter lands on it — no long URL to paste, nothing to guess.
49
- console.log('\n 2) Which AI writes the filtering + summaries? Pick a provider:\n');
50
- console.log(' 1) Gemini — FREE key, best way to start · https://aistudio.google.com/apikey');
51
- console.log(' 2) OpenRouter — one key for Gemini + GPT + … · https://openrouter.ai/keys');
52
- console.log(' 3) OpenAI — GPT models · https://platform.openai.com/api-keys');
53
- console.log(' 4) Other / local (Ollama or any custom endpoint)\n');
54
- console.log(' Type 1, 2, 3 or 4 and press Enter. (Just press Enter for 1 — Gemini, recommended.)');
55
- const PROVIDERS = {
56
- '1': { name: 'Gemini', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.5-flash', keyUrl: 'https://aistudio.google.com/apikey' },
57
- '2': { name: 'OpenRouter', base: 'https://openrouter.ai/api/v1', model: 'google/gemini-2.5-flash', keyUrl: 'https://openrouter.ai/keys' },
58
- '3': { name: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', keyUrl: 'https://platform.openai.com/api-keys' },
59
- };
60
- let llmBaseUrl, llmModel, llmKey;
61
- const picked = PROVIDERS[ask(' Your choice', '1')];
62
- if (picked) {
63
- console.log(`\n → ${picked.name}. Get your key here: ${picked.keyUrl}`);
64
- llmKey = ask(' Paste your API key');
65
- llmBaseUrl = picked.base;
66
- llmModel = ask(' Model (Enter to accept)', picked.model);
67
- }
68
- else {
69
- console.log('\n → Custom / local endpoint (e.g. Ollama at http://localhost:11434/v1)');
70
- llmBaseUrl = ask(' YT_BRIEFING_LLM_BASE_URL', 'http://localhost:11434/v1');
71
- llmModel = ask(' YT_BRIEFING_LLM_MODEL', 'llama3.1');
72
- llmKey = ask(' YT_BRIEFING_LLM_API_KEY (blank for local)', '');
73
- }
74
- console.log('\n 3) YouTube Data API (needed to list channel uploads)');
75
- console.log(' Get a key: https://console.cloud.google.com → YouTube Data API v3');
76
- const ytKey = ask(' YT_BRIEFING_YOUTUBE_API_KEY');
77
- 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)', '');
79
- // 5. Channels ----------------------------------------------------------------
48
+ // 2. Channels ----------------------------------------------------------------
80
49
  // Just collect a flat list. No categories, no per-channel rules to define up front —
81
50
  // each channel's profile LEARNS what to skip as you rate it (## Skip titles / ## Notes).
82
- console.log('\n 5) Channels you follow');
51
+ console.log('\n 2) Channels you follow');
83
52
  console.log(' Add one per line — paste whichever form you have, all work as-is:');
84
53
  console.log(' eg. @betterstack, betterstack or https://www.youtube.com/@betterstack');
85
54
  console.log(' (Paste the full URL directly — it reads the @handle for you) Empty line to finish.');
@@ -105,34 +74,22 @@ function main() {
105
74
  if (channels.length === 0) {
106
75
  console.log('\n No channels added — you can add them later by editing data/channels.md.\n');
107
76
  }
108
- // 6. Coding agent ------------------------------------------------------------
77
+ // 3. Coding agent ------------------------------------------------------------
109
78
  // Place the skill INTO THIS PROJECT (the package folder you open in the agent) — never a
110
79
  // home-global dir (that's the npm -g antipattern: machine-wide, invisible, easy to forget).
111
80
  // SKILL.md is the cross-agent standard, so the shipped skill runs in any compatible agent —
112
81
  // we just install it into that agent's skills dir (.claude/skills, .cursor/skills, .codex/skills).
113
82
  // 1/2/3 = known agents; 4 = any other compatible agent (a project folder you name).
114
- console.log('\n 6) Which agent will you run /yt in? (it ships a standard Agent Skill — any compatible agent works)');
83
+ console.log('\n 3) Which agent will you run /yt in? (it ships a standard Agent Skill — any compatible agent works)');
115
84
  console.log(' 1) Claude Code 2) Cursor 3) Codex 4) Custom folder (any other agent)\n');
116
85
  const agentKey = ask(' Your agent', '1');
117
86
  // For a custom target, ask the folder now (keeps all prompts in the interactive block).
118
87
  const customDir = AGENTS[agentKey] ? '' : ask(' Skills folder to install into', customSkillsRootDefault());
119
- // 7. Write everything --------------------------------------------------------
88
+ // 4. Write everything --------------------------------------------------------
120
89
  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.
90
+ // Keep the throwaway cache out of git for the consume layout. data/ stays versionable (for sync).
134
91
  if (BASE_DIR !== PKG_ROOT) {
135
- writeFileSync(join(BASE_DIR, '.gitignore'), '.env\ndata/.cache/\n', 'utf8');
92
+ writeFileSync(join(BASE_DIR, '.gitignore'), 'data/.cache/\n', 'utf8');
136
93
  }
137
94
  // config.json
138
95
  writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
@@ -144,7 +101,6 @@ function main() {
144
101
  writeFileSync(profilePath(c.slug), profileBody(c.handle, c.slug), 'utf8');
145
102
  console.log(' ' + '─'.repeat(40));
146
103
  console.log(` Done. Wrote:`);
147
- console.log(` ${ENV_PATH}`);
148
104
  console.log(` ${CONFIG_JSON}`);
149
105
  console.log(` ${CHANNELS_MD}`);
150
106
  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.8.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": {