vipcare 0.1.0 → 0.2.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/CLAUDE.md +58 -0
- package/README.md +21 -0
- package/bin/vip.js +253 -14
- package/lib/card.js +306 -0
- package/lib/config.js +9 -2
- package/lib/fetchers/search.js +17 -16
- package/lib/fetchers/twitter.js +3 -3
- package/lib/fetchers/youtube.js +108 -0
- package/lib/monitor.js +2 -2
- package/lib/profile.js +11 -1
- package/lib/scheduler.js +5 -5
- package/lib/synthesizer.js +5 -5
- package/lib/templates.js +94 -11
- package/package.json +2 -2
- package/web/index.html +204 -0
- package/lib/fetchers/web.js +0 -29
- package/profiles/.gitkeep +0 -0
- package/profiles/sam-altman.md +0 -49
- package/skill/vip.md +0 -96
- package/tests/fetchers.test.js +0 -21
- package/tests/monitor.test.js +0 -28
- package/tests/profile.test.js +0 -89
- package/tests/resolver.test.js +0 -40
- package/tests/scheduler.test.js +0 -22
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# VIPCare
|
|
2
|
+
|
|
3
|
+
## What is this?
|
|
4
|
+
CLI tool that auto-builds VIP person profiles from Twitter, LinkedIn, and web search data, synthesized by AI into structured Markdown files.
|
|
5
|
+
|
|
6
|
+
## Tech Stack
|
|
7
|
+
- Node.js ESM, commander CLI
|
|
8
|
+
- No build step, no bundler
|
|
9
|
+
- Single dependency: commander
|
|
10
|
+
|
|
11
|
+
## Project Structure
|
|
12
|
+
```
|
|
13
|
+
bin/vip.js — CLI entry point, all command definitions
|
|
14
|
+
lib/
|
|
15
|
+
config.js — Config loading (~/.vip-crm/config.json), tool checks
|
|
16
|
+
profile.js — CRUD for profile Markdown files in profiles/
|
|
17
|
+
resolver.js — Parse input (name vs URL) into person object
|
|
18
|
+
fetchers/
|
|
19
|
+
twitter.js — Twitter data via bird CLI
|
|
20
|
+
search.js — Web search for person info
|
|
21
|
+
youtube.js — YouTube transcript via yt-dlp + whisper
|
|
22
|
+
synthesizer.js — AI synthesis (Claude CLI / Anthropic API / GitHub Copilot)
|
|
23
|
+
monitor.js — Change detection and changelog
|
|
24
|
+
scheduler.js — macOS launchd for auto-refresh
|
|
25
|
+
card.js — H5 baseball card generator
|
|
26
|
+
templates.js — Profile templates
|
|
27
|
+
tests/ — One test file per module
|
|
28
|
+
profiles/ — Generated profile Markdown files
|
|
29
|
+
web/ — Card HTML output
|
|
30
|
+
skill/ — Claude Code slash command
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Dev Commands
|
|
34
|
+
- `npm test` — run all tests (uses node:test with --experimental-test-module-mocks)
|
|
35
|
+
- `npm publish` — publish to npm (requires npm auth token)
|
|
36
|
+
- `node bin/vip.js <command>` — run CLI locally without installing
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
Input flows through a pipeline: **resolver** parses the input (name or URL) into a person object, **fetchers** (twitter, search, youtube) gather raw data from external sources, **synthesizer** sends the raw data to an AI backend to produce structured fields, **profile** saves the result as a Markdown file, and **card** can render profiles into an H5 baseball card page.
|
|
40
|
+
|
|
41
|
+
## Conventions
|
|
42
|
+
- All shell commands use `execFileSync` with arg arrays (never `execSync` with string interpolation) for security
|
|
43
|
+
- ESM modules (`"type": "module"` in package.json)
|
|
44
|
+
- Tests use node:test built-in runner with assert
|
|
45
|
+
- Profile data stored as Markdown files in profiles/
|
|
46
|
+
- Config at ~/.vip-crm/config.json
|
|
47
|
+
- AI backends: Claude CLI, Anthropic API, GitHub Copilot (auto-detected in that order)
|
|
48
|
+
|
|
49
|
+
## Adding a New Command
|
|
50
|
+
1. Add command definition in bin/vip.js using commander
|
|
51
|
+
2. Use existing helpers from lib/profile.js, lib/config.js
|
|
52
|
+
3. Add --json flag for scripting support
|
|
53
|
+
4. Add tests in tests/
|
|
54
|
+
|
|
55
|
+
## Common Pitfalls
|
|
56
|
+
- Must use --experimental-test-module-mocks flag for tests that mock modules
|
|
57
|
+
- YouTube transcription requires Python + yt-dlp + whisper (optional dependency)
|
|
58
|
+
- The `slugify` function returns 'unnamed' for empty/special-char-only inputs
|
package/README.md
CHANGED
|
@@ -42,6 +42,27 @@ vip rm sam-altman -y
|
|
|
42
42
|
vip update sam-altman
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | Description |
|
|
48
|
+
|---------|-------------|
|
|
49
|
+
| `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 show <name>` | Display a profile |
|
|
52
|
+
| `vip search <keyword>` | Search across all profiles |
|
|
53
|
+
| `vip edit <name>` | Edit profile fields (`--title`, `--company`, `--twitter`, `--linkedin`, `--note`) |
|
|
54
|
+
| `vip rm <name>` | Delete a profile (`-y` to confirm) |
|
|
55
|
+
| `vip update <name>` | Refresh a profile with latest data |
|
|
56
|
+
| `vip open <name>` | Open a profile in your editor |
|
|
57
|
+
| `vip youtube <name> <url>` | Add YouTube video transcript to a profile |
|
|
58
|
+
| `vip youtube-search <name>` | Search YouTube for a person's talks (`-n` max results) |
|
|
59
|
+
| `vip card` | Generate H5 baseball card page (`-o` output path) |
|
|
60
|
+
| `vip export` | Export all profiles for backup |
|
|
61
|
+
| `vip import` | Restore profiles from backup |
|
|
62
|
+
| `vip digest` | Show recent profile changes |
|
|
63
|
+
| `vip monitor start\|stop\|status\|run` | Manage automatic profile refresh |
|
|
64
|
+
| `vip config` | View settings |
|
|
65
|
+
|
|
45
66
|
## Features
|
|
46
67
|
|
|
47
68
|
- **Auto profile building** — Give a name or URL, get a structured profile
|
package/bin/vip.js
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import os from 'os';
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
7
|
import { checkTool, getProfilesDir, loadConfig, saveConfig } from '../lib/config.js';
|
|
8
|
-
import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles } from '../lib/profile.js';
|
|
8
|
+
import { deleteProfile, getProfilePath, listProfiles, loadProfile, profileExists, saveProfile, searchProfiles, slugify } from '../lib/profile.js';
|
|
9
9
|
import { isUrl, resolveFromName, resolveFromUrl } from '../lib/resolver.js';
|
|
10
10
|
import * as twitter from '../lib/fetchers/twitter.js';
|
|
11
11
|
import { searchPerson } from '../lib/fetchers/search.js';
|
|
12
12
|
import { synthesizeProfile, getBackendName } from '../lib/synthesizer.js';
|
|
13
|
-
import { readChangelog, runMonitor, unreadCount } from '../lib/monitor.js';
|
|
13
|
+
import { appendChangelog, readChangelog, runMonitor, unreadCount } from '../lib/monitor.js';
|
|
14
14
|
import { install, uninstall, status } from '../lib/scheduler.js';
|
|
15
|
+
import * as youtube from '../lib/fetchers/youtube.js';
|
|
16
|
+
import { generateCards, extractVipData } from '../lib/card.js';
|
|
15
17
|
|
|
16
18
|
// Colors
|
|
17
19
|
const c = {
|
|
@@ -73,7 +75,7 @@ try {
|
|
|
73
75
|
} catch {}
|
|
74
76
|
|
|
75
77
|
const program = new Command();
|
|
76
|
-
program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.
|
|
78
|
+
program.name('vip').description('VIP Profile Builder - Auto-build VIP person profiles from public data').version('0.2.0');
|
|
77
79
|
|
|
78
80
|
// --- add ---
|
|
79
81
|
program.command('add')
|
|
@@ -83,6 +85,7 @@ program.command('add')
|
|
|
83
85
|
.option('--dry-run', 'Print without saving')
|
|
84
86
|
.option('--no-ai', 'Skip AI synthesis')
|
|
85
87
|
.option('-f, --force', 'Overwrite existing')
|
|
88
|
+
.option('-y, --youtube <urls...>', 'YouTube video URLs to transcribe')
|
|
86
89
|
.action(async (query, opts) => {
|
|
87
90
|
console.log(c.cyan(`Resolving ${query}...`));
|
|
88
91
|
|
|
@@ -120,7 +123,29 @@ program.command('add')
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
console.log(c.cyan('Gathering data...'));
|
|
123
|
-
|
|
126
|
+
let [rawData, sources] = gatherData(person);
|
|
127
|
+
|
|
128
|
+
// YouTube transcription
|
|
129
|
+
if (opts.youtube?.length) {
|
|
130
|
+
if (!youtube.isAvailable()) {
|
|
131
|
+
console.log(c.yellow('YouTube transcriber not available. Skipping videos.'));
|
|
132
|
+
} else {
|
|
133
|
+
for (const ytUrl of opts.youtube) {
|
|
134
|
+
const stop = spinner(`Transcribing video: ${ytUrl}...`);
|
|
135
|
+
try {
|
|
136
|
+
const yt = youtube.transcribeVideo(ytUrl);
|
|
137
|
+
stop();
|
|
138
|
+
console.log(c.green(` Transcribed: ${yt.title}`));
|
|
139
|
+
rawData += `\n\n=== YouTube Video: ${yt.title} (${yt.url}) ===\n${yt.transcript}`;
|
|
140
|
+
sources.push(yt.url);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
stop();
|
|
143
|
+
console.log(c.yellow(` Failed: ${e.message}`));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
if (!rawData.trim()) { console.error(c.red('No data found.')); process.exit(1); }
|
|
125
150
|
|
|
126
151
|
let profile;
|
|
@@ -139,14 +164,27 @@ program.command('add')
|
|
|
139
164
|
} else {
|
|
140
165
|
const filepath = saveProfile(person.name, profile);
|
|
141
166
|
console.log(c.green(`\nProfile saved: ${filepath}`));
|
|
167
|
+
appendChangelog({
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
name: person.name,
|
|
170
|
+
slug: slugify(person.name),
|
|
171
|
+
type: 'created',
|
|
172
|
+
summary: `Profile created for ${person.name}`,
|
|
173
|
+
});
|
|
142
174
|
}
|
|
143
175
|
});
|
|
144
176
|
|
|
145
177
|
// --- list ---
|
|
146
178
|
program.command('list')
|
|
147
179
|
.description('List all VIP profiles')
|
|
148
|
-
.
|
|
180
|
+
.option('--json', 'Output as JSON')
|
|
181
|
+
.action((opts) => {
|
|
149
182
|
const profiles = listProfiles();
|
|
183
|
+
if (opts.json) {
|
|
184
|
+
const data = profiles.map(p => ({ slug: p.slug, name: p.name, summary: p.summary, updated: p.updated, path: p.path }));
|
|
185
|
+
console.log(JSON.stringify(data, null, 2));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
150
188
|
if (!profiles.length) { console.log(c.dim('No profiles yet. Use "vip add" to create one.')); return; }
|
|
151
189
|
|
|
152
190
|
const cols = process.stdout.columns || 80;
|
|
@@ -168,10 +206,21 @@ program.command('list')
|
|
|
168
206
|
program.command('show')
|
|
169
207
|
.description('Show a VIP profile')
|
|
170
208
|
.argument('<name>')
|
|
171
|
-
.
|
|
209
|
+
.option('--json', 'Output as JSON')
|
|
210
|
+
.action((name, opts) => {
|
|
172
211
|
const content = loadProfile(name);
|
|
173
212
|
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
174
213
|
|
|
214
|
+
if (opts.json) {
|
|
215
|
+
const slug = slugify(name);
|
|
216
|
+
const nameMatch = content.match(/^# (.+)$/m);
|
|
217
|
+
const profileName = nameMatch ? nameMatch[1] : name;
|
|
218
|
+
const vipData = extractVipData(content);
|
|
219
|
+
const data = { slug, name: profileName, content, vipData: vipData || null };
|
|
220
|
+
console.log(JSON.stringify(data, null, 2));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
175
224
|
for (const line of content.split('\n')) {
|
|
176
225
|
if (line.startsWith('# ')) console.log(c.bold(c.cyan(line)));
|
|
177
226
|
else if (line.startsWith('## ')) console.log(c.bold(c.green(line)));
|
|
@@ -185,8 +234,14 @@ program.command('show')
|
|
|
185
234
|
program.command('search')
|
|
186
235
|
.description('Search across all profiles')
|
|
187
236
|
.argument('<keyword>')
|
|
188
|
-
.
|
|
237
|
+
.option('--json', 'Output as JSON')
|
|
238
|
+
.action((keyword, opts) => {
|
|
189
239
|
const results = searchProfiles(keyword);
|
|
240
|
+
if (opts.json) {
|
|
241
|
+
const data = results.map(r => ({ slug: r.slug, name: r.name, matches: r.matches }));
|
|
242
|
+
console.log(JSON.stringify(data, null, 2));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
190
245
|
if (!results.length) { console.log(c.dim(`No matches for '${keyword}'.`)); return; }
|
|
191
246
|
|
|
192
247
|
console.log(c.green(`Found ${results.length} profile(s) matching '${keyword}':\n`));
|
|
@@ -205,7 +260,7 @@ program.command('open')
|
|
|
205
260
|
const p = getProfilePath(name);
|
|
206
261
|
if (!fs.existsSync(p)) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
207
262
|
const editor = process.env.EDITOR || 'open';
|
|
208
|
-
|
|
263
|
+
execFileSync(editor, [p], { stdio: 'inherit' });
|
|
209
264
|
});
|
|
210
265
|
|
|
211
266
|
// --- update ---
|
|
@@ -256,6 +311,16 @@ program.command('rm')
|
|
|
256
311
|
console.log(c.green(`Profile deleted: ${name}`));
|
|
257
312
|
});
|
|
258
313
|
|
|
314
|
+
function appendNote(content, note) {
|
|
315
|
+
if (content.includes('## Notes')) {
|
|
316
|
+
return content.replace('## Notes\n', `## Notes\n- ${note}\n`);
|
|
317
|
+
} else if (content.includes('\n---\n')) {
|
|
318
|
+
return content.replace('\n---\n', `\n## Notes\n- ${note}\n\n---\n`);
|
|
319
|
+
} else {
|
|
320
|
+
return content.trimEnd() + `\n\n## Notes\n- ${note}\n`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
259
324
|
// --- edit ---
|
|
260
325
|
program.command('edit')
|
|
261
326
|
.description('Edit profile fields')
|
|
@@ -271,14 +336,43 @@ program.command('edit')
|
|
|
271
336
|
|
|
272
337
|
let modified = false;
|
|
273
338
|
|
|
274
|
-
if (opts.title) {
|
|
275
|
-
|
|
339
|
+
if (opts.title) {
|
|
340
|
+
const before = content;
|
|
341
|
+
content = content.replace(/(\*\*Title:\*\*) .+/, `$1 ${opts.title}`);
|
|
342
|
+
if (content === before) {
|
|
343
|
+
console.log(c.yellow(` Warning: Could not find Title field to update. Adding to notes instead.`));
|
|
344
|
+
content = appendNote(content, `Title: ${opts.title}`);
|
|
345
|
+
}
|
|
346
|
+
modified = true;
|
|
347
|
+
}
|
|
348
|
+
if (opts.company) {
|
|
349
|
+
const before = content;
|
|
350
|
+
content = content.replace(/(\*\*Company:\*\*) .+/, `$1 ${opts.company}`);
|
|
351
|
+
if (content === before) {
|
|
352
|
+
console.log(c.yellow(` Warning: Could not find Company field to update. Adding to notes instead.`));
|
|
353
|
+
content = appendNote(content, `Company: ${opts.company}`);
|
|
354
|
+
}
|
|
355
|
+
modified = true;
|
|
356
|
+
}
|
|
276
357
|
if (opts.twitter) {
|
|
277
358
|
const handle = opts.twitter.replace(/^@/, '');
|
|
359
|
+
const before = content;
|
|
278
360
|
content = content.replace(/(Twitter:) .+/, `$1 https://twitter.com/${handle}`);
|
|
361
|
+
if (content === before) {
|
|
362
|
+
console.log(c.yellow(` Warning: Could not find Twitter field to update. Adding to notes instead.`));
|
|
363
|
+
content = appendNote(content, `Twitter: https://twitter.com/${handle}`);
|
|
364
|
+
}
|
|
365
|
+
modified = true;
|
|
366
|
+
}
|
|
367
|
+
if (opts.linkedin) {
|
|
368
|
+
const before = content;
|
|
369
|
+
content = content.replace(/(LinkedIn:) .+/, `$1 ${opts.linkedin}`);
|
|
370
|
+
if (content === before) {
|
|
371
|
+
console.log(c.yellow(` Warning: Could not find LinkedIn field to update. Adding to notes instead.`));
|
|
372
|
+
content = appendNote(content, `LinkedIn: ${opts.linkedin}`);
|
|
373
|
+
}
|
|
279
374
|
modified = true;
|
|
280
375
|
}
|
|
281
|
-
if (opts.linkedin) { content = content.replace(/(LinkedIn:) .+/, `$1 ${opts.linkedin}`); modified = true; }
|
|
282
376
|
if (opts.note) {
|
|
283
377
|
if (content.includes('## Notes')) {
|
|
284
378
|
content = content.replace('## Notes\n', `## Notes\n- ${opts.note}\n`);
|
|
@@ -294,6 +388,75 @@ program.command('edit')
|
|
|
294
388
|
else console.log(c.yellow('No changes. Use --title, --company, --twitter, --linkedin, or --note.'));
|
|
295
389
|
});
|
|
296
390
|
|
|
391
|
+
// --- youtube ---
|
|
392
|
+
program.command('youtube')
|
|
393
|
+
.description('Add YouTube video transcript to existing profile')
|
|
394
|
+
.argument('<name>', 'Profile name')
|
|
395
|
+
.argument('<url>', 'YouTube video URL')
|
|
396
|
+
.action(async (name, url) => {
|
|
397
|
+
const content = loadProfile(name);
|
|
398
|
+
if (!content) { console.error(c.red(`Profile not found: ${name}`)); process.exit(1); }
|
|
399
|
+
|
|
400
|
+
if (!youtube.isAvailable()) {
|
|
401
|
+
console.error(c.red('YouTube transcriber not available.'));
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const stop = spinner(`Transcribing: ${url}...`);
|
|
406
|
+
let yt;
|
|
407
|
+
try { yt = youtube.transcribeVideo(url); } finally { stop(); }
|
|
408
|
+
console.log(c.green(` Transcribed: ${yt.title}`));
|
|
409
|
+
|
|
410
|
+
const { extractMetadata } = await import('../lib/monitor.js');
|
|
411
|
+
const meta = extractMetadata(content);
|
|
412
|
+
|
|
413
|
+
// Combine existing profile data with new transcript
|
|
414
|
+
const rawData = content + `\n\n=== YouTube Video: ${yt.title} (${yt.url}) ===\n${yt.transcript}`;
|
|
415
|
+
const sources = [yt.url];
|
|
416
|
+
|
|
417
|
+
const stop2 = spinner('Re-synthesizing profile...');
|
|
418
|
+
let profile;
|
|
419
|
+
try { profile = await synthesizeProfile(rawData, sources); } finally { stop2(); }
|
|
420
|
+
|
|
421
|
+
const filepath = saveProfile(meta.name || name, profile);
|
|
422
|
+
console.log(c.green(`Profile updated: ${filepath}`));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// --- youtube-search ---
|
|
426
|
+
program.command('youtube-search')
|
|
427
|
+
.description('Find YouTube videos for a person')
|
|
428
|
+
.argument('<name>', 'Person name')
|
|
429
|
+
.option('-n, --count <n>', 'Max results', '5')
|
|
430
|
+
.action((name, opts) => {
|
|
431
|
+
console.log(c.cyan(`Searching YouTube for ${name}...`));
|
|
432
|
+
const results = youtube.searchYouTubeVideos(name, parseInt(opts.count));
|
|
433
|
+
|
|
434
|
+
if (!results.length) { console.log(c.dim('No YouTube videos found.')); return; }
|
|
435
|
+
|
|
436
|
+
console.log(c.green(`Found ${results.length} video(s):\n`));
|
|
437
|
+
results.forEach((r, i) => {
|
|
438
|
+
console.log(` ${c.bold(c.cyan(`${i + 1}.`))} ${r.title}`);
|
|
439
|
+
console.log(c.dim(` ${r.url}`));
|
|
440
|
+
if (r.body) console.log(c.dim(` ${r.body.slice(0, 100)}`));
|
|
441
|
+
console.log();
|
|
442
|
+
});
|
|
443
|
+
console.log(c.dim('Use: vip youtube <name> <url> to transcribe and add to profile'));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// --- card ---
|
|
447
|
+
program.command('card')
|
|
448
|
+
.description('Generate H5 baseball card page from all profiles')
|
|
449
|
+
.option('-o, --output <path>', 'Output HTML file', 'web/index.html')
|
|
450
|
+
.action((opts) => {
|
|
451
|
+
console.log(c.cyan('Generating baseball cards...'));
|
|
452
|
+
const profiles = listProfiles();
|
|
453
|
+
if (!profiles.length) { console.log(c.dim('No profiles. Use "vip add" first.')); return; }
|
|
454
|
+
|
|
455
|
+
const outputPath = generateCards(profiles, opts.output);
|
|
456
|
+
console.log(c.green(`Cards generated: ${outputPath}`));
|
|
457
|
+
console.log(c.dim(`Open in browser: open ${outputPath}`));
|
|
458
|
+
});
|
|
459
|
+
|
|
297
460
|
// --- digest ---
|
|
298
461
|
program.command('digest')
|
|
299
462
|
.description('Show recent changes')
|
|
@@ -303,7 +466,8 @@ program.command('digest')
|
|
|
303
466
|
|
|
304
467
|
console.log(c.bold(c.cyan('Changes in the last 30 days:\n')));
|
|
305
468
|
for (const e of entries.reverse()) {
|
|
306
|
-
|
|
469
|
+
const label = e.type === 'created' ? c.cyan('[created]') : c.green('[updated]');
|
|
470
|
+
console.log(` ${label} [${(e.timestamp || '').slice(0, 10)}] ${e.name}`);
|
|
307
471
|
console.log(` ${e.summary}`);
|
|
308
472
|
console.log();
|
|
309
473
|
}
|
|
@@ -348,4 +512,79 @@ program.command('config')
|
|
|
348
512
|
console.log(` AI backend: ${(() => { const b = getBackendName(); return b !== 'none' ? c.green(b) : c.red('not found'); })()}`);
|
|
349
513
|
});
|
|
350
514
|
|
|
351
|
-
|
|
515
|
+
// --- export ---
|
|
516
|
+
program.command('export')
|
|
517
|
+
.description('Export all profiles as JSON')
|
|
518
|
+
.option('-o, --output <file>', 'Write JSON to file instead of stdout')
|
|
519
|
+
.action((opts) => {
|
|
520
|
+
const profiles = listProfiles();
|
|
521
|
+
if (!profiles.length) { console.error(c.red('No profiles to export.')); process.exit(1); }
|
|
522
|
+
|
|
523
|
+
const exported = profiles.map(p => {
|
|
524
|
+
const content = loadProfile(p.slug);
|
|
525
|
+
const vipData = content ? extractVipData(content) : null;
|
|
526
|
+
return {
|
|
527
|
+
slug: p.slug,
|
|
528
|
+
filePath: p.path,
|
|
529
|
+
name: p.name,
|
|
530
|
+
summary: p.summary,
|
|
531
|
+
updated: p.updated,
|
|
532
|
+
vipData: vipData || null,
|
|
533
|
+
content: content || '',
|
|
534
|
+
exportedAt: new Date().toISOString(),
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const json = JSON.stringify(exported, null, 2);
|
|
539
|
+
|
|
540
|
+
if (opts.output) {
|
|
541
|
+
fs.writeFileSync(opts.output, json, 'utf-8');
|
|
542
|
+
console.log(c.green(`Exported ${exported.length} profile(s) to ${opts.output}`));
|
|
543
|
+
} else {
|
|
544
|
+
process.stdout.write(json + '\n');
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// --- import ---
|
|
549
|
+
program.command('import')
|
|
550
|
+
.description('Import profiles from a JSON export file')
|
|
551
|
+
.argument('<file>', 'JSON file to import')
|
|
552
|
+
.option('-f, --force', 'Overwrite existing profiles')
|
|
553
|
+
.action((file, opts) => {
|
|
554
|
+
if (!fs.existsSync(file)) { console.error(c.red(`File not found: ${file}`)); process.exit(1); }
|
|
555
|
+
|
|
556
|
+
let data;
|
|
557
|
+
try {
|
|
558
|
+
data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error(c.red(`Invalid JSON: ${e.message}`));
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!Array.isArray(data)) { console.error(c.red('Expected a JSON array of profiles.')); process.exit(1); }
|
|
565
|
+
|
|
566
|
+
let imported = 0;
|
|
567
|
+
let skipped = 0;
|
|
568
|
+
for (const entry of data) {
|
|
569
|
+
if (!entry.content || !entry.slug) {
|
|
570
|
+
console.log(c.yellow(` Skipping entry: missing content or slug`));
|
|
571
|
+
skipped++;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const name = entry.name || entry.slug;
|
|
576
|
+
if (!opts.force && profileExists(name)) {
|
|
577
|
+
console.log(c.yellow(` Skipping '${name}': already exists (use -f to overwrite)`));
|
|
578
|
+
skipped++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
saveProfile(name, entry.content);
|
|
583
|
+
imported++;
|
|
584
|
+
console.log(c.green(` Imported: ${name}`));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
console.log(`\nDone: ${imported} imported, ${skipped} skipped.`);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
program.parseAsync();
|