vipcare 0.1.0 → 0.2.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/CLAUDE.md ADDED
@@ -0,0 +1,58 @@
1
+ # VIPCare
2
+
3
+ ## What is this?
4
+ CLI tool that auto-builds VIP person profiles from Twitter, LinkedIn, and web search data, synthesized by AI into structured Markdown files.
5
+
6
+ ## Tech Stack
7
+ - Node.js ESM, commander CLI
8
+ - No build step, no bundler
9
+ - Single dependency: commander
10
+
11
+ ## Project Structure
12
+ ```
13
+ bin/vip.js — CLI entry point, all command definitions
14
+ lib/
15
+ config.js — Config loading (~/.vip-crm/config.json), tool checks
16
+ profile.js — CRUD for profile Markdown files in profiles/
17
+ resolver.js — Parse input (name vs URL) into person object
18
+ fetchers/
19
+ twitter.js — Twitter data via bird CLI
20
+ search.js — Web search for person info
21
+ youtube.js — YouTube transcript via yt-dlp + whisper
22
+ synthesizer.js — AI synthesis (Claude CLI / Anthropic API / GitHub Copilot)
23
+ monitor.js — Change detection and changelog
24
+ scheduler.js — macOS launchd for auto-refresh
25
+ card.js — H5 baseball card generator
26
+ templates.js — Profile templates
27
+ tests/ — One test file per module
28
+ profiles/ — Generated profile Markdown files
29
+ web/ — Card HTML output
30
+ skill/ — Claude Code slash command
31
+ ```
32
+
33
+ ## Dev Commands
34
+ - `npm test` — run all tests (uses node:test with --experimental-test-module-mocks)
35
+ - `npm publish` — publish to npm (requires npm auth token)
36
+ - `node bin/vip.js <command>` — run CLI locally without installing
37
+
38
+ ## Architecture
39
+ Input flows through a pipeline: **resolver** parses the input (name or URL) into a person object, **fetchers** (twitter, search, youtube) gather raw data from external sources, **synthesizer** sends the raw data to an AI backend to produce structured fields, **profile** saves the result as a Markdown file, and **card** can render profiles into an H5 baseball card page.
40
+
41
+ ## Conventions
42
+ - All shell commands use `execFileSync` with arg arrays (never `execSync` with string interpolation) for security
43
+ - ESM modules (`"type": "module"` in package.json)
44
+ - Tests use node:test built-in runner with assert
45
+ - Profile data stored as Markdown files in profiles/
46
+ - Config at ~/.vip-crm/config.json
47
+ - AI backends: Claude CLI, Anthropic API, GitHub Copilot (auto-detected in that order)
48
+
49
+ ## Adding a New Command
50
+ 1. Add command definition in bin/vip.js using commander
51
+ 2. Use existing helpers from lib/profile.js, lib/config.js
52
+ 3. Add --json flag for scripting support
53
+ 4. Add tests in tests/
54
+
55
+ ## Common Pitfalls
56
+ - Must use --experimental-test-module-mocks flag for tests that mock modules
57
+ - YouTube transcription requires Python + yt-dlp + whisper (optional dependency)
58
+ - The `slugify` function returns 'unnamed' for empty/special-char-only inputs
package/README.md CHANGED
@@ -42,6 +42,27 @@ vip rm sam-altman -y
42
42
  vip update sam-altman
