yt-briefing 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -53,6 +53,14 @@ npx yt-briefing init # or: bunx yt-briefing init
53
53
 
54
54
  `init` asks for your language, your keys, the channels to follow, and which tool runs `/yt`.
55
55
 
56
+ Add or remove channels anytime:
57
+
58
+ ```bash
59
+ npx yt-briefing add @handle https://youtube.com/@another # one or more, handle or URL
60
+ npx yt-briefing remove @handle # also deletes its learned profile
61
+ npx yt-briefing list # show the current list
62
+ ```
63
+
56
64
  ## Run it
57
65
 
58
66
  Open your project in Claude Code or Cursor and run `/yt`. If it's not listed, start a fresh
package/dist/bootstrap.js CHANGED
@@ -18,6 +18,7 @@ import { join } from 'node:path';
18
18
  import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ENV_PATH, profilePath, } from "./lib/paths.js";
19
19
  import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
20
20
  import { question } from "./lib/prompt.js";
21
+ import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
21
22
  const ask = (q, def = '') => {
22
23
  const a = question(def ? `${q} [${def}]:` : `${q}:`).trim();
23
24
  return a || def;
@@ -28,35 +29,6 @@ const askYN = (q, def = true) => {
28
29
  return def;
29
30
  return a.startsWith('y');
30
31
  };
31
- /**
32
- * Accept any of: `@betterstack`, `betterstack`, `https://www.youtube.com/@betterstack`
33
- * (with or without a trailing `/videos` etc.) → canonical `@betterstack`. Returns null if
34
- * no handle can be read (e.g. a bare `/channel/UC…` URL — ask the user for the @handle).
35
- */
36
- function normalizeHandle(input) {
37
- let s = input.trim();
38
- if (/youtube\.com/i.test(s) || /^https?:\/\//i.test(s)) {
39
- const m = s.match(/@[A-Za-z0-9._-]+/); // pull the @handle out of a URL
40
- s = m ? m[0] : '';
41
- }
42
- s = s.replace(/^@+/, '').replace(/[^A-Za-z0-9._-].*$/, ''); // bare token
43
- return s ? `@${s}` : null;
44
- }
45
- /** kebab-case slug that also breaks CamelCase: "BetterStack" → "better-stack". */
46
- function slugify(s) {
47
- return s
48
- .replace(/@/g, '')
49
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
50
- .replace(/[^A-Za-z0-9]+/g, '-')
51
- .replace(/-+/g, '-')
52
- .replace(/^-|-$/g, '')
53
- .toLowerCase();
54
- }
55
- const today = new Date().toISOString().slice(0, 10);
56
- // Fresh channels start "baseline": the first sweep surfaces the latest upload per type.
57
- // updated is set this far back so that window actually contains a recent video.
58
- const BASELINE_LOOKBACK_DAYS = 60;
59
- const lookback = new Date(Date.now() - BASELINE_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
60
32
  function main() {
61
33
  console.log('\n yt-briefing — onboarding\n ' + '─'.repeat(40) + '\n');
62
34
  if (existsSync(CHANNELS_MD)) {
@@ -163,58 +135,12 @@ function main() {
163
135
  }
164
136
  // config.json
165
137
  writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
166
- // channels.md — a flat list of the channels you follow.
167
- const chParts = [
168
- '---',
169
- 'type: yt-config',
170
- 'name: yt-channels',
171
- 'description: The channels you follow. Per-channel learned signal lives in channels/<slug>.md.',
172
- `updated: ${today}`,
173
- '---',
174
- '',
175
- '# Channels',
176
- '',
177
- ];
178
- for (const c of channels) {
179
- chParts.push(`- [${c.handle}](https://www.youtube.com/${c.handle}) → [[channels/${c.slug}]]`);
180
- }
181
- writeFileSync(CHANNELS_MD, chParts.join('\n') + '\n', 'utf8');
182
- // state.md — one flat table (baseline rows: pointers "—", updated = lookback so the first
183
- // sweep finds the latest upload per type).
184
- const stParts = [
185
- '---',
186
- 'type: yt-state',
187
- 'name: yt-state',
188
- 'description: Per-channel per-type cursor. The sweep reads and updates it.',
189
- `updated: ${today}`,
190
- '---',
191
- '',
192
- '# State',
193
- '',
194
- '| Channel | last_longform_id | last_short_id | last_live_id | updated | session |',
195
- '|---|---|---|---|---|---|',
196
- ];
197
- for (const c of channels) {
198
- stParts.push(`| ${c.handle} | — | — | — | ${lookback} | 0 |`);
199
- }
200
- writeFileSync(STATE_MD, stParts.join('\n') + '\n', 'utf8');
201
- // per-channel profiles
202
- for (const c of channels) {
203
- const pParts = [
204
- '---',
205
- 'type: yt-channel-profile',
206
- `name: ${c.slug}-profile`,
207
- `channel: ${c.handle}`,
208
- `channel_url: https://www.youtube.com/${c.handle}`,
209
- `updated: ${today}`,
210
- 'sessions_observed: 0',
211
- '---',
212
- '',
213
- `# ${c.handle} — Profile`,
214
- '',
215
- ];
216
- writeFileSync(profilePath(c.slug), pParts.join('\n') + '\n', 'utf8');
217
- }
138
+ // channels.md / state.md / per-channel profiles — via the shared serializers, so the
139
+ // on-disk format is identical to what the add/remove command writes (single source).
140
+ writeFileSync(CHANNELS_MD, serializeChannels(channels), 'utf8');
141
+ writeFileSync(STATE_MD, serializeState(channels.map(c => baselineStateRow(c.handle))), 'utf8');
142
+ for (const c of channels)
143
+ writeFileSync(profilePath(c.slug), profileBody(c.handle, c.slug), 'utf8');
218
144
  console.log(' ' + '─'.repeat(40));
219
145
  console.log(` Done. Wrote:`);
220
146
  console.log(` ${ENV_PATH}`);
package/dist/cli.js CHANGED
@@ -7,6 +7,8 @@
7
7
  *
8
8
  * yt-briefing init interactive onboarding wizard
9
9
  * yt-briefing install-skill install the /yt skill into a coding agent
10
+ * yt-briefing add|remove <@handle|url> add or remove channels (also list)
11
+ * yt-briefing list list the channels you follow
10
12
  * yt-briefing sweep [--reset] advance one step; prints a JSON status line
11
13
  * yt-briefing rate --rating 0|1 [...] record a rating for the pending video
12
14
  * yt-briefing transcribe <url|id> print a single video's transcript
@@ -14,6 +16,8 @@
14
16
  import { spawnSync } from 'node:child_process';
15
17
  import { script } from "./lib/paths.js";
16
18
  const [cmd, ...rest] = process.argv.slice(2);
19
+ // Subcommand → engine script. Channel actions all route to yt-channels with the action
20
+ // passed through as its first arg.
17
21
  const TARGETS = {
18
22
  init: 'bootstrap',
19
23
  'install-skill': 'install-skill',
@@ -21,9 +25,15 @@ const TARGETS = {
21
25
  rate: 'yt-rating',
22
26
  transcribe: 'yt-transcript',
23
27
  };
24
- if (!cmd || !TARGETS[cmd]) {
25
- console.error('Usage: yt-briefing <init|install-skill|sweep|rate|transcribe> [args...]');
28
+ const CHANNEL_ACTIONS = new Set(['add', 'remove', 'list']);
29
+ let argv = null;
30
+ if (cmd && CHANNEL_ACTIONS.has(cmd))
31
+ argv = [script('yt-channels'), cmd, ...rest];
32
+ else if (cmd && TARGETS[cmd])
33
+ argv = [script(TARGETS[cmd]), ...rest];
34
+ if (!argv) {
35
+ console.error('Usage: yt-briefing <init|install-skill|add|remove|list|sweep|rate|transcribe> [args...]');
26
36
  process.exit(cmd ? 1 : 0);
27
37
  }
28
- const res = spawnSync(process.execPath, [script(TARGETS[cmd]), ...rest], { stdio: 'inherit' });
38
+ const res = spawnSync(process.execPath, argv, { stdio: 'inherit' });
29
39
  process.exit(res.status ?? 1);
@@ -0,0 +1,82 @@
1
+ export const BASELINE_LOOKBACK_DAYS = 60;
2
+ export const today = () => new Date().toISOString().slice(0, 10);
3
+ // Fresh channels start "baseline": updated is set this far back so the first sweep's window
4
+ // still contains a recent upload per type (which becomes the starting point).
5
+ export const lookbackDate = () => new Date(Date.now() - BASELINE_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
6
+ export const channelUrl = (handle) => `https://www.youtube.com/${handle}`;
7
+ /**
8
+ * Accept any of: `@betterstack`, `betterstack`, `https://www.youtube.com/@betterstack`
9
+ * (with or without a trailing `/videos` etc.) → canonical `@betterstack`. Returns null if no
10
+ * handle can be read (e.g. a bare `/channel/UC…` URL — ask the user for the @handle).
11
+ */
12
+ export function normalizeHandle(input) {
13
+ let s = input.trim();
14
+ if (/youtube\.com/i.test(s) || /^https?:\/\//i.test(s)) {
15
+ const m = s.match(/@[\p{L}\p{N}._-]+/u); // pull the @handle out of a URL (Unicode-aware)
16
+ s = m ? m[0] : '';
17
+ }
18
+ s = s.replace(/^@+/, '');
19
+ // Keep the leading run of handle chars — letters (any script, e.g. ł), digits, . _ - —
20
+ // so non-ASCII handles like @DziałZagraniczny survive instead of truncating at the first ł.
21
+ const m = s.match(/^[\p{L}\p{N}._-]+/u);
22
+ s = m ? m[0] : '';
23
+ return s ? `@${s}` : null;
24
+ }
25
+ /**
26
+ * kebab-case slug that also breaks CamelCase: "BetterStack" → "better-stack". Diacritics are
27
+ * folded to ASCII so non-Latin handles get readable slugs: "DziałZagraniczny" → "dzial-zagraniczny"
28
+ * (ł has no NFD decomposition, so it's mapped explicitly; the rest go through NFD).
29
+ */
30
+ export function slugify(s) {
31
+ return s
32
+ .replace(/@/g, '')
33
+ .replace(/ł/g, 'l').replace(/Ł/g, 'L')
34
+ .normalize('NFD').replace(/[̀-ͯ]/g, '') // strip combining diacritics (ą→a, é→e, …)
35
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
36
+ .replace(/[^A-Za-z0-9]+/g, '-')
37
+ .replace(/-+/g, '-')
38
+ .replace(/^-|-$/g, '')
39
+ .toLowerCase();
40
+ }
41
+ /** A fresh state row for a just-added channel: no cursors yet, dated back to the lookback. */
42
+ export const baselineStateRow = (handle) => ({
43
+ handle,
44
+ last_longform_id: null,
45
+ last_short_id: null,
46
+ last_live_id: null,
47
+ updated: lookbackDate(),
48
+ session: 0,
49
+ });
50
+ /** channels.md — a flat list of the channels you follow. */
51
+ export function serializeChannels(entries, date = today()) {
52
+ const parts = [
53
+ '---', 'type: yt-config', 'name: yt-channels',
54
+ 'description: The channels you follow. Per-channel learned signal lives in channels/<slug>.md.',
55
+ `updated: ${date}`, '---', '', '# Channels', '',
56
+ ];
57
+ for (const c of entries)
58
+ parts.push(`- [${c.handle}](${channelUrl(c.handle)}) → [[channels/${c.slug}]]`);
59
+ return parts.join('\n') + '\n';
60
+ }
61
+ /** state.md — one flat table; baseline rows use "—" pointers and the lookback date. */
62
+ export function serializeState(rows, date = today()) {
63
+ const parts = [
64
+ '---', 'type: yt-state', 'name: yt-state',
65
+ 'description: Per-channel per-type cursor. The sweep reads and updates it.',
66
+ `updated: ${date}`, '---', '', '# State', '',
67
+ '| Channel | last_longform_id | last_short_id | last_live_id | updated | session |',
68
+ '|---|---|---|---|---|---|',
69
+ ];
70
+ for (const r of rows) {
71
+ parts.push(`| ${r.handle} | ${r.last_longform_id ?? '—'} | ${r.last_short_id ?? '—'} | ${r.last_live_id ?? '—'} | ${r.updated ?? lookbackDate()} | ${r.session} |`);
72
+ }
73
+ return parts.join('\n') + '\n';
74
+ }
75
+ /** A fresh, empty channel profile (learned signal accumulates here as you rate). */
76
+ export function profileBody(handle, slug, date = today()) {
77
+ return [
78
+ '---', 'type: yt-channel-profile', `name: ${slug}-profile`,
79
+ `channel: ${handle}`, `channel_url: ${channelUrl(handle)}`, `updated: ${date}`,
80
+ 'sessions_observed: 0', '---', '', `# ${handle} — Profile`, '',
81
+ ].join('\n') + '\n';
82
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Add, remove, or list the channels you follow — keeping channels.md, state.md, and the
4
+ * per-channel profile in sync. Driven by the CLI: `yt-briefing add|remove|list ...`.
5
+ *
6
+ * yt-briefing add @foo https://youtube.com/@bar # add one or more (handle or URL)
7
+ * yt-briefing remove @foo # remove (also deletes its profile)
8
+ * yt-briefing list # show the current list
9
+ *
10
+ * Adding a channel starts it at "baseline" (next sweep surfaces the latest upload per type);
11
+ * removing one drops its row and learned profile. Existing channels' cursors are untouched.
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
14
+ import { CHANNELS_MD, STATE_MD, profilePath } from "./lib/paths.js";
15
+ import { parseChannels, parseState } from "./lib/yt-lib.js";
16
+ import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow, } from "./lib/channels.js";
17
+ const [action, ...args] = process.argv.slice(2);
18
+ if (!existsSync(CHANNELS_MD) || !existsSync(STATE_MD)) {
19
+ console.error('No channel list yet — run onboarding first: yt-briefing init');
20
+ process.exit(1);
21
+ }
22
+ let entries = parseChannels(readFileSync(CHANNELS_MD, 'utf8'));
23
+ if (action === 'list') {
24
+ for (const e of entries)
25
+ console.log(` ${e.handle}`);
26
+ console.log(`\n ${entries.length} channel(s).`);
27
+ process.exit(0);
28
+ }
29
+ if ((action !== 'add' && action !== 'remove') || args.length === 0) {
30
+ console.error('Usage: yt-briefing add|remove <@handle | url> [...] · yt-briefing list');
31
+ process.exit(1);
32
+ }
33
+ let rows = parseState(readFileSync(STATE_MD, 'utf8'));
34
+ let changed = 0;
35
+ for (const raw of args) {
36
+ const handle = normalizeHandle(raw);
37
+ if (!handle) {
38
+ console.log(` ! couldn't read a handle from "${raw}" — use @name or the channel URL`);
39
+ continue;
40
+ }
41
+ const slug = slugify(handle);
42
+ const exists = entries.some(e => e.slug === slug);
43
+ if (action === 'add') {
44
+ if (exists) {
45
+ console.log(` • ${handle} already on the list — skipping`);
46
+ continue;
47
+ }
48
+ entries.push({ handle, slug });
49
+ rows.push(baselineStateRow(handle));
50
+ writeFileSync(profilePath(slug), profileBody(handle, slug), 'utf8');
51
+ console.log(` + ${handle}`);
52
+ changed++;
53
+ }
54
+ else {
55
+ if (!exists) {
56
+ console.log(` • ${handle} not on the list — skipping`);
57
+ continue;
58
+ }
59
+ const entry = entries.find(e => e.slug === slug);
60
+ entries = entries.filter(e => e.slug !== slug);
61
+ rows = rows.filter(r => r.handle !== entry.handle);
62
+ try {
63
+ rmSync(profilePath(slug));
64
+ }
65
+ catch { /* profile already gone */ }
66
+ console.log(` − ${handle} (profile deleted)`);
67
+ changed++;
68
+ }
69
+ }
70
+ if (changed) {
71
+ writeFileSync(CHANNELS_MD, serializeChannels(entries), 'utf8');
72
+ writeFileSync(STATE_MD, serializeState(rows), 'utf8');
73
+ }
74
+ console.log(`\n ${entries.length} channel(s) now. The next sweep picks up the change.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {