vipcare 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/bin/vip.js +430 -2
- package/package.json +1 -1
- package/web/index.html +35 -10
package/bin/vip.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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
9
|
import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles, slugify } from '../lib/profile.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.0');
|
|
79
80
|
|
|
80
81
|
// --- add ---
|
|
81
82
|
program.command('add')
|
|
@@ -178,8 +179,21 @@ program.command('add')
|
|
|
178
179
|
program.command('list')
|
|
179
180
|
.description('List all VIP profiles')
|
|
180
181
|
.option('--json', 'Output as JSON')
|
|
182
|
+
.option('--tag <tag>', 'Filter by tag')
|
|
181
183
|
.action((opts) => {
|
|
182
|
-
|
|
184
|
+
let profiles = listProfiles();
|
|
185
|
+
|
|
186
|
+
if (opts.tag) {
|
|
187
|
+
profiles = profiles.filter(p => {
|
|
188
|
+
const content = loadProfile(p.slug);
|
|
189
|
+
if (!content) return false;
|
|
190
|
+
const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
|
|
191
|
+
if (!tagsMatch) return false;
|
|
192
|
+
const tags = tagsMatch[1].split('\n').filter(l => l.match(/^- /)).map(l => l.replace(/^- /, '').trim());
|
|
193
|
+
return tags.includes(opts.tag);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
183
197
|
if (opts.json) {
|
|
184
198
|
const data = profiles.map(p => ({ slug: p.slug, name: p.name, summary: p.summary, updated: p.updated, path: p.path }));
|
|
185
199
|
console.log(JSON.stringify(data, null, 2));
|
|
@@ -500,6 +514,226 @@ mon.command('run').description('Run monitoring now').option('-v, --verbose', 'Ve
|
|
|
500
514
|
} else console.log(c.dim('No significant changes detected.'));
|
|
501
515
|
});
|
|
502
516
|
|
|
517
|
+
// --- compare ---
|
|
518
|
+
program.command('compare')
|
|
519
|
+
.description('Compare two VIP profiles side by side')
|
|
520
|
+
.argument('<name1>', 'First profile name')
|
|
521
|
+
.argument('<name2>', 'Second profile name')
|
|
522
|
+
.option('--json', 'Output as JSON')
|
|
523
|
+
.action((name1, name2, opts) => {
|
|
524
|
+
const content1 = loadProfile(name1);
|
|
525
|
+
if (!content1) { console.error(c.red(`Profile not found: ${name1}`)); process.exit(1); }
|
|
526
|
+
const content2 = loadProfile(name2);
|
|
527
|
+
if (!content2) { console.error(c.red(`Profile not found: ${name2}`)); process.exit(1); }
|
|
528
|
+
|
|
529
|
+
function parseProfile(content) {
|
|
530
|
+
const vip = extractVipData(content);
|
|
531
|
+
if (vip) return vip;
|
|
532
|
+
|
|
533
|
+
const nameMatch = content.match(/^# (.+)$/m);
|
|
534
|
+
const titleMatch = content.match(/\*\*Title:\*\*\s*(.+)/);
|
|
535
|
+
const companyMatch = content.match(/\*\*Company:\*\*\s*(.+)/);
|
|
536
|
+
const locationMatch = content.match(/\*\*Location:\*\*\s*(.+)/);
|
|
537
|
+
const discMatch = content.match(/\*\*DISC:\*\*\s*(.+)/);
|
|
538
|
+
const mbtiMatch = content.match(/\*\*MBTI:\*\*\s*(.+)/);
|
|
539
|
+
const industryMatch = content.match(/\*\*Industry:\*\*\s*(.+)/);
|
|
540
|
+
|
|
541
|
+
// Parse tags from ## Tags section
|
|
542
|
+
const tags = [];
|
|
543
|
+
const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
|
|
544
|
+
if (tagsMatch) {
|
|
545
|
+
for (const line of tagsMatch[1].split('\n')) {
|
|
546
|
+
const m = line.match(/^- (.+)/);
|
|
547
|
+
if (m) tags.push(m[1].trim());
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (industryMatch && !tags.length) tags.push(industryMatch[1].trim());
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
name: nameMatch ? nameMatch[1] : '',
|
|
554
|
+
title: titleMatch ? titleMatch[1].trim() : '',
|
|
555
|
+
company: companyMatch ? companyMatch[1].trim() : '',
|
|
556
|
+
location: locationMatch ? locationMatch[1].trim() : '',
|
|
557
|
+
disc: discMatch ? discMatch[1].trim() : '',
|
|
558
|
+
mbti: mbtiMatch ? mbtiMatch[1].trim() : '',
|
|
559
|
+
tags,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const p1 = parseProfile(content1);
|
|
564
|
+
const p2 = parseProfile(content2);
|
|
565
|
+
|
|
566
|
+
const name1Display = p1.name || name1;
|
|
567
|
+
const name2Display = p2.name || name2;
|
|
568
|
+
|
|
569
|
+
const tags1 = (p1.tags || []).map(t => t.toLowerCase());
|
|
570
|
+
const tags2 = (p2.tags || []).map(t => t.toLowerCase());
|
|
571
|
+
const shared = tags1.filter(t => tags2.includes(t));
|
|
572
|
+
const unique1 = tags1.filter(t => !tags2.includes(t));
|
|
573
|
+
const unique2 = tags2.filter(t => !tags1.includes(t));
|
|
574
|
+
|
|
575
|
+
if (opts.json) {
|
|
576
|
+
console.log(JSON.stringify({
|
|
577
|
+
profile1: { name: name1Display, title: p1.title, company: p1.company, location: p1.location, disc: p1.disc, mbti: p1.mbti, tags: p1.tags },
|
|
578
|
+
profile2: { name: name2Display, title: p2.title, company: p2.company, location: p2.location, disc: p2.disc, mbti: p2.mbti, tags: p2.tags },
|
|
579
|
+
shared,
|
|
580
|
+
uniqueToFirst: unique1,
|
|
581
|
+
uniqueToSecond: unique2,
|
|
582
|
+
}, null, 2));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const col1W = Math.max(20, name1Display.length + 4);
|
|
587
|
+
const col2W = Math.max(20, name2Display.length + 4);
|
|
588
|
+
const labelW = 16;
|
|
589
|
+
|
|
590
|
+
console.log();
|
|
591
|
+
console.log(c.bold(c.cyan(`${name1Display} vs ${name2Display}`)));
|
|
592
|
+
console.log('═'.repeat(labelW + col1W + col2W));
|
|
593
|
+
|
|
594
|
+
console.log(`${''.padEnd(labelW)}${c.bold(name1Display.padEnd(col1W))}${c.bold(name2Display.padEnd(col2W))}`);
|
|
595
|
+
|
|
596
|
+
const fields = [
|
|
597
|
+
['Title:', p1.title, p2.title],
|
|
598
|
+
['Company:', p1.company, p2.company],
|
|
599
|
+
['Location:', p1.location, p2.location],
|
|
600
|
+
['DISC:', p1.disc, p2.disc],
|
|
601
|
+
['MBTI:', p1.mbti, p2.mbti],
|
|
602
|
+
];
|
|
603
|
+
|
|
604
|
+
for (const [label, v1, v2] of fields) {
|
|
605
|
+
if (!v1 && !v2) continue;
|
|
606
|
+
console.log(`${c.dim(label.padEnd(labelW))}${(v1 || c.dim('—')).toString().padEnd(col1W)}${(v2 || c.dim('—')).toString().padEnd(col2W)}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
console.log();
|
|
610
|
+
if (shared.length) console.log(`${c.green('Shared interests:')} ${shared.join(', ')}`);
|
|
611
|
+
if (unique1.length) console.log(`${c.cyan(`Unique to ${name1Display}:`)} ${unique1.join(', ')}`);
|
|
612
|
+
if (unique2.length) console.log(`${c.cyan(`Unique to ${name2Display}:`)} ${unique2.join(', ')}`);
|
|
613
|
+
if (!shared.length && !unique1.length && !unique2.length) console.log(c.dim('No tags to compare.'));
|
|
614
|
+
console.log();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// --- tag ---
|
|
618
|
+
program.command('tag')
|
|
619
|
+
.description('Add a tag to a profile')
|
|
620
|
+
.argument('<name>', 'Profile name')
|
|
621
|
+
.argument('<tag>', 'Tag to add')
|
|
622
|
+
.action((name, tag) => {
|
|
623
|
+
let content = loadProfile(name);
|
|
624
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
625
|
+
|
|
626
|
+
const tagLine = `- ${tag}`;
|
|
627
|
+
const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
|
|
628
|
+
|
|
629
|
+
if (tagsMatch) {
|
|
630
|
+
const existingTags = tagsMatch[1].split('\n').filter(l => l.match(/^- /)).map(l => l.replace(/^- /, '').trim());
|
|
631
|
+
if (existingTags.includes(tag)) {
|
|
632
|
+
console.log(c.yellow(`Tag '${tag}' already exists on ${name}.`));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
content = content.replace(tagsMatch[0], tagsMatch[0].trimEnd() + '\n' + tagLine);
|
|
636
|
+
} else if (content.includes('\n---\n')) {
|
|
637
|
+
content = content.replace('\n---\n', `\n## Tags\n${tagLine}\n\n---\n`);
|
|
638
|
+
} else {
|
|
639
|
+
content = content.trimEnd() + `\n\n## Tags\n${tagLine}\n`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
saveProfile(name, content);
|
|
643
|
+
console.log(c.green(`Tagged ${name} with '${tag}'.`));
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// --- untag ---
|
|
647
|
+
program.command('untag')
|
|
648
|
+
.description('Remove a tag from a profile')
|
|
649
|
+
.argument('<name>', 'Profile name')
|
|
650
|
+
.argument('<tag>', 'Tag to remove')
|
|
651
|
+
.action((name, tag) => {
|
|
652
|
+
let content = loadProfile(name);
|
|
653
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
654
|
+
|
|
655
|
+
const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
|
|
656
|
+
if (!tagsMatch) {
|
|
657
|
+
console.log(c.yellow(`No tags found on ${name}.`));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const lines = tagsMatch[1].split('\n').filter(l => l.match(/^- /));
|
|
662
|
+
const remaining = lines.filter(l => l.replace(/^- /, '').trim() !== tag);
|
|
663
|
+
|
|
664
|
+
if (remaining.length === lines.length) {
|
|
665
|
+
console.log(c.yellow(`Tag '${tag}' not found on ${name}.`));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (remaining.length === 0) {
|
|
670
|
+
// Remove the entire Tags section
|
|
671
|
+
content = content.replace(/\n?## Tags\n[\s\S]*?(?=\n##|\n---|$)/, '');
|
|
672
|
+
} else {
|
|
673
|
+
content = content.replace(tagsMatch[0], '## Tags\n' + remaining.join('\n'));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
saveProfile(name, content);
|
|
677
|
+
console.log(c.green(`Removed tag '${tag}' from ${name}.`));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// --- tags ---
|
|
681
|
+
program.command('tags')
|
|
682
|
+
.description('List tags across profiles or for a specific profile')
|
|
683
|
+
.argument('[name]', 'Profile name (optional)')
|
|
684
|
+
.option('--json', 'Output as JSON')
|
|
685
|
+
.action((name, opts) => {
|
|
686
|
+
function parseTags(content) {
|
|
687
|
+
const tags = [];
|
|
688
|
+
const tagsMatch = content.match(/## Tags\n([\s\S]*?)(?=\n##|\n---|$)/);
|
|
689
|
+
if (tagsMatch) {
|
|
690
|
+
for (const line of tagsMatch[1].split('\n')) {
|
|
691
|
+
const m = line.match(/^- (.+)/);
|
|
692
|
+
if (m) tags.push(m[1].trim());
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return tags;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (name) {
|
|
699
|
+
const content = loadProfile(name);
|
|
700
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
701
|
+
|
|
702
|
+
const tags = parseTags(content);
|
|
703
|
+
if (opts.json) {
|
|
704
|
+
console.log(JSON.stringify(tags, null, 2));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (!tags.length) { console.log(c.dim(`No tags on ${name}.`)); return; }
|
|
708
|
+
console.log(c.bold(c.cyan(`Tags for ${name}:\n`)));
|
|
709
|
+
for (const t of tags) console.log(` - ${t}`);
|
|
710
|
+
console.log();
|
|
711
|
+
} else {
|
|
712
|
+
const profiles = listProfiles();
|
|
713
|
+
const counts = {};
|
|
714
|
+
for (const p of profiles) {
|
|
715
|
+
const content = loadProfile(p.slug);
|
|
716
|
+
if (!content) continue;
|
|
717
|
+
for (const t of parseTags(content)) {
|
|
718
|
+
counts[t] = (counts[t] || 0) + 1;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (opts.json) {
|
|
723
|
+
console.log(JSON.stringify(counts, null, 2));
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
728
|
+
if (!entries.length) { console.log(c.dim('No tags found across any profiles.')); return; }
|
|
729
|
+
console.log(c.bold(c.cyan('All tags:\n')));
|
|
730
|
+
for (const [tag, count] of entries) {
|
|
731
|
+
console.log(` ${tag.padEnd(30)} ${c.dim(`(${count})`)}`);
|
|
732
|
+
}
|
|
733
|
+
console.log();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
503
737
|
// --- config ---
|
|
504
738
|
program.command('config')
|
|
505
739
|
.description('View/edit settings')
|
|
@@ -512,6 +746,58 @@ program.command('config')
|
|
|
512
746
|
console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
|
|
513
747
|
});
|
|
514
748
|
|
|
749
|
+
// --- init ---
|
|
750
|
+
program.command('init')
|
|
751
|
+
.description('Interactive first-time setup for VIPCare')
|
|
752
|
+
.action(async () => {
|
|
753
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
console.log(c.bold(c.cyan('\nWelcome to VIPCare!\n')));
|
|
757
|
+
|
|
758
|
+
const defaultDir = path.join(os.homedir(), 'Projects', 'vip-crm', 'profiles');
|
|
759
|
+
const profilesAnswer = await rl.question(`Where should profiles be stored?\n (default: ${defaultDir}) > `);
|
|
760
|
+
const profilesDir = profilesAnswer.trim() || defaultDir;
|
|
761
|
+
|
|
762
|
+
console.log(`\n${c.cyan('AI backend preference:')}`);
|
|
763
|
+
console.log(' 1. Auto-detect (recommended)');
|
|
764
|
+
console.log(' 2. Claude CLI');
|
|
765
|
+
console.log(' 3. Anthropic API');
|
|
766
|
+
console.log(' 4. GitHub Copilot CLI');
|
|
767
|
+
const backendAnswer = await rl.question(' > ');
|
|
768
|
+
const backendChoice = backendAnswer.trim() || '1';
|
|
769
|
+
|
|
770
|
+
const backendMap = { '1': 'auto', '2': 'claude-cli', '3': 'anthropic-api', '4': 'github-copilot' };
|
|
771
|
+
const aiBackend = backendMap[backendChoice] || 'auto';
|
|
772
|
+
|
|
773
|
+
const config = {
|
|
774
|
+
profiles_dir: profilesDir.replace(/^~/, os.homedir()),
|
|
775
|
+
ai_backend: aiBackend,
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
if (aiBackend === 'anthropic-api') {
|
|
779
|
+
const apiKey = await rl.question('\nAnthropic API key: ');
|
|
780
|
+
if (apiKey.trim()) {
|
|
781
|
+
config.anthropic_api_key = apiKey.trim();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Merge with existing config to preserve other settings
|
|
786
|
+
let existing = {};
|
|
787
|
+
try {
|
|
788
|
+
const { loadConfig: lc } = await import('../lib/config.js');
|
|
789
|
+
existing = lc();
|
|
790
|
+
} catch {}
|
|
791
|
+
saveConfig({ ...existing, ...config });
|
|
792
|
+
|
|
793
|
+
const { CONFIG_FILE: cfgPath } = await import('../lib/config.js');
|
|
794
|
+
console.log(c.green(`\nConfig saved to ${cfgPath}`));
|
|
795
|
+
console.log(`You're ready! Try: ${c.cyan('vip add "Sam Altman" --company "OpenAI"')}\n`);
|
|
796
|
+
} finally {
|
|
797
|
+
rl.close();
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
515
801
|
// --- export ---
|
|
516
802
|
program.command('export')
|
|
517
803
|
.description('Export all profiles as JSON')
|
|
@@ -587,4 +873,146 @@ program.command('import')
|
|
|
587
873
|
console.log(`\nDone: ${imported} imported, ${skipped} skipped.`);
|
|
588
874
|
});
|
|
589
875
|
|
|
876
|
+
// --- stats ---
|
|
877
|
+
program.command('stats')
|
|
878
|
+
.description('Show dashboard overview')
|
|
879
|
+
.option('--json', 'Output as JSON')
|
|
880
|
+
.action((opts) => {
|
|
881
|
+
const profiles = listProfiles();
|
|
882
|
+
const activity = readChangelog(7);
|
|
883
|
+
const backend = getBackendName();
|
|
884
|
+
const birdAvailable = checkTool('bird');
|
|
885
|
+
|
|
886
|
+
// Find most recently updated profile
|
|
887
|
+
let lastUpdated = null;
|
|
888
|
+
let lastUpdatedName = null;
|
|
889
|
+
for (const p of profiles) {
|
|
890
|
+
if (!lastUpdated || p.updated > lastUpdated) {
|
|
891
|
+
lastUpdated = p.updated;
|
|
892
|
+
lastUpdatedName = p.name;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (opts.json) {
|
|
897
|
+
const data = {
|
|
898
|
+
profileCount: profiles.length,
|
|
899
|
+
lastUpdated: lastUpdated ? { date: lastUpdated, name: lastUpdatedName } : null,
|
|
900
|
+
aiBackend: backend,
|
|
901
|
+
birdCli: birdAvailable ? 'available' : 'not found',
|
|
902
|
+
recentActivity: activity.map(e => ({
|
|
903
|
+
date: (e.timestamp || '').slice(0, 10),
|
|
904
|
+
name: e.name,
|
|
905
|
+
type: e.type,
|
|
906
|
+
summary: e.summary,
|
|
907
|
+
})),
|
|
908
|
+
};
|
|
909
|
+
console.log(JSON.stringify(data, null, 2));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
console.log(c.bold(c.cyan('\nVIPCare Stats')));
|
|
914
|
+
console.log('─'.repeat(15));
|
|
915
|
+
console.log(` Profiles: ${c.green(String(profiles.length))}`);
|
|
916
|
+
if (lastUpdated) {
|
|
917
|
+
console.log(` Last updated: ${c.green(lastUpdated)} (${lastUpdatedName})`);
|
|
918
|
+
} else {
|
|
919
|
+
console.log(` Last updated: ${c.dim('n/a')}`);
|
|
920
|
+
}
|
|
921
|
+
console.log(` AI backend: ${backend !== 'none' ? c.green(backend) : c.dim('not found')}`);
|
|
922
|
+
console.log(` Bird CLI: ${birdAvailable ? c.green('available') : c.dim('not found')}`);
|
|
923
|
+
|
|
924
|
+
if (activity.length) {
|
|
925
|
+
console.log(`\n Recent activity (7 days):`);
|
|
926
|
+
for (const e of activity) {
|
|
927
|
+
const date = (e.timestamp || '').slice(0, 10);
|
|
928
|
+
const label = e.summary || (e.type === 'created' ? 'Profile created' : 'Profile updated');
|
|
929
|
+
console.log(` [${date}] ${e.name} — ${label}`);
|
|
930
|
+
}
|
|
931
|
+
} else {
|
|
932
|
+
console.log(`\n ${c.dim('No recent activity (7 days)')}`);
|
|
933
|
+
}
|
|
934
|
+
console.log();
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// --- regenerate ---
|
|
938
|
+
program.command('regenerate')
|
|
939
|
+
.description('Re-synthesize all profiles with current AI template')
|
|
940
|
+
.option('--dry-run', 'Show what would be regenerated without doing it')
|
|
941
|
+
.option('--no-ai', 'Skip AI synthesis (raw data only)')
|
|
942
|
+
.action(async (opts) => {
|
|
943
|
+
const profiles = listProfiles();
|
|
944
|
+
if (!profiles.length) {
|
|
945
|
+
console.log(c.dim('No profiles to regenerate. Use "vip add" to create one.'));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (opts.dryRun) {
|
|
950
|
+
console.log(c.cyan('Dry run — would regenerate:\n'));
|
|
951
|
+
profiles.forEach((p, i) => {
|
|
952
|
+
console.log(` [${i + 1}/${profiles.length}] ${p.name}`);
|
|
953
|
+
});
|
|
954
|
+
console.log(`\n${profiles.length} profile(s) would be regenerated.`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
console.log(c.cyan('Regenerating all profiles with current template...'));
|
|
959
|
+
let count = 0;
|
|
960
|
+
|
|
961
|
+
for (let i = 0; i < profiles.length; i++) {
|
|
962
|
+
const p = profiles[i];
|
|
963
|
+
const prefix = ` [${i + 1}/${profiles.length}] ${p.name}`;
|
|
964
|
+
const stop = spinner(`${prefix}...`);
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const content = loadProfile(p.slug);
|
|
968
|
+
if (!content) {
|
|
969
|
+
stop();
|
|
970
|
+
console.log(`${prefix}... ${c.yellow('skipped (not found)')}`);
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const { extractMetadata } = await import('../lib/monitor.js');
|
|
975
|
+
const meta = extractMetadata(content);
|
|
976
|
+
const personName = meta.name || p.name;
|
|
977
|
+
|
|
978
|
+
const person = resolveFromName(personName);
|
|
979
|
+
if (meta.twitterHandle) person.twitterHandle = person.twitterHandle || meta.twitterHandle;
|
|
980
|
+
if (meta.linkedinUrl) person.linkedinUrl = person.linkedinUrl || meta.linkedinUrl;
|
|
981
|
+
|
|
982
|
+
const [rawData, sources] = gatherData(person);
|
|
983
|
+
if (!rawData.trim()) {
|
|
984
|
+
stop();
|
|
985
|
+
console.log(`${prefix}... ${c.yellow('skipped (no data)')}`);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let profile;
|
|
990
|
+
if (opts.ai === false) {
|
|
991
|
+
profile = `# ${personName}\n\n## Raw Data\n\n${rawData}`;
|
|
992
|
+
} else {
|
|
993
|
+
profile = await synthesizeProfile(rawData, sources);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
saveProfile(personName, profile);
|
|
997
|
+
stop();
|
|
998
|
+
console.log(`${prefix}... ${c.green('done')}`);
|
|
999
|
+
|
|
1000
|
+
appendChangelog({
|
|
1001
|
+
timestamp: new Date().toISOString(),
|
|
1002
|
+
name: personName,
|
|
1003
|
+
slug: p.slug,
|
|
1004
|
+
type: 'updated',
|
|
1005
|
+
summary: `Profile regenerated with current template`,
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
count++;
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
stop();
|
|
1011
|
+
console.log(`${prefix}... ${c.red(`error: ${e.message}`)}`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
console.log(`\n${count} profile(s) regenerated.`);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
590
1018
|
program.parseAsync();
|
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);
|