shepherd-onboard 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ npx -y shepherd-onboard@latest agent
11
11
  ```
12
12
 
13
13
  The command prints the exact prompt the agent should follow, then the exact follow-up commands to open Shepherd WorkOS login/signup, guide Google Workspace Admin Console delegation, open source auth for non-Google sources, open Granola's API key page, finalize, start cloud raw polling/backfills, and install local Messages sync.
14
- The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. If Messages is selected, the agent runs `messages-chats`, opens a minimal local webpage with the 20 most recent local Messages chats, and has the user select which contacts/groups to sync. Account creation/relinking always starts with Shepherd WorkOS auth.
14
+ The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. If Messages is selected, the agent runs `messages-chats`, opens a minimal searchable local webpage, and has the user select which contacts/groups to sync. Account creation/relinking always starts with Shepherd WorkOS auth.
15
15
  Existing-organization joins are verified from Shepherd login and company email domain; the typed org name is not trusted by itself.
16
16
 
17
17
  ## Human Terminal One-liner
@@ -31,7 +31,7 @@ The command:
31
31
  - opens Slack authorization
32
32
  - opens the Granola desktop app to Settings -> Connectors -> API keys
33
33
  - collects the Granola API key after opening the Granola screen when Granola is enabled
34
- - opens a minimal local webpage showing the 20 most recent local Messages chats with contact/group names and only syncs the chats selected by the user
34
+ - opens a minimal searchable local webpage with contact/group names and only syncs the chats selected by the user
35
35
  - sets up local macOS Messages raw sync with a background LaunchAgent scoped to the selected chats
36
36
  - starts raw polling/backfill for connected sources
37
37
  - does not start wiki generation, memory compilation, or doc summaries
@@ -82,6 +82,8 @@ npx -y shepherd-onboard@latest messages-chats
82
82
 
83
83
  Use `--json` for machine-readable chat metadata, or `--text` for a terminal list.
84
84
 
85
+ The browser selector displays the first page of recent chats and lets the user search contacts or groups loaded from local Messages.
86
+
85
87
  Pass the selected chat IDs when finishing onboarding:
86
88
 
87
89
  ```sh
@@ -6,14 +6,18 @@ import { createServer } from "node:http";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
8
  import readline from "node:readline";
9
+ import { fileURLToPath } from "node:url";
9
10
 
10
11
  const DEFAULT_API_URL = "https://brain-api-customer-facing.up.railway.app";
11
12
  const PACKAGE_NAME = "shepherd-onboard";
12
13
  const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
14
+ const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
13
15
  const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
14
16
  const MAX_BATCH_SIZE = 50;
15
17
  const MAX_QUEUE_MESSAGES = 10_000;
16
- const DEFAULT_RECENT_MESSAGE_CHATS = 20;
18
+ const DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT = 200;
19
+ const INITIAL_MESSAGE_CHAT_ROWS = 20;
20
+ const SHEPHERD_LOGO_PATH = join(PACKAGE_DIR, "assets", "shepherd_G_vector_136033.png");
17
21
  const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
18
22
  const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
19
23
  const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
