vipcare 0.2.0 → 0.3.1

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
@@ -46,8 +46,9 @@ vip update sam-altman
46
46
 
47
47
  | Command | Description |
48
48
  |---------|-------------|
49
+ | `vip init` | Interactive setup |
49
50
  | `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 list` | List all profiles (`--tag` filter by tag) |
51
52
  | `vip show <name>` | Display a profile |
52
53
  | `vip search <keyword>` | Search across all profiles |
53
54
  | `vip edit <name>` | Edit profile fields (`--title`, `--company`, `--twitter`, `--linkedin`, `--note`) |
@@ -56,6 +57,12 @@ vip update sam-altman
56
57
  | `vip open <name>` | Open a profile in your editor |
57
58
  | `vip youtube <name> <url>` | Add YouTube video transcript to a profile |
58
59
  | `vip youtube-search <name>` | Search YouTube for a person's talks (`-n` max results) |
60
+ | `vip compare <n1> <n2>` | Side-by-side comparison (`--json`) |
61
+ | `vip tag <name> <tag>` | Add tag to profile |
62
+ | `vip untag <name> <tag>` | Remove tag from profile |
63
+ | `vip tags [name]` | List tags (`--json`) |
64
+ | `vip stats` | Show dashboard overview (`--json`) |
65
+ | `vip regenerate` | Re-synthesize all profiles (`--dry-run`, `--no-ai`) |
59
66
  | `vip card` | Generate H5 baseball card page (`-o` output path) |
60
67
  | `vip export` | Export all profiles for backup |
61
68
  | `vip import` | Restore profiles from backup |
package/bin/vip.js CHANGED
@@ -3,9 +3,10 @@
3
3
  import { Command } from 'commander';
4
4
  import fs from 'fs';
5
5
  import os from 'os';
6
+ import readline from 'readline/promises';
6
7
  import { execFileSync } from 'child_process';
7
8
  import { checkTool, getProfilesDir, loadConfig, saveConfig } from '../lib/config.js';
8
- import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles, slugify } from '../lib/profile.js';
9
+ import { deleteProfile, getProfilePath, listProfiles, loadProfile, parseTags, profileExists, saveProfile, searchProfiles, slugify } from '../lib/profile.js';
9
10
  import { isUrl, resolveFromName, resolveFromUrl } from '../lib/resolver.js';
10
11
  import * as twitter from '../lib/fetchers/twitter.js';
11
12
  import { searchPerson } from '../lib/fetchers/search.js';
@@ -75,7 +76,7 @@ try {
75
76
  } catch {}
76
77
 
77
78
  const program = new Command();
78
- program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.2.0');
79
+ program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.3.1');
79
80
 
80
81
  // --- add ---
81
82
  program.command('add')
@@ -155,6 +156,10 @@ program.command('add')
155
156
  const stop = spinner('Synthesizing profile with AI...');
156
157
  try {
157
158
  profile = await synthesizeProfile(rawData, sources);
159
+ } catch (e) {
160
+ console.error(c.red(`AI synthesis failed: ${e.message}`));
161
+ console.error(c.dim('Use --no-ai to save raw data without synthesis.'));
162
+ process.exit(1);
158
163
  } finally { stop(); }
159
164
  }
160
165
 
@@ -178,8 +183,19 @@ program.command('add')
178
183
  program.command('list')
179
184
  .description('List all VIP profiles')
180
185
  .option('--json', 'Output as JSON')
186
+ .option('--tag <tag>', 'Filter by tag')
181
187
  .action((opts) => {
182
- const profiles = listProfiles();
188
+ let profiles = listProfiles();
189
+
190
+ if (opts.tag) {
191
+ profiles = profiles.filter(p => {
192
+ const content = loadProfile(p.slug);
193
+ if (!content) return false;
194
+ const tags = parseTags(content);
195
+ return tags.includes(opts.tag);
196
+ });
197
+ }
198
+
183
199
  if (opts.json) {
184
200
  const data = profiles.map(p => ({ slug: p.slug, name: p.name, summary: p.summary, updated: p.updated, path: p.path }));
185
201
  console.log(JSON.stringify(data, null, 2));
@@ -292,7 +308,13 @@ program.command('update')
292
308
  profile = `# ${personName}\n\n## Raw Data\n\n${rawData}`;
293
309
  } else {
294
310
  const stop2 = spinner('Re-synthesizing profile...');
295
- try { profile = await synthesizeProfile(rawData, sources); } finally { stop2(); }
311
+ try {
312
+ profile = await synthesizeProfile(rawData, sources);
313
+ } catch (e) {
314
+ console.error(c.red(`AI synthesis failed: ${e.message}`));
315
+ console.error(c.dim('Use --no-ai to save raw data without synthesis.'));
316
+ process.exit(1);
317
+ } finally { stop2(); }
296
318
  }
297
319
 
298
320
  const filepath = saveProfile(personName, profile);
@@ -374,13 +396,7 @@ program.command('edit')
374
396
  modified = true;
375
397
  }
376
398
  if (opts.note) {
377
- if (content.includes('## Notes')) {
378
- content = content.replace('## Notes\n', `## Notes\n- ${opts.note}\n`);
379
- } else if (content.includes('\n---\n')) {
380
- content = content.replace('\n---\n', `\n## Notes\n- ${opts.note}\n\n---\n`);
381
- } else {
382
- content = content.trimEnd() + `\n\n## Notes\n- ${opts.note}\n`;
383
- }
399
+ content = appendNote(content, opts.note);
384
400
  modified = true;
385
401
  }
386
402
 
@@ -393,7 +409,8 @@ program.command('youtube')
393
409
  .description('Add YouTube video transcript to existing profile')
394
410
  .argument('<name>', 'Profile name')
395
411
  .argument('<url>', 'YouTube video URL')
396
- .action(async (name, url) => {
412
+ .option('--no-ai', 'Skip AI synthesis')
413
+ .action(async (name, url, opts) => {
397
414
  const content = loadProfile(name);
398
415
  if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
399
416
 
@@ -414,9 +431,19 @@ program.command('youtube')
414
431
  const rawData = content + `\n\n=== YouTube Video: ${yt.title} (${yt.url}) ===\n${yt.transcript}`;
415
432
  const sources = [yt.url];
416
433
 
417
- const stop2 = spinner('Re-synthesizing profile...');
418
434
  let profile;
419
- try { profile = await synthesizeProfile(rawData, sources); } finally { stop2(); }
435
+ if (opts.ai === false) {
436
+ profile = `# ${meta.name || name}\n\n## Raw Data\n\n${rawData}`;
437
+ } else {
438
+ const stop2 = spinner('Re-synthesizing profile...');
439
+ try {
440
+ profile = await synthesizeProfile(rawData, sources);
441
+ } catch (e) {
442
+ console.error(c.red(`AI synthesis failed: ${e.message}`));
443
+ console.error(c.dim('Use --no-ai to save raw data without synthesis.'));
444
+ process.exit(1);
445
+ } finally { stop2(); }
446
+ }
420
447
 
421
448
  const filepath = saveProfile(meta.name || name, profile);
422
449
  console.log(c.green(`Profile updated: ${filepath}`));
@@ -460,8 +487,13 @@ program.command('card')
460
487
  // --- digest ---
461
488
  program.command('digest')
462
489
  .description('Show recent changes')
463
- .action(() => {
490
+ .option('--json', 'Output as JSON')
491
+ .action((opts) => {
464
492
  const entries = readChangelog(30);
493
+ if (opts.json) {
494
+ console.log(JSON.stringify(entries, null, 2));
495
+ return;
496
+ }
465
497
  if (!entries.length) { console.log(c.dim('No recent changes.')); return; }
466
498
 
467
499
  console.log(c.bold(c.cyan('Changes in the last 30 days:\n')));
@@ -500,11 +532,219 @@ mon.command('run').description('Run monitoring now').option('-v, --verbose', 'Ve
500
532
  } else console.log(c.dim('No significant changes detected.'));
501
533
  });
502
534
 
535
+ // --- compare ---
536
+ program.command('compare')
537
+ .description('Compare two VIP profiles side by side')
538
+ .argument('<name1>', 'First profile name')
539
+ .argument('<name2>', 'Second profile name')
540
+ .option('--json', 'Output as JSON')
541
+ .action((name1, name2, opts) => {
542
+ const content1 = loadProfile(name1);
543
+ if (!content1) { console.error(c.red(`Profile not found: ${name1}`)); process.exit(1); }
544
+ const content2 = loadProfile(name2);
545
+ if (!content2) { console.error(c.red(`Profile not found: ${name2}`)); process.exit(1); }
546
+
547
+ function parseProfile(content) {
548
+ const vip = extractVipData(content);
549
+ if (vip) return vip;
550
+
551
+ const nameMatch = content.match(/^# (.+)$/m);
552
+ const titleMatch = content.match(/\*\*Title:\*\*\s*(.+)/);
553
+ const companyMatch = content.match(/\*\*Company:\*\*\s*(.+)/);
554
+ const locationMatch = content.match(/\*\*Location:\*\*\s*(.+)/);
555
+ const discMatch = content.match(/\*\*DISC:\*\*\s*(.+)/);
556
+ const mbtiMatch = content.match(/\*\*MBTI:\*\*\s*(.+)/);
557
+ const industryMatch = content.match(/\*\*Industry:\*\*\s*(.+)/);
558
+
559
+ const tags = parseTags(content);
560
+ if (industryMatch && !tags.length) tags.push(industryMatch[1].trim());
561
+
562
+ return {
563
+ name: nameMatch ? nameMatch[1] : '',
564
+ title: titleMatch ? titleMatch[1].trim() : '',
565
+ company: companyMatch ? companyMatch[1].trim() : '',
566
+ location: locationMatch ? locationMatch[1].trim() : '',
567
+ disc: discMatch ? discMatch[1].trim() : '',
568
+ mbti: mbtiMatch ? mbtiMatch[1].trim() : '',
569
+ tags,
570
+ };
571
+ }
572
+
573
+ const p1 = parseProfile(content1);
574
+ const p2 = parseProfile(content2);
575
+
576
+ const name1Display = p1.name || name1;
577
+ const name2Display = p2.name || name2;
578
+
579
+ const tags1 = (p1.tags || []).map(t => t.toLowerCase());
580
+ const tags2 = (p2.tags || []).map(t => t.toLowerCase());
581
+ const shared = tags1.filter(t => tags2.includes(t));
582
+ const unique1 = tags1.filter(t => !tags2.includes(t));
583
+ const unique2 = tags2.filter(t => !tags1.includes(t));
584
+
585
+ if (opts.json) {
586
+ console.log(JSON.stringify({
587
+ profile1: { name: name1Display, title: p1.title, company: p1.company, location: p1.location, disc: p1.disc, mbti: p1.mbti, tags: p1.tags },
588
+ profile2: { name: name2Display, title: p2.title, company: p2.company, location: p2.location, disc: p2.disc, mbti: p2.mbti, tags: p2.tags },
589
+ shared,
590
+ uniqueToFirst: unique1,
591
+ uniqueToSecond: unique2,
592
+ }, null, 2));
593
+ return;
594
+ }
595
+
596
+ const col1W = Math.max(20, name1Display.length + 4);
597
+ const col2W = Math.max(20, name2Display.length + 4);
598
+ const labelW = 16;
599
+
600
+ console.log();
601
+ console.log(c.bold(c.cyan(`${name1Display} vs ${name2Display}`)));
602
+ console.log('═'.repeat(labelW + col1W + col2W));
603
+
604
+ console.log(`${''.padEnd(labelW)}${c.bold(name1Display.padEnd(col1W))}${c.bold(name2Display.padEnd(col2W))}`);
605
+
606
+ const fields = [
607
+ ['Title:', p1.title, p2.title],
608
+ ['Company:', p1.company, p2.company],
609
+ ['Location:', p1.location, p2.location],
610
+ ['DISC:', p1.disc, p2.disc],
611
+ ['MBTI:', p1.mbti, p2.mbti],
612
+ ];
613
+
614
+ for (const [label, v1, v2] of fields) {
615
+ if (!v1 && !v2) continue;
616
+ console.log(`${c.dim(label.padEnd(labelW))}${(v1 || c.dim('—')).toString().padEnd(col1W)}${(v2 || c.dim('—')).toString().padEnd(col2W)}`);
617
+ }
618
+
619
+ console.log();
620
+ if (shared.length) console.log(`${c.green('Shared interests:')} ${shared.join(', ')}`);
621
+ if (unique1.length) console.log(`${c.cyan(`Unique to ${name1Display}:`)} ${unique1.join(', ')}`);
622
+ if (unique2.length) console.log(`${c.cyan(`Unique to ${name2Display}:`)} ${unique2.join(', ')}`);
623
+ if (!shared.length && !unique1.length && !unique2.length) console.log(c.dim('No tags to compare.'));
624
+ console.log();
625
+ });
626
+
627
+ // --- tag ---
628
+ program.command('tag')
629
+ .description('Add a tag to a profile')
630
+ .argument('<name>', 'Profile name')
631
+ .argument('<tag>', 'Tag to add')
632
+ .action((name, tag) => {
633
+ let content = loadProfile(name);
634
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
635
+
636
+ const tagLine = `- ${tag}`;
637
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
638
+
639
+ if (tagsMatch) {
640
+ const existingTags = parseTags(content);
641
+ if (existingTags.includes(tag)) {
642
+ console.log(c.yellow(`Tag '${tag}' already exists on ${name}.`));
643
+ return;
644
+ }
645
+ content = content.replace(tagsMatch[0], tagsMatch[0].trimEnd() + '\n' + tagLine);
646
+ } else if (content.includes('\n---\n')) {
647
+ content = content.replace('\n---\n', `\n## Tags\n${tagLine}\n\n---\n`);
648
+ } else {
649
+ content = content.trimEnd() + `\n\n## Tags\n${tagLine}\n`;
650
+ }
651
+
652
+ saveProfile(name, content);
653
+ console.log(c.green(`Tagged ${name} with '${tag}'.`));
654
+ });
655
+
656
+ // --- untag ---
657
+ program.command('untag')
658
+ .description('Remove a tag from a profile')
659
+ .argument('<name>', 'Profile name')
660
+ .argument('<tag>', 'Tag to remove')
661
+ .action((name, tag) => {
662
+ let content = loadProfile(name);
663
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
664
+
665
+ const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
666
+ if (!tagsMatch) {
667
+ console.log(c.yellow(`No tags found on ${name}.`));
668
+ return;
669
+ }
670
+
671
+ const lines = tagsMatch[1].split('\n').filter(l => l.match(/^- /));
672
+ const remaining = lines.filter(l => l.replace(/^- /, '').trim() !== tag);
673
+
674
+ if (remaining.length === lines.length) {
675
+ console.log(c.yellow(`Tag '${tag}' not found on ${name}.`));
676
+ return;
677
+ }
678
+
679
+ if (remaining.length === 0) {
680
+ // Remove the entire Tags section
681
+ content = content.replace(/\n?## Tags\n[\s\S]*?(?=\n##|\n---|$)/, '');
682
+ } else {
683
+ content = content.replace(tagsMatch[0], '## Tags\n' + remaining.join('\n'));
684
+ }
685
+
686
+ saveProfile(name, content);
687
+ console.log(c.green(`Removed tag '${tag}' from ${name}.`));
688
+ });
689
+
690
+ // --- tags ---
691
+ program.command('tags')
692
+ .description('List tags across profiles or for a specific profile')
693
+ .argument('[name]', 'Profile name (optional)')
694
+ .option('--json', 'Output as JSON')
695
+ .action((name, opts) => {
696
+ if (name) {
697
+ const content = loadProfile(name);
698
+ if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
699
+
700
+ const tags = parseTags(content);
701
+ if (opts.json) {
702
+ console.log(JSON.stringify(tags, null, 2));
703
+ return;
704
+ }
705
+ if (!tags.length) { console.log(c.dim(`No tags on ${name}.`)); return; }
706
+ console.log(c.bold(c.cyan(`Tags for ${name}:\n`)));
707
+ for (const t of tags) console.log(` - ${t}`);
708
+ console.log();
709
+ } else {
710
+ const profiles = listProfiles();
711
+ const counts = {};
712
+ for (const p of profiles) {
713
+ const content = loadProfile(p.slug);
714
+ if (!content) continue;
715
+ for (const t of parseTags(content)) {
716
+ counts[t] = (counts[t] || 0) + 1;
717
+ }
718
+ }
719
+
720
+ if (opts.json) {
721
+ console.log(JSON.stringify(counts, null, 2));
722
+ return;
723
+ }
724
+
725
+ const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
726
+ if (!entries.length) { console.log(c.dim('No tags found across any profiles.')); return; }
727
+ console.log(c.bold(c.cyan('All tags:\n')));
728
+ for (const [tag, count] of entries) {
729
+ console.log(` ${tag.padEnd(30)} ${c.dim(`(${count})`)}`);
730
+ }
731
+ console.log();
732
+ }
733
+ });
734
+
503
735
  // --- config ---
504
736
  program.command('config')
505
737
  .description('View/edit settings')
506
- .action(() => {
738
+ .option('--json', 'Output as JSON')
739
+ .action((opts) => {
507
740
  const cfg = loadConfig();
741
+ if (opts.json) {
742
+ console.log(JSON.stringify({
743
+ ...cfg,
744
+ tools: { bird: checkTool('bird'), ai_backend: getBackendName() }
745
+ }, null, 2));
746
+ return;
747
+ }
508
748
  console.log(c.bold(c.cyan('Current config:')));
509
749
  console.log(` Profiles dir: ${cfg.profiles_dir}`);
510
750
  console.log(` Monitor interval: ${cfg.monitor_interval_hours}h`);
@@ -512,6 +752,58 @@ program.command('config')
512
752
  console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
513
753
  });
514
754
 
755
+ // --- init ---
756
+ program.command('init')
757
+ .description('Interactive first-time setup for VIPCare')
758
+ .action(async () => {
759
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
760
+
761
+ try {
762
+ console.log(c.bold(c.cyan('\nWelcome to VIPCare!\n')));
763
+
764
+ const defaultDir = path.join(os.homedir(), 'Projects', 'vip-crm', 'profiles');
765
+ const profilesAnswer = await rl.question(`Where should profiles be stored?\n (default: ${defaultDir}) > `);
766
+ const profilesDir = profilesAnswer.trim() || defaultDir;
767
+
768
+ console.log(`\n${c.cyan('AI backend preference:')}`);
769
+ console.log(' 1. Auto-detect (recommended)');
770
+ console.log(' 2. Claude CLI');
771
+ console.log(' 3. Anthropic API');
772
+ console.log(' 4. GitHub Copilot CLI');
773
+ const backendAnswer = await rl.question(' > ');
774
+ const backendChoice = backendAnswer.trim() || '1';
775
+
776
+ const backendMap = { '1': 'auto', '2': 'claude-cli', '3': 'anthropic', '4': 'copilot-cli' };
777
+ const aiBackend = backendMap[backendChoice] || 'auto';
778
+
779
+ const config = {
780
+ profiles_dir: profilesDir.replace(/^~/, os.homedir()),
781
+ ai_backend: aiBackend,
782
+ };
783
+
784
+ if (aiBackend === 'anthropic') {
785
+ const apiKey = await rl.question('\nAnthropic API key: ');
786
+ if (apiKey.trim()) {
787
+ config.anthropic_api_key = apiKey.trim();
788
+ }
789
+ }
790
+
791
+ // Merge with existing config to preserve other settings
792
+ let existing = {};
793
+ try {
794
+ const { loadConfig: lc } = await import('../lib/config.js');
795
+ existing = lc();
796
+ } catch {}
797
+ saveConfig({ ...existing, ...config });
798
+
799
+ const { CONFIG_FILE: cfgPath } = await import('../lib/config.js');
800
+ console.log(c.green(`\nConfig saved to ${cfgPath}`));
801
+ console.log(`You're ready! Try: ${c.cyan('vip add "Sam Altman" --company "OpenAI"')}\n`);
802
+ } finally {
803
+ rl.close();
804
+ }
805
+ });
806
+
515
807
  // --- export ---
516
808
  program.command('export')
517
809
  .description('Export all profiles as JSON')
@@ -587,4 +879,146 @@ program.command('import')
587
879
  console.log(`\nDone: ${imported} imported, ${skipped} skipped.`);
588
880
  });
589
881
 
882
+ // --- stats ---
883
+ program.command('stats')
884
+ .description('Show dashboard overview')
885
+ .option('--json', 'Output as JSON')
886
+ .action((opts) => {
887
+ const profiles = listProfiles();
888
+ const activity = readChangelog(7);
889
+ const backend = getBackendName();
890
+ const birdAvailable = checkTool('bird');
891
+
892
+ // Find most recently updated profile
893
+ let lastUpdated = null;
894
+ let lastUpdatedName = null;
895
+ for (const p of profiles) {
896
+ if (!lastUpdated || p.updated > lastUpdated) {
897
+ lastUpdated = p.updated;
898
+ lastUpdatedName = p.name;
899
+ }
900
+ }
901
+
902
+ if (opts.json) {
903
+ const data = {
904
+ profileCount: profiles.length,
905
+ lastUpdated: lastUpdated ? { date: lastUpdated, name: lastUpdatedName } : null,
906
+ aiBackend: backend,
907
+ birdCli: birdAvailable ? 'available' : 'not found',
908
+ recentActivity: activity.map(e => ({
909
+ date: (e.timestamp || '').slice(0, 10),
910
+ name: e.name,
911
+ type: e.type,
912
+ summary: e.summary,
913
+ })),
914
+ };
915
+ console.log(JSON.stringify(data, null, 2));
916
+ return;
917
+ }
918
+
919
+ console.log(c.bold(c.cyan('\nVIPCare Stats')));
920
+ console.log('─'.repeat(15));
921
+ console.log(` Profiles: ${c.green(String(profiles.length))}`);
922
+ if (lastUpdated) {
923
+ console.log(` Last updated: ${c.green(lastUpdated)} (${lastUpdatedName})`);
924
+ } else {
925
+ console.log(` Last updated: ${c.dim('n/a')}`);
926
+ }
927
+ console.log(` AI backend: ${backend !== 'none' ? c.green(backend) : c.dim('not found')}`);
928
+ console.log(` Bird CLI: ${birdAvailable ? c.green('available') : c.dim('not found')}`);
929
+
930
+ if (activity.length) {
931
+ console.log(`\n Recent activity (7 days):`);
932
+ for (const e of activity) {
933
+ const date = (e.timestamp || '').slice(0, 10);
934
+ const label = e.summary || (e.type === 'created' ? 'Profile created' : 'Profile updated');
935
+ console.log(` [${date}] ${e.name} — ${label}`);
936
+ }
937
+ } else {
938
+ console.log(`\n ${c.dim('No recent activity (7 days)')}`);
939
+ }
940
+ console.log();
941
+ });
942
+
943
+ // --- regenerate ---
944
+ program.command('regenerate')
945
+ .description('Re-synthesize all profiles with current AI template')
946
+ .option('--dry-run', 'Show what would be regenerated without doing it')
947
+ .option('--no-ai', 'Skip AI synthesis (raw data only)')
948
+ .action(async (opts) => {
949
+ const profiles = listProfiles();
950
+ if (!profiles.length) {
951
+ console.log(c.dim('No profiles to regenerate. Use "vip add" to create one.'));
952
+ return;
953
+ }
954
+
955
+ if (opts.dryRun) {
956
+ console.log(c.cyan('Dry run — would regenerate:\n'));
957
+ profiles.forEach((p, i) => {
958
+ console.log(` [${i + 1}/${profiles.length}] ${p.name}`);
959
+ });
960
+ console.log(`\n${profiles.length} profile(s) would be regenerated.`);
961
+ return;
962
+ }
963
+
964
+ console.log(c.cyan('Regenerating all profiles with current template...'));
965
+ let count = 0;
966
+
967
+ for (let i = 0; i < profiles.length; i++) {
968
+ const p = profiles[i];
969
+ const prefix = ` [${i + 1}/${profiles.length}] ${p.name}`;
970
+ const stop = spinner(`${prefix}...`);
971
+
972
+ try {
973
+ const content = loadProfile(p.slug);
974
+ if (!content) {
975
+ stop();
976
+ console.log(`${prefix}... ${c.yellow('skipped (not found)')}`);
977
+ continue;
978
+ }
979
+
980
+ const { extractMetadata } = await import('../lib/monitor.js');
981
+ const meta = extractMetadata(content);
982
+ const personName = meta.name || p.name;
983
+
984
+ const person = resolveFromName(personName);
985
+ if (meta.twitterHandle) person.twitterHandle = person.twitterHandle || meta.twitterHandle;
986
+ if (meta.linkedinUrl) person.linkedinUrl = person.linkedinUrl || meta.linkedinUrl;
987
+
988
+ const [rawData, sources] = gatherData(person);
989
+ if (!rawData.trim()) {
990
+ stop();
991
+ console.log(`${prefix}... ${c.yellow('skipped (no data)')}`);
992
+ continue;
993
+ }
994
+
995
+ let profile;
996
+ if (opts.ai === false) {
997
+ profile = `# ${personName}\n\n## Raw Data\n\n${rawData}`;
998
+ } else {
999
+ profile = await synthesizeProfile(rawData, sources);
1000
+ }
1001
+
1002
+ saveProfile(personName, profile);
1003
+ stop();
1004
+ console.log(`${prefix}... ${c.green('done')}`);
1005
+
1006
+ appendChangelog({
1007
+ timestamp: new Date().toISOString(),
1008
+ name: personName,
1009
+ slug: p.slug,
1010
+ type: 'updated',
1011
+ summary: `Profile regenerated with current template`,
1012
+ });
1013
+
1014
+ count++;
1015
+ } catch (e) {
1016
+ stop();
1017
+ console.log(`${prefix}... ${c.red(`error: ${e.message}`)}`);
1018
+ }
1019
+ }
1020
+
1021
+ console.log(`\n${count} profile(s) regenerated.`);
1022
+ });
1023
+
590
1024
  program.parseAsync();
@@ -59,16 +59,3 @@ export function fetchProfile(handle) {
59
59
  return data;
60
60
  }
61
61
 
62
- export function fetchTweetsByUrl(url) {
63
- if (!isAvailable()) return null;
64
-
65
- try {
66
- return execFileSync('bird', ['read', url], {
67
- encoding: 'utf-8',
68
- timeout: 30000,
69
- stdio: ['pipe', 'pipe', 'pipe'],
70
- });
71
- } catch {
72
- return null;
73
- }
74
- }
package/lib/profile.js CHANGED
@@ -114,3 +114,11 @@ export function deleteProfile(name, profilesDir) {
114
114
  }
115
115
  return false;
116
116
  }
117
+
118
+ export function parseTags(content) {
119
+ const match = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
120
+ if (!match) return [];
121
+ return match[1].split('\n')
122
+ .map(line => line.replace(/^-\s*/, '').trim())
123
+ .filter(Boolean);
124
+ }
@@ -9,7 +9,7 @@ function getBackend() {
9
9
  const config = loadConfig();
10
10
  if (config.ai_backend) return config.ai_backend.toLowerCase();
11
11
 
12
- if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
12
+ if (process.env.ANTHROPIC_API_KEY || config.anthropic_api_key) return 'anthropic';
13
13
  if (checkTool('claude')) return 'claude-cli';
14
14
  if (checkTool('gh') && copilotAvailable()) return 'copilot-cli';
15
15
 
@@ -48,8 +48,8 @@ async function callAnthropicApi(prompt) {
48
48
  throw new Error('Anthropic SDK not installed. Run: npm install @anthropic-ai/sdk');
49
49
  }
50
50
 
51
- const apiKey = process.env.ANTHROPIC_API_KEY;
52
- if (!apiKey) throw new Error('ANTHROPIC_API_KEY environment variable not set.');
51
+ const apiKey = process.env.ANTHROPIC_API_KEY || loadConfig().anthropic_api_key;
52
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set. Run "vip init" or set the environment variable.');
53
53
 
54
54
  const config = loadConfig();
55
55
  const model = config.anthropic_model || 'claude-sonnet-4-20250514';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcare",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Auto-build VIP person profiles from Twitter/LinkedIn public data",
5
5
  "type": "module",
6
6
  "bin": {
package/web/index.html CHANGED
@@ -6,9 +6,10 @@
6
6
  <title>VIPCare - Baseball Cards</title>
7
7
  <style>
8
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 20px; }
9
+ html { scroll-behavior: smooth; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 20px; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); }
10
11
  h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8; }
11
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
12
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
12
13
 
13
14
  .card {
14
15
  background: linear-gradient(145deg, #1e293b, #334155);
@@ -19,8 +20,11 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
19
20
  transition: transform 0.2s, box-shadow 0.2s;
20
21
  position: relative;
21
22
  overflow: hidden;
23
+ min-height: 44px;
24
+ -webkit-tap-highlight-color: transparent;
22
25
  }
23
26
  .card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(56,189,248,0.15); }
27
+ .card:active { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(56,189,248,0.1); }
24
28
  .card::before {
25
29
  content: '';
26
30
  position: absolute;
@@ -33,17 +37,17 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
33
37
  .card-name { font-size: 1.4em; font-weight: 700; color: #f1f5f9; }
34
38
  .card-role { font-size: 0.85em; color: #94a3b8; margin-top: 2px; }
35
39
  .card-badges { display: flex; gap: 6px; }
36
- .badge { padding: 3px 8px; border-radius: 6px; font-size: 0.7em; font-weight: 700; }
40
+ .badge { padding: 4px 10px; border-radius: 6px; font-size: 0.75em; font-weight: 700; min-height: 28px; display: inline-flex; align-items: center; }
37
41
  .badge-disc { background: #38bdf8; color: #0f172a; }
38
42
  .badge-mbti { background: #818cf8; color: #0f172a; }
39
43
 
40
44
  .card-quote { font-style: italic; color: #94a3b8; font-size: 0.8em; margin: 10px 0; padding: 8px 12px; border-left: 3px solid #475569; }
41
45
 
42
46
  .radar-container { display: flex; justify-content: center; margin: 16px 0; }
43
- .radar { width: 200px; height: 200px; }
47
+ .radar { width: 200px; height: 200px; max-width: 100%; }
44
48
 
45
49
  .tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
46
- .tag { background: #1e3a5f; color: #38bdf8; padding: 3px 10px; border-radius: 12px; font-size: 0.75em; }
50
+ .tag { background: #1e3a5f; color: #38bdf8; padding: 4px 12px; border-radius: 12px; font-size: 0.75em; min-height: 28px; display: inline-flex; align-items: center; }
47
51
 
48
52
  .expertise { margin: 10px 0; }
49
53
  .expertise-title { font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
@@ -51,7 +55,7 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
51
55
  .superpower { color: #fbbf24; font-weight: 600; font-size: 0.85em; margin: 6px 0; }
52
56
 
53
57
  .tips { margin-top: 12px; border-top: 1px solid #475569; padding-top: 12px; }
54
- .tip-row { display: flex; gap: 4px; font-size: 0.8em; margin: 4px 0; color: #cbd5e1; }
58
+ .tip-row { display: flex; gap: 4px; font-size: 0.8em; margin: 4px 0; color: #cbd5e1; min-height: 44px; align-items: center; }
55
59
  .tip-icon { width: 20px; text-align: center; }
56
60
  .tip-label { color: #64748b; min-width: 55px; }
57
61
 
@@ -61,11 +65,32 @@ h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8;
61
65
  .modal {
62
66
  background: #1e293b; border-radius: 16px; max-width: 600px; width: 100%; max-height: 90vh; overflow-y: auto; padding: 32px;
63
67
  border: 1px solid #475569;
68
+ -webkit-overflow-scrolling: touch;
64
69
  }
65
- .modal-close { float: right; background: none; border: none; color: #94a3b8; font-size: 1.5em; cursor: pointer; }
70
+ .modal-close { float: right; background: none; border: none; color: #94a3b8; font-size: 1.5em; cursor: pointer; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
66
71
  .modal h2 { color: #38bdf8; margin: 16px 0 8px; font-size: 1.1em; }
67
72
  .modal p, .modal li { color: #cbd5e1; font-size: 0.9em; line-height: 1.6; }
68
73
  .modal ul { padding-left: 20px; }
74
+
75
+ /* Mobile: screens < 480px */
76
+ @media (max-width: 480px) {
77
+ body { padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
78
+ h1 { font-size: 1.5em; margin: 12px 0 20px; }
79
+ .grid { grid-template-columns: 1fr; gap: 16px; }
80
+ .card { padding: 18px; }
81
+ .card-name { font-size: 1.25em; }
82
+ .card-role { font-size: 0.9em; }
83
+ .card-quote { font-size: 0.85em; }
84
+ .tip-row { font-size: 0.85em; }
85
+ .radar { width: 180px; height: 180px; }
86
+ .badge { font-size: 0.8em; padding: 5px 12px; }
87
+ .tag { font-size: 0.8em; padding: 5px 14px; }
88
+
89
+ .modal-overlay { padding: 0; align-items: stretch; }
90
+ .modal { max-width: 100%; max-height: 100vh; height: 100%; border-radius: 0; padding: 20px; padding-top: max(20px, env(safe-area-inset-top)); padding-bottom: max(20px, env(safe-area-inset-bottom)); }
91
+ .modal h2 { font-size: 1.15em; }
92
+ .modal p, .modal li { font-size: 0.95em; line-height: 1.7; }
93
+ }
69
94
  </style>
70
95
  </head>
71
96
  <body>
@@ -116,10 +141,10 @@ function radarSvg(scores, size = 200) {
116
141
  const y = cy + r * Math.sin(angle);
117
142
  axes += `<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="#334155" stroke-width="0.5"/>`;
118
143
 
119
- const lx = cx + (r + 18) * Math.cos(angle);
120
- const ly = cy + (r + 18) * Math.sin(angle);
144
+ const lx = cx + (r + 22) * Math.cos(angle);
145
+ const ly = cy + (r + 22) * Math.sin(angle);
121
146
  const label = SCORE_LABELS[keys[i]] || keys[i];
122
- labels += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#64748b" font-size="7">${label}</text>`;
147
+ labels += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#64748b" font-size="9">${label}</text>`;
123
148
 
124
149
  const val = (scores[keys[i]] || 0) / 5;
125
150
  const dx = cx + r * val * Math.cos(angle);