43
43
  ```
44
44
 
45
+ ## Commands
46
+
47
+ | Command | Description |
48
+ |---------|-------------|
49
+ | `vip add <name-or-url>` | Add a new profile (`-c` company, `-f` force, `--no-ai`, `--dry-run`, `-y` YouTube URLs) |
50
+ | `vip list` | List all profiles |
51
+ | `vip show <name>` | Display a profile |
52
+ | `vip search <keyword>` | Search across all profiles |
53
+ | `vip edit <name>` | Edit profile fields (`--title`, `--company`, `--twitter`, `--linkedin`, `--note`) |
54
+ | `vip rm <name>` | Delete a profile (`-y` to confirm) |
55
+ | `vip update <name>` | Refresh a profile with latest data |
56
+ | `vip open <name>` | Open a profile in your editor |
57
+ | `vip youtube <name> <url>` | Add YouTube video transcript to a profile |
58
+ | `vip youtube-search <name>` | Search YouTube for a person's talks (`-n` max results) |
59
+ | `vip card` | Generate H5 baseball card page (`-o` output path) |
60
+ | `vip export` | Export all profiles for backup |
61
+ | `vip import` | Restore profiles from backup |
62
+ | `vip digest` | Show recent profile changes |
63
+ | `vip monitor start\|stop\|status\|run` | Manage automatic profile refresh |
64
+ | `vip config` | View settings |
65
+
45
66
  ## Features
46
67
 
47
68
  - **Auto profile building** — Give a name or URL, get a structured profile
package/bin/vip.js CHANGED
@@ -3,15 +3,17 @@
3
3
  import { Command } from 'commander';
4
4
  import fs from 'fs';
5
5
  import os from 'os';
6
- import { execSync as exec } from 'child_process';
6
+ import { execFileSync } from 'child_process';
7
7
  import { checkTool, getProfilesDir, loadConfig, saveConfig } from '../lib/config.js';
8
- import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles } from '../lib/profile.js';
8
+ import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles, slugify } from '../lib/profile.js';
9
9
  import { isUrl, resolveFromName, resolveFromUrl } from '../lib/resolver.js';
10
10
  import * as twitter from '../lib/fetchers/twitter.js';
11
11
  import { searchPerson } from '../lib/fetchers/search.js';
12
12
  import { synthesizeProfile, getBackendName } from '../lib/synthesizer.js';
13
- import { readChangelog, runMonitor, unreadCount } from '../lib/monitor.js';
13
+ import { appendChangelog, readChangelog, runMonitor, unreadCount } from '../lib/monitor.js';
14
14
  import { install, uninstall, status } from '../lib/scheduler.js';
15
+ import * as youtube from '../lib/fetchers/youtube.js';
16
+ import { generateCards, extractVipData } from '../lib/card.js';
15
17
 
16
18
  // Colors
17
19
  const c = {
@@ -73,7 +75,7 @@ try {
73
75
  } catch {}
74
76
 
75
77
  const program = new Command();
76
- program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.1.0');
78
+ program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.2.0');
77
79
 
78
80
  // --- add ---
79
81
  program.command('add')
@@ -83,6 +85,7 @@ program.command('add')
83
85
  .option('--dry-run', 'Print without saving')
84
86
  .option('--no-ai', 'Skip AI synthesis')
85
87
  .option('-f, --force', 'Overwrite existing')
88
+ .option('-y, --youtube <urls...>', 'YouTube video URLs to transcribe')
86
89
  .action(async (query, opts) => {
87
90
  console.log(c.cyan(`Resolving ${query}...`));
88
91
 
@@ -120,7 +123,29 @@ program.command('add')
120
123
  }
121
124
 
122
125
  console.log(c.cyan('Gathering data...'));
123
- const [rawData, sources] = gatherData(person);
126
+ let [rawData, sources] = gatherData(person);
127
+
128
+ // YouTube transcription
129
+ if (opts.youtube?.length) {
130
+ if (!youtube.isAvailable()) {
131
+ console.log(c.yellow('YouTube transcriber not available. Skipping videos.'));
132
+ } else {
133
+ for (const ytUrl of opts.youtube) {
134
+ const stop = spinner(`Transcribing video: ${ytUrl}...`);
135
+ try {
136
+ const yt = youtube.transcribeVideo(ytUrl);
137
+ stop();
138
+ console.log(c.green(` Transcribed: ${yt.title}`));
139
+ rawData += `\n\n=== YouTube Video: ${yt.title} (${yt.url}) ===\n${yt.transcript}`;
140
+ sources.push(yt.url);
141
+ } catch (e) {
142
+ stop();
143
+ console.log(c.yellow(` Failed: ${e.message}`));
144
+ }
145
+ }
146
+ }
147
+ }
148
+
124
149
  if (!rawData.trim()) { console.error(c.red('No data found.')); process.exit(1); }
125
150
 
126
151
  let profile;
@@ -139,14 +164,27 @@ program.command('add')
139
164
  } else {
140
165
  const filepath = saveProfile(person.name, profile);
141
166
  console.log(c.green(`\nProfile saved: ${filepath}`));
167
+ appendChangelog({
168
+ timestamp: new Date().toISOString(),
169
+ name: person.name,
170
+ slug: slugify(person.name),
171
+ type: 'created',
172
+ summary: `Profile created for ${person.name}`,
173
+ });
142
174
  }
143
175
  });
144
176
 
145
177
  // --- list ---
146
178
  program.command('list')
147
179
  .description('List all VIP profiles')
148
- .action(() => {
180
+ .option('--json', 'Output as JSON')
181
+ .action((opts) => {
149
182
  const profiles = listProfiles();
183
+ if (opts.json) {
184
+ const data = profiles.map(p => ({ slug: p.slug, name: p.name, summary: p.summary, updated: p.updated, path: p.path }));
185
+ console.log(JSON.stringify(data, null, 2));
186
+ return;
187
+ }
150
188
  if (!profiles.length) { console.log(c.dim('No profiles yet. Use "vip add" to create one.')); return; }
151
189
 
152
190
  const cols = process.stdout.columns || 80;
@@ -168,10 +206,21 @@ program.command('list')
168
206
  program.command('show')
169
207
  .description('Show a VIP profile')
170
208
  .argument('<name>')
171
- .action((name) => {
209
+ .option('--json', 'Output as JSON')
210
+ .action((name, opts) => {
172
211
  const content = loadProfile(name);
173
212
  if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
174
213
 
214
+ if (opts.json) {
215
+ const slug = slugify(name);
216
+ const nameMatch = content.match(/^# (.+)$/m);
217
+ const profileName = nameMatch ? nameMatch[1] : name;
218
+ const vipData = extractVipData(content);
219
+ const data = { slug, name: profileName, content, vipData: vipData || null };
220
+ console.log(JSON.stringify(data, null, 2));
221
+ return;
222
+ }
223
+
175
224
  for (const line of content.split('\n')) {
176
225
  if (line.startsWith('# ')) console.log(c.bold(c.cyan(line)));
177
226
  else if (line.startsWith('## ')) console.log(c.bold(c.green(line)));
@@ -185,8 +234,14 @@ program.command('show')
185
234
  program.command('search')
186
235
  .description('Search across all profiles')
187
236
  .argument('<keyword>')
188
- .action((keyword) => {
237
+ .option('--json', 'Output as JSON')
238
+ .action((keyword, opts) => {
189
239
  const results = searchProfiles(keyword);
240
+ if (opts.json) {
241
+ const data = results.map(r => ({ slug: r.slug, name: r.name, matches: r.matches }));
242
+ console.log(JSON.stringify(data, null, 2));
243
+ return;
244
+ }
190
245
  if (!results.length) { console.log(c.dim(`No matches for '${keyword}'.`)); return; }
191
246
 
192
247
  console.log(c.green(`Found ${results.length} profile(s) matching '${keyword}':\n`));
@@ -205,7 +260,7 @@ program.command('open')
205
260
  const p = getProfilePath(name);
206
261
  if (!fs.existsSync(p)) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
207
262
  const editor = process.env.EDITOR || 'open';
208
- exec(`${editor} "${p}"`);
263
+ execFileSync(editor, [p], { stdio: 'inherit' });
209
264
  });
210
265
 
211
266
  // --- update ---
@@ -256,6 +311,16 @@ program.command('rm')
256
311
  console.log(c.green(`Profile deleted: ${name}`));
257
312
  });
258
313
 
314
+ function appendNote(content, note) {
315
+ if (content.includes('## Notes')) {
316
+ return content.replace('## Notes\n', `## Notes\n- ${note}\n`);
317
+ } else if (content.includes('\n---\n')) {
318
+ return content.replace('\n---\n', `\n## Notes\n- ${note}\n\n---\n`);
319
+ } else {
320
+ return content.trimEnd() + `\n\n## Notes\n- ${note}\n`;
321
+ }
322
+ }
323
+
259
324
  // --- edit ---
