@hasna/contacts 0.2.8 → 0.2.9

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/dist/index.d.ts CHANGED
@@ -5,6 +5,12 @@ export { createTag, getTag, getTagByName, listTags, updateTag, deleteTag, addTag
5
5
  export { createRelationship, listRelationships, getRelationship, deleteRelationship, } from "./db/relationships.js";
6
6
  export { createGroup, getGroup, listGroups, updateGroup, deleteGroup, addContactToGroup, removeContactFromGroup, listContactsInGroup, listGroupsForContact, addCompanyToGroup, removeCompanyFromGroup, listCompaniesInGroup, listGroupsForCompany, } from "./db/groups.js";
7
7
  export { logActivity, listActivity, getActivity } from "./db/activity.js";
8
+ export { runConnector, readConnectorTokens, getConnectorTokenPath, ConnectorNotInstalledError, ConnectorAuthError } from "./lib/connector.js";
9
+ export type { ConnectorRunOptions } from "./lib/connector.js";
10
+ export { extractContactsFromGmail, parseAddressHeader, domainToCompany, parseName } from "./lib/gmail-import.js";
11
+ export type { GmailImportOptions, ExtractedContact } from "./lib/gmail-import.js";
12
+ export { listGoogleContacts, searchGoogleContacts, pullGoogleContactsAsInputs, pushContactToGoogle, googlePersonToContactInput, contactToGoogleArgs, } from "./lib/google-contacts.js";
13
+ export type { GooglePerson, GoogleContactsSyncOptions, GoogleContactsPushOptions, SyncResult } from "./lib/google-contacts.js";
8
14
  export type { EmailType, PhoneType, AddressType, SocialPlatform, RelationshipType, ContactSource, PreferredContactMethod, ContactStatus, Email, Phone, Address, SocialProfile, Tag, Contact, ContactWithDetails, Company, CompanyWithDetails, ContactRelationship, ActivityLog, Webhook, Group, CreateGroupInput, CreateEmailInput, CreatePhoneInput, CreateAddressInput, CreateSocialProfileInput, CreateTagInput, UpdateTagInput, CreateContactInput, UpdateContactInput, ContactListOptions, CreateCompanyInput, UpdateCompanyInput, CompanyListOptions, CreateRelationshipInput, CreateActivityInput, CreateWebhookInput, UpdateWebhookInput, DuplicateByEmail, DuplicateByName, ContactRow, CompanyRow, EmailRow, PhoneRow, AddressRow, SocialProfileRow, TagRow, RelationshipRow, ActivityRow, WebhookRow, } from "./types/index.js";
9
15
  export { ContactNotFoundError, CompanyNotFoundError, TagNotFoundError, DuplicateTagNameError, } from "./types/index.js";
10
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EACL,aAAa,EACb,UAAU,EACV,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,aAAa,EACb,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,cAAc,EACd,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACL,aAAa,EACb,UAAU,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,cAAc,EACd,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,SAAS,EACT,MAAM,EACN,YAAY,EACZ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,oBAAoB,GACrB,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EACf,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EACjB,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAG1E,YAAY,EAEV,SAAS,EACT,SAAS,EACT,WAAW,EACX,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,sBAAsB,EACtB,aAAa,EAEb,KAAK,EACL,KAAK,EACL,OAAO,EACP,aAAa,EAEb,GAAG,EACH,OAAO,EACP,kBAAkB,EAClB,OAAO,EACP,kBAAkB,EAClB,mBAAmB,EACnB,WAAW,EACX,OAAO,EACP,KAAK,EACL,gBAAgB,EAEhB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,uBAAuB,EACvB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EAEf,UAAU,EACV,UAAU,EACV,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,eAAe,EACf,WAAW,EACX,UAAU,GACX,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,gBAAgB,EAChB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EACL,aAAa,EACb,UAAU,EACV,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,aAAa,EACb,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,cAAc,EACd,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACL,aAAa,EACb,UAAU,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,cAAc,EACd,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,SAAS,EACT,MAAM,EACN,YAAY,EACZ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,oBAAoB,GACrB,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EACf,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EACjB,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAG1E,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,0BAA0B,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC9I,YAAY,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAG9D,OAAO,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACjH,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAGlF,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,0BAA0B,EAC1B,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,GACpB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,YAAY,EAAE,yBAAyB,EAAE,yBAAyB,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAG/H,YAAY,EAEV,SAAS,EACT,SAAS,EACT,WAAW,EACX,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,sBAAsB,EACtB,aAAa,EAEb,KAAK,EACL,KAAK,EACL,OAAO,EACP,aAAa,EAEb,GAAG,EACH,OAAO,EACP,kBAAkB,EAClB,OAAO,EACP,kBAAkB,EAClB,mBAAmB,EACnB,WAAW,EACX,OAAO,EACP,KAAK,EACL,gBAAgB,EAEhB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,uBAAuB,EACvB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EAEf,UAAU,EACV,UAAU,EACV,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,eAAe,EACf,WAAW,EACX,UAAU,GACX,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,gBAAgB,EAChB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -1312,6 +1312,354 @@ function listCompaniesInGroup(db, groupId) {
1312
1312
  function listGroupsForCompany(db, companyId) {
1313
1313
  return db.query(`SELECT g.* FROM groups g JOIN company_groups cog ON g.id = cog.group_id WHERE cog.company_id = ? ORDER BY g.name`).all(companyId);
1314
1314
  }
1315
+ // src/lib/connector.ts
1316
+ import { join as join2 } from "path";
1317
+ import { existsSync as existsSync2, readFileSync } from "fs";
1318
+ import { homedir } from "os";
1319
+
1320
+ class ConnectorNotInstalledError extends Error {
1321
+ connectorName;
1322
+ constructor(connectorName) {
1323
+ super(`connect-${connectorName} is not installed. ` + `Run: bun install -g @hasnaxyz/connect-${connectorName}`);
1324
+ this.connectorName = connectorName;
1325
+ this.name = "ConnectorNotInstalledError";
1326
+ }
1327
+ }
1328
+
1329
+ class ConnectorAuthError extends Error {
1330
+ connectorName;
1331
+ constructor(connectorName, detail) {
1332
+ super(`connect-${connectorName} is not authenticated. ` + `Run: connect-${connectorName} auth login` + (detail ? ` (${detail})` : ""));
1333
+ this.connectorName = connectorName;
1334
+ this.name = "ConnectorAuthError";
1335
+ }
1336
+ }
1337
+ async function runConnector(name, args, opts = {}) {
1338
+ const binary = `connect-${name}`;
1339
+ const profile = opts.profile ?? "default";
1340
+ const fullArgs = [
1341
+ "--format",
1342
+ "json",
1343
+ "--profile",
1344
+ profile,
1345
+ ...args
1346
+ ];
1347
+ let proc;
1348
+ try {
1349
+ proc = Bun.spawn([binary, ...fullArgs], {
1350
+ stdout: "pipe",
1351
+ stderr: "pipe"
1352
+ });
1353
+ } catch (err) {
1354
+ const msg = err instanceof Error ? err.message : String(err);
1355
+ if (msg.includes("not found") || msg.includes("ENOENT")) {
1356
+ throw new ConnectorNotInstalledError(name);
1357
+ }
1358
+ throw err;
1359
+ }
1360
+ const timeoutMs = opts.timeout ?? 30000;
1361
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
1362
+ try {
1363
+ const [stdout, stderr, exitCode] = await Promise.all([
1364
+ proc.stdout ? new Response(proc.stdout).text() : Promise.resolve(""),
1365
+ proc.stderr ? new Response(proc.stderr).text() : Promise.resolve(""),
1366
+ proc.exited
1367
+ ]);
1368
+ if (exitCode !== 0) {
1369
+ const errText = (stderr || stdout).trim();
1370
+ const lower = errText.toLowerCase();
1371
+ if (lower.includes("auth") || lower.includes("token") || lower.includes("401") || lower.includes("unauthorized") || lower.includes("not authenticated")) {
1372
+ throw new ConnectorAuthError(name, errText.slice(0, 200));
1373
+ }
1374
+ throw new Error(`connect-${name} exited ${exitCode}: ${errText.slice(0, 400)}`);
1375
+ }
1376
+ const text = stdout.trim();
1377
+ if (!text || text === "null")
1378
+ return null;
1379
+ return JSON.parse(text);
1380
+ } finally {
1381
+ clearTimeout(timer);
1382
+ }
1383
+ }
1384
+ function getConnectorTokenPath(name, profile = "default") {
1385
+ const bases = [
1386
+ join2(homedir(), ".connectors", `connect-${name}`, "profiles", profile, "tokens.json"),
1387
+ join2(homedir(), ".connect", `connect-${name}`, "profiles", profile, "tokens.json"),
1388
+ join2(homedir(), ".connect", `connect-${name}`, "tokens.json")
1389
+ ];
1390
+ for (const p of bases) {
1391
+ if (existsSync2(p))
1392
+ return p;
1393
+ }
1394
+ return null;
1395
+ }
1396
+ function readConnectorTokens(name, profile = "default") {
1397
+ const path = getConnectorTokenPath(name, profile);
1398
+ if (!path)
1399
+ throw new ConnectorAuthError(name);
1400
+ try {
1401
+ return JSON.parse(readFileSync(path, "utf-8"));
1402
+ } catch {
1403
+ throw new ConnectorAuthError(name, "tokens file unreadable");
1404
+ }
1405
+ }
1406
+ // src/lib/gmail-import.ts
1407
+ function parseAddressHeader(header) {
1408
+ const results = [];
1409
+ const parts = header.split(/,(?![^<>]*>)(?![^"]*")/);
1410
+ for (const part of parts) {
1411
+ const trimmed = part.trim();
1412
+ if (!trimmed)
1413
+ continue;
1414
+ const angleMatch = trimmed.match(/^(.*?)\s*<([^>]+)>\s*$/);
1415
+ if (angleMatch) {
1416
+ const name = angleMatch[1].trim().replace(/^"|"$/g, "");
1417
+ const email = angleMatch[2].trim().toLowerCase();
1418
+ if (email.includes("@"))
1419
+ results.push({ name, email });
1420
+ } else if (trimmed.includes("@")) {
1421
+ results.push({ name: "", email: trimmed.toLowerCase() });
1422
+ }
1423
+ }
1424
+ return results;
1425
+ }
1426
+ function domainToCompany(email) {
1427
+ const domain = email.split("@")[1];
1428
+ if (!domain)
1429
+ return null;
1430
+ const genericDomains = new Set([
1431
+ "gmail.com",
1432
+ "googlemail.com",
1433
+ "yahoo.com",
1434
+ "yahoo.co.uk",
1435
+ "hotmail.com",
1436
+ "hotmail.co.uk",
1437
+ "outlook.com",
1438
+ "live.com",
1439
+ "icloud.com",
1440
+ "me.com",
1441
+ "mac.com",
1442
+ "protonmail.com",
1443
+ "pm.me",
1444
+ "fastmail.com",
1445
+ "hey.com",
1446
+ "aol.com",
1447
+ "msn.com"
1448
+ ]);
1449
+ if (genericDomains.has(domain.toLowerCase()))
1450
+ return null;
1451
+ const parts = domain.split(".");
1452
+ const name = parts.length > 2 ? parts[parts.length - 2] : parts[0];
1453
+ return name.charAt(0).toUpperCase() + name.slice(1);
1454
+ }
1455
+ function parseName(displayName) {
1456
+ const name = displayName.trim();
1457
+ if (!name)
1458
+ return {};
1459
+ const parts = name.split(/\s+/);
1460
+ if (parts.length === 1)
1461
+ return { first_name: parts[0] };
1462
+ const last = parts.pop();
1463
+ return { first_name: parts.join(" "), last_name: last };
1464
+ }
1465
+ async function extractContactsFromGmail(opts) {
1466
+ const maxMessages = Math.min(opts.max_messages ?? 200, 500);
1467
+ const profile = opts.gmail_profile ?? "default";
1468
+ let token;
1469
+ try {
1470
+ const tokens = readConnectorTokens("gmail", profile);
1471
+ token = tokens["accessToken"];
1472
+ if (!token)
1473
+ throw new ConnectorAuthError("gmail", "accessToken missing");
1474
+ } catch (err) {
1475
+ if (err instanceof ConnectorAuthError)
1476
+ throw err;
1477
+ throw new ConnectorAuthError("gmail", String(err));
1478
+ }
1479
+ const listUrl = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
1480
+ listUrl.searchParams.set("q", opts.query);
1481
+ listUrl.searchParams.set("maxResults", String(maxMessages));
1482
+ const listResp = await fetch(listUrl.toString(), {
1483
+ headers: { Authorization: `Bearer ${token}` }
1484
+ });
1485
+ if (!listResp.ok) {
1486
+ if (listResp.status === 401) {
1487
+ throw new ConnectorAuthError("gmail", "token expired \u2014 run connect-gmail auth login");
1488
+ }
1489
+ throw new Error(`Gmail API error ${listResp.status}: ${await listResp.text()}`);
1490
+ }
1491
+ const listData = await listResp.json();
1492
+ const messageRefs = listData.messages ?? [];
1493
+ if (messageRefs.length === 0)
1494
+ return [];
1495
+ const seen = new Map;
1496
+ const batchSize = 20;
1497
+ for (let i = 0;i < messageRefs.length; i += batchSize) {
1498
+ const batch = messageRefs.slice(i, i + batchSize);
1499
+ const fetches = batch.map((ref) => {
1500
+ const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${ref.id}`);
1501
+ url.searchParams.set("format", "metadata");
1502
+ url.searchParams.append("metadataHeaders", "From");
1503
+ url.searchParams.append("metadataHeaders", "To");
1504
+ url.searchParams.append("metadataHeaders", "Cc");
1505
+ return fetch(url.toString(), {
1506
+ headers: { Authorization: `Bearer ${token}` }
1507
+ }).then((r) => r.ok ? r.json() : null);
1508
+ });
1509
+ const results = await Promise.all(fetches);
1510
+ for (const msg of results) {
1511
+ if (!msg?.payload?.headers)
1512
+ continue;
1513
+ const getHeader = (name) => msg.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
1514
+ const addresses = [
1515
+ ...parseAddressHeader(getHeader("From")),
1516
+ ...parseAddressHeader(getHeader("To")),
1517
+ ...parseAddressHeader(getHeader("Cc"))
1518
+ ];
1519
+ for (const { name, email } of addresses) {
1520
+ if (seen.has(email))
1521
+ continue;
1522
+ const company_hint = domainToCompany(email);
1523
+ const nameParts = parseName(name);
1524
+ const contact_input = {
1525
+ ...nameParts,
1526
+ display_name: name || email.split("@")[0],
1527
+ emails: [{ address: email, type: "work", is_primary: true }],
1528
+ ...opts.tag_ids?.length ? { tag_ids: opts.tag_ids } : {},
1529
+ source: "email"
1530
+ };
1531
+ seen.set(email, { email, name, company_hint, contact_input });
1532
+ }
1533
+ }
1534
+ }
1535
+ return Array.from(seen.values());
1536
+ }
1537
+ // src/lib/google-contacts.ts
1538
+ function googlePersonToContactInput(person) {
1539
+ const name = person.names?.[0];
1540
+ const primaryEmail = person.emailAddresses?.[0]?.value;
1541
+ const nameParts = name?.givenName || name?.familyName ? { first_name: name.givenName, last_name: name.familyName } : primaryEmail ? parseName(name?.displayName ?? primaryEmail.split("@")[0] ?? "") : {};
1542
+ const emails = (person.emailAddresses ?? []).filter((e) => e.value).map((e, i) => ({
1543
+ address: e.value.toLowerCase(),
1544
+ type: normalizeEmailType(e.type),
1545
+ is_primary: i === 0
1546
+ }));
1547
+ const phones = (person.phoneNumbers ?? []).filter((p) => p.value).map((p, i) => ({
1548
+ number: p.value,
1549
+ type: normalizePhoneType(p.type),
1550
+ is_primary: i === 0
1551
+ }));
1552
+ const org = person.organizations?.[0];
1553
+ const website = person.urls?.[0]?.value;
1554
+ const birthday = (() => {
1555
+ const b = person.birthdays?.[0]?.date;
1556
+ if (!b?.year || !b?.month || !b?.day)
1557
+ return;
1558
+ const mm = String(b.month).padStart(2, "0");
1559
+ const dd = String(b.day).padStart(2, "0");
1560
+ return `${b.year}-${mm}-${dd}`;
1561
+ })();
1562
+ const notes = person.biographies?.map((b) => b.value).filter(Boolean).join(`
1563
+
1564
+ `) || undefined;
1565
+ return {
1566
+ ...nameParts,
1567
+ display_name: name?.displayName ?? (nameParts.first_name ? `${nameParts.first_name} ${nameParts.last_name ?? ""}`.trim() : primaryEmail?.split("@")[0] ?? "Unknown"),
1568
+ job_title: org?.title,
1569
+ notes,
1570
+ birthday,
1571
+ website,
1572
+ source: "import",
1573
+ emails,
1574
+ phones,
1575
+ custom_fields: { google_resource_name: person.resourceName }
1576
+ };
1577
+ }
1578
+ function normalizeEmailType(type) {
1579
+ if (!type)
1580
+ return "work";
1581
+ const t = type.toLowerCase();
1582
+ if (t === "work" || t === "home" || t === "personal")
1583
+ return t === "home" ? "personal" : "work";
1584
+ return "other";
1585
+ }
1586
+ function normalizePhoneType(type) {
1587
+ if (!type)
1588
+ return "mobile";
1589
+ const t = type.toLowerCase();
1590
+ if (t === "mobile" || t === "cell")
1591
+ return "mobile";
1592
+ if (t === "work")
1593
+ return "work";
1594
+ if (t === "home")
1595
+ return "home";
1596
+ if (t === "fax")
1597
+ return "fax";
1598
+ return "other";
1599
+ }
1600
+ function contactToGoogleArgs(contact) {
1601
+ const args = [];
1602
+ const displayName = contact.display_name;
1603
+ if (displayName)
1604
+ args.push("--name", displayName);
1605
+ if (contact.first_name)
1606
+ args.push("--given-name", contact.first_name);
1607
+ if (contact.last_name)
1608
+ args.push("--family-name", contact.last_name);
1609
+ const primaryEmail = contact.emails.find((e) => e.is_primary) ?? contact.emails[0];
1610
+ if (primaryEmail)
1611
+ args.push("--email", primaryEmail.address);
1612
+ const primaryPhone = contact.phones.find((p) => p.is_primary) ?? contact.phones[0];
1613
+ if (primaryPhone)
1614
+ args.push("--phone", primaryPhone.number);
1615
+ if (contact.job_title)
1616
+ args.push("--title", contact.job_title);
1617
+ if (contact.company)
1618
+ args.push("--company", contact.company.name);
1619
+ if (contact.notes)
1620
+ args.push("--notes", contact.notes);
1621
+ if (contact.website)
1622
+ args.push("--url", contact.website);
1623
+ return args;
1624
+ }
1625
+ async function listGoogleContacts(opts = {}) {
1626
+ const args = ["contacts", "list"];
1627
+ if (opts.page_size)
1628
+ args.push("--page-size", String(opts.page_size));
1629
+ const raw = await runConnector("googlecontacts", args, opts);
1630
+ return normalizeGoogleContactsResponse(raw);
1631
+ }
1632
+ async function searchGoogleContacts(query, opts = {}) {
1633
+ const raw = await runConnector("googlecontacts", ["contacts", "search", query], opts);
1634
+ return normalizeGoogleContactsResponse(raw);
1635
+ }
1636
+ function normalizeGoogleContactsResponse(raw) {
1637
+ if (!raw)
1638
+ return [];
1639
+ if (Array.isArray(raw))
1640
+ return raw;
1641
+ const obj = raw;
1642
+ if (Array.isArray(obj["connections"]))
1643
+ return obj["connections"];
1644
+ if (Array.isArray(obj["contacts"]))
1645
+ return obj["contacts"];
1646
+ return [];
1647
+ }
1648
+ async function pushContactToGoogle(contact, opts = {}) {
1649
+ const googleResourceName = contact.custom_fields?.["google_resource_name"];
1650
+ if (googleResourceName && opts.update_existing) {
1651
+ const args2 = ["contacts", "update", googleResourceName, ...contactToGoogleArgs(contact)];
1652
+ const result2 = await runConnector("googlecontacts", args2, opts);
1653
+ return { resourceName: result2?.resourceName ?? googleResourceName, action: "updated" };
1654
+ }
1655
+ const args = ["contacts", "create", ...contactToGoogleArgs(contact)];
1656
+ const result = await runConnector("googlecontacts", args, opts);
1657
+ return { resourceName: result?.resourceName ?? "", action: "created" };
1658
+ }
1659
+ async function pullGoogleContactsAsInputs(opts = {}) {
1660
+ const people = opts.query ? await searchGoogleContacts(opts.query, opts) : await listGoogleContacts(opts);
1661
+ return people.filter((p) => p.emailAddresses?.some((e) => e.value)).map(googlePersonToContactInput);
1662
+ }
1315
1663
  export {
1316
1664
  updateTag,
1317
1665
  updateGroup,
@@ -1319,13 +1667,20 @@ export {
1319
1667
  updateCompany,
1320
1668
  unarchiveContact,
1321
1669
  unarchiveCompany,
1670
+ searchGoogleContacts,
1322
1671
  searchContacts,
1323
1672
  searchCompanies,
1673
+ runConnector,
1324
1674
  resetDatabase,
1325
1675
  removeTagFromContact,
1326
1676
  removeTagFromCompany,
1327
1677
  removeContactFromGroup,
1328
1678
  removeCompanyFromGroup,
1679
+ readConnectorTokens,
1680
+ pushContactToGoogle,
1681
+ pullGoogleContactsAsInputs,
1682
+ parseName,
1683
+ parseAddressHeader,
1329
1684
  mergeContacts,
1330
1685
  logActivity,
1331
1686
  listTags,
@@ -1334,6 +1689,7 @@ export {
1334
1689
  listGroupsForContact,
1335
1690
  listGroupsForCompany,
1336
1691
  listGroups,
1692
+ listGoogleContacts,
1337
1693
  listContactsInGroup,
1338
1694
  listContactsByTag,
1339
1695
  listContacts,
@@ -1341,6 +1697,7 @@ export {
1341
1697
  listCompaniesInGroup,
1342
1698
  listCompanies,
1343
1699
  listActivity,
1700
+ googlePersonToContactInput,
1344
1701
  getTagByName,
1345
1702
  getTag,
1346
1703
  getRelationship,
@@ -1348,8 +1705,11 @@ export {
1348
1705
  getDatabase,
1349
1706
  getContactByEmail,
1350
1707
  getContact,
1708
+ getConnectorTokenPath,
1351
1709
  getCompany,
1352
1710
  getActivity,
1711
+ extractContactsFromGmail,
1712
+ domainToCompany,
1353
1713
  deleteTag,
1354
1714
  deleteRelationship,
1355
1715
  deleteGroup,
@@ -1360,6 +1720,7 @@ export {
1360
1720
  createGroup,
1361
1721
  createContact,
1362
1722
  createCompany,
1723
+ contactToGoogleArgs,
1363
1724
  autoLinkContactToCompany,
1364
1725
  archiveContact,
1365
1726
  archiveCompany,
@@ -1372,5 +1733,7 @@ export {
1372
1733
  TagNotFoundError,
1373
1734
  DuplicateTagNameError,
1374
1735
  ContactNotFoundError,
1736
+ ConnectorNotInstalledError,
1737
+ ConnectorAuthError,
1375
1738
  CompanyNotFoundError
1376
1739
  };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=google-contacts.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-contacts.test.d.ts","sourceRoot":"","sources":["../../src/lib/google-contacts.test.ts"],"names":[],"mappings":""}
package/dist/mcp/index.js CHANGED
@@ -1777,6 +1777,16 @@ async function exportContacts(format, contacts) {
1777
1777
  import { join as join2 } from "path";
1778
1778
  import { existsSync as existsSync2, readFileSync } from "fs";
1779
1779
  import { homedir } from "os";
1780
+
1781
+ class ConnectorNotInstalledError extends Error {
1782
+ connectorName;
1783
+ constructor(connectorName) {
1784
+ super(`connect-${connectorName} is not installed. ` + `Run: bun install -g @hasnaxyz/connect-${connectorName}`);
1785
+ this.connectorName = connectorName;
1786
+ this.name = "ConnectorNotInstalledError";
1787
+ }
1788
+ }
1789
+
1780
1790
  class ConnectorAuthError extends Error {
1781
1791
  connectorName;
1782
1792
  constructor(connectorName, detail) {
@@ -1785,6 +1795,53 @@ class ConnectorAuthError extends Error {
1785
1795
  this.name = "ConnectorAuthError";
1786
1796
  }
1787
1797
  }
1798
+ async function runConnector(name, args, opts = {}) {
1799
+ const binary = `connect-${name}`;
1800
+ const profile = opts.profile ?? "default";
1801
+ const fullArgs = [
1802
+ "--format",
1803
+ "json",
1804
+ "--profile",
1805
+ profile,
1806
+ ...args
1807
+ ];
1808
+ let proc;
1809
+ try {
1810
+ proc = Bun.spawn([binary, ...fullArgs], {
1811
+ stdout: "pipe",
1812
+ stderr: "pipe"
1813
+ });
1814
+ } catch (err) {
1815
+ const msg = err instanceof Error ? err.message : String(err);
1816
+ if (msg.includes("not found") || msg.includes("ENOENT")) {
1817
+ throw new ConnectorNotInstalledError(name);
1818
+ }
1819
+ throw err;
1820
+ }
1821
+ const timeoutMs = opts.timeout ?? 30000;
1822
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
1823
+ try {
1824
+ const [stdout, stderr, exitCode] = await Promise.all([
1825
+ proc.stdout ? new Response(proc.stdout).text() : Promise.resolve(""),
1826
+ proc.stderr ? new Response(proc.stderr).text() : Promise.resolve(""),
1827
+ proc.exited
1828
+ ]);
1829
+ if (exitCode !== 0) {
1830
+ const errText = (stderr || stdout).trim();
1831
+ const lower = errText.toLowerCase();
1832
+ if (lower.includes("auth") || lower.includes("token") || lower.includes("401") || lower.includes("unauthorized") || lower.includes("not authenticated")) {
1833
+ throw new ConnectorAuthError(name, errText.slice(0, 200));
1834
+ }
1835
+ throw new Error(`connect-${name} exited ${exitCode}: ${errText.slice(0, 400)}`);
1836
+ }
1837
+ const text = stdout.trim();
1838
+ if (!text || text === "null")
1839
+ return null;
1840
+ return JSON.parse(text);
1841
+ } finally {
1842
+ clearTimeout(timer);
1843
+ }
1844
+ }
1788
1845
  function getConnectorTokenPath(name, profile = "default") {
1789
1846
  const bases = [
1790
1847
  join2(homedir(), ".connectors", `connect-${name}`, "profiles", profile, "tokens.json"),
@@ -1940,6 +1997,133 @@ async function extractContactsFromGmail(opts) {
1940
1997
  return Array.from(seen.values());
1941
1998
  }
1942
1999
 
2000
+ // src/lib/google-contacts.ts
2001
+ function googlePersonToContactInput(person) {
2002
+ const name = person.names?.[0];
2003
+ const primaryEmail = person.emailAddresses?.[0]?.value;
2004
+ const nameParts = name?.givenName || name?.familyName ? { first_name: name.givenName, last_name: name.familyName } : primaryEmail ? parseName(name?.displayName ?? primaryEmail.split("@")[0] ?? "") : {};
2005
+ const emails = (person.emailAddresses ?? []).filter((e) => e.value).map((e, i) => ({
2006
+ address: e.value.toLowerCase(),
2007
+ type: normalizeEmailType(e.type),
2008
+ is_primary: i === 0
2009
+ }));
2010
+ const phones = (person.phoneNumbers ?? []).filter((p) => p.value).map((p, i) => ({
2011
+ number: p.value,
2012
+ type: normalizePhoneType(p.type),
2013
+ is_primary: i === 0
2014
+ }));
2015
+ const org = person.organizations?.[0];
2016
+ const website = person.urls?.[0]?.value;
2017
+ const birthday = (() => {
2018
+ const b = person.birthdays?.[0]?.date;
2019
+ if (!b?.year || !b?.month || !b?.day)
2020
+ return;
2021
+ const mm = String(b.month).padStart(2, "0");
2022
+ const dd = String(b.day).padStart(2, "0");
2023
+ return `${b.year}-${mm}-${dd}`;
2024
+ })();
2025
+ const notes = person.biographies?.map((b) => b.value).filter(Boolean).join(`
2026
+
2027
+ `) || undefined;
2028
+ return {
2029
+ ...nameParts,
2030
+ display_name: name?.displayName ?? (nameParts.first_name ? `${nameParts.first_name} ${nameParts.last_name ?? ""}`.trim() : primaryEmail?.split("@")[0] ?? "Unknown"),
2031
+ job_title: org?.title,
2032
+ notes,
2033
+ birthday,
2034
+ website,
2035
+ source: "import",
2036
+ emails,
2037
+ phones,
2038
+ custom_fields: { google_resource_name: person.resourceName }
2039
+ };
2040
+ }
2041
+ function normalizeEmailType(type) {
2042
+ if (!type)
2043
+ return "work";
2044
+ const t = type.toLowerCase();
2045
+ if (t === "work" || t === "home" || t === "personal")
2046
+ return t === "home" ? "personal" : "work";
2047
+ return "other";
2048
+ }
2049
+ function normalizePhoneType(type) {
2050
+ if (!type)
2051
+ return "mobile";
2052
+ const t = type.toLowerCase();
2053
+ if (t === "mobile" || t === "cell")
2054
+ return "mobile";
2055
+ if (t === "work")
2056
+ return "work";
2057
+ if (t === "home")
2058
+ return "home";
2059
+ if (t === "fax")
2060
+ return "fax";
2061
+ return "other";
2062
+ }
2063
+ function contactToGoogleArgs(contact) {
2064
+ const args = [];
2065
+ const displayName = contact.display_name;
2066
+ if (displayName)
2067
+ args.push("--name", displayName);
2068
+ if (contact.first_name)
2069
+ args.push("--given-name", contact.first_name);
2070
+ if (contact.last_name)
2071
+ args.push("--family-name", contact.last_name);
2072
+ const primaryEmail = contact.emails.find((e) => e.is_primary) ?? contact.emails[0];
2073
+ if (primaryEmail)
2074
+ args.push("--email", primaryEmail.address);
2075
+ const primaryPhone = contact.phones.find((p) => p.is_primary) ?? contact.phones[0];
2076
+ if (primaryPhone)
2077
+ args.push("--phone", primaryPhone.number);
2078
+ if (contact.job_title)
2079
+ args.push("--title", contact.job_title);
2080
+ if (contact.company)
2081
+ args.push("--company", contact.company.name);
2082
+ if (contact.notes)
2083
+ args.push("--notes", contact.notes);
2084
+ if (contact.website)
2085
+ args.push("--url", contact.website);
2086
+ return args;
2087
+ }
2088
+ async function listGoogleContacts(opts = {}) {
2089
+ const args = ["contacts", "list"];
2090
+ if (opts.page_size)
2091
+ args.push("--page-size", String(opts.page_size));
2092
+ const raw = await runConnector("googlecontacts", args, opts);
2093
+ return normalizeGoogleContactsResponse(raw);
2094
+ }
2095
+ async function searchGoogleContacts(query, opts = {}) {
2096
+ const raw = await runConnector("googlecontacts", ["contacts", "search", query], opts);
2097
+ return normalizeGoogleContactsResponse(raw);
2098
+ }
2099
+ function normalizeGoogleContactsResponse(raw) {
2100
+ if (!raw)
2101
+ return [];
2102
+ if (Array.isArray(raw))
2103
+ return raw;
2104
+ const obj = raw;
2105
+ if (Array.isArray(obj["connections"]))
2106
+ return obj["connections"];
2107
+ if (Array.isArray(obj["contacts"]))
2108
+ return obj["contacts"];
2109
+ return [];
2110
+ }
2111
+ async function pushContactToGoogle(contact, opts = {}) {
2112
+ const googleResourceName = contact.custom_fields?.["google_resource_name"];
2113
+ if (googleResourceName && opts.update_existing) {
2114
+ const args2 = ["contacts", "update", googleResourceName, ...contactToGoogleArgs(contact)];
2115
+ const result2 = await runConnector("googlecontacts", args2, opts);
2116
+ return { resourceName: result2?.resourceName ?? googleResourceName, action: "updated" };
2117
+ }
2118
+ const args = ["contacts", "create", ...contactToGoogleArgs(contact)];
2119
+ const result = await runConnector("googlecontacts", args, opts);
2120
+ return { resourceName: result?.resourceName ?? "", action: "created" };
2121
+ }
2122
+ async function pullGoogleContactsAsInputs(opts = {}) {
2123
+ const people = opts.query ? await searchGoogleContacts(opts.query, opts) : await listGoogleContacts(opts);
2124
+ return people.filter((p) => p.emailAddresses?.some((e) => e.value)).map(googlePersonToContactInput);
2125
+ }
2126
+
1943
2127
  // src/mcp/index.ts
1944
2128
  var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
1945
2129
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -2754,6 +2938,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2754
2938
  },
2755
2939
  required: ["query"]
2756
2940
  }
2941
+ },
2942
+ {
2943
+ name: "sync_from_google_contacts",
2944
+ description: "Pull contacts from Google Contacts (People API) and upsert them into the local database. Skips contacts that already exist by email. Requires connect-googlecontacts auth login. Returns { imported, skipped, errors }.",
2945
+ inputSchema: {
2946
+ type: "object",
2947
+ properties: {
2948
+ query: { type: "string", description: "Optional search query to filter which Google Contacts to import" },
2949
+ page_size: { type: "number", description: "Contacts per page (default: connector default)" },
2950
+ google_profile: { type: "string", description: "connect-googlecontacts profile (default: 'default')" },
2951
+ tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to apply to all imported contacts" },
2952
+ project_id: { type: "string", description: "Project ID to assign to all imported contacts" },
2953
+ dry_run: { type: "boolean", description: "Preview what would be imported without saving" }
2954
+ }
2955
+ }
2956
+ },
2957
+ {
2958
+ name: "push_contact_to_google",
2959
+ description: "Push a local contact to Google Contacts \u2014 creates a new Google contact (or updates if google_resource_name is stored in custom_fields). Requires connect-googlecontacts auth login.",
2960
+ inputSchema: {
2961
+ type: "object",
2962
+ properties: {
2963
+ contact_id: { type: "string", description: "Local contact ID to push" },
2964
+ google_profile: { type: "string", description: "connect-googlecontacts profile (default: 'default')" },
2965
+ update_existing: { type: "boolean", description: "If true, update existing Google contact when google_resource_name is present in custom_fields" }
2966
+ },
2967
+ required: ["contact_id"]
2968
+ }
2969
+ },
2970
+ {
2971
+ name: "search_google_contacts",
2972
+ description: "Search Google Contacts by name or email (read-only, does not import). Returns raw Google People API objects. Useful for lookup or preview before a push/sync.",
2973
+ inputSchema: {
2974
+ type: "object",
2975
+ properties: {
2976
+ query: { type: "string" },
2977
+ google_profile: { type: "string", description: "connect-googlecontacts profile (default: 'default')" }
2978
+ },
2979
+ required: ["query"]
2980
+ }
2757
2981
  }
2758
2982
  ]
2759
2983
  }));
@@ -3384,14 +3608,79 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3384
3608
  }]
3385
3609
  };
3386
3610
  }
3611
+ case "sync_from_google_contacts": {
3612
+ const db = getDatabase();
3613
+ const googleProfile = a.google_profile;
3614
+ const inputs = await pullGoogleContactsAsInputs({
3615
+ query: a.query,
3616
+ page_size: a.page_size,
3617
+ profile: googleProfile ?? "default"
3618
+ });
3619
+ const enriched = inputs.map((inp) => ({
3620
+ ...inp,
3621
+ ...a.tag_ids ? { tag_ids: a.tag_ids } : {},
3622
+ ...a.project_id ? { project_id: a.project_id } : {}
3623
+ }));
3624
+ if (a.dry_run) {
3625
+ return {
3626
+ content: [{
3627
+ type: "text",
3628
+ text: JSON.stringify({ dry_run: true, would_import: enriched.length, contacts: enriched }, null, 2)
3629
+ }]
3630
+ };
3631
+ }
3632
+ let imported = 0;
3633
+ let skipped = 0;
3634
+ const syncErrors = [];
3635
+ for (const input of enriched) {
3636
+ const primaryEmail = input.emails?.[0]?.address;
3637
+ if (!primaryEmail) {
3638
+ skipped++;
3639
+ continue;
3640
+ }
3641
+ try {
3642
+ const existing = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(primaryEmail);
3643
+ if (existing) {
3644
+ skipped++;
3645
+ continue;
3646
+ }
3647
+ createContact(input);
3648
+ imported++;
3649
+ } catch (err) {
3650
+ syncErrors.push(`${primaryEmail}: ${err instanceof Error ? err.message : String(err)}`);
3651
+ }
3652
+ }
3653
+ return {
3654
+ content: [{
3655
+ type: "text",
3656
+ text: JSON.stringify({ imported, skipped, errors: syncErrors.length, error_details: syncErrors }, null, 2)
3657
+ }]
3658
+ };
3659
+ }
3660
+ case "push_contact_to_google": {
3661
+ const contact = getContact(a.contact_id);
3662
+ const result = await pushContactToGoogle(contact, {
3663
+ profile: a.google_profile ?? "default",
3664
+ update_existing: a.update_existing
3665
+ });
3666
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, contact_id: contact.id }, null, 2) }] };
3667
+ }
3668
+ case "search_google_contacts": {
3669
+ const people = await searchGoogleContacts(a.query, {
3670
+ profile: a.google_profile ?? "default"
3671
+ });
3672
+ const mapped = people.map((p) => ({
3673
+ google: p,
3674
+ as_contact_input: googlePersonToContactInput(p)
3675
+ }));
3676
+ return { content: [{ type: "text", text: JSON.stringify(mapped, null, 2) }] };
3677
+ }
3387
3678
  default:
3388
3679
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
3389
3680
  }
3390
3681
  } catch (err) {
3391
- return {
3392
- content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
3393
- isError: true
3394
- };
3682
+ const msg = err instanceof ConnectorNotInstalledError || err instanceof ConnectorAuthError ? err.message : `Error: ${err instanceof Error ? err.message : String(err)}`;
3683
+ return { content: [{ type: "text", text: msg }], isError: true };
3395
3684
  }
3396
3685
  });
3397
3686
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/contacts",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Contact management for AI coding agents — CLI + MCP + Web",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",