@@ -515,7 +519,7 @@ async function printAgentStatus() {
515
519
 
516
520
  async function runMessagesChatsCommand() {
517
521
  const chats = await listRecentMessageChats({
518
- limit: clampInt(Number(args.limit ?? DEFAULT_RECENT_MESSAGE_CHATS), 1, 100),
522
+ limit: clampInt(Number(args.limit ?? DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT), 1, 500),
519
523
  });
520
524
 
521
525
  if (args.json) {
@@ -647,7 +651,7 @@ Usage:
647
651
  npx -y ${PACKAGE_NAME}@latest messages-chats --json
648
652
 
649
653
  Options:
650
- --limit <n> Number of recent chats to show. Defaults to ${DEFAULT_RECENT_MESSAGE_CHATS}.
654
+ --limit <n> Number of recent chats to load for search. Defaults to ${DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT}.
651
655
  --text Print a terminal list instead of opening the selector page.
652
656
  --no-open Print the local selector URL instead of opening it.
653
657
  --json Print machine-readable chat IDs and labels.
@@ -702,7 +706,7 @@ function printAgentContract() {
702
706
  "If Google Workspace is selected, guide the customer's Google Workspace super admin to authorize Shepherd's Client ID and scopes in Google Admin Console.",
703
707
  "Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
704
708
  "If the user chooses Provide handle, ask for the phone number or Apple ID email.",
705
- "If Messages is selected, run the recent-chat command and ask which of the 20 most recent chats to sync. Never sync all Messages chats by default.",
709
+ "If Messages is selected, run the recent-chat command. It opens a browser selector with recent chats and search. Never sync all Messages chats by default.",
706
710
  ],
707
711
  selectionQuestions: [
708
712
  {
@@ -726,7 +730,7 @@ function printAgentContract() {
726
730
  "Full name",
727
731
  "Organization name",
728
732
  "Messages phone number or Apple ID email, if they want local Messages connected",
729
- "Selected local Messages chats from the 20 most recent chats, if they want local Messages connected",
733
+ "Selected local Messages chats from the browser selector, if they want local Messages connected",
730
734
  ],
731
735
  afterStartCommand: [
732
736
  "For Google Workspace, have a super admin authorize the Shepherd Client ID and scopes in Google Admin Console.",
@@ -798,7 +802,7 @@ If they are joining an existing org, ask for the org name they believe they belo
798
802
  If Messages is selected, run:
799
803
  ${payload.messagesChatsCommand}
800
804
 
801
- This opens a minimal local webpage with the 20 recent local Messages chats. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. When the command returns, keep the printed comma-separated chat IDs.
805
+ This opens a minimal local webpage with recent local Messages chats and search. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. When the command returns, keep the printed comma-separated chat IDs.
802
806
 
803
807
  Then run:
804
808
  ${payload.startCommand}
@@ -1207,7 +1211,7 @@ async function selectRecentMessageChats() {
1207
1211
  throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>".`);
1208
1212
  }
1209
1213
 
1210
- const chats = await listRecentMessageChats({ limit: DEFAULT_RECENT_MESSAGE_CHATS });
1214
+ const chats = await listRecentMessageChats({ limit: clampInt(Number(args.limit ?? DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT), 1, 500) });
1211
1215
  if (chats.length === 0) {
1212
1216
  throw new Error("No recent local Messages chats were found on this Mac.");
1213
1217
  }
@@ -1314,8 +1318,23 @@ async function selectChatsInBrowser(chats, opts = {}) {
1314
1318
  }
1315
1319
 
1316
1320
  function renderMessagesSelectorPage(chats, token, error = "") {
1317
- const rows = chats.map((chat) => `
1318
- <label class="chat-row">
1321
+ const logo = shepherdLogoDataUri();
1322
+ const rows = chats.map((chat, index) => {
1323
+ const people = chatPeopleLine(chat);
1324
+ const when = formatMessageChatMeta(chat);
1325
+ const searchText = [
1326
+ chat.label,
1327
+ chat.kind,
1328
+ people,
1329
+ when,
1330
+ ...(chat.participants ?? []).flatMap((participant) => [participant.name, participant.handle]),
1331
+ ]
1332
+ .filter(Boolean)
1333
+ .join(" ")
1334
+ .toLowerCase();
1335
+
1336
+ return `
1337
+ <label class="chat-row" data-index="${index}" data-search="${htmlAttr(searchText)}">
1319
1338
  <input type="checkbox" name="chatId" value="${htmlAttr(chat.chatId)}">
1320
1339
  <span class="box" aria-hidden="true"></span>
1321
1340
  <span class="chat-main">
@@ -1323,10 +1342,11 @@ function renderMessagesSelectorPage(chats, token, error = "") {
1323
1342
  <span class="chat-name">${html(chat.label)}</span>
1324
1343
  <span class="chat-kind">${html(chat.kind === "group" ? "Group" : chat.kind === "dm" ? "Contact" : "Chat")}</span>
1325
1344
  </span>
1326
- ${renderChatPeople(chat)}
1327
- <span class="chat-meta">${html(formatMessageChatMeta(chat))}</span>
1345
+ ${people ? `<span class="chat-people">${html(people)}</span>` : ""}
1346
+ ${when ? `<span class="chat-meta">${html(when)}</span>` : ""}
1328
1347
  </span>
1329
- </label>`).join("");
1348
+ </label>`;
1349
+ }).join("");
1330
1350
 
1331
1351
  return `<!doctype html>
1332
1352
  <html lang="en">
@@ -1368,31 +1388,69 @@ function renderMessagesSelectorPage(chats, token, error = "") {
1368
1388
  letter-spacing: 0;
1369
1389
  }
1370
1390
  main {
1371
- width: min(760px, calc(100vw - 32px));
1372
- margin: 40px auto;
1391
+ width: min(700px, calc(100vw - 32px));
1392
+ margin: 36px auto;
1373
1393
  }
1374
1394
  header {
1375
1395
  display: flex;
1376
1396
  justify-content: space-between;
1377
1397
  gap: 16px;
1378
- align-items: end;
1398
+ align-items: center;
1379
1399
  padding-bottom: 18px;
1380
1400
  border-bottom: 1px solid var(--line);
1381
1401
  }
1402
+ .brand {
1403
+ display: flex;
1404
+ gap: 10px;
1405
+ align-items: center;
1406
+ min-width: 0;
1407
+ }
1408
+ .logo {
1409
+ width: 30px;
1410
+ height: 30px;
1411
+ border-radius: 8px;
1412
+ object-fit: contain;
1413
+ flex: none;
1414
+ }
1415
+ .logo-fallback {
1416
+ width: 30px;
1417
+ height: 30px;
1418
+ border-radius: 8px;
1419
+ display: grid;
1420
+ place-items: center;
1421
+ color: #FFFFFF;
1422
+ background: #136033;
1423
+ font-weight: 700;
1424
+ flex: none;
1425
+ }
1382
1426
  h1 {
1383
1427
  margin: 0;
1384
- font-size: 24px;
1428
+ font-size: 22px;
1385
1429
  line-height: 1.1;
1386
1430
  font-weight: 650;
1387
1431
  }
1388
- .count {
1389
- color: var(--muted);
1390
- font-size: 13px;
1391
- white-space: nowrap;
1392
- }
1393
1432
  form {
1394
1433
  margin-top: 18px;
1395
1434
  }
1435
+ .search {
1436
+ width: 100%;
1437
+ margin: 0 0 12px;
1438
+ border: 1px solid var(--line);
1439
+ border-radius: var(--radius);
1440
+ background: var(--panel);
1441
+ color: var(--fg);
1442
+ padding: 11px 12px;
1443
+ font: inherit;
1444
+ font-size: 14px;
1445
+ outline: none;
1446
+ }
1447
+ .search:focus {
1448
+ border-color: #136033;
1449
+ box-shadow: 0 0 0 3px color-mix(in srgb, #136033 16%, transparent);
1450
+ }
1451
+ .search::placeholder {
1452
+ color: var(--muted);
1453
+ }
1396
1454
  .error {
1397
1455
  margin: 0 0 12px;
1398
1456
  color: #9B1C1C;
@@ -1473,14 +1531,29 @@ function renderMessagesSelectorPage(chats, token, error = "") {
1473
1531
  line-height: 1.35;
1474
1532
  overflow-wrap: anywhere;
1475
1533
  }
1534
+ [hidden] {
1535
+ display: none !important;
1536
+ }
1537
+ .empty {
1538
+ margin: 18px 0 28px;
1539
+ color: var(--muted);
1540
+ font-size: 14px;
1541
+ text-align: center;
1542
+ }
1476
1543
  .actions {
1477
1544
  display: flex;
1478
- justify-content: flex-end;
1545
+ justify-content: space-between;
1546
+ align-items: center;
1547
+ gap: 12px;
1479
1548
  position: sticky;
1480
1549
  bottom: 0;
1481
1550
  padding: 14px 0 0;
1482
1551
  background: linear-gradient(to top, var(--bg) 70%, transparent);
1483
1552
  }
1553
+ .selection-count {
1554
+ color: var(--muted);
1555
+ font-size: 13px;
1556
+ }
1484
1557
  button {
1485
1558
  appearance: none;
1486
1559
  border: 0;
@@ -1498,18 +1571,54 @@ function renderMessagesSelectorPage(chats, token, error = "") {
1498
1571
  <body>
1499
1572
  <main>
1500
1573
  <header>
1501
- <h1>Select Messages chats</h1>
1502
- <div class="count">${chats.length} recent chats</div>
1574
+ <div class="brand">
1575
+ ${logo ? `<img class="logo" src="${htmlAttr(logo)}" alt="">` : `<span class="logo-fallback" aria-hidden="true">G</span>`}
1576
+ <h1>Select chats</h1>
1577
+ </div>
1503
1578
  </header>
1504
1579
  <form method="post" action="/select">
1505
1580
  <input type="hidden" name="token" value="${htmlAttr(token)}">
1581
+ <input class="search" id="search" type="search" placeholder="Search contacts or groups" autocomplete="off">
1506
1582
  ${error ? `<p class="error">${html(error)}</p>` : ""}
1507
1583
  <div class="chat-list">${rows}</div>
1584
+ <p class="empty" id="empty" hidden>No chats found.</p>
1508
1585
  <div class="actions">
1509
- <button type="submit">Use selected chats</button>
1586
+ <span class="selection-count" id="selection-count">Select one or more</span>
1587
+ <button type="submit">return</button>
1510
1588
  </div>
1511
1589
  </form>
1512
1590
  </main>
1591
+ <script>
1592
+ const initialRows = ${INITIAL_MESSAGE_CHAT_ROWS};
1593
+ const rows = Array.from(document.querySelectorAll(".chat-row"));
1594
+ const search = document.getElementById("search");
1595
+ const empty = document.getElementById("empty");
1596
+ const selected = document.getElementById("selection-count");
1597
+ const checks = Array.from(document.querySelectorAll('input[name="chatId"]'));
1598
+
1599
+ function updateRows() {
1600
+ const query = search.value.trim().toLowerCase();
1601
+ let visible = 0;
1602
+ for (const row of rows) {
1603
+ const matches = query
1604
+ ? row.dataset.search.includes(query)
1605
+ : Number(row.dataset.index) < initialRows;
1606
+ row.hidden = !matches;
1607
+ if (matches) visible += 1;
1608
+ }
1609
+ empty.hidden = visible !== 0;
1610
+ }
1611
+
1612
+ function updateSelected() {
1613
+ const count = checks.filter((check) => check.checked).length;
1614
+ selected.textContent = count ? count + " selected" : "Select one or more";
1615
+ }
1616
+
1617
+ search.addEventListener("input", updateRows);
1618
+ for (const check of checks) check.addEventListener("change", updateSelected);
1619
+ updateRows();
1620
+ updateSelected();
1621
+ </script>
1513
1622
  </body>
1514
1623
  </html>`;
1515
1624
  }
@@ -1542,16 +1651,59 @@ function renderMessagesDonePage(message, isError = false) {
1542
1651
  }
1543
1652
 
1544
1653
  function renderChatPeople(chat) {
1654
+ const people = chatPeopleLine(chat);
1655
+ if (!people) return "";
1656
+ return `<span class="chat-people">${html(people)}</span>`;
1657
+ }
1658
+
1659
+ function chatPeopleLine(chat) {
1660
+ if (chat.kind !== "group") return "";
1545
1661
  const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
1546
- if (!names.length) return "";
1547
- return `<span class="chat-people">${html(names.slice(0, 6).join(", "))}</span>`;
1662
+ const line = names.slice(0, 6).join(", ");
1663
+ if (!line || normalizeDisplayText(line) === normalizeDisplayText(chat.label)) return "";
1664
+ return line;
1548
1665
  }
1549
1666
 
1550
1667
  function formatMessageChatMeta(chat) {
1551
- const parts = [];
1552
- if (chat.service) parts.push(chat.service);
1553
- if (chat.lastMessageAt) parts.push(new Date(chat.lastMessageAt).toLocaleString());
1554
- return parts.join(" · ");
1668
+ return formatShortChatTime(chat.lastMessageAt);
1669
+ }
1670
+
1671
+ function formatShortChatTime(value) {
1672
+ if (!value) return "";
1673
+ const date = new Date(value);
1674
+ if (Number.isNaN(date.getTime())) return "";
1675
+
1676
+ const now = new Date();
1677
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1678
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
1679
+ const diffDays = Math.round((today.getTime() - day.getTime()) / (24 * 60 * 60 * 1000));
1680
+ const time = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" })
1681
+ .format(date)
1682
+ .replace(":00", "")
1683
+ .replace(/\s/g, "")
1684
+ .toLowerCase();
1685
+
1686
+ if (diffDays === 0) return `Today ${time}`;
1687
+ if (diffDays === 1) return `Yesterday ${time}`;
1688
+ if (diffDays >= 0 && diffDays < 7) {
1689
+ const weekday = new Intl.DateTimeFormat(undefined, { weekday: "long" }).format(date);
1690
+ return `${weekday} ${time}`;
1691
+ }
1692
+ const monthDay = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
1693
+ return `${monthDay} ${time}`;
1694
+ }
1695
+
1696
+ function normalizeDisplayText(value) {
1697
+ return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
1698
+ }
1699
+
1700
+ function shepherdLogoDataUri() {
1701
+ try {
1702
+ const bytes = readFileSync(SHEPHERD_LOGO_PATH);
1703
+ return `data:image/png;base64,${bytes.toString("base64")}`;
1704
+ } catch {
1705
+ return null;
1706
+ }
1555
1707
  }
1556
1708
 
1557
1709
  function sendHtml(res, body, status = 200) {
@@ -1589,7 +1741,7 @@ async function listRecentMessageChats({ limit }) {
1589
1741
  try {
1590
1742
  const chats = await sdk.listChats({
1591
1743
  sortBy: "recent",
1592
- limit: Math.max(limit, DEFAULT_RECENT_MESSAGE_CHATS),
1744
+ limit: Math.max(limit, INITIAL_MESSAGE_CHAT_ROWS),
1593
1745
  });
1594
1746
  const visible = chats
1595
1747
  .filter((chat) => typeof chat.chatId === "string" && chat.chatId.trim())
@@ -1614,10 +1766,12 @@ async function enrichMessageChat(sdk, chat, contactLookup) {
1614
1766
  : null;
1615
1767
  const dmName = dmHandle ? contactLookup.resolveName(dmHandle) : null;
1616
1768
  const groupNames = participants.map((participant) => participant.name ?? participant.handle).filter(Boolean);
1617
- const label = cleanChatName(chat.name)
1618
- ?? (chat.kind === "dm" ? dmName ?? dmHandle : null)
1619
- ?? (chat.kind === "group" && groupNames.length ? groupNames.slice(0, 4).join(", ") : null)
1620
- ?? (chat.kind === "group" ? "Group chat" : "Direct message");
1769
+ const chatName = cleanChatName(chat.name);
1770
+ const label = chat.kind === "dm"
1771
+ ? dmName ?? nonHandleChatName(chatName) ?? dmHandle ?? "Contact"
1772
+ : nonHandleChatName(chatName)
1773
+ ?? (groupNames.length ? groupNames.slice(0, 4).join(", ") : null)
1774
+ ?? "Group";
1621
1775
 
1622
1776
  return {
1623
1777
  chatId: chat.chatId,
@@ -1647,11 +1801,10 @@ function uniqueParticipants(messages, contactLookup) {
1647
1801
  }
1648
1802
 
1649
1803
  function formatMessageChatOption(chat) {
1650
- const kind = chat.kind === "group" ? "group" : chat.kind === "dm" ? "dm" : "chat";
1651
- const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
1652
- const people = names.length ? ` · ${names.slice(0, 4).join(", ")}` : "";
1653
- const when = chat.lastMessageAt ? ` · ${new Date(chat.lastMessageAt).toLocaleString()}` : "";
1654
- return `${chat.label} (${kind})${people}${when}`;
1804
+ const kind = chat.kind === "group" ? "Group" : chat.kind === "dm" ? "Contact" : "Chat";
1805
+ const people = chatPeopleLine(chat);
1806
+ const when = formatMessageChatMeta(chat);
1807
+ return [chat.label, kind, people, when].filter(Boolean).join(" · ");
1655
1808
  }
1656
1809
 
1657
1810
  function publicMessageChat(chat) {
@@ -2008,6 +2161,27 @@ function cleanChatName(name) {
2008
2161
  return trimmed || null;
2009
2162
  }
2010
2163
 
2164
+ function nonHandleChatName(name) {
2165
+ if (!name) return null;
2166
+ return looksLikeHandleList(name) ? null : name;
2167
+ }
2168
+
2169
+ function looksLikeHandleList(value) {
2170
+ const text = String(value ?? "").trim();
2171
+ if (!text) return false;
2172
+ const tokens = text
2173
+ .split(/[,;/&\s]+/)
2174
+ .map((token) => token.trim())
2175
+ .filter(Boolean);
2176
+ if (!tokens.length) return false;
2177
+ return tokens.every((token) => {
2178
+ if (token.includes("@")) return true;
2179
+ const digits = token.replace(/\D/g, "");
2180
+ if (digits.length >= 4 && /^[+()\-\d.\s]+$/.test(token)) return true;
2181
+ return /^\d{4,}$/.test(token);
2182
+ });
2183
+ }
2184
+
2011
2185
  function parseDmHandleFromChatId(chatId) {
2012
2186
  const parts = String(chatId ?? "").split(";");
2013
2187
  if (parts.length >= 3 && parts[1] === "-") return parts.slice(2).join(";") || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shepherd-onboard",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Customer-facing Shepherd raw sync onboarding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "@photon-ai/imessage-kit": "^3.0.0"
11
11
  },
12
12
  "files": [
13
+ "assets",
13
14
  "bin",
14
15
  "README.md"
15
16
  ],