260
325
  program.command('edit')
261
326
  .description('Edit profile fields')
@@ -271,14 +336,43 @@ program.command('edit')
271
336
 
272
337
  let modified = false;
273
338
 
274
- if (opts.title) { content = content.replace(/(\*\*Title:\*\*) .+/, `$1 ${opts.title}`); modified = true; }
275
- if (opts.company) { content = content.replace(/(\*\*Company:\*\*) .+/, `$1 ${opts.company}`); modified = true; }
339
+ if (opts.title) {
340
+ const before = content;
341
+ content = content.replace(/(\*\*Title:\*\*) .+/, `$1 ${opts.title}`);
342
+ if (content === before) {
343
+ console.log(c.yellow(` Warning: Could not find Title field to update. Adding to notes instead.`));
344
+ content = appendNote(content, `Title: ${opts.title}`);
345
+ }
346
+ modified = true;
347
+ }
348
+ if (opts.company) {
349
+ const before = content;
350
+ content = content.replace(/(\*\*Company:\*\*) .+/, `$1 ${opts.company}`);
351
+ if (content === before) {
352
+ console.log(c.yellow(` Warning: Could not find Company field to update. Adding to notes instead.`));
353
+ content = appendNote(content, `Company: ${opts.company}`);
354
+ }
355
+ modified = true;
356
+ }
276
357
  if (opts.twitter) {
277
358
  const handle = opts.twitter.replace(/^@/, '');
359
+ const before = content;
278
360
  content = content.replace(/(Twitter:) .+/, `$1 https://twitter.com/${handle}`);
361
+ if (content === before) {
362
+ console.log(c.yellow(` Warning: Could not find Twitter field to update. Adding to notes instead.`));
363
+ content = appendNote(content, `Twitter: https://twitter.com/${handle}`);
364
+ }
365
+ modified = true;
366
+ }
367
+ if (opts.linkedin) {
368
+ const before = content;
369
+ content = content.replace(/(LinkedIn:) .+/, `$1 ${opts.linkedin}`);
370
+ if (content === before) {
371
+ console.log(c.yellow(` Warning: Could not find LinkedIn field to update. Adding to notes instead.`));
372
+ content = appendNote(content, `LinkedIn: ${opts.linkedin}`);
373
+ }
279
374
  modified = true;
280
375
  }
