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/skill/vip.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
You are a VIP Profile Manager assistant. You use the `vip` CLI tool at `/Users/iris/Library/Python/3.9/bin/vip` for data management, and YOU (Claude) provide the intelligence layer — synthesizing, analyzing, and enriching profiles.
|
|
2
|
+
|
|
3
|
+
The user's request is: $ARGUMENTS
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
- **`vip` CLI** = pure data tool (fetch, store, list, search, edit, delete). No AI inside.
|
|
8
|
+
- **You (Claude)** = the smart layer. You read raw data from profiles, synthesize structured content, answer questions, and write polished profiles.
|
|
9
|
+
|
|
10
|
+
## Available CLI Commands
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
vip add "Name" --company "Company" # Fetch public data, save raw profile
|
|
14
|
+
vip add <twitter_url> # Fetch from Twitter URL
|
|
15
|
+
vip list # List all profiles
|
|
16
|
+
vip show <name> # Display a profile
|
|
17
|
+
vip search <keyword> # Search across profiles
|
|
18
|
+
vip edit <name> --title/--company/--twitter/--linkedin/--note # Edit fields
|
|
19
|
+
vip update <name> # Re-fetch data
|
|
20
|
+
vip rm <name> -y # Delete a profile
|
|
21
|
+
vip raw <name> # Show raw gathered data
|
|
22
|
+
vip digest # Recent changes
|
|
23
|
+
vip monitor start/stop/status/run # Auto-monitoring
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Workflow for Adding a Person
|
|
27
|
+
|
|
28
|
+
1. Run `vip add "Name" --company "Company"` to gather raw data
|
|
29
|
+
2. Run `vip show <name>` to read the raw profile
|
|
30
|
+
3. **You synthesize**: Read the raw data, then rewrite the profile into a polished, structured format with:
|
|
31
|
+
- Proper summary line
|
|
32
|
+
- Filled-in Basic Info (title, company, location, industry)
|
|
33
|
+
- Bio (2-3 paragraphs)
|
|
34
|
+
- Key interests, achievements, recent activity
|
|
35
|
+
- Background, personal info
|
|
36
|
+
4. Write the polished profile back using the Edit tool to the file at `~/Projects/vip-crm/profiles/<slug>.md`
|
|
37
|
+
|
|
38
|
+
## Workflow for Answering Questions
|
|
39
|
+
|
|
40
|
+
- If the user asks about a person: run `vip show` or `vip search`, then answer based on the profile content
|
|
41
|
+
- If they ask to compare people: read multiple profiles and synthesize a comparison
|
|
42
|
+
- If they ask "who do I know in AI?": run `vip search "AI"` and summarize
|
|
43
|
+
|
|
44
|
+
## Profile Format (your output when synthesizing)
|
|
45
|
+
|
|
46
|
+
```markdown
|
|
47
|
+
# {Full Name}
|
|
48
|
+
|
|
49
|
+
> {One-line summary}
|
|
50
|
+
|
|
51
|
+
## Basic Info
|
|
52
|
+
- **Title:** {role}
|
|
53
|
+
- **Company:** {company}
|
|
54
|
+
- **Location:** {city, country}
|
|
55
|
+
- **Industry:** {domain}
|
|
56
|
+
|
|
57
|
+
## Links
|
|
58
|
+
- Twitter: {url}
|
|
59
|
+
- LinkedIn: {url}
|
|
60
|
+
- Website: {url}
|
|
61
|
+
|
|
62
|
+
## Bio
|
|
63
|
+
{2-3 paragraph biography}
|
|
64
|
+
|
|
65
|
+
## Key Interests & Topics
|
|
66
|
+
- {topic 1}
|
|
67
|
+
- {topic 2}
|
|
68
|
+
|
|
69
|
+
## Notable Achievements
|
|
70
|
+
- {achievement 1}
|
|
71
|
+
- {achievement 2}
|
|
72
|
+
|
|
73
|
+
## Recent Activity
|
|
74
|
+
- {recent news/posts}
|
|
75
|
+
|
|
76
|
+
## Background
|
|
77
|
+
{education, career history}
|
|
78
|
+
|
|
79
|
+
## Personal
|
|
80
|
+
{family, hobbies — only if publicly known}
|
|
81
|
+
|
|
82
|
+
## Notes
|
|
83
|
+
{any extra observations}
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
*Last updated: {date}*
|
|
87
|
+
*Sources: {urls}*
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Rules
|
|
91
|
+
|
|
92
|
+
- Always use the full path `/Users/iris/Library/Python/3.9/bin/vip` for CLI commands
|
|
93
|
+
- Only include information supported by the raw data — do not fabricate
|
|
94
|
+
- If a section has no data, write "No public information found."
|
|
95
|
+
- For batch operations (adding multiple people), process them one at a time
|
|
96
|
+
- After synthesizing a profile, write it directly to the .md file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { parseTweets } from '../lib/fetchers/twitter.js';
|
|
4
|
+
|
|
5
|
+
describe('parseTweets', () => {
|
|
6
|
+
it('filters separators and metadata', () => {
|
|
7
|
+
const output = 'Hello world tweet\n─────────\n2024-01-01\n5 likes\nAnother tweet here\n';
|
|
8
|
+
const tweets = parseTweets(output);
|
|
9
|
+
assert.ok(tweets.includes('Hello world tweet'));
|
|
10
|
+
assert.ok(tweets.includes('Another tweet here'));
|
|
11
|
+
assert.strictEqual(tweets.length, 2);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('empty input', () => {
|
|
15
|
+
assert.deepStrictEqual(parseTweets(''), []);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('short lines ignored', () => {
|
|
19
|
+
assert.deepStrictEqual(parseTweets('hi\n'), []);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { extractMetadata } from '../lib/monitor.js';
|
|
4
|
+
|
|
5
|
+
describe('extractMetadata', () => {
|
|
6
|
+
it('extracts twitter', () => {
|
|
7
|
+
const meta = extractMetadata('# Test\n\n- Twitter: https://twitter.com/testuser\n');
|
|
8
|
+
assert.strictEqual(meta.twitterHandle, 'testuser');
|
|
9
|
+
assert.strictEqual(meta.name, 'Test');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('extracts x.com', () => {
|
|
13
|
+
const meta = extractMetadata('# Test\n\n- Twitter: https://x.com/testuser\n');
|
|
14
|
+
assert.strictEqual(meta.twitterHandle, 'testuser');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('extracts linkedin', () => {
|
|
18
|
+
const meta = extractMetadata('# Test\n\n- LinkedIn: https://linkedin.com/in/test-user\n');
|
|
19
|
+
assert.strictEqual(meta.linkedinUrl, 'https://linkedin.com/in/test-user');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles empty', () => {
|
|
23
|
+
const meta = extractMetadata('No links here');
|
|
24
|
+
assert.strictEqual(meta.twitterHandle, null);
|
|
25
|
+
assert.strictEqual(meta.linkedinUrl, null);
|
|
26
|
+
assert.strictEqual(meta.name, null);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { slugify, saveProfile, loadProfile, listProfiles, searchProfiles, profileExists, deleteProfile } from '../lib/profile.js';
|
|
7
|
+
|
|
8
|
+
let tmpDir;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vip-test-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('slugify', () => {
|
|
15
|
+
it('basic name', () => assert.strictEqual(slugify('Sam Altman'), 'sam-altman'));
|
|
16
|
+
it('special chars', () => assert.strictEqual(slugify("Dr. John O'Brien"), 'dr-john-obrien'));
|
|
17
|
+
it('extra spaces', () => assert.strictEqual(slugify(' Elon Musk '), 'elon-musk'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('save and load', () => {
|
|
21
|
+
it('round-trips content', () => {
|
|
22
|
+
saveProfile('Test Person', '# Test\nContent', tmpDir);
|
|
23
|
+
assert.strictEqual(loadProfile('Test Person', tmpDir), '# Test\nContent');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null for missing', () => {
|
|
27
|
+
assert.strictEqual(loadProfile('Nobody', tmpDir), null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('fuzzy matches', () => {
|
|
31
|
+
saveProfile('Sam Altman', '# Sam Altman\nContent', tmpDir);
|
|
32
|
+
const result = loadProfile('sam', tmpDir);
|
|
33
|
+
assert.ok(result?.includes('Sam Altman'));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('listProfiles', () => {
|
|
38
|
+
it('empty dir', () => assert.deepStrictEqual(listProfiles(tmpDir), []));
|
|
39
|
+
|
|
40
|
+
it('lists profiles', () => {
|
|
41
|
+
saveProfile('Sam Altman', '# Sam Altman\n\n> CEO of OpenAI', tmpDir);
|
|
42
|
+
saveProfile('Elon Musk', '# Elon Musk\n\n> CEO of Tesla', tmpDir);
|
|
43
|
+
const profiles = listProfiles(tmpDir);
|
|
44
|
+
assert.strictEqual(profiles.length, 2);
|
|
45
|
+
assert.ok(profiles.some(p => p.name === 'Sam Altman'));
|
|
46
|
+
assert.ok(profiles.some(p => p.name === 'Elon Musk'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('extracts summary', () => {
|
|
50
|
+
saveProfile('Test', '# Test\n\n> Summary here', tmpDir);
|
|
51
|
+
assert.strictEqual(listProfiles(tmpDir)[0].summary, 'Summary here');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('searchProfiles', () => {
|
|
56
|
+
it('finds matches', () => {
|
|
57
|
+
saveProfile('Sam', '# Sam\nOpenAI CEO', tmpDir);
|
|
58
|
+
saveProfile('Elon', '# Elon\nTesla CEO', tmpDir);
|
|
59
|
+
assert.strictEqual(searchProfiles('OpenAI', tmpDir).length, 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('case insensitive', () => {
|
|
63
|
+
saveProfile('Sam', '# Sam\nOpenAI', tmpDir);
|
|
64
|
+
assert.strictEqual(searchProfiles('openai', tmpDir).length, 1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('no match', () => {
|
|
68
|
+
saveProfile('Sam', '# Sam\nOpenAI', tmpDir);
|
|
69
|
+
assert.strictEqual(searchProfiles('nonexistent', tmpDir).length, 0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('profileExists / deleteProfile', () => {
|
|
74
|
+
it('exists check', () => {
|
|
75
|
+
saveProfile('Test', 'content', tmpDir);
|
|
76
|
+
assert.ok(profileExists('Test', tmpDir));
|
|
77
|
+
assert.ok(!profileExists('Nobody', tmpDir));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('delete', () => {
|
|
81
|
+
saveProfile('Test', 'content', tmpDir);
|
|
82
|
+
assert.ok(deleteProfile('Test', tmpDir));
|
|
83
|
+
assert.ok(!profileExists('Test', tmpDir));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('delete missing returns false', () => {
|
|
87
|
+
assert.ok(!deleteProfile('Nobody', tmpDir));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { parseTwitterHandle, parseLinkedinUrl, isUrl, resolveFromUrl } from '../lib/resolver.js';
|
|
4
|
+
|
|
5
|
+
describe('parseTwitterHandle', () => {
|
|
6
|
+
it('twitter.com', () => assert.strictEqual(parseTwitterHandle('https://twitter.com/elonmusk'), 'elonmusk'));
|
|
7
|
+
it('x.com', () => assert.strictEqual(parseTwitterHandle('https://x.com/sama'), 'sama'));
|
|
8
|
+
it('with @', () => assert.strictEqual(parseTwitterHandle('https://twitter.com/@test'), 'test'));
|
|
9
|
+
it('ignores search', () => assert.strictEqual(parseTwitterHandle('https://twitter.com/search'), null));
|
|
10
|
+
it('ignores explore', () => assert.strictEqual(parseTwitterHandle('https://twitter.com/explore'), null));
|
|
11
|
+
it('no match', () => assert.strictEqual(parseTwitterHandle('https://example.com'), null));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('parseLinkedinUrl', () => {
|
|
15
|
+
it('valid profile', () => {
|
|
16
|
+
assert.strictEqual(parseLinkedinUrl('https://www.linkedin.com/in/samaltman'), 'https://www.linkedin.com/in/samaltman');
|
|
17
|
+
});
|
|
18
|
+
it('with params', () => {
|
|
19
|
+
assert.strictEqual(parseLinkedinUrl('https://linkedin.com/in/samaltman?trk=x'), 'https://linkedin.com/in/samaltman');
|
|
20
|
+
});
|
|
21
|
+
it('not profile', () => {
|
|
22
|
+
assert.strictEqual(parseLinkedinUrl('https://linkedin.com/company/openai'), null);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('isUrl', () => {
|
|
27
|
+
it('https', () => assert.ok(isUrl('https://twitter.com/test')));
|
|
28
|
+
it('http', () => assert.ok(isUrl('http://example.com')));
|
|
29
|
+
it('twitter.com', () => assert.ok(isUrl('twitter.com/test')));
|
|
30
|
+
it('not url', () => assert.ok(!isUrl('Sam Altman')));
|
|
31
|
+
it('email not url', () => assert.ok(!isUrl('test@email.com')));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('resolveFromUrl', () => {
|
|
35
|
+
it('unknown URL', () => {
|
|
36
|
+
const p = resolveFromUrl('https://example.com/unknown');
|
|
37
|
+
assert.strictEqual(p.name, '');
|
|
38
|
+
assert.strictEqual(p.otherUrls.length, 1);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { createPlist } from '../lib/scheduler.js';
|
|
4
|
+
|
|
5
|
+
describe('createPlist', () => {
|
|
6
|
+
it('contains interval', () => {
|
|
7
|
+
// This may fail if vip is not installed, skip gracefully
|
|
8
|
+
try {
|
|
9
|
+
const plist = createPlist(12);
|
|
10
|
+
assert.ok(plist.includes('<integer>43200</integer>'));
|
|
11
|
+
assert.ok(plist.includes('com.vip-crm.monitor'));
|
|
12
|
+
assert.ok(plist.includes('monitor'));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
if (e.message.includes("Cannot find 'vip'")) {
|
|
15
|
+
// Expected in test env without global install
|
|
16
|
+
assert.ok(true);
|
|
17
|
+
} else {
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|