shepherd-onboard 0.1.11 → 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 --json`, shows the 20 most recent local Messages chats with contact/group names, and asks which chats 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
- - shows 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
@@ -74,12 +74,16 @@ Shepherd must still enforce selected users and groups internally before imperson
74
74
 
75
75
  ## Messages Chat Selection
76
76
 
77
- List recent local Messages chats:
77
+ Open the local Messages chat selector:
78
78
 
79
79
  ```sh
80
- npx -y shepherd-onboard@latest messages-chats --json
80
+ npx -y shepherd-onboard@latest messages-chats
81
81
  ```
82
82
 
83
+ Use `--json` for machine-readable chat metadata, or `--text` for a terminal list.
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
+
83
87
  Pass the selected chat IDs when finishing onboarding:
84
88
 
85
89
  ```sh
@@ -2,17 +2,22 @@
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
5
6
  import { homedir, platform } from "node:os";
6
7
  import { dirname, join } from "node:path";
7
8
  import readline from "node:readline";
9
+ import { fileURLToPath } from "node:url";
8
10
 
9
11
  const DEFAULT_API_URL = "https://brain-api-customer-facing.up.railway.app";
10
12
  const PACKAGE_NAME = "shepherd-onboard";
11
13
  const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
14
+ const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
12
15
  const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
13
16
  const MAX_BATCH_SIZE = 50;
14
17
  const MAX_QUEUE_MESSAGES = 10_000;
15
- 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");
16
21
  const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
17
22
  const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
18
23
  const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
@@ -312,7 +317,7 @@ async function runAgentOnboarding() {
312
317
  googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
313
318
  granolaApiKeyPage,
314
319
  statePath,
315
- messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats --json` : undefined,
320
+ messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
316
321
  nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
317
322
  needsUserAction: agentNeedsUserAction(sources, opened),
318
323
  }, null, 2));
@@ -347,7 +352,7 @@ async function runAgentOnboarding() {
347
352
  if (sources.slack) console.log(`${step++}. Ask the user to finish the opened Slack browser authorization.`);
348
353
  if (sources.granola) console.log(`${step++}. Ask the user for their Granola API key from the Granola Mac app.`);
349
354
  if (sources.messages) {
350
- console.log(`${step++}. Run ${agentCommand()} messages-chats --json, ask the user which recent chats to sync, and keep those chat IDs.`);
355
+ console.log(`${step++}. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, and keep the printed chat IDs.`);
351
356
  }
352
357
  console.log(`${step++}. Run:`);
353
358
  console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
@@ -440,7 +445,7 @@ async function continueAgentOnboarding() {
440
445
  if (granolaApiKey) body.granolaApiKey = granolaApiKey;
441
446
  if (messagesHandle) body.imessage = { handle: messagesHandle };
442
447
  if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
443
- throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json, ask the user which chats to sync, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
448
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
444
449
  }
445
450
 
446
451
  const finalized = await postJson(
@@ -514,7 +519,7 @@ async function printAgentStatus() {
514
519
 
515
520
  async function runMessagesChatsCommand() {
516
521
  const chats = await listRecentMessageChats({
517
- limit: clampInt(Number(args.limit ?? DEFAULT_RECENT_MESSAGE_CHATS), 1, 100),
522
+ limit: clampInt(Number(args.limit ?? DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT), 1, 500),
518
523
  });
519
524
 
520
525
  if (args.json) {
@@ -522,6 +527,16 @@ async function runMessagesChatsCommand() {
522
527
  return;
523
528
  }
524
529
 
530
+ if (!args.text && !args.list) {
531
+ const selected = await selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
532
+ const selectedIds = selected.map((chat) => chat.chatId).join(",");
533
+ console.log(`\nSelected ${selected.length} Messages chat(s).`);
534
+ console.log(`messages-chat-ids=${selectedIds}`);
535
+ console.log("\nContinue with:");
536
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "${selectedIds}"`);
537
+ return;
538
+ }
539
+
525
540
  console.log(`\nRecent local Messages chats (${chats.length})\n`);
526
541
  for (let i = 0; i < chats.length; i++) {
527
542
  console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
@@ -602,7 +617,7 @@ Usage:
602
617
  npx -y ${PACKAGE_NAME}@latest agent
603
618
  npx -y ${PACKAGE_NAME}@latest agent --login
604
619
  npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
605
- npx -y ${PACKAGE_NAME}@latest messages-chats --json
620
+ npx -y ${PACKAGE_NAME}@latest messages-chats
606
621
  npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
607
622
  npx -y ${PACKAGE_NAME}@latest agent --status
608
623
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
@@ -636,7 +651,9 @@ Usage:
636
651
  npx -y ${PACKAGE_NAME}@latest messages-chats --json
637
652
 
638
653
  Options:
639
- --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}.
655
+ --text Print a terminal list instead of opening the selector page.
656
+ --no-open Print the local selector URL instead of opening it.
640
657
  --json Print machine-readable chat IDs and labels.
641
658
  --help Show this help.
642
659
  `);
@@ -689,7 +706,7 @@ function printAgentContract() {
689
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.",
690
707
  "Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
691
708
  "If the user chooses Provide handle, ask for the phone number or Apple ID email.",
692
- "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.",
693
710
  ],
694
711
  selectionQuestions: [
695
712
  {
@@ -713,7 +730,7 @@ function printAgentContract() {
713
730
  "Full name",
714
731
  "Organization name",
715
732
  "Messages phone number or Apple ID email, if they want local Messages connected",
716
- "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",
717
734
  ],
718
735
  afterStartCommand: [
719
736
  "For Google Workspace, have a super admin authorize the Shepherd Client ID and scopes in Google Admin Console.",
@@ -739,7 +756,7 @@ function printAgentContract() {
739
756
  "--granola-api-key \"<granola_key>\" if Granola is being connected",
740
757
  ],
741
758
  statusCommand: `${command} agent --status`,
742
- messagesChatsCommand: `${command} messages-chats --json`,
759
+ messagesChatsCommand: `${command} messages-chats`,
743
760
  googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
744
761
  orgSecurity: "Existing organizations are only reused when Shepherd can verify the authenticated user belongs there, for example by an existing Shepherd account/membership or matching non-personal company email domain. Similar spelling helps match verified orgs, but cannot attach an unverified user to someone else's org.",
745
762
  expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd environment. Local Messages starts via a macOS LaunchAgent when run on macOS. Downstream wiki, memory, and summary compilers remain outside this onboarding flow.",
@@ -785,7 +802,7 @@ If they are joining an existing org, ask for the org name they believe they belo
785
802
  If Messages is selected, run:
786
803
  ${payload.messagesChatsCommand}
787
804
 
788
- Show the 20 recent local Messages chats to the user as a selection question. Include both DMs and groups, and use the displayed contact/group names. Ask which chats Shepherd should sync. Do not select all chats by default.
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.
789
806
 
790
807
  Then run:
791
808
  ${payload.startCommand}
@@ -923,7 +940,7 @@ function agentNeedsUserAction(sources, opened) {
923
940
  if (sources.google) actions.push("Have the customer's Google Workspace super admin authorize Shepherd's domain-wide delegation Client ID and scopes in Google Admin Console.");
924
941
  if (sources.slack && opened.includes("slack")) actions.push("Complete Slack browser authorization.");
925
942
  if (sources.granola) actions.push("Create/copy a Granola API key from the Granola Mac app.");
926
- if (sources.messages) actions.push("Run messages-chats, ask the user which recent local Messages chats to sync, then pass the selected chat IDs with the Messages handle.");
943
+ if (sources.messages) actions.push("Run messages-chats, have the user select local Messages contacts/groups in the browser, then pass the printed chat IDs with the Messages handle.");
927
944
  return actions;
928
945
  }
929
946
 
@@ -1191,14 +1208,18 @@ async function selectRecentMessageChats() {
1191
1208
  }
1192
1209
 
1193
1210
  if (!process.stdin.isTTY) {
1194
- throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json and pass --messages-chat-ids "<id1>,<id2>".`);
1211
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>".`);
1195
1212
  }
1196
1213
 
1197
- 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) });
1198
1215
  if (chats.length === 0) {
1199
1216
  throw new Error("No recent local Messages chats were found on this Mac.");
1200
1217
  }
1201
1218
 
1219
+ if (!args.text && !args.list) {
1220
+ return selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
1221
+ }
1222
+
1202
1223
  console.log(`\nSelect local Messages chats to sync\n`);
1203
1224
  console.log("Shepherd will only pull from the chats you select.");
1204
1225
  for (let i = 0; i < chats.length; i++) {
@@ -1211,6 +1232,504 @@ async function selectRecentMessageChats() {
1211
1232
  return indexes.map((idx) => chats[idx]);
1212
1233
  }
1213
1234
 
1235
+ async function selectChatsInBrowser(chats, opts = {}) {
1236
+ if (!chats.length) throw new Error("No recent local Messages chats were found on this Mac.");
1237
+
1238
+ const token = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1239
+ let settled = false;
1240
+ let server;
1241
+
1242
+ return new Promise((resolve, reject) => {
1243
+ const timeout = setTimeout(() => {
1244
+ if (settled) return;
1245
+ settled = true;
1246
+ server?.close();
1247
+ reject(new Error("Messages chat selection timed out."));
1248
+ }, 20 * 60 * 1000);
1249
+
1250
+ server = createServer(async (req, res) => {
1251
+ try {
1252
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
1253
+
1254
+ if (req.method === "GET" && url.pathname === "/") {
1255
+ sendHtml(res, renderMessagesSelectorPage(chats, token));
1256
+ return;
1257
+ }
1258
+
1259
+ if (req.method === "POST" && url.pathname === "/select") {
1260
+ const body = await readRequestBody(req);
1261
+ const form = new URLSearchParams(body);
1262
+ if (form.get("token") !== token) {
1263
+ sendHtml(res, renderMessagesDonePage("Invalid selection session.", true), 403);
1264
+ return;
1265
+ }
1266
+ const selectedIds = form.getAll("chatId").filter(Boolean);
1267
+ const selectedSet = new Set(selectedIds);
1268
+ const selected = chats.filter((chat) => selectedSet.has(chat.chatId));
1269
+ if (selected.length === 0) {
1270
+ sendHtml(res, renderMessagesSelectorPage(chats, token, "Select at least one chat."));
1271
+ return;
1272
+ }
1273
+
1274
+ sendHtml(res, renderMessagesDonePage(`${selected.length} chat${selected.length === 1 ? "" : "s"} selected.`));
1275
+ if (!settled) {
1276
+ settled = true;
1277
+ clearTimeout(timeout);
1278
+ setTimeout(() => server.close(), 100);
1279
+ resolve(selected);
1280
+ }
1281
+ return;
1282
+ }
1283
+
1284
+ sendHtml(res, renderMessagesDonePage("Not found.", true), 404);
1285
+ } catch (err) {
1286
+ if (!settled) {
1287
+ settled = true;
1288
+ clearTimeout(timeout);
1289
+ server?.close();
1290
+ reject(err);
1291
+ }
1292
+ }
1293
+ });
1294
+
1295
+ server.on("error", (err) => {
1296
+ if (settled) return;
1297
+ settled = true;
1298
+ clearTimeout(timeout);
1299
+ reject(err);
1300
+ });
1301
+
1302
+ server.listen(0, "127.0.0.1", async () => {
1303
+ const address = server.address();
1304
+ const port = typeof address === "object" && address ? address.port : null;
1305
+ if (!port) {
1306
+ settled = true;
1307
+ clearTimeout(timeout);
1308
+ server.close();
1309
+ reject(new Error("Could not start local Messages selector."));
1310
+ return;
1311
+ }
1312
+ const url = `http://127.0.0.1:${port}/`;
1313
+ console.log(`\nOpening Messages chat selector: ${url}`);
1314
+ await openOrPrint(url, { noOpen: Boolean(opts.noOpen) });
1315
+ console.log("Select the Messages chats to sync in the browser.");
1316
+ });
1317
+ });
1318
+ }
1319
+
1320
+ function renderMessagesSelectorPage(chats, token, error = "") {
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)}">
1338
+ <input type="checkbox" name="chatId" value="${htmlAttr(chat.chatId)}">
1339
+ <span class="box" aria-hidden="true"></span>
1340
+ <span class="chat-main">
1341
+ <span class="chat-top">
1342
+ <span class="chat-name">${html(chat.label)}</span>
1343
+ <span class="chat-kind">${html(chat.kind === "group" ? "Group" : chat.kind === "dm" ? "Contact" : "Chat")}</span>
1344
+ </span>
1345
+ ${people ? `<span class="chat-people">${html(people)}</span>` : ""}
1346
+ ${when ? `<span class="chat-meta">${html(when)}</span>` : ""}
1347
+ </span>
1348
+ </label>`;
1349
+ }).join("");
1350
+
1351
+ return `<!doctype html>
1352
+ <html lang="en">
1353
+ <head>
1354
+ <meta charset="utf-8">
1355
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1356
+ <title>Select Messages Chats</title>
1357
+ <style>
1358
+ :root {
1359
+ color-scheme: light dark;
1360
+ --bg: #FCFCFC;
1361
+ --fg: #111111;
1362
+ --muted: #6D726D;
1363
+ --line: #E8ECE8;
1364
+ --panel: #FFFFFF;
1365
+ --button: #136033;
1366
+ --button-text: #FFFFFF;
1367
+ --link: #136033;
1368
+ --radius: 10px;
1369
+ }
1370
+ @media (prefers-color-scheme: dark) {
1371
+ :root {
1372
+ --bg: #000000;
1373
+ --fg: #F8F8F8;
1374
+ --muted: #A2A8A2;
1375
+ --line: #202520;
1376
+ --panel: #070907;
1377
+ --button: #FFFFFF;
1378
+ --button-text: #000000;
1379
+ --link: #136033;
1380
+ }
1381
+ }
1382
+ * { box-sizing: border-box; }
1383
+ body {
1384
+ margin: 0;
1385
+ background: var(--bg);
1386
+ color: var(--fg);
1387
+ font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1388
+ letter-spacing: 0;
1389
+ }
1390
+ main {
1391
+ width: min(700px, calc(100vw - 32px));
1392
+ margin: 36px auto;
1393
+ }
1394
+ header {
1395
+ display: flex;
1396
+ justify-content: space-between;
1397
+ gap: 16px;
1398
+ align-items: center;
1399
+ padding-bottom: 18px;
1400
+ border-bottom: 1px solid var(--line);
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
+ }
1426
+ h1 {
1427
+ margin: 0;
1428
+ font-size: 22px;
1429
+ line-height: 1.1;
1430
+ font-weight: 650;
1431
+ }
1432
+ form {
1433
+ margin-top: 18px;
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
+ }
1454
+ .error {
1455
+ margin: 0 0 12px;
1456
+ color: #9B1C1C;
1457
+ font-size: 14px;
1458
+ }
1459
+ .chat-list {
1460
+ display: grid;
1461
+ gap: 8px;
1462
+ margin: 0 0 18px;
1463
+ }
1464
+ .chat-row {
1465
+ display: grid;
1466
+ grid-template-columns: 24px 1fr;
1467
+ gap: 12px;
1468
+ align-items: start;
1469
+ padding: 13px 14px;
1470
+ background: var(--panel);
1471
+ border: 1px solid var(--line);
1472
+ border-radius: var(--radius);
1473
+ cursor: pointer;
1474
+ }
1475
+ .chat-row:hover {
1476
+ border-color: color-mix(in srgb, var(--link) 45%, var(--line));
1477
+ }
1478
+ input[type="checkbox"] {
1479
+ position: absolute;
1480
+ opacity: 0;
1481
+ pointer-events: none;
1482
+ }
1483
+ .box {
1484
+ width: 18px;
1485
+ height: 18px;
1486
+ margin-top: 2px;
1487
+ border: 1.5px solid var(--muted);
1488
+ border-radius: 5px;
1489
+ display: inline-grid;
1490
+ place-items: center;
1491
+ }
1492
+ input[type="checkbox"]:checked + .box {
1493
+ background: var(--button);
1494
+ border-color: var(--button);
1495
+ }
1496
+ input[type="checkbox"]:checked + .box::after {
1497
+ content: "";
1498
+ width: 7px;
1499
+ height: 4px;
1500
+ border-left: 2px solid var(--button-text);
1501
+ border-bottom: 2px solid var(--button-text);
1502
+ transform: rotate(-45deg) translateY(-1px);
1503
+ }
1504
+ .chat-main {
1505
+ min-width: 0;
1506
+ display: grid;
1507
+ gap: 4px;
1508
+ }
1509
+ .chat-top {
1510
+ display: flex;
1511
+ gap: 10px;
1512
+ align-items: center;
1513
+ min-width: 0;
1514
+ }
1515
+ .chat-name {
1516
+ overflow: hidden;
1517
+ text-overflow: ellipsis;
1518
+ white-space: nowrap;
1519
+ font-size: 15px;
1520
+ font-weight: 600;
1521
+ }
1522
+ .chat-kind {
1523
+ color: var(--link);
1524
+ font-size: 12px;
1525
+ flex: none;
1526
+ }
1527
+ .chat-people,
1528
+ .chat-meta {
1529
+ color: var(--muted);
1530
+ font-size: 13px;
1531
+ line-height: 1.35;
1532
+ overflow-wrap: anywhere;
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
+ }
1543
+ .actions {
1544
+ display: flex;
1545
+ justify-content: space-between;
1546
+ align-items: center;
1547
+ gap: 12px;
1548
+ position: sticky;
1549
+ bottom: 0;
1550
+ padding: 14px 0 0;
1551
+ background: linear-gradient(to top, var(--bg) 70%, transparent);
1552
+ }
1553
+ .selection-count {
1554
+ color: var(--muted);
1555
+ font-size: 13px;
1556
+ }
1557
+ button {
1558
+ appearance: none;
1559
+ border: 0;
1560
+ border-radius: var(--radius);
1561
+ background: var(--button);
1562
+ color: var(--button-text);
1563
+ padding: 10px 14px;
1564
+ font: inherit;
1565
+ font-weight: 620;
1566
+ cursor: pointer;
1567
+ }
1568
+ a { color: var(--link); }
1569
+ </style>
1570
+ </head>
1571
+ <body>
1572
+ <main>
1573
+ <header>
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>
1578
+ </header>
1579
+ <form method="post" action="/select">
1580
+ <input type="hidden" name="token" value="${htmlAttr(token)}">
1581
+ <input class="search" id="search" type="search" placeholder="Search contacts or groups" autocomplete="off">
1582
+ ${error ? `<p class="error">${html(error)}</p>` : ""}
1583
+ <div class="chat-list">${rows}</div>
1584
+ <p class="empty" id="empty" hidden>No chats found.</p>
1585
+ <div class="actions">
1586
+ <span class="selection-count" id="selection-count">Select one or more</span>
1587
+ <button type="submit">return</button>
1588
+ </div>
1589
+ </form>
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>
1622
+ </body>
1623
+ </html>`;
1624
+ }
1625
+
1626
+ function renderMessagesDonePage(message, isError = false) {
1627
+ return `<!doctype html>
1628
+ <html lang="en">
1629
+ <head>
1630
+ <meta charset="utf-8">
1631
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1632
+ <title>Messages Selection</title>
1633
+ <style>
1634
+ :root { color-scheme: light dark; --bg: #FCFCFC; --fg: #111; --muted: #6D726D; --button: #136033; --button-text: #FFFFFF; --radius: 10px; }
1635
+ @media (prefers-color-scheme: dark) { :root { --bg: #000; --fg: #F8F8F8; --muted: #A2A8A2; --button: #FFFFFF; --button-text: #000; } }
1636
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; }
1637
+ main { width: min(420px, calc(100vw - 32px)); }
1638
+ h1 { margin: 0 0 8px; font-size: 24px; line-height: 1.1; font-weight: 650; }
1639
+ p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.45; }
1640
+ .mark { width: 34px; height: 34px; border-radius: var(--radius); display: grid; place-items: center; margin-bottom: 14px; background: var(--button); color: var(--button-text); font-weight: 700; }
1641
+ </style>
1642
+ </head>
1643
+ <body>
1644
+ <main>
1645
+ <div class="mark">${isError ? "!" : "OK"}</div>
1646
+ <h1>${html(message)}</h1>
1647
+ <p>${isError ? "Return to the terminal and retry." : "You can close this tab and return to the terminal."}</p>
1648
+ </main>
1649
+ </body>
1650
+ </html>`;
1651
+ }
1652
+
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 "";
1661
+ const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
1662
+ const line = names.slice(0, 6).join(", ");
1663
+ if (!line || normalizeDisplayText(line) === normalizeDisplayText(chat.label)) return "";
1664
+ return line;
1665
+ }
1666
+
1667
+ function formatMessageChatMeta(chat) {
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
+ }
1707
+ }
1708
+
1709
+ function sendHtml(res, body, status = 200) {
1710
+ res.writeHead(status, {
1711
+ "Content-Type": "text/html; charset=utf-8",
1712
+ "Cache-Control": "no-store",
1713
+ });
1714
+ res.end(body);
1715
+ }
1716
+
1717
+ function readRequestBody(req) {
1718
+ return new Promise((resolve, reject) => {
1719
+ let body = "";
1720
+ req.setEncoding("utf8");
1721
+ req.on("data", (chunk) => {
1722
+ body += chunk;
1723
+ if (body.length > 64_000) {
1724
+ reject(new Error("Request body too large."));
1725
+ req.destroy();
1726
+ }
1727
+ });
1728
+ req.on("end", () => resolve(body));
1729
+ req.on("error", reject);
1730
+ });
1731
+ }
1732
+
1214
1733
  async function listRecentMessageChats({ limit }) {
1215
1734
  if (platform() !== "darwin") {
1216
1735
  throw new Error("local Messages chat discovery is only supported on macOS");
@@ -1222,7 +1741,7 @@ async function listRecentMessageChats({ limit }) {
1222
1741
  try {
1223
1742
  const chats = await sdk.listChats({
1224
1743
  sortBy: "recent",
1225
- limit: Math.max(limit, DEFAULT_RECENT_MESSAGE_CHATS),
1744
+ limit: Math.max(limit, INITIAL_MESSAGE_CHAT_ROWS),
1226
1745
  });
1227
1746
  const visible = chats
1228
1747
  .filter((chat) => typeof chat.chatId === "string" && chat.chatId.trim())
@@ -1247,10 +1766,12 @@ async function enrichMessageChat(sdk, chat, contactLookup) {
1247
1766
  : null;
1248
1767
  const dmName = dmHandle ? contactLookup.resolveName(dmHandle) : null;
1249
1768
  const groupNames = participants.map((participant) => participant.name ?? participant.handle).filter(Boolean);
1250
- const label = cleanChatName(chat.name)
1251
- ?? (chat.kind === "dm" ? dmName ?? dmHandle : null)
1252
- ?? (chat.kind === "group" && groupNames.length ? groupNames.slice(0, 4).join(", ") : null)
1253
- ?? (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";
1254
1775
 
1255
1776
  return {
1256
1777
  chatId: chat.chatId,
@@ -1280,11 +1801,10 @@ function uniqueParticipants(messages, contactLookup) {
1280
1801
  }
1281
1802
 
1282
1803
  function formatMessageChatOption(chat) {
1283
- const kind = chat.kind === "group" ? "group" : chat.kind === "dm" ? "dm" : "chat";
1284
- const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
1285
- const people = names.length ? ` · ${names.slice(0, 4).join(", ")}` : "";
1286
- const when = chat.lastMessageAt ? ` · ${new Date(chat.lastMessageAt).toLocaleString()}` : "";
1287
- 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(" · ");
1288
1808
  }
1289
1809
 
1290
1810
  function publicMessageChat(chat) {
@@ -1641,6 +2161,27 @@ function cleanChatName(name) {
1641
2161
  return trimmed || null;
1642
2162
  }
1643
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
+
1644
2185
  function parseDmHandleFromChatId(chatId) {
1645
2186
  const parts = String(chatId ?? "").split(";");
1646
2187
  if (parts.length >= 3 && parts[1] === "-") return parts.slice(2).join(";") || null;
@@ -1675,6 +2216,17 @@ function parseAllowedChatIds(value) {
1675
2216
  return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
1676
2217
  }
1677
2218
 
2219
+ function html(value) {
2220
+ return String(value ?? "")
2221
+ .replace(/&/g, "&amp;")
2222
+ .replace(/</g, "&lt;")
2223
+ .replace(/>/g, "&gt;");
2224
+ }
2225
+
2226
+ function htmlAttr(value) {
2227
+ return html(value).replace(/"/g, "&quot;");
2228
+ }
2229
+
1678
2230
  class MessagesBatchSender {
1679
2231
  constructor(apiUrl, agentToken, userId) {
1680
2232
  this.apiUrl = trimTrailingSlash(apiUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shepherd-onboard",
3
- "version": "0.1.11",
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
  ],