281
- if (opts.linkedin) { content = content.replace(/(LinkedIn:) .+/, `$1 ${opts.linkedin}`); modified = true; }
282
376
  if (opts.note) {
283
377
  if (content.includes('## Notes')) {
284
378
  content = content.replace('## Notes\n', `## Notes\n- ${opts.note}\n`);
@@ -294,6 +388,75 @@ program.command('edit')
294
388
  else console.log(c.yellow('No changes. Use --title, --company, --twitter, --linkedin, or --note.'));
295
389
  });
296
390
 
391
+ // --- youtube ---
392
+ program.command('youtube')
393
+ .description('Add YouTube video transcript to existing profile')
394
+ .argument('<name>', 'Profile name')
395
+ .argument('<url>', 'YouTube video URL')
396
+ .action(async (name, url) => {
397
+ const content = loadProfile(name);
398
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
399
+
400
+ if (!youtube.isAvailable()) {
401
+ console.error(c.red('YouTube transcriber not available.'));
402
+ process.exit(1);
403
+ }
404
+
405
+ const stop = spinner(`Transcribing: ${url}...`);
406
+ let yt;
407
+ try { yt = youtube.transcribeVideo(url); } finally { stop(); }
408
+ console.log(c.green(` Transcribed: ${yt.title}`));
409
+
410
+ const { extractMetadata } = await import('../lib/monitor.js');
411
+ const meta = extractMetadata(content);
412
+
413
+ // Combine existing profile data with new transcript
414
+ const rawData = content + `\n\n=== YouTube Video: ${yt.title} (${yt.url}) ===\n${yt.transcript}`;
415
+ const sources = [yt.url];
416
+
417
+ const stop2 = spinner('Re-synthesizing profile...');
418
+ let profile;
419
+ try { profile = await synthesizeProfile(rawData, sources); } finally { stop2(); }
420
+
421
+ const filepath = saveProfile(meta.name || name, profile);
422
+ console.log(c.green(`Profile updated: ${filepath}`));
423
+ });
424
+
425
+ // --- youtube-search ---
426
+ program.command('youtube-search')
427
+ .description('Find YouTube videos for a person')
428
+ .argument('<name>', 'Person name')
429
+ .option('-n, --count <n>', 'Max results', '5')
430
+ .action((name, opts) => {
431
+ console.log(c.cyan(`Searching YouTube for ${name}...`));
432
+ const results = youtube.searchYouTubeVideos(name, parseInt(opts.count));
433
+
434
+ if (!results.length) { console.log(c.dim('No YouTube videos found.')); return; }
435
+
436
+ console.log(c.green(`Found ${results.length} video(s):\n`));
437
+ results.forEach((r, i) => {
438
+ console.log(` ${c.bold(c.cyan(`${i + 1}.`))} ${r.title}`);
439
+ console.log(c.dim(` ${r.url}`));
440
+ if (r.body) console.log(c.dim(` ${r.body.slice(0, 100)}`));
441
+ console.log();
442
+ });
443
+ console.log(c.dim('Use: vip youtube <name> <url> to transcribe and add to profile'));
444
+ });
445
+
446
+ // --- card ---
447
+ program.command('card')
448
+ .description('Generate H5 baseball card page from all profiles')
449
+ .option('-o, --output <path>', 'Output HTML file', 'web/index.html')
450
+ .action((opts) => {
451
+ console.log(c.cyan('Generating baseball cards...'));
452
+ const profiles = listProfiles();
453
+ if (!profiles.length) { console.log(c.dim('No profiles. Use "vip add" first.')); return; }
454
+
455
+ const outputPath = generateCards(profiles, opts.output);
456
+ console.log(c.green(`Cards generated: ${outputPath}`));
457
+ console.log(c.dim(`Open in browser: open ${outputPath}`));
458
+ });
459
+
297
460
  // --- digest ---
