@hasna/contacts 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dashboard/dist/assets/index-0l6aQb1t.css +1 -0
  2. package/dashboard/dist/assets/index-opnZdkVD.js +229 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/cli/index.js +531 -17
  5. package/dist/db/companies.d.ts.map +1 -1
  6. package/dist/db/companies.test.d.ts +2 -0
  7. package/dist/db/companies.test.d.ts.map +1 -0
  8. package/dist/db/contacts.d.ts +1 -0
  9. package/dist/db/contacts.d.ts.map +1 -1
  10. package/dist/db/contacts.test.d.ts +2 -0
  11. package/dist/db/contacts.test.d.ts.map +1 -0
  12. package/dist/db/database.d.ts +1 -0
  13. package/dist/db/database.d.ts.map +1 -1
  14. package/dist/db/groups.d.ts +12 -0
  15. package/dist/db/groups.d.ts.map +1 -0
  16. package/dist/db/relationships.test.d.ts +2 -0
  17. package/dist/db/relationships.test.d.ts.map +1 -0
  18. package/dist/db/tags.d.ts.map +1 -1
  19. package/dist/db/tags.test.d.ts +2 -0
  20. package/dist/db/tags.test.d.ts.map +1 -0
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +102 -5
  24. package/dist/lib/config.d.ts +7 -0
  25. package/dist/lib/config.d.ts.map +1 -0
  26. package/dist/lib/dedup.d.ts +10 -0
  27. package/dist/lib/dedup.d.ts.map +1 -0
  28. package/dist/lib/import.test.d.ts +2 -0
  29. package/dist/lib/import.test.d.ts.map +1 -0
  30. package/dist/mcp/index.js +567 -76
  31. package/dist/mcp/mcp.test.d.ts +2 -0
  32. package/dist/mcp/mcp.test.d.ts.map +1 -0
  33. package/dist/server/index.js +38 -3
  34. package/dist/types/index.d.ts +25 -0
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/dashboard/dist/assets/index-B4ndI7Qt.js +0 -49
  38. package/dashboard/dist/assets/index-C5bn2HWO.css +0 -1
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Open Contacts</title>
7
- <script type="module" crossorigin src="/assets/index-B4ndI7Qt.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-C5bn2HWO.css">
7
+ <script type="module" crossorigin src="/assets/index-opnZdkVD.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-0l6aQb1t.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/dist/cli/index.js CHANGED
@@ -2093,6 +2093,14 @@ var init_types = __esm(() => {
2093
2093
  });
2094
2094
 
2095
2095
  // src/db/database.ts
2096
+ var exports_database = {};
2097
+ __export(exports_database, {
2098
+ uuid: () => uuid,
2099
+ resetDatabase: () => resetDatabase,
2100
+ now: () => now,
2101
+ getDbPath: () => getDbPath,
2102
+ getDatabase: () => getDatabase
2103
+ });
2096
2104
  import { Database } from "bun:sqlite";
2097
2105
  import { existsSync, mkdirSync } from "fs";
2098
2106
  import { dirname, join, resolve } from "path";
@@ -2121,6 +2129,9 @@ function getDatabase(path) {
2121
2129
  _db = db;
2122
2130
  return db;
2123
2131
  }
2132
+ function resetDatabase() {
2133
+ _db = null;
2134
+ }
2124
2135
  function uuid() {
2125
2136
  return crypto.randomUUID();
2126
2137
  }
