vipcare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/bin/vip.js +351 -0
- package/lib/config.js +48 -0
- package/lib/fetchers/search.js +73 -0
- package/lib/fetchers/twitter.js +74 -0
- package/lib/fetchers/web.js +29 -0
- package/lib/monitor.js +109 -0
- package/lib/profile.js +106 -0
- package/lib/resolver.js +95 -0
- package/lib/scheduler.js +92 -0
- package/lib/synthesizer.js +124 -0
- package/lib/templates.js +66 -0
- package/package.json +24 -0
- package/profiles/.gitkeep +0 -0
- package/profiles/sam-altman.md +49 -0
- package/skill/vip.md +96 -0
- package/tests/fetchers.test.js +21 -0
- package/tests/monitor.test.js +28 -0
- package/tests/profile.test.js +89 -0
- package/tests/resolver.test.js +40 -0
- package/tests/scheduler.test.js +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# VIPCare
|
|
2
|
+
|
|
3
|
+
Auto-build VIP person profiles from Twitter/LinkedIn public data.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g vipcare
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run directly:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx vipcare --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Add a person by name
|
|
21
|
+
vip add "Sam Altman" --company "OpenAI"
|
|
22
|
+
|
|
23
|
+
# Add from Twitter URL
|
|
24
|
+
vip add https://twitter.com/sama
|
|
25
|
+
|
|
26
|
+
# List all profiles
|
|
27
|
+
vip list
|
|
28
|
+
|
|
29
|
+
# Show a profile
|
|
30
|
+
vip show sam-altman
|
|
31
|
+
|
|
32
|
+
# Search across profiles
|
|
33
|
+
vip search "AI"
|
|
34
|
+
|
|
35
|
+
# Edit a profile
|
|
36
|
+
vip edit sam-altman --note "Met at conference"
|
|
37
|
+
|
|
38
|
+
# Delete a profile
|
|
39
|
+
vip rm sam-altman -y
|
|
40
|
+
|
|
41
|
+
# Refresh a profile
|
|
42
|
+
vip update sam-altman
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Auto profile building** — Give a name or URL, get a structured profile
|
|
48
|
+
- **Multi-source data** — Twitter (via [bird CLI](https://github.com/nickytonline/bird)), LinkedIn, web search
|
|
49
|
+
- **AI synthesis** — Claude CLI, Anthropic API, or GitHub Copilot CLI
|
|
50
|
+
- **Auto monitoring** — Scheduled profile refresh with change detection (macOS launchd)
|
|
51
|
+
- **Markdown output** — One `.md` file per person
|
|
52
|
+
|
|
53
|
+
## AI Backend
|
|
54
|
+
|
|
55
|
+
Auto-detected in this order:
|
|
56
|
+
|
|
57
|
+
| Backend | Setup |
|
|
58
|
+
|---------|-------|
|
|
59
|
+
| Anthropic API | Set `ANTHROPIC_API_KEY` env var |
|
|
60
|
+
| Claude CLI | Install [Claude Code](https://claude.ai/code) |
|
|
61
|
+
| GitHub Copilot | Install `gh copilot` |
|
|
62
|
+
|
|
63
|
+
Override: `VIP_AI_BACKEND=anthropic vip add "Name"`
|
|
64
|
+
|
|
65
|
+
## Claude Code Skill
|
|
66
|
+
|
|
67
|
+
Install the `/vip` slash command for Claude Code:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cp skill/vip.md ~/.claude/commands/vip.md
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then use natural language:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
/vip add Jensen Huang from NVIDIA
|
|
77
|
+
/vip who works in AI?
|
|
78
|
+
/vip compare Sam Altman and Elon Musk
|
|
79
|
+
/vip add a note to sam: met at dinner
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Monitoring
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
vip monitor start # Start auto-refresh (every 24h)
|
|
86
|
+
vip monitor stop # Stop
|
|
87
|
+
vip monitor status # Check status
|
|
88
|
+
vip monitor run # Run once now
|
|
89
|
+
vip digest # View recent changes
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Config
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
vip config # View settings
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Settings: `~/.vip-crm/config.json` | Profiles: `~/Projects/vip-crm/profiles/`
|
package/bin/vip.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { execSync as exec } from 'child_process';
|
|
7
|
+
import { checkTool, getProfilesDir, loadConfig, saveConfig } from '../lib/config.js';
|
|
8
|
+
import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles } from '../lib/profile.js';
|
|
9
|
+
import { isUrl, resolveFromName, resolveFromUrl } from '../lib/resolver.js';
|
|
10
|
+
import * as twitter from '../lib/fetchers/twitter.js';
|
|
11
|
+
import { searchPerson } from '../lib/fetchers/search.js';
|
|
12
|
+
import { synthesizeProfile, getBackendName } from '../lib/synthesizer.js';
|
|
13
|
+
import { readChangelog, runMonitor, unreadCount } from '../lib/monitor.js';
|
|
14
|
+
import { install, uninstall, status } from '../lib/scheduler.js';
|
|
15
|
+
|
|
16
|
+
// Colors
|
|
17
|
+
const c = {
|
|
18
|
+
cyan: s => `\x1b[36m${s}\x1b[0m`,
|
|
19
|
+
green: s => `\x1b[32m${s}\x1b[0m`,
|
|
20
|
+
yellow: s => `\x1b[33m${s}\x1b[0m`,
|
|
21
|
+
red: s => `\x1b[31m${s}\x1b[0m`,
|
|
22
|
+
dim: s => `\x1b[90m${s}\x1b[0m`,
|
|
23
|
+
bold: s => `\x1b[1m${s}\x1b[0m`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function spinner(msg) {
|
|
27
|
+
const chars = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
28
|
+
let i = 0;
|
|
29
|
+
const id = setInterval(() => {
|
|
30
|
+
process.stdout.write(`\r${chars[i++ % chars.length]} ${msg}`);
|
|
31
|
+
}, 100);
|
|
32
|
+
return () => { clearInterval(id); process.stdout.write(`\r${' '.repeat(msg.length + 4)}\r`); };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gatherData(person) {
|
|
36
|
+
const rawParts = [];
|
|
37
|
+
const sources = [];
|
|
38
|
+
|
|
39
|
+
if (person.twitterHandle) {
|
|
40
|
+
console.log(c.dim(` Fetching Twitter @${person.twitterHandle}...`));
|
|
41
|
+
const data = twitter.fetchProfile(person.twitterHandle);
|
|
42
|
+
if (data?.rawOutput) {
|
|
43
|
+
rawParts.push(`=== Twitter (@${person.twitterHandle}) ===\n${data.rawOutput}`);
|
|
44
|
+
sources.push(`https://twitter.com/${person.twitterHandle}`);
|
|
45
|
+
} else if (!twitter.isAvailable()) {
|
|
46
|
+
console.log(c.yellow(' (bird CLI not found, skipping Twitter)'));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (person.linkedinUrl) sources.push(person.linkedinUrl);
|
|
51
|
+
|
|
52
|
+
if (person.rawSnippets.length) {
|
|
53
|
+
rawParts.push('=== Web Search Results ===');
|
|
54
|
+
rawParts.push(...person.rawSnippets);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (rawParts.length < 2 && person.name) {
|
|
58
|
+
console.log(c.dim(` Searching the web for ${person.name}...`));
|
|
59
|
+
const results = searchPerson(person.name);
|
|
60
|
+
for (const r of results) {
|
|
61
|
+
rawParts.push(`${r.title}\n${r.body}`);
|
|
62
|
+
if (!sources.includes(r.url)) sources.push(r.url);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [rawParts.join('\n\n'), sources];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Show unread count
|
|
70
|
+
try {
|
|
71
|
+
const count = unreadCount();
|
|
72
|
+
if (count > 0) console.log(c.yellow(`[${count} new change(s) - run 'vip digest' to view]`));
|
|
73
|
+
} catch {}
|
|
74
|
+
|
|
75
|
+
const program = new Command();
|
|
76
|
+
program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.1.0');
|
|
77
|
+
|
|
78
|
+
// --- add ---
|
|
79
|
+
program.command('add')
|
|
80
|
+
.description('Add a new VIP profile')
|
|
81
|
+
.argument('<query>', 'Name or URL')
|
|
82
|
+
.option('-c, --company <company>', 'Company name')
|
|
83
|
+
.option('--dry-run', 'Print without saving')
|
|
84
|
+
.option('--no-ai', 'Skip AI synthesis')
|
|
85
|
+
.option('-f, --force', 'Overwrite existing')
|
|
86
|
+
.action(async (query, opts) => {
|
|
87
|
+
console.log(c.cyan(`Resolving ${query}...`));
|
|
88
|
+
|
|
89
|
+
let person;
|
|
90
|
+
if (isUrl(query)) {
|
|
91
|
+
const stop = spinner('Searching for profile...');
|
|
92
|
+
person = resolveFromUrl(query);
|
|
93
|
+
stop();
|
|
94
|
+
if (person.name) {
|
|
95
|
+
console.log(c.green(` Found: ${person.name}`));
|
|
96
|
+
if (person.twitterHandle) console.log(` Twitter: @${person.twitterHandle}`);
|
|
97
|
+
const stop2 = spinner('Enriching profile data...');
|
|
98
|
+
const enriched = resolveFromName(person.name);
|
|
99
|
+
stop2();
|
|
100
|
+
person.linkedinUrl = person.linkedinUrl || enriched.linkedinUrl;
|
|
101
|
+
const existing = new Set(person.rawSnippets);
|
|
102
|
+
for (const s of enriched.rawSnippets) if (!existing.has(s)) person.rawSnippets.push(s);
|
|
103
|
+
for (const u of enriched.otherUrls) if (!person.otherUrls.includes(u)) person.otherUrls.push(u);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const stop = spinner('Searching for profile...');
|
|
107
|
+
person = resolveFromName(query, opts.company);
|
|
108
|
+
stop();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!person.name) { console.error(c.red('Could not identify person.')); process.exit(1); }
|
|
112
|
+
|
|
113
|
+
console.log(c.green(` Name: ${person.name}`));
|
|
114
|
+
if (person.twitterHandle) console.log(` Twitter: @${person.twitterHandle}`);
|
|
115
|
+
if (person.linkedinUrl) console.log(` LinkedIn: ${person.linkedinUrl}`);
|
|
116
|
+
|
|
117
|
+
if (!opts.force && profileExists(person.name)) {
|
|
118
|
+
console.log(`Profile for '${person.name}' already exists. Use -f to overwrite.`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(c.cyan('Gathering data...'));
|
|
123
|
+
const [rawData, sources] = gatherData(person);
|
|
124
|
+
if (!rawData.trim()) { console.error(c.red('No data found.')); process.exit(1); }
|
|
125
|
+
|
|
126
|
+
let profile;
|
|
127
|
+
if (opts.ai === false) {
|
|
128
|
+
profile = `# ${person.name}\n\n## Raw Data\n\n${rawData}`;
|
|
129
|
+
} else {
|
|
130
|
+
const stop = spinner('Synthesizing profile with AI...');
|
|
131
|
+
try {
|
|
132
|
+
profile = await synthesizeProfile(rawData, sources);
|
|
133
|
+
} finally { stop(); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (opts.dryRun) {
|
|
137
|
+
console.log('\n' + '='.repeat(60));
|
|
138
|
+
console.log(profile);
|
|
139
|
+
} else {
|
|
140
|
+
const filepath = saveProfile(person.name, profile);
|
|
141
|
+
console.log(c.green(`\nProfile saved: ${filepath}`));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- list ---
|
|
146
|
+
program.command('list')
|
|
147
|
+
.description('List all VIP profiles')
|
|
148
|
+
.action(() => {
|
|
149
|
+
const profiles = listProfiles();
|
|
150
|
+
if (!profiles.length) { console.log(c.dim('No profiles yet. Use "vip add" to create one.')); return; }
|
|
151
|
+
|
|
152
|
+
const cols = process.stdout.columns || 80;
|
|
153
|
+
const nameW = Math.min(30, Math.max(15, cols / 4 | 0));
|
|
154
|
+
const dateW = 12;
|
|
155
|
+
const sumW = Math.max(20, cols - nameW - dateW - 4);
|
|
156
|
+
|
|
157
|
+
console.log(c.bold(c.cyan(`\n${'Name'.padEnd(nameW)} ${'Summary'.padEnd(sumW)} ${'Updated'.padEnd(dateW)}`)));
|
|
158
|
+
console.log('─'.repeat(nameW + sumW + dateW + 2));
|
|
159
|
+
for (const p of profiles) {
|
|
160
|
+
const name = p.name.slice(0, nameW - 1).padEnd(nameW);
|
|
161
|
+
const summary = c.dim(p.summary.slice(0, sumW - 1).padEnd(sumW));
|
|
162
|
+
console.log(`${name} ${summary} ${p.updated}`);
|
|
163
|
+
}
|
|
164
|
+
console.log(`\nTotal: ${profiles.length} profile(s)`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// --- show ---
|
|
168
|
+
program.command('show')
|
|
169
|
+
.description('Show a VIP profile')
|
|
170
|
+
.argument('<name>')
|
|
171
|
+
.action((name) => {
|
|
172
|
+
const content = loadProfile(name);
|
|
173
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
174
|
+
|
|
175
|
+
for (const line of content.split('\n')) {
|
|
176
|
+
if (line.startsWith('# ')) console.log(c.bold(c.cyan(line)));
|
|
177
|
+
else if (line.startsWith('## ')) console.log(c.bold(c.green(line)));
|
|
178
|
+
else if (line.startsWith('> ')) console.log(c.yellow(line));
|
|
179
|
+
else if (line.startsWith('---') || line.startsWith('*Last') || line.startsWith('*Sources')) console.log(c.dim(line));
|
|
180
|
+
else console.log(line);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- search ---
|
|
185
|
+
program.command('search')
|
|
186
|
+
.description('Search across all profiles')
|
|
187
|
+
.argument('<keyword>')
|
|
188
|
+
.action((keyword) => {
|
|
189
|
+
const results = searchProfiles(keyword);
|
|
190
|
+
if (!results.length) { console.log(c.dim(`No matches for '${keyword}'.`)); return; }
|
|
191
|
+
|
|
192
|
+
console.log(c.green(`Found ${results.length} profile(s) matching '${keyword}':\n`));
|
|
193
|
+
for (const r of results) {
|
|
194
|
+
console.log(c.bold(c.cyan(` ${r.name}`)));
|
|
195
|
+
for (const m of r.matches) console.log(` > ${m}`);
|
|
196
|
+
console.log();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- open ---
|
|
201
|
+
program.command('open')
|
|
202
|
+
.description('Open a profile in editor')
|
|
203
|
+
.argument('<name>')
|
|
204
|
+
.action((name) => {
|
|
205
|
+
const p = getProfilePath(name);
|
|
206
|
+
if (!fs.existsSync(p)) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
207
|
+
const editor = process.env.EDITOR || 'open';
|
|
208
|
+
exec(`${editor} "${p}"`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- update ---
|
|
212
|
+
program.command('update')
|
|
213
|
+
.description('Refresh an existing profile')
|
|
214
|
+
.argument('<name>')
|
|
215
|
+
.option('--no-ai', 'Skip AI synthesis')
|
|
216
|
+
.action(async (name, opts) => {
|
|
217
|
+
const content = loadProfile(name);
|
|
218
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
219
|
+
|
|
220
|
+
const { extractMetadata } = await import('../lib/monitor.js');
|
|
221
|
+
const meta = extractMetadata(content);
|
|
222
|
+
const personName = meta.name || name;
|
|
223
|
+
|
|
224
|
+
console.log(c.cyan(`Refreshing profile for ${personName}...`));
|
|
225
|
+
const stop = spinner('Resolving...');
|
|
226
|
+
const person = resolveFromName(personName);
|
|
227
|
+
stop();
|
|
228
|
+
|
|
229
|
+
if (meta.twitterHandle) person.twitterHandle = person.twitterHandle || meta.twitterHandle;
|
|
230
|
+
if (meta.linkedinUrl) person.linkedinUrl = person.linkedinUrl || meta.linkedinUrl;
|
|
231
|
+
|
|
232
|
+
const [rawData, sources] = gatherData(person);
|
|
233
|
+
if (!rawData.trim()) { console.log(c.yellow('No new data found.')); return; }
|
|
234
|
+
|
|
235
|
+
let profile;
|
|
236
|
+
if (opts.ai === false) {
|
|
237
|
+
profile = `# ${personName}\n\n## Raw Data\n\n${rawData}`;
|
|
238
|
+
} else {
|
|
239
|
+
const stop2 = spinner('Re-synthesizing profile...');
|
|
240
|
+
try { profile = await synthesizeProfile(rawData, sources); } finally { stop2(); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const filepath = saveProfile(personName, profile);
|
|
244
|
+
console.log(c.green(`Profile updated: ${filepath}`));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// --- rm ---
|
|
248
|
+
program.command('rm')
|
|
249
|
+
.description('Delete a VIP profile')
|
|
250
|
+
.argument('<name>')
|
|
251
|
+
.option('-y, --yes', 'Skip confirmation')
|
|
252
|
+
.action((name, opts) => {
|
|
253
|
+
if (!loadProfile(name)) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
254
|
+
if (!opts.yes) { console.log('Use -y to confirm deletion.'); return; }
|
|
255
|
+
deleteProfile(name);
|
|
256
|
+
console.log(c.green(`Profile deleted: ${name}`));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// --- edit ---
|
|
260
|
+
program.command('edit')
|
|
261
|
+
.description('Edit profile fields')
|
|
262
|
+
.argument('<name>')
|
|
263
|
+
.option('--title <title>', 'Set job title')
|
|
264
|
+
.option('--company <company>', 'Set company')
|
|
265
|
+
.option('--twitter <handle>', 'Set Twitter handle')
|
|
266
|
+
.option('--linkedin <url>', 'Set LinkedIn URL')
|
|
267
|
+
.option('--note <note>', 'Append a note')
|
|
268
|
+
.action((name, opts) => {
|
|
269
|
+
let content = loadProfile(name);
|
|
270
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
271
|
+
|
|
272
|
+
let modified = false;
|
|
273
|
+
|
|
274
|
+
if (opts.title) { content = content.replace(/(\*\*Title:\*\*) .+/, `$1 ${opts.title}`); modified = true; }
|
|
275
|
+
if (opts.company) { content = content.replace(/(\*\*Company:\*\*) .+/, `$1 ${opts.company}`); modified = true; }
|
|
276
|
+
if (opts.twitter) {
|
|
277
|
+
const handle = opts.twitter.replace(/^@/, '');
|
|
278
|
+
content = content.replace(/(Twitter:) .+/, `$1 https://twitter.com/${handle}`);
|
|
279
|
+
modified = true;
|
|
280
|
+
}
|
|
281
|
+
if (opts.linkedin) { content = content.replace(/(LinkedIn:) .+/, `$1 ${opts.linkedin}`); modified = true; }
|
|
282
|
+
if (opts.note) {
|
|
283
|
+
if (content.includes('## Notes')) {
|
|
284
|
+
content = content.replace('## Notes\n', `## Notes\n- ${opts.note}\n`);
|
|
285
|
+
} else if (content.includes('\n---\n')) {
|
|
286
|
+
content = content.replace('\n---\n', `\n## Notes\n- ${opts.note}\n\n---\n`);
|
|
287
|
+
} else {
|
|
288
|
+
content = content.trimEnd() + `\n\n## Notes\n- ${opts.note}\n`;
|
|
289
|
+
}
|
|
290
|
+
modified = true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (modified) { saveProfile(name, content); console.log(c.green('Profile updated.')); }
|
|
294
|
+
else console.log(c.yellow('No changes. Use --title, --company, --twitter, --linkedin, or --note.'));
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// --- digest ---
|
|
298
|
+
program.command('digest')
|
|
299
|
+
.description('Show recent changes')
|
|
300
|
+
.action(() => {
|
|
301
|
+
const entries = readChangelog(30);
|
|
302
|
+
if (!entries.length) { console.log(c.dim('No recent changes.')); return; }
|
|
303
|
+
|
|
304
|
+
console.log(c.bold(c.cyan('Changes in the last 30 days:\n')));
|
|
305
|
+
for (const e of entries.reverse()) {
|
|
306
|
+
console.log(c.green(` [${(e.timestamp || '').slice(0, 10)}] ${e.name}`));
|
|
307
|
+
console.log(` ${e.summary}`);
|
|
308
|
+
console.log();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// --- monitor ---
|
|
313
|
+
const mon = program.command('monitor').description('Manage automatic monitoring');
|
|
314
|
+
|
|
315
|
+
mon.command('start').description('Start monitoring').action(() => {
|
|
316
|
+
try { install(); const s = status(); console.log(c.green(`Monitor started (every ${s.intervalHours}h)`)); }
|
|
317
|
+
catch (e) { console.error(c.red(e.message)); process.exit(1); }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
mon.command('stop').description('Stop monitoring').action(() => { uninstall(); console.log(c.green('Monitor stopped.')); });
|
|
321
|
+
|
|
322
|
+
mon.command('status').description('Show status').action(() => {
|
|
323
|
+
const s = status();
|
|
324
|
+
console.log(`Status: ${s.running ? c.green('running') : c.red('stopped')}`);
|
|
325
|
+
console.log(`Interval: every ${s.intervalHours}h`);
|
|
326
|
+
console.log(`Installed: ${s.installed}`);
|
|
327
|
+
if (s.installed) console.log(`Plist: ${s.plistPath}`);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
mon.command('run').description('Run monitoring now').option('-v, --verbose', 'Verbose').action(async (opts) => {
|
|
331
|
+
console.log(c.cyan('Running monitor...'));
|
|
332
|
+
const changes = await runMonitor(null, opts.verbose);
|
|
333
|
+
if (changes.length) {
|
|
334
|
+
console.log(c.green(`\n${changes.length} profile(s) updated:`));
|
|
335
|
+
for (const ch of changes) console.log(` - ${ch.name}: ${ch.summary}`);
|
|
336
|
+
} else console.log(c.dim('No significant changes detected.'));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// --- config ---
|
|
340
|
+
program.command('config')
|
|
341
|
+
.description('View/edit settings')
|
|
342
|
+
.action(() => {
|
|
343
|
+
const cfg = loadConfig();
|
|
344
|
+
console.log(c.bold(c.cyan('Current config:')));
|
|
345
|
+
console.log(` Profiles dir: ${cfg.profiles_dir}`);
|
|
346
|
+
console.log(` Monitor interval: ${cfg.monitor_interval_hours}h`);
|
|
347
|
+
console.log(` Bird CLI: ${checkTool('bird') ? c.green('available') : c.red('not found')}`);
|
|
348
|
+
console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
program.parse();
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), '.vip-crm');
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
8
|
+
const CHANGELOG_FILE = path.join(CONFIG_DIR, 'changelog.jsonl');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
profiles_dir: path.join(os.homedir(), 'Projects', 'vip-crm', 'profiles'),
|
|
12
|
+
monitor_interval_hours: 24,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export { CONFIG_DIR, CONFIG_FILE, CHANGELOG_FILE };
|
|
16
|
+
|
|
17
|
+
export function loadConfig() {
|
|
18
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
19
|
+
|
|
20
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
21
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
22
|
+
return { ...DEFAULT_CONFIG, ...config };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
saveConfig(DEFAULT_CONFIG);
|
|
26
|
+
return { ...DEFAULT_CONFIG };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveConfig(config) {
|
|
30
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
31
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getProfilesDir() {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const dir = config.profiles_dir.replace(/^~/, os.homedir());
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function checkTool(name) {
|
|
42
|
+
try {
|
|
43
|
+
execSync(`which ${name}`, { stdio: 'ignore' });
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { checkTool } from '../config.js';
|
|
3
|
+
|
|
4
|
+
export function search(query, maxResults = 5) {
|
|
5
|
+
// Use ddgs CLI (installed via npm or pip)
|
|
6
|
+
if (checkTool('ddgs')) {
|
|
7
|
+
try {
|
|
8
|
+
const output = execSync(
|
|
9
|
+
`ddgs text "${query.replace(/"/g, '\\"')}" -m ${maxResults} -o json`,
|
|
10
|
+
{ encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const results = JSON.parse(output);
|
|
14
|
+
return results.map(r => ({
|
|
15
|
+
title: r.title || '',
|
|
16
|
+
url: r.href || r.url || '',
|
|
17
|
+
body: r.body || '',
|
|
18
|
+
}));
|
|
19
|
+
} catch {
|
|
20
|
+
// fall through
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fallback: use curl with DuckDuckGo lite
|
|
25
|
+
try {
|
|
26
|
+
const encoded = encodeURIComponent(query);
|
|
27
|
+
const output = execSync(
|
|
28
|
+
`curl -s "https://lite.duckduckgo.com/lite/?q=${encoded}" -H "User-Agent: VIPCare/0.1"`,
|
|
29
|
+
{ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Basic parsing of DDG lite HTML results
|
|
33
|
+
const results = [];
|
|
34
|
+
const linkRegex = /<a[^>]+href="([^"]+)"[^>]*class="result-link"[^>]*>([^<]+)<\/a>/g;
|
|
35
|
+
const snippetRegex = /<td class="result-snippet">([^<]+)<\/td>/g;
|
|
36
|
+
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = linkRegex.exec(output)) && results.length < maxResults) {
|
|
39
|
+
results.push({ title: match[2].trim(), url: match[1], body: '' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let i = 0;
|
|
43
|
+
while ((match = snippetRegex.exec(output)) && i < results.length) {
|
|
44
|
+
results[i].body = match[1].trim();
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return results;
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function searchPerson(name, company) {
|
|
55
|
+
const queries = [`"${name}"`];
|
|
56
|
+
if (company) queries.push(`"${name}" "${company}"`);
|
|
57
|
+
queries.push(`"${name}" site:twitter.com OR site:x.com`);
|
|
58
|
+
queries.push(`"${name}" site:linkedin.com/in`);
|
|
59
|
+
|
|
60
|
+
const allResults = [];
|
|
61
|
+
const seenUrls = new Set();
|
|
62
|
+
|
|
63
|
+
for (const q of queries) {
|
|
64
|
+
for (const r of search(q, 5)) {
|
|
65
|
+
if (!seenUrls.has(r.url)) {
|
|
66
|
+
seenUrls.add(r.url);
|
|
67
|
+
allResults.push(r);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return allResults;
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { checkTool } from '../config.js';
|
|
3
|
+
|
|
4
|
+
export function isAvailable() {
|
|
5
|
+
return checkTool('bird');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseTweets(output) {
|
|
9
|
+
const tweets = [];
|
|
10
|
+
let current = [];
|
|
11
|
+
|
|
12
|
+
for (const line of output.split('\n')) {
|
|
13
|
+
const stripped = line.trim();
|
|
14
|
+
|
|
15
|
+
if (/^[─━═]+$/.test(stripped) || !stripped) {
|
|
16
|
+
if (current.length) {
|
|
17
|
+
const text = current.join(' ').trim();
|
|
18
|
+
if (text.length > 5) tweets.push(text);
|
|
19
|
+
current = [];
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (/^\d+ (retweets?|likes?|replies|views)/i.test(stripped)) continue;
|
|
25
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(stripped)) continue;
|
|
26
|
+
|
|
27
|
+
current.push(stripped);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (current.length) {
|
|
31
|
+
const text = current.join(' ').trim();
|
|
32
|
+
if (text.length > 5) tweets.push(text);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return tweets;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fetchProfile(handle) {
|
|
39
|
+
if (!isAvailable()) return null;
|
|
40
|
+
|
|
41
|
+
handle = handle.replace(/^@/, '');
|
|
42
|
+
const data = { handle, bio: '', displayName: '', tweets: [], rawOutput: '' };
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const output = execSync(`bird search "from:${handle}" --count 10`, {
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
timeout: 30000,
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (output.trim()) {
|
|
52
|
+
data.rawOutput = output;
|
|
53
|
+
data.tweets = parseTweets(output);
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// timeout or not found
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fetchTweetsByUrl(url) {
|
|
63
|
+
if (!isAvailable()) return null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return execSync(`bird read "${url}"`, {
|
|
67
|
+
encoding: 'utf-8',
|
|
68
|
+
timeout: 30000,
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export async function fetchPageText(url, timeout = 15000) {
|
|
2
|
+
try {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
5
|
+
|
|
6
|
+
const resp = await fetch(url, {
|
|
7
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; VIPCare/0.1)' },
|
|
8
|
+
signal: controller.signal,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
|
|
13
|
+
if (!resp.ok) return '';
|
|
14
|
+
|
|
15
|
+
const html = await resp.text();
|
|
16
|
+
// Strip tags
|
|
17
|
+
let text = html
|
|
18
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
19
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
20
|
+
.replace(/<[^>]+>/g, ' ')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
|
|
24
|
+
if (text.length > 5000) text = text.slice(0, 5000) + '...';
|
|
25
|
+
return text;
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
}
|