298
461
  program.command('digest')
299
462
  .description('Show recent changes')
@@ -303,7 +466,8 @@ program.command('digest')
303
466
 
304
467
  console.log(c.bold(c.cyan('Changes in the last 30 days:\n')));
305
468
  for (const e of entries.reverse()) {
306
- console.log(c.green(` [${(e.timestamp || '').slice(0, 10)}] ${e.name}`));
469
+ const label = e.type === 'created' ? c.cyan('[created]') : c.green('[updated]');
470
+ console.log(` ${label} [${(e.timestamp || '').slice(0, 10)}] ${e.name}`);
307
471
  console.log(` ${e.summary}`);
308
472
  console.log();
309
473
  }
@@ -348,4 +512,79 @@ program.command('config')
348
512
  console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
349
513
  });
350
514
 
351
- program.parse();
515
+ // --- export ---
516
+ program.command('export')
517
+ .description('Export all profiles as JSON')
518
+ .option('-o, --output <file>', 'Write JSON to file instead of stdout')
519
+ .action((opts) => {
520
+ const profiles = listProfiles();
521
+ if (!profiles.length) { console.error(c.red('No profiles to export.')); process.exit(1); }
522
+
523
+ const exported = profiles.map(p => {
524
+ const content = loadProfile(p.slug);
525
+ const vipData = content ? extractVipData(content) : null;
526
+ return {
527
+ slug: p.slug,
528
+ filePath: p.path,
529
+ name: p.name,
530
+ summary: p.summary,
531
+ updated: p.updated,
532
+ vipData: vipData || null,
533
+ content: content || '',
534
+ exportedAt: new Date().toISOString(),
535
+ };
536
+ });
537
+
538
+ const json = JSON.stringify(exported, null, 2);
539
+
540
+ if (opts.output) {
541
+ fs.writeFileSync(opts.output, json, 'utf-8');
542
+ console.log(c.green(`Exported ${exported.length} profile(s) to ${opts.output}`));
543
+ } else {
544
+ process.stdout.write(json + '\n');
545
+ }
546
+ });
547
+
548
+ // --- import ---
549
+ program.command('import')
550
+ .description('Import profiles from a JSON export file')
551
+ .argument('<file>', 'JSON file to import')
552
+ .option('-f, --force', 'Overwrite existing profiles')
553
+ .action((file, opts) => {
554
+ if (!fs.existsSync(file)) { console.error(c.red(`File not found: ${file}`)); process.exit(1); }
555
+
556
+ let data;
557
+ try {
558
+ data = JSON.parse(fs.readFileSync(file, 'utf-8'));
559
+ } catch (e) {
560
+ console.error(c.red(`Invalid JSON: ${e.message}`));
561
+ process.exit(1);
562
+ }
563
+
564
+ if (!Array.isArray(data)) { console.error(c.red('Expected a JSON array of profiles.')); process.exit(1); }
565
+
566
+ let imported = 0;
567
+ let skipped = 0;
568
+ for (const entry of data) {
569
+ if (!entry.content || !entry.slug) {
570
+ console.log(c.yellow(` Skipping entry: missing content or slug`));
571
+ skipped++;
572
+ continue;
573
+ }
574
+
575
+ const name = entry.name || entry.slug;
576
+ if (!opts.force && profileExists(name)) {
577
+ console.log(c.yellow(` Skipping '${name}': already exists (use -f to overwrite)`));
578
+ skipped++;
579
+ continue;
580
+ }
581
+
582
+ saveProfile(name, entry.content);
583
+ imported++;
584
+ console.log(c.green(` Imported: ${name}`));
585
+ }
586
+
587
+ console.log(`\nDone: ${imported} imported, ${skipped} skipped.`);
588
+ });
589
+
590
+ program.parseAsync();