@@ -2303,6 +2314,25 @@ var init_database = __esm(() => {
2303
2314
  END;
2304
2315
 
2305
2316
  CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
2317
+ `,
2318
+ `
2319
+ ALTER TABLE contacts ADD COLUMN last_contacted_at TEXT;
2320
+ ALTER TABLE contacts ADD COLUMN website TEXT;
2321
+ ALTER TABLE contacts ADD COLUMN preferred_contact_method TEXT;
2322
+
2323
+ CREATE TABLE IF NOT EXISTS groups (
2324
+ id TEXT PRIMARY KEY,
2325
+ name TEXT NOT NULL UNIQUE,
2326
+ description TEXT,
2327
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2328
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2329
+ );
2330
+
2331
+ CREATE TABLE IF NOT EXISTS contact_groups (
2332
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2333
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
2334
+ PRIMARY KEY (contact_id, group_id)
2335
+ );
2306
2336
  `
2307
2337
  ];
2308
2338
  });
@@ -2322,7 +2352,8 @@ function rowToContact(row) {
2322
2352
  return {
2323
2353
  ...row,
2324
2354
  source: row.source,
2325
- custom_fields: JSON.parse(row.custom_fields || "{}")
2355
+ custom_fields: JSON.parse(row.custom_fields || "{}"),
2356
+ preferred_contact_method: row.preferred_contact_method ?? null
2326
2357
  };
2327
2358
  }
2328
2359
  function rowToEmail(row) {
@@ -2403,8 +2434,8 @@ function createContact(input, db) {
2403
2434
  const firstName = input.first_name ?? "";
2404
2435
  const lastName = input.last_name ?? "";
2405
2436
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
2406
- d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, created_at, updated_at)
2407
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
2437
+ d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, created_at, updated_at)
2438
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
2408
2439
  id,
2409
2440
  firstName,
2410
2441
  lastName,
@@ -2417,6 +2448,9 @@ function createContact(input, db) {
2417
2448
  input.job_title ?? null,
2418
2449
  input.source ?? "manual",
2419
2450
  JSON.stringify(input.custom_fields ?? {}),
2451
+ input.last_contacted_at ?? null,
2452
+ input.website ?? null,
2453
+ input.preferred_contact_method ?? null,
2420
2454
  timestamp,
2421
2455
  timestamp
2422
2456
  ]);
@@ -2528,6 +2562,18 @@ function updateContact(id, input, db) {
2528
2562
  setClauses.push("custom_fields = ?");
2529
2563
  params.push(JSON.stringify(input.custom_fields));
2530
2564
  }
2565
+ if (input.last_contacted_at !== undefined) {
2566
+ setClauses.push("last_contacted_at = ?");
2567
+ params.push(input.last_contacted_at);
2568
+ }
2569
+ if (input.website !== undefined) {
2570
+ setClauses.push("website = ?");
2571
+ params.push(input.website);
2572
+ }
2573
+ if (input.preferred_contact_method !== undefined) {
2574
+ setClauses.push("preferred_contact_method = ?");
2575
+ params.push(input.preferred_contact_method);
2576
+ }
2531
2577
  params.push(id);
2532
2578
  d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
2533
2579
  logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
@@ -2573,6 +2619,11 @@ function searchContacts(query, db) {
2573
2619
  }
2574
2620
  return allRows.map((row) => loadContactDetails(d, rowToContact(row)));
2575
2621
  }
2622
+ function listRecentContacts(limit, db) {
2623
+ const d = db || getDatabase();
2624
+ const rows = d.query(`SELECT * FROM contacts ORDER BY updated_at DESC LIMIT ?`).all(limit);
2625
+ return rows.map((row) => loadContactDetails(d, rowToContact(row)));
2626
+ }
2576
2627
  var init_contacts = __esm(() => {
2577
2628
  init_types();
2578
2629
  init_database();
@@ -3256,8 +3307,8 @@ var exports_serve = {};
3256
3307
  __export(exports_serve, {
3257
3308
  startServer: () => startServer
3258
3309
  });
3259
- import { existsSync as existsSync2 } from "fs";
3260
- import { join as join2 } from "path";
3310
+ import { existsSync as existsSync3 } from "fs";
3311
+ import { join as join3 } from "path";
3261
3312
  function json(data, status = 200) {
3262
3313
  return new Response(JSON.stringify(data), {
3263
3314
  status,
@@ -3463,7 +3514,7 @@ async function handleExport(req) {
3463
3514
  });
3464
3515
  }
3465
3516
  function serveStaticFile(filePath) {
3466
- if (!existsSync2(filePath))
3517
+ if (!existsSync3(filePath))
3467
3518
  return null;
3468
3519
  return new Response(Bun.file(filePath));
3469
3520
  }
@@ -3507,8 +3558,8 @@ function startServer(port) {
3507
3558
  response = apiError("Not found", 404);
3508
3559
  }
3509
3560
  } else {
3510
- const filePath = join2(DASHBOARD_DIST, url.pathname === "/" ? "index.html" : url.pathname);
3511
- response = serveStaticFile(filePath) ?? serveStaticFile(join2(DASHBOARD_DIST, "index.html")) ?? new Response("Not Found", { status: 404 });
3561
+ const filePath = join3(DASHBOARD_DIST, url.pathname === "/" ? "index.html" : url.pathname);
3562
+ response = serveStaticFile(filePath) ?? serveStaticFile(join3(DASHBOARD_DIST, "index.html")) ?? new Response("Not Found", { status: 404 });
3512
3563
  }
3513
3564
  } catch (err) {
3514
3565
  console.error("Request error:", err);
@@ -3529,7 +3580,7 @@ var init_serve = __esm(() => {
3529
3580
  init_contacts();
3530
3581
  init_companies();
3531
3582
  init_tags();
3532
- DASHBOARD_DIST = join2(import.meta.dir, "../../dashboard/dist");
3583
+ DASHBOARD_DIST = join3(import.meta.dir, "../../dashboard/dist");
3533
3584
  });
3534
3585
 
3535
3586
  // node_modules/commander/esm.mjs
@@ -3553,8 +3604,85 @@ init_contacts();
3553
3604
  init_companies();
3554
3605
  init_tags();
3555
3606
  import chalk from "chalk";
3556
- import { readFileSync, writeFileSync, existsSync as existsSync3 } from "fs";
3557
- import { extname } from "path";
3607
+
3608
+ // src/db/groups.ts
3609
+ init_database();
3610
+ function createGroup(db, input) {
3611
+ const id = uuid();
3612
+ db.query(`INSERT INTO groups(id, name, description, created_at, updated_at) VALUES(?,?,?,?,?)`).run(id, input.name, input.description ?? null, now(), now());
3613
+ return getGroup(db, id);
3614
+ }
3615
+ function getGroup(db, id) {
3616
+ return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
3617
+ }
3618
+ function listGroups(db) {
3619
+ return db.query(`SELECT g.*, COUNT(cg.contact_id) as member_count FROM groups g LEFT JOIN contact_groups cg ON g.id = cg.group_id GROUP BY g.id ORDER BY g.name`).all();
3620
+ }
3621
+ function addContactToGroup(db, contactId, groupId) {
3622
+ db.query(`INSERT OR IGNORE INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
3623
+ }
3624
+ function removeContactFromGroup(db, contactId, groupId) {
3625
+ db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
3626
+ }
3627
+ function listContactsInGroup(db, groupId) {
3628
+ const rows = db.query(`SELECT contact_id FROM contact_groups WHERE group_id = ?`).all(groupId);
3629
+ return rows.map((r) => r.contact_id);
3630
+ }
3631
+
3632
+ // src/cli/index.tsx
3633
+ init_database();
3634
+
3635
+ // src/lib/dedup.ts
3636
+ function findEmailDuplicates(db) {
3637
+ const rows = db.query(`
3638
+ SELECT e.address as email, GROUP_CONCAT(e.contact_id) as ids
3639
+ FROM emails e
3640
+ WHERE e.contact_id IS NOT NULL
3641
+ GROUP BY LOWER(e.address)
3642
+ HAVING COUNT(*) > 1
3643
+ `).all();
3644
+ return rows.map((r) => ({ email: r.email, contact_ids: r.ids.split(",") }));
3645
+ }
3646
+ function levenshtein(a, b) {
3647
+ const m = a.length, n = b.length;
3648
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
3649
+ for (let i = 1;i <= m; i++)
3650
+ for (let j = 1;j <= n; j++)
3651
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
3652
+ return dp[m][n];
3653
+ }
3654
+ function findNameDuplicates(db) {
3655
+ const contacts = db.query(`SELECT id, display_name FROM contacts`).all();
3656
+ const pairs = [];
3657
+ for (let i = 0;i < contacts.length; i++) {
3658
+ for (let j = i + 1;j < contacts.length; j++) {
3659
+ const dist = levenshtein(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
3660
+ if (dist <= 2 && dist > 0) {
3661
+ pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
3662
+ }
3663
+ }
3664
+ }
3665
+ return pairs;
3666
+ }
3667
+
3668
+ // src/lib/config.ts
3669
+ import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
3670
+ import { join as join2 } from "path";
3671
+ var CONFIG_DIR = join2(process.env["HOME"] || "~", ".contacts");
3672
+ var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
3673
+ function readConfig() {
3674
+ if (!existsSync2(CONFIG_FILE))
3675
+ return {};
3676
+ try {
3677
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
3678
+ } catch {
3679
+ return {};
3680
+ }
3681
+ }
3682
+
3683
+ // src/cli/index.tsx
3684
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync4, copyFileSync, statSync, mkdirSync as mkdirSync3, readdirSync } from "fs";
3685
+ import { extname, join as join4 } from "path";
3558
3686
  function renderTable(headers, rows) {
3559
3687
  const colWidths = headers.map((h) => h.length);
3560
3688
  for (const row of rows) {
@@ -3659,8 +3787,44 @@ async function confirm(question) {
3659
3787
  const answer = await prompt(question + " [y/N]");
3660
3788
  return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
3661
3789
  }
3662
- program.name("contacts").description("Open Contacts \u2014 contact management for AI coding agents").version("0.1.0");
3663
- program.command("add").description("Add a new contact interactively").action(async () => {
3790
+ program.name("contacts").description("Open Contacts \u2014 contact management for AI coding agents").version("0.2.1");
3791
+ function collect(val, prev) {
3792
+ return [...prev, val];
3793
+ }
3794
+ program.command("add").description("Add a new contact (interactive or via flags)").option("--first <name>", "First name").option("--last <name>", "Last name").option("--display <name>", "Display name").option("--email <email>", "Email address").option("--phone <phone>", "Phone number").option("--title <title>", "Job title").option("--company <id>", "Company ID").option("--tag <tag>", "Tag name (can specify multiple times)", collect, []).option("--note <text>", "Notes").option("--website <url>", "Website URL").action(async (opts) => {
3795
+ if (opts.first || opts.last || opts.display) {
3796
+ const firstName = opts.first ?? "";
3797
+ const lastName = opts.last ?? "";
3798
+ const displayName = opts.display ?? (`${firstName} ${lastName}`.trim() || "Unnamed Contact");
3799
+ const input2 = {
3800
+ display_name: displayName,
3801
+ first_name: firstName || undefined,
3802
+ last_name: lastName || undefined,
3803
+ job_title: opts.title || undefined,
3804
+ notes: opts.note || undefined,
3805
+ website: opts.website || undefined,
3806
+ company_id: opts.company || undefined,
3807
+ emails: opts.email ? [{ address: opts.email, type: "work", is_primary: true }] : undefined,
3808
+ phones: opts.phone ? [{ number: opts.phone, type: "mobile", is_primary: true }] : undefined
3809
+ };
3810
+ const contact2 = createContact(input2);
3811
+ if (opts.tag.length > 0) {
3812
+ const db = getDatabase();
3813
+ const allTags = listTags();
3814
+ for (const tagName of opts.tag) {
3815
+ const tag = allTags.find((t) => t.name === tagName);
3816
+ if (tag) {
3817
+ db.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [contact2.id, tag.id]);
3818
+ } else {
3819
+ console.log(chalk.yellow(` ! Tag not found: ${tagName} (skipped)`));
3820
+ }
3821
+ }
3822
+ }
3823
+ console.log(chalk.green(`
3824
+ \u2713 Contact created: ${contact2.display_name} (${contact2.id})
3825
+ `));
3826
+ return;
3827
+ }
3664
3828
  console.log(chalk.bold.blue(`
3665
3829
  Add New Contact
3666
3830
  `));
@@ -3718,8 +3882,40 @@ program.command("show <id>").description("Show full contact details").action((id
3718
3882
  const contact = getContact(id);
3719
3883
  formatContact(contact);
3720
3884
  });
3721
- program.command("edit <id>").description("Edit a contact interactively").action(async (id) => {
3885
+ program.command("edit <id>").description("Edit a contact (interactive or via flags)").option("--first <name>", "First name").option("--last <name>", "Last name").option("--display <name>", "Display name").option("--email <email>", "Email address").option("--phone <phone>", "Phone number").option("--title <title>", "Job title").option("--note <text>", "Notes").option("--website <url>", "Website URL").action(async (id, opts) => {
3722
3886
  const contact = getContact(id);
3887
+ const hasFlags = opts.first || opts.last || opts.display || opts.email || opts.phone || opts.title || opts.note || opts.website;
3888
+ if (hasFlags) {
3889
+ const updates2 = {};
3890
+ if (opts.first !== undefined)
3891
+ updates2.first_name = opts.first;
3892
+ if (opts.last !== undefined)
3893
+ updates2.last_name = opts.last;
3894
+ if (opts.display !== undefined)
3895
+ updates2.display_name = opts.display;
3896
+ if (opts.title !== undefined)
3897
+ updates2.job_title = opts.title;
3898
+ if (opts.note !== undefined)
3899
+ updates2.notes = opts.note;
3900
+ if (opts.website !== undefined)
3901
+ updates2.website = opts.website;
3902
+ const updated2 = updateContact(id, updates2);
3903
+ if (opts.email) {
3904
+ const db = getDatabase();
3905
+ const { uuid: uuid2 } = await Promise.resolve().then(() => (init_database(), exports_database));
3906
+ db.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, 'work', 0)`, [uuid2(), id, opts.email]);
3907
+ }
3908
+ if (opts.phone) {
3909
+ const db = getDatabase();
3910
+ const { uuid: uuid2 } = await Promise.resolve().then(() => (init_database(), exports_database));
3911
+ db.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, NULL, 'mobile', 0)`, [uuid2(), id, opts.phone]);
3912
+ }
3913
+ console.log(chalk.green(`
3914
+ \u2713 Contact updated: ${updated2.display_name}
3915
+ `));
3916
+ formatContact(getContact(id));
3917
+ return;
3918
+ }
3723
3919
  console.log(chalk.bold.blue(`
3724
3920
  Editing: ${contact.display_name}
3725
3921
  `));
@@ -3729,6 +3925,7 @@ Editing: ${contact.display_name}
3729
3925
  const first_name = await prompt(`First name [${contact.first_name}]:`);
3730
3926
  const last_name = await prompt(`Last name [${contact.last_name}]:`);
3731
3927
  const job_title = await prompt(`Job title [${contact.job_title ?? ""}]:`);
3928
+ const website = await prompt(`Website [${contact.website ?? ""}]:`);
3732
3929
  const notes = await prompt(`Notes [${contact.notes ? contact.notes.slice(0, 30) + "..." : ""}]:`);
3733
3930
  const updates = {};
3734
3931
  if (display_name)
@@ -3739,6 +3936,8 @@ Editing: ${contact.display_name}
3739
3936
  updates.last_name = last_name;
3740
3937
  if (job_title)
3741
3938
  updates.job_title = job_title;
3939
+ if (website)
3940
+ updates.website = website;
3742
3941
  if (notes)
3743
3942
  updates.notes = notes;
3744
3943
  if (Object.keys(updates).length === 0) {
@@ -3894,7 +4093,7 @@ Add New Tag
3894
4093
  `));
3895
4094
  });
3896
4095
  program.command("import <file>").description("Import contacts from CSV, vCard (.vcf), or JSON file").action(async (file) => {
3897
- if (!existsSync3(file)) {
4096
+ if (!existsSync4(file)) {
3898
4097
  console.error(chalk.red(`
3899
4098
  File not found: ${file}
3900
4099
  `));
@@ -3914,7 +4113,7 @@ Unsupported file type: ${ext}. Use .csv, .vcf, or .json
3914
4113
  `));
3915
4114
  process.exit(1);
3916
4115
  }
3917
- const data = readFileSync(file, "utf8");
4116
+ const data = readFileSync2(file, "utf8");
3918
4117
  console.log(chalk.blue(`
3919
4118
  Importing ${format.toUpperCase()} from ${file}...
3920
4119
  `));
@@ -3945,7 +4144,7 @@ Invalid format: ${format}. Use csv, vcf, or json
3945
4144
  const { contacts } = listContacts({ limit: 1e5 });
3946
4145
  const output = await exportContacts(format, contacts);
3947
4146
  if (opts.output) {
3948
- writeFileSync(opts.output, output, "utf8");
4147
+ writeFileSync2(opts.output, output, "utf8");
3949
4148
  console.log(chalk.green(`
3950
4149
  \u2713 Exported ${contacts.length} contact(s) to ${opts.output}
3951
4150
  `));
@@ -3989,4 +4188,319 @@ ${chalk.bold("Available tools (24 total):")}
3989
4188
  ${chalk.gray("I/O: ")}${chalk.white("import_contacts export_contacts get_stats")}
3990
4189
  `);
3991
4190
  });
4191
+ program.command("open [id]").description("Open the web dashboard in browser").action(async (id) => {
4192
+ const port = 19428;
4193
+ const url = id ? `http://localhost:${port}/#/contacts/${id}` : `http://localhost:${port}`;
4194
+ const platform = process.platform;
4195
+ const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
4196
+ const proc = Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] });
4197
+ await proc.exited;
4198
+ console.log(chalk.green(`Opening ${url}`));
4199
+ });
4200
+ program.command("recent").description("Show recently added or modified contacts").option("--limit <n>", "Number to show", "10").action((opts) => {
4201
+ const limit = parseInt(opts.limit, 10);
4202
+ const contacts = listRecentContacts(limit);
4203
+ if (contacts.length === 0) {
4204
+ console.log(chalk.gray(`
4205
+ No contacts found.
4206
+ `));
4207
+ return;
4208
+ }
4209
+ console.log();
4210
+ const rows = contacts.map((c) => ({
4211
+ Name: c.display_name,
4212
+ Company: c.company?.name ?? "",
4213
+ Email: c.emails?.[0]?.address ?? "",
4214
+ Phone: c.phones?.[0]?.number ?? "",
4215
+ Updated: c.updated_at.slice(0, 10)
4216
+ }));
4217
+ renderTable(["Name", "Company", "Email", "Phone", "Updated"], rows);
4218
+ console.log(chalk.gray(`
4219
+ ${contacts.length} recent contact(s)
4220
+ `));
4221
+ });
4222
+ program.command("dupe").description("Find potential duplicate contacts").action(() => {
4223
+ const db = getDatabase();
4224
+ const emailDupes = findEmailDuplicates(db);
4225
+ const nameDupes = findNameDuplicates(db);
4226
+ let total = 0;
4227
+ if (emailDupes.length > 0) {
4228
+ console.log(chalk.bold.yellow(`
4229
+ Duplicate Emails:
4230
+ `));
4231
+ for (const group of emailDupes) {
4232
+ console.log(` ${chalk.cyan(group.email)}`);
4233
+ for (const cid of group.contact_ids) {
4234
+ try {
4235
+ const c = getContact(cid);
4236
+ console.log(` ${chalk.gray(cid)} ${c.display_name}`);
4237
+ } catch {
4238
+ console.log(` ${chalk.gray(cid)} (not found)`);
4239
+ }
4240
+ }
4241
+ console.log();
4242
+ total++;
4243
+ }
4244
+ }
4245
+ if (nameDupes.length > 0) {
4246
+ console.log(chalk.bold.yellow(`Similar Names:
4247
+ `));
4248
+ for (const pair of nameDupes) {
4249
+ try {
4250
+ const a = getContact(pair.contact_ids[0]);
4251
+ const b = getContact(pair.contact_ids[1]);
4252
+ console.log(` ${chalk.magenta(a.display_name)} \u2194 ${chalk.magenta(b.display_name)} ${chalk.gray(`(distance: ${pair.similarity})`)}`);
4253
+ console.log(` ${chalk.gray(pair.contact_ids[0])} vs ${chalk.gray(pair.contact_ids[1])}`);
4254
+ console.log();
4255
+ total++;
4256
+ } catch {}
4257
+ }
4258
+ }
4259
+ if (total === 0) {
4260
+ console.log(chalk.green(`
4261
+ No duplicates found.
4262
+ `));
4263
+ } else {
4264
+ console.log(chalk.gray(`Found ${total} duplicate group(s). Use 'contacts show <id>' to inspect and 'contacts delete <id>' to clean up.
4265
+ `));
4266
+ }
4267
+ });
4268
+ program.command("log <id>").description("Log a contact interaction (sets last_contacted_at)").option("--note <text>", "Note to append").option("--date <YYYY-MM-DD>", "Date of contact (default: today)").action((id, opts) => {
4269
+ const contact = getContact(id);
4270
+ const date = opts.date ?? new Date().toISOString().slice(0, 10);
4271
+ const updates = {
4272
+ last_contacted_at: date
4273
+ };
4274
+ if (opts.note) {
4275
+ const existing = contact.notes ?? "";
4276
+ const separator = existing ? `
4277
+ ` : "";
4278
+ updates.notes = `${existing}${separator}[${date}] ${opts.note}`;
4279
+ }
4280
+ const updated = updateContact(id, updates);
4281
+ console.log(chalk.green(`
4282
+ \u2713 Logged contact with ${updated.display_name} on ${date}
4283
+ `));
4284
+ if (opts.note) {
4285
+ console.log(chalk.gray(` Note: ${opts.note}
4286
+ `));
4287
+ }
4288
+ });
4289
+ var groupsCmd = program.command("groups").description("Manage contact groups").action(() => {
4290
+ const db = getDatabase();
4291
+ const groups = listGroups(db);
4292
+ if (groups.length === 0) {
4293
+ console.log(chalk.gray(`
4294
+ No groups found.
4295
+ `));
4296
+ return;
4297
+ }
4298
+ console.log();
4299
+ const rows = groups.map((g) => ({
4300
+ ID: g.id,
4301
+ Name: g.name,
4302
+ Description: g.description ?? "",
4303
+ Members: String(g.member_count ?? 0)
4304
+ }));
4305
+ renderTable(["ID", "Name", "Description", "Members"], rows);
4306
+ console.log(chalk.gray(`
4307
+ ${groups.length} group(s)
4308
+ `));
4309
+ });
4310
+ groupsCmd.command("add").description("Create a new group").option("--name <name>", "Group name (required)").option("--description <desc>", "Description").action(async (opts) => {
4311
+ const db = getDatabase();
4312
+ let name = opts.name;
4313
+ if (!name) {
4314
+ name = await prompt("Group name (required):");
4315
+ if (!name) {
4316
+ console.error(chalk.red("Group name is required."));
4317
+ process.exit(1);
4318
+ }
4319
+ }
4320
+ const group = createGroup(db, { name, description: opts.description });
4321
+ console.log(chalk.green(`
4322
+ \u2713 Group created: ${group.name} (${group.id})
4323
+ `));
4324
+ });
4325
+ groupsCmd.command("show <id>").description("Show group details with members").action((id) => {
4326
+ const db = getDatabase();
4327
+ const group = getGroup(db, id);
4328
+ if (!group) {
4329
+ console.error(chalk.red(`
4330
+ Group not found: ${id}
4331
+ `));
4332
+ process.exit(1);
4333
+ }
4334
+ console.log(`
4335
+ ` + chalk.bold.blue("\u2501\u2501\u2501 Group: ") + chalk.bold(group.name) + chalk.bold.blue(" \u2501\u2501\u2501"));
4336
+ if (group.description)
4337
+ console.log(chalk.gray(" Description: ") + group.description);
4338
+ console.log(chalk.gray(` ID: ${group.id}`));
4339
+ console.log();
4340
+ const memberIds = listContactsInGroup(db, id);
4341
+ if (memberIds.length === 0) {
4342
+ console.log(chalk.gray(` No members.
4343
+ `));
4344
+ return;
4345
+ }
4346
+ console.log(chalk.yellow(` Members (${memberIds.length}):
4347
+ `));
4348
+ for (const cid of memberIds) {
4349
+ try {
4350
+ const c = getContact(cid);
4351
+ console.log(` ${chalk.bold(c.display_name)} ${chalk.gray(cid)}`);
4352
+ } catch {
4353
+ console.log(` ${chalk.gray(cid)} (not found)`);
4354
+ }
4355
+ }
4356
+ console.log();
4357
+ });
4358
+ groupsCmd.command("add-member <group-id> <contact-id>").description("Add a contact to a group").action((groupId, contactId) => {
4359
+ const db = getDatabase();
4360
+ const group = getGroup(db, groupId);
4361
+ if (!group) {
4362
+ console.error(chalk.red(`
4363
+ Group not found: ${groupId}
4364
+ `));
4365
+ process.exit(1);
4366
+ }
4367
+ const contact = getContact(contactId);
4368
+ addContactToGroup(db, contactId, groupId);
4369
+ console.log(chalk.green(`
4370
+ \u2713 Added ${contact.display_name} to group ${group.name}
4371
+ `));
4372
+ });
4373
+ groupsCmd.command("remove-member <group-id> <contact-id>").description("Remove a contact from a group").action((groupId, contactId) => {
4374
+ const db = getDatabase();
4375
+ const group = getGroup(db, groupId);
4376
+ if (!group) {
4377
+ console.error(chalk.red(`
4378
+ Group not found: ${groupId}
4379
+ `));
4380
+ process.exit(1);
4381
+ }
4382
+ const contact = getContact(contactId);
4383
+ removeContactFromGroup(db, contactId, groupId);
4384
+ console.log(chalk.green(`
4385
+ \u2713 Removed ${contact.display_name} from group ${group.name}
4386
+ `));
4387
+ });
4388
+ program.command("init").description("Show setup info, stats, and configuration").action(() => {
4389
+ const dbPath = getDbPath();
4390
+ const config = readConfig();
4391
+ console.log(chalk.bold.blue(`
4392
+ \u2501\u2501\u2501 Open Contacts Setup \u2501\u2501\u2501
4393
+ `));
4394
+ console.log(chalk.gray(" DB path: ") + (config.db_path ?? dbPath));
4395
+ console.log();
4396
+ try {
4397
+ const db = getDatabase();
4398
+ const contactCount = db.query("SELECT COUNT(*) as n FROM contacts").get().n;
4399
+ const companyCount = db.query("SELECT COUNT(*) as n FROM companies").get().n;
4400
+ const tagCount = db.query("SELECT COUNT(*) as n FROM tags").get().n;
4401
+ console.log(chalk.bold(" Stats:"));
4402
+ console.log(` ${chalk.cyan(String(contactCount))} contacts`);
4403
+ console.log(` ${chalk.cyan(String(companyCount))} companies`);
4404
+ console.log(` ${chalk.cyan(String(tagCount))} tags`);
4405
+ } catch {
4406
+ console.log(chalk.gray(" (Database not yet initialized)"));
4407
+ }
4408
+ console.log();
4409
+ console.log(chalk.bold(" MCP Setup (Claude Code):"));
4410
+ console.log(" " + chalk.cyan("claude mcp add --transport stdio --scope user contacts -- contacts-mcp"));
4411
+ console.log();
4412
+ console.log(chalk.bold(" Shell Completion (zsh):"));
4413
+ console.log(" " + chalk.cyan("contacts completion zsh > ~/.zsh/completions/_contacts"));
4414
+ console.log(" " + chalk.cyan("contacts completion bash >> ~/.bashrc"));
4415
+ console.log(" " + chalk.cyan("contacts completion fish > ~/.config/fish/completions/contacts.fish"));
4416
+ console.log();
4417
+ });
4418
+ program.command("backup").description("Backup the contacts database").option("--output <path>", "Output path").option("--list", "List existing backups").action((opts) => {
4419
+ const backupDir = join4(process.env["HOME"] || "~", ".contacts", "backups");
4420
+ if (opts.list) {
4421
+ if (!existsSync4(backupDir)) {
4422
+ console.log(chalk.gray(`
4423
+ No backups found.
4424
+ `));
4425
+ return;
4426
+ }
4427
+ const files = readdirSync(backupDir).filter((f) => f.endsWith(".db")).sort().reverse();
4428
+ if (files.length === 0) {
4429
+ console.log(chalk.gray(`
4430
+ No backups found.
4431
+ `));
4432
+ return;
4433
+ }
4434
+ console.log(chalk.bold.blue(`
4435
+ Existing Backups:
4436
+ `));
4437
+ for (const f of files) {
4438
+ const filePath = join4(backupDir, f);
4439
+ const size2 = statSync(filePath).size;
4440
+ const mtime = statSync(filePath).mtime.toISOString().slice(0, 19).replace("T", " ");
4441
+ console.log(` ${chalk.cyan(f)} ${chalk.gray(`${(size2 / 1024).toFixed(1)} KB ${mtime}`)}`);
4442
+ }
4443
+ console.log();
4444
+ return;
4445
+ }
4446
+ const src = getDbPath();
4447
+ if (!existsSync4(src)) {
4448
+ console.error(chalk.red(`
4449
+ Database not found: ${src}
4450
+ `));
4451
+ process.exit(1);
4452
+ }
4453
+ mkdirSync3(backupDir, { recursive: true });
4454
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
4455
+ const dest = opts.output || join4(backupDir, `contacts-${ts}.db`);
4456
+ copyFileSync(src, dest);
4457
+ const size = statSync(dest).size;
4458
+ console.log(chalk.green(`
4459
+ \u2713 Backed up to ${dest} (${(size / 1024).toFixed(1)} KB)
4460
+ `));
4461
+ });
4462
+ program.command("completion <shell>").description("Generate shell completion script (bash, zsh, fish)").action((shell) => {
4463
+ const commands = [
4464
+ "add",
4465
+ "list",
4466
+ "show",
4467
+ "edit",
4468
+ "delete",
4469
+ "search",
4470
+ "recent",
4471
+ "dupe",
4472
+ "log",
4473
+ "open",
4474
+ "import",
4475
+ "export",
4476
+ "companies",
4477
+ "tags",
4478
+ "groups",
4479
+ "serve",
4480
+ "mcp",
4481
+ "init",
4482
+ "backup",
4483
+ "completion"
4484
+ ];
4485
+ if (shell === "zsh") {
4486
+ console.log(`#compdef contacts
4487
+ _contacts() {
4488
+ local commands=(${commands.map((c) => `'${c}'`).join(" ")})
4489
+ _describe 'command' commands
4490
+ }
4491
+ _contacts "$@"`);
4492
+ } else if (shell === "bash") {
4493
+ console.log(`_contacts_completion() {
4494
+ local cur=\${COMP_WORDS[COMP_CWORD]}
4495
+ COMPREPLY=($(compgen -W "${commands.join(" ")}" -- "$cur"))
4496
+ }
4497
+ complete -F _contacts_completion contacts`);
4498
+ } else if (shell === "fish") {
4499
+ for (const c of commands) {
4500
+ console.log(`complete -c contacts -f -a ${c}`);
4501
+ }
4502
+ } else {
4503
+ console.error(chalk.red("Supported shells: bash, zsh, fish"));
4504
+ }
4505
+ });
3992
4506
  program.parse(process.argv);