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 +8 -1
- package/bin/vip.js +450 -16
- package/lib/fetchers/twitter.js +0 -13
- package/lib/profile.js +8 -0
- package/lib/synthesizer.js +3 -3
- package/package.json +1 -1
- package/web/index.html +35 -10
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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();
|
package/lib/fetchers/twitter.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/synthesizer.js
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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 +
|
|
120
|
-
const ly = cy + (r +
|
|
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="
|
|
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);
|