shepherd-onboard 0.1.9 → 0.1.11

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
@@ -10,8 +10,8 @@ Give this to a coding agent:
10
10
  npx -y shepherd-onboard@latest agent
11
11
  ```
12
12
 
13
- The command prints the exact prompt the agent should follow, then the exact follow-up commands to open Shepherd WorkOS login/signup, open source auth, 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. Account creation/relinking always starts with Shepherd WorkOS auth.
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.
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
@@ -27,16 +27,33 @@ The command:
27
27
  - creates or reuses the Shepherd customer account from the WorkOS-authenticated email
28
28
  - creates or reuses the organization, including case-insensitive and close-name matches
29
29
  - only reuses an existing organization when the authenticated account is allowed to join it
30
- - opens Google Workspace authorization for Gmail, Drive, Docs, and Calendar consent
30
+ - prints Google Workspace domain-wide delegation setup for a Workspace super admin
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
- - sets up local macOS Messages raw sync with a background LaunchAgent
34
+ - shows the 20 most recent local Messages chats with contact/group names and only syncs the chats selected by the user
35
+ - sets up local macOS Messages raw sync with a background LaunchAgent scoped to the selected chats
35
36
  - starts raw polling/backfill for connected sources
36
37
  - does not start wiki generation, memory compilation, or doc summaries
37
38
 
38
39
  The command does not expose Railway, database, Redis, or internal service details to the user.
39
40
 
41
+ ## Google Workspace Domain-wide Delegation
42
+
43
+ Business customers connect Google Workspace once as an admin. Employees do not create service accounts and do not each complete Google OAuth for Gmail, Calendar, Drive, Docs, Sheets, Slides, Tasks, or Contacts.
44
+
45
+ Customer-side setup in Google Admin Console:
46
+
47
+ ```text
48
+ App name: Shepherd
49
+ Service account email: gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com
50
+ Domain-wide delegation OAuth Client ID: 118363960386741325727
51
+ ```
52
+
53
+ The customer super admin authorizes that Client ID with the scopes printed by the CLI. The customer does not upload service account JSON; Shepherd stores its private service account JSON server-side in `GOOGLE_WORKSPACE_SERVICE_ACCOUNT_KEY_JSON` and requests delegated tokens with `sub=<allowed_employee_email>`.
54
+
55
+ Shepherd must still enforce selected users and groups internally before impersonating or polling employee emails.
56
+
40
57
  ## Options
41
58
 
42
59
  ```sh
@@ -45,8 +62,9 @@ The command does not expose Railway, database, Redis, or internal service detail
45
62
  --org <name> Organization name
46
63
  --granola-api-key <key> Granola API key
47
64
  --messages-handle <value> Messages phone number or Apple ID email
65
+ --messages-chat-ids <ids> Comma-separated chat IDs selected from messages-chats
48
66
  --messages-backfill-days Local Messages backfill window, default 30
49
- --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar)
67
+ --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)
50
68
  --no-slack Skip Slack
51
69
  --no-granola Skip Granola
52
70
  --no-open-granola Do not open the Granola API key screen
@@ -54,6 +72,20 @@ The command does not expose Railway, database, Redis, or internal service detail
54
72
  --no-open Print auth URLs instead of opening the browser
55
73
  ```
56
74
 
75
+ ## Messages Chat Selection
76
+
77
+ List recent local Messages chats:
78
+
79
+ ```sh
80
+ npx -y shepherd-onboard@latest messages-chats --json
81
+ ```
82
+
83
+ Pass the selected chat IDs when finishing onboarding:
84
+
85
+ ```sh
86
+ npx -y shepherd-onboard@latest agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"
87
+ ```
88
+
57
89
  ## Granola API Keys
58
90
 
59
91
  If Granola does not land on the API keys page, run:
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { execFile, spawn } from "node:child_process";
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
5
  import { homedir, platform } from "node:os";
@@ -12,6 +12,63 @@ const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
12
12
  const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
13
13
  const MAX_BATCH_SIZE = 50;
14
14
  const MAX_QUEUE_MESSAGES = 10_000;
15
+ const DEFAULT_RECENT_MESSAGE_CHATS = 20;
16
+ const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
17
+ const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
18
+ const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
19
+ "gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
20
+ const GOOGLE_WORKSPACE_DELEGATION_CLIENT_ID = "118363960386741325727";
21
+ const GOOGLE_WORKSPACE_DELEGATION_SCOPES = [
22
+ "https://mail.google.com/",
23
+ "https://www.googleapis.com/auth/gmail.addons.current.action.compose",
24
+ "https://www.googleapis.com/auth/gmail.addons.current.message.action",
25
+ "https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
26
+ "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
27
+ "https://www.googleapis.com/auth/gmail.compose",
28
+ "https://www.googleapis.com/auth/gmail.insert",
29
+ "https://www.googleapis.com/auth/gmail.labels",
30
+ "https://www.googleapis.com/auth/gmail.metadata",
31
+ "https://www.googleapis.com/auth/gmail.modify",
32
+ "https://www.googleapis.com/auth/gmail.readonly",
33
+ "https://www.googleapis.com/auth/gmail.send",
34
+ "https://www.googleapis.com/auth/gmail.settings.basic",
35
+ "https://www.googleapis.com/auth/gmail.settings.sharing",
36
+ "https://www.googleapis.com/auth/calendar",
37
+ "https://www.googleapis.com/auth/calendar.acls",
38
+ "https://www.googleapis.com/auth/calendar.acls.readonly",
39
+ "https://www.googleapis.com/auth/calendar.app.created",
40
+ "https://www.googleapis.com/auth/calendar.calendarlist",
41
+ "https://www.googleapis.com/auth/calendar.calendarlist.readonly",
42
+ "https://www.googleapis.com/auth/calendar.calendars",
43
+ "https://www.googleapis.com/auth/calendar.calendars.readonly",
44
+ "https://www.googleapis.com/auth/calendar.events",
45
+ "https://www.googleapis.com/auth/calendar.events.freebusy",
46
+ "https://www.googleapis.com/auth/calendar.events.owned",
47
+ "https://www.googleapis.com/auth/calendar.events.owned.readonly",
48
+ "https://www.googleapis.com/auth/calendar.events.public.readonly",
49
+ "https://www.googleapis.com/auth/calendar.events.readonly",
50
+ "https://www.googleapis.com/auth/calendar.freebusy",
51
+ "https://www.googleapis.com/auth/calendar.readonly",
52
+ "https://www.googleapis.com/auth/calendar.settings.readonly",
53
+ "https://www.googleapis.com/auth/drive",
54
+ "https://www.googleapis.com/auth/drive.appdata",
55
+ "https://www.googleapis.com/auth/drive.apps.readonly",
56
+ "https://www.googleapis.com/auth/drive.file",
57
+ "https://www.googleapis.com/auth/drive.install",
58
+ "https://www.googleapis.com/auth/drive.meet.readonly",
59
+ "https://www.googleapis.com/auth/drive.metadata",
60
+ "https://www.googleapis.com/auth/drive.metadata.readonly",
61
+ "https://www.googleapis.com/auth/drive.photos.readonly",
62
+ "https://www.googleapis.com/auth/drive.readonly",
63
+ "https://www.googleapis.com/auth/drive.scripts",
64
+ "https://www.googleapis.com/auth/documents",
65
+ "https://www.googleapis.com/auth/spreadsheets",
66
+ "https://www.googleapis.com/auth/presentations",
67
+ "https://www.googleapis.com/auth/tasks",
68
+ "https://www.googleapis.com/auth/contacts.readonly",
69
+ "https://www.googleapis.com/auth/drive.activity.readonly",
70
+ "https://www.googleapis.com/auth/directory.readonly",
71
+ ];
15
72
 
16
73
  const rawArgv = process.argv.slice(2);
17
74
  const command = rawArgv[0] && !rawArgv[0].startsWith("--") ? rawArgv[0] : "onboard";
@@ -34,6 +91,8 @@ async function dispatch() {
34
91
  await runAgentOnboarding();
35
92
  } else if (command === "granola-api-keys") {
36
93
  await openGranolaApiKeys({ noOpen: Boolean(args["no-open"]) });
94
+ } else if (command === "messages-chats") {
95
+ await runMessagesChatsCommand();
37
96
  } else if (command === "messages-agent") {
38
97
  await runMessagesAgent();
39
98
  } else {
@@ -81,10 +140,10 @@ async function runOnboarding() {
81
140
  console.log(`Matched existing organization by ${session.account.organizationMatch.type}.`);
82
141
  }
83
142
 
84
- if (session.authUrls?.google) {
85
- console.log("\nGoogle Workspace authorization");
86
- await openOrPrint(session.authUrls.google, { noOpen });
87
- await waitForEnter("Complete Google Workspace authorization in the browser, then press Enter.");
143
+ if (sources.google) {
144
+ console.log("\nGoogle Workspace domain-wide delegation");
145
+ printGoogleWorkspaceDelegationSetup(session.googleWorkspaceDelegation);
146
+ await waitForEnter("After the Google Workspace super admin authorizes Shepherd in Admin Console, press Enter.");
88
147
  }
89
148
 
90
149
  if (session.authUrls?.slack) {
@@ -94,6 +153,7 @@ async function runOnboarding() {
94
153
  }
95
154
 
96
155
  const finalizeBody = { sessionToken: session.sessionToken };
156
+ let selectedMessageChats = [];
97
157
 
98
158
  if (sources.granola) {
99
159
  await openGranolaApiKeys({ noOpen });
@@ -103,7 +163,10 @@ async function runOnboarding() {
103
163
 
104
164
  if (sources.messages) {
105
165
  const handle = await valueOrPrompt("messages-handle", "Messages phone number or Apple ID email", { optional: true });
106
- if (handle) finalizeBody.imessage = { handle };
166
+ if (handle) {
167
+ finalizeBody.imessage = { handle };
168
+ selectedMessageChats = await selectRecentMessageChats();
169
+ }
107
170
  }
108
171
 
109
172
  const finalized = await postJson(
@@ -127,6 +190,8 @@ async function runOnboarding() {
127
190
  userId: session.sessionId,
128
191
  agentToken: finalized.connected.messages.agentToken,
129
192
  backfillDays: Number(args["messages-backfill-days"] ?? 30),
193
+ allowedChatIds: selectedMessageChats.map((chat) => chat.chatId),
194
+ selectedChats: selectedMessageChats,
130
195
  });
131
196
 
132
197
  if (!args["no-install-messages-agent"]) {
@@ -219,6 +284,9 @@ async function runAgentOnboarding() {
219
284
  account: session.account,
220
285
  sources,
221
286
  authUrls: session.authUrls ?? {},
287
+ googleWorkspaceDelegation: sources.google
288
+ ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation)
289
+ : undefined,
222
290
  workosAuth,
223
291
  createdAt: new Date().toISOString(),
224
292
  });
@@ -226,6 +294,7 @@ async function runAgentOnboarding() {
226
294
  const opened = [];
227
295
  for (const [provider, url] of Object.entries(session.authUrls ?? {})) {
228
296
  if (typeof url !== "string") continue;
297
+ if (provider === "google") continue;
229
298
  if (!noOpen) await openOrPrint(url, { noOpen: false });
230
299
  opened.push(provider);
231
300
  }
@@ -240,9 +309,11 @@ async function runAgentOnboarding() {
240
309
  status: "auth_required",
241
310
  account: publicAgentAccount(session.account),
242
311
  opened,
312
+ googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
243
313
  granolaApiKeyPage,
244
314
  statePath,
245
- nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"`,
315
+ messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats --json` : undefined,
316
+ nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
246
317
  needsUserAction: agentNeedsUserAction(sources, opened),
247
318
  }, null, 2));
248
319
  return;
@@ -257,18 +328,29 @@ async function runAgentOnboarding() {
257
328
  if (opened.length) {
258
329
  console.log(`Opened browser authorization: ${opened.join(", ")}`);
259
330
  }
331
+ if (sources.google) {
332
+ console.log("\nGoogle Workspace domain-wide delegation setup:");
333
+ printGoogleWorkspaceDelegationSetup(session.googleWorkspaceDelegation);
334
+ }
260
335
  if (noOpen) {
261
336
  for (const [provider, url] of Object.entries(session.authUrls ?? {})) {
337
+ if (provider === "google") continue;
262
338
  console.log(`${provider} auth URL: ${url}`);
263
339
  }
264
340
  }
265
341
  console.log(`State saved: ${statePath}`);
266
342
  console.log("\nCoding agent next steps:");
267
- console.log("1. Ask the user to finish the opened Google/Slack browser authorization.");
268
- if (sources.granola) console.log("2. Ask the user for their Granola API key from the Granola Mac app.");
269
- if (sources.messages) console.log("3. Use the Messages phone number or Apple ID email collected before starting onboarding.");
270
- console.log("4. Run:");
271
- console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"`);
343
+ let step = 1;
344
+ if (sources.google) {
345
+ console.log(`${step++}. Ask the user's Google Workspace super admin to authorize Shepherd in Google Admin Console with the Client ID and scopes above.`);
346
+ }
347
+ if (sources.slack) console.log(`${step++}. Ask the user to finish the opened Slack browser authorization.`);
348
+ if (sources.granola) console.log(`${step++}. Ask the user for their Granola API key from the Granola Mac app.`);
349
+ 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.`);
351
+ }
352
+ console.log(`${step++}. Run:`);
353
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
272
354
  console.log(" Omit either optional flag if that source is not being connected.");
273
355
  }
274
356
 
@@ -354,8 +436,12 @@ async function continueAgentOnboarding() {
354
436
  const body = { sessionToken: state.sessionToken };
355
437
  const granolaApiKey = stringArg("granola-api-key");
356
438
  const messagesHandle = stringArg("messages-handle");
439
+ const selectedMessageChatIds = parseMessageChatIdsArg();
357
440
  if (granolaApiKey) body.granolaApiKey = granolaApiKey;
358
441
  if (messagesHandle) body.imessage = { handle: messagesHandle };
442
+ 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>".`);
444
+ }
359
445
 
360
446
  const finalized = await postJson(
361
447
  `${state.apiUrl}/onboarding/raw/session/${encodeURIComponent(state.sessionId)}/finalize`,
@@ -369,6 +455,7 @@ async function continueAgentOnboarding() {
369
455
  userId: state.sessionId,
370
456
  agentToken: finalized.connected.messages.agentToken,
371
457
  backfillDays: Number(args["messages-backfill-days"] ?? 30),
458
+ allowedChatIds: selectedMessageChatIds,
372
459
  });
373
460
 
374
461
  if (!args["no-install-messages-agent"]) {
@@ -390,7 +477,7 @@ async function continueAgentOnboarding() {
390
477
  status: errors ? "waiting" : "completed",
391
478
  connected: Object.keys(finalized.connected ?? {}),
392
479
  errors: errors ? safeErrorRecord(errors) : undefined,
393
- nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"` : undefined,
480
+ nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"` : undefined,
394
481
  }, null, 2));
395
482
  return;
396
483
  }
@@ -401,7 +488,7 @@ async function continueAgentOnboarding() {
401
488
  console.log(`- ${source}: ${safeError(message)}`);
402
489
  }
403
490
  console.log("\nAfter the user completes missing auth/details, rerun:");
404
- console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"`);
491
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
405
492
  console.log(" Omit either optional flag if that source is not being connected.");
406
493
  return;
407
494
  }
@@ -425,6 +512,25 @@ async function printAgentStatus() {
425
512
  }, null, 2));
426
513
  }
427
514
 
515
+ async function runMessagesChatsCommand() {
516
+ const chats = await listRecentMessageChats({
517
+ limit: clampInt(Number(args.limit ?? DEFAULT_RECENT_MESSAGE_CHATS), 1, 100),
518
+ });
519
+
520
+ if (args.json) {
521
+ console.log(JSON.stringify({ chats }, null, 2));
522
+ return;
523
+ }
524
+
525
+ console.log(`\nRecent local Messages chats (${chats.length})\n`);
526
+ for (let i = 0; i < chats.length; i++) {
527
+ console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
528
+ console.log(` ${chats[i].chatId}`);
529
+ }
530
+ console.log("\nPass selected IDs to:");
531
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"`);
532
+ }
533
+
428
534
  async function runMessagesAgent() {
429
535
  const configPath = stringArg("config");
430
536
  if (!configPath) throw new Error("messages-agent requires --config <path>");
@@ -434,23 +540,30 @@ async function runMessagesAgent() {
434
540
  const userId = requiredConfigString(config.userId, "userId");
435
541
  const agentToken = requiredConfigString(config.agentToken, "agentToken");
436
542
  const backfillDays = clampInt(Number(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays ?? 30), 0, 3650);
543
+ const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
544
+ if (allowedChatIds.length === 0) {
545
+ throw new Error("Messages config must include selected chat IDs. Re-run onboarding and select one or more recent Messages chats.");
546
+ }
437
547
 
438
548
  const kit = await import("@photon-ai/imessage-kit");
439
549
  const sdk = new kit.IMessageSDK({ debug: args.debug === true });
440
550
  const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
441
- const serializer = createMessageSerializer(kit);
551
+ const contactLookup = buildContactLookup();
552
+ const serializer = createMessageSerializer(kit, contactLookup);
442
553
 
443
554
  console.log("Shepherd Messages raw sync starting");
555
+ console.log(`Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
444
556
 
445
557
  try {
446
558
  await loadGroupChatNames(sdk, serializer);
559
+ loadSelectedChatNames(config.selectedChats, serializer);
447
560
 
448
561
  if (backfillDays > 0) {
449
- await runMessagesBackfill(sdk, sender, serializer, backfillDays);
562
+ await runMessagesBackfill(sdk, sender, serializer, backfillDays, allowedChatIds);
450
563
  }
451
564
 
452
- await gapFillFromWatermark(sdk, sender, serializer, userId);
453
- await watchMessages(sdk, sender, serializer, userId);
565
+ await gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds);
566
+ await watchMessages(sdk, sender, serializer, userId, allowedChatIds);
454
567
  } catch (err) {
455
568
  await sdk.close?.().catch(() => undefined);
456
569
  throw err;
@@ -489,12 +602,13 @@ Usage:
489
602
  npx -y ${PACKAGE_NAME}@latest agent
490
603
  npx -y ${PACKAGE_NAME}@latest agent --login
491
604
  npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
492
- npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value>
605
+ npx -y ${PACKAGE_NAME}@latest messages-chats --json
606
+ npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
493
607
  npx -y ${PACKAGE_NAME}@latest agent --status
494
608
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
495
609
 
496
610
  Agent mode is non-interactive. It prints the user prompt and exact commands a coding agent should run.
497
- Always run --login first. WorkOS login/signup creates or relinks the Shepherd account before source setup.
611
+ Always run --login first. WorkOS login/signup creates or relinks the Shepherd account before source setup; Google Workspace uses Admin Console domain-wide delegation, not WorkOS OAuth.
498
612
  `);
499
613
  return;
500
614
  }
@@ -514,11 +628,27 @@ Options:
514
628
  return;
515
629
  }
516
630
 
631
+ if (which === "messages-chats") {
632
+ console.log(`Shepherd Messages recent chat selector
633
+
634
+ Usage:
635
+ npx -y ${PACKAGE_NAME}@latest messages-chats
636
+ npx -y ${PACKAGE_NAME}@latest messages-chats --json
637
+
638
+ Options:
639
+ --limit <n> Number of recent chats to show. Defaults to ${DEFAULT_RECENT_MESSAGE_CHATS}.
640
+ --json Print machine-readable chat IDs and labels.
641
+ --help Show this help.
642
+ `);
643
+ return;
644
+ }
645
+
517
646
  console.log(`Shepherd raw sync onboarding
518
647
 
519
648
  Usage:
520
649
  npx -y ${PACKAGE_NAME}@latest
521
650
  npx -y ${PACKAGE_NAME}@latest agent
651
+ npx -y ${PACKAGE_NAME}@latest messages-chats
522
652
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
523
653
 
524
654
  Options:
@@ -527,9 +657,10 @@ Options:
527
657
  --org <name> Organization name.
528
658
  --granola-api-key <key> Granola API key.
529
659
  --messages-handle <value> Messages phone number or Apple ID email.
660
+ --messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats.
530
661
  --messages-backfill-days <days>
531
662
  Local Messages backfill window. Defaults to 30.
532
- --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar).
663
+ --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts).
533
664
  --no-slack Skip Slack.
534
665
  --no-granola Skip Granola.
535
666
  --no-open-granola Do not open the Granola API key screen.
@@ -552,10 +683,13 @@ function printAgentContract() {
552
683
  "Start with selection questions to determine what the user wants connected.",
553
684
  "Ask whether they are joining an existing organization or creating a new one.",
554
685
  "Run Shepherd WorkOS login/signup before source setup. Do not ask whether they already have an account.",
686
+ "Treat WorkOS login as Shepherd account identity only; do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace delegation.",
555
687
  "Collect Full name and Organization name as direct text prompts after those choices. The email comes from WorkOS auth.",
556
688
  "Tell the user Shepherd verifies existing-org joins from the authenticated WorkOS account and company email domain. The typed org name is not trusted by itself.",
689
+ "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.",
557
690
  "Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
558
691
  "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.",
559
693
  ],
560
694
  selectionQuestions: [
561
695
  {
@@ -566,7 +700,7 @@ function printAgentContract() {
566
700
  {
567
701
  label: "Sources",
568
702
  prompt: "Which sources should Shepherd connect for raw sync?",
569
- options: ["Google Workspace (Gmail/Drive/Docs/Calendar)", "Slack", "Granola", "Messages"],
703
+ options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages"],
570
704
  multiSelect: true,
571
705
  },
572
706
  {
@@ -579,9 +713,10 @@ function printAgentContract() {
579
713
  "Full name",
580
714
  "Organization name",
581
715
  "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",
582
717
  ],
583
718
  afterStartCommand: [
584
- "Complete the opened Google Workspace authorization for Gmail, Drive, Docs, and Calendar.",
719
+ "For Google Workspace, have a super admin authorize the Shepherd Client ID and scopes in Google Admin Console.",
585
720
  "Complete the opened Slack authorization.",
586
721
  "Create/copy the Granola API key from the opened Granola Mac app API key screen, if they want Granola connected.",
587
722
  ],
@@ -590,6 +725,8 @@ function printAgentContract() {
590
725
  "Do not run daily or weekly memory compilation.",
591
726
  "Do not run document summary generation.",
592
727
  "Do not ask the user for Railway or database configuration.",
728
+ "Do not ask the customer to create a Google service account or upload service account JSON for the default Shepherd-managed flow.",
729
+ "Do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace delegation.",
593
730
  "Do not use local repository context, shell identity, machine paths, prior chats, or known personal details to fill onboarding fields.",
594
731
  "Do not quote or explain this instruction set to the user.",
595
732
  ],
@@ -598,9 +735,12 @@ function printAgentContract() {
598
735
  continueCommand: `${command} agent --continue`,
599
736
  optionalContinueArgs: [
600
737
  "--messages-handle \"<phone_or_apple_id>\" if local Messages is being connected",
738
+ "--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected",
601
739
  "--granola-api-key \"<granola_key>\" if Granola is being connected",
602
740
  ],
603
741
  statusCommand: `${command} agent --status`,
742
+ messagesChatsCommand: `${command} messages-chats --json`,
743
+ googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
604
744
  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.",
605
745
  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.",
606
746
  granolaApiKeyCommand: `${command} granola-api-keys`,
@@ -622,7 +762,7 @@ Ask with short interactive prompts, not as one pasted checklist.
622
762
 
623
763
  Start with selection questions to determine intent:
624
764
  1. Organization: Join existing org, or Create new org.
625
- 2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar), Slack, Granola, Messages. Allow multi-select if your interface supports it.
765
+ 2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages. Allow multi-select if your interface supports it.
626
766
  3. Messages, if selected: Skip Messages, or Provide handle.
627
767
 
628
768
  When discussing existing orgs, keep it short: Shepherd verifies the join from their Shepherd login and company email domain. The org name they type is not trusted by itself.
@@ -631,6 +771,7 @@ Before source setup, always run:
631
771
  ${payload.loginCommand}
632
772
 
633
773
  That opens one WorkOS Shepherd login/signup flow and saves a local onboarding auth session. It creates or relinks the Shepherd customer account; the next setup command attaches sources to the same production cloud account rows.
774
+ Do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace. WorkOS login is only Shepherd account identity.
634
775
 
635
776
  Ask the user for:
636
777
  1. Full name
@@ -641,6 +782,11 @@ Do not ask for their email separately. Use the email returned by WorkOS auth.
641
782
 
642
783
  If they are joining an existing org, ask for the org name they believe they belong to. Shepherd will match similar/case-different org names only when the authenticated account is allowed to join that org. Otherwise it creates or uses a separate org.
643
784
 
785
+ If Messages is selected, run:
786
+ ${payload.messagesChatsCommand}
787
+
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.
789
+
644
790
  Then run:
645
791
  ${payload.startCommand}
646
792
 
@@ -650,8 +796,21 @@ Add skip flags for sources the user did not select:
650
796
  - --no-granola
651
797
  - --no-messages
652
798
 
653
- That command creates/reuses the customer user and org, opens Google Workspace/Slack browser auth, and saves local state.
654
- If your browser automation can complete those auth screens, do it. If it cannot click through OAuth screens, leave the opened browser tabs for the user and ask them to complete Google Workspace and Slack auth.
799
+ That command creates/reuses the customer user and org, prints Google Workspace domain-wide delegation setup values, opens Slack browser auth if selected, and saves local state.
800
+
801
+ If Google Workspace is selected, show this Admin Console setup to the user and have their Google Workspace super admin authorize it:
802
+
803
+ App name: ${payload.googleWorkspaceDelegation.appName}
804
+ Service account email: ${payload.googleWorkspaceDelegation.serviceAccountEmail}
805
+ Domain-wide delegation OAuth Client ID: ${payload.googleWorkspaceDelegation.clientId}
806
+
807
+ Scopes:
808
+ ${payload.googleWorkspaceDelegation.scopes.join("\n")}
809
+
810
+ The customer does not create a service account and does not upload service account JSON in the default Shepherd-managed flow. Their super admin only authorizes the Client ID and scopes in Google Admin Console. Shepherd's backend stores and uses its private service account JSON server-side.
811
+ Shepherd must still enforce selected users and groups internally before polling or impersonating any employee email.
812
+
813
+ If Slack is selected and your browser automation can complete that auth screen, do it. If it cannot click through OAuth screens, leave the opened browser tab for the user and ask them to complete Slack auth.
655
814
 
656
815
  If Granola is selected, it also opens the Granola desktop app. If your local app automation can navigate it, go to:
657
816
  Settings -> Connectors -> API keys
@@ -661,8 +820,8 @@ If Granola did not come forward, run:
661
820
  ${payload.granolaApiKeyCommand}
662
821
  That command opens Granola and tries to navigate to Settings -> Connectors -> API keys. If your tool cannot click inside Granola, leave Granola open and ask the user to go to that screen.
663
822
 
664
- After Google Workspace and Slack browser auth is complete, and after the user has copied a Granola API key from the opened Granola screen if they want Granola, run:
665
- ${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"
823
+ After Google Workspace Admin Console delegation and Slack browser auth are complete, and after the user has copied a Granola API key from the opened Granola screen if they want Granola, run:
824
+ ${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"
666
825
 
667
826
  Omit either optional flag if that source is not being connected.
668
827
 
@@ -728,6 +887,29 @@ function publicAgentAccount(account) {
728
887
  };
729
888
  }
730
889
 
890
+ function googleWorkspaceDelegationSetup(setup) {
891
+ return {
892
+ appName: setup?.appName ?? GOOGLE_WORKSPACE_DELEGATION_APP_NAME,
893
+ serviceAccountEmail:
894
+ setup?.serviceAccountEmail ?? GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL,
895
+ clientId: setup?.clientId ?? GOOGLE_WORKSPACE_DELEGATION_CLIENT_ID,
896
+ scopes: Array.isArray(setup?.scopes) && setup.scopes.length > 0
897
+ ? setup.scopes
898
+ : GOOGLE_WORKSPACE_DELEGATION_SCOPES,
899
+ };
900
+ }
901
+
902
+ function printGoogleWorkspaceDelegationSetup(setup) {
903
+ const resolved = googleWorkspaceDelegationSetup(setup);
904
+ console.log(`App name: ${resolved.appName}`);
905
+ console.log(`Service account email: ${resolved.serviceAccountEmail}`);
906
+ console.log(`Domain-wide delegation OAuth Client ID: ${resolved.clientId}`);
907
+ console.log("Customer action: in Google Admin Console, add the Client ID above and paste these scopes.");
908
+ console.log("Customers do not create a service account or upload service account JSON; Shepherd stores its private service account JSON server-side.");
909
+ console.log("\nScopes:");
910
+ for (const scope of resolved.scopes) console.log(scope);
911
+ }
912
+
731
913
  function authenticatedEmail(authenticated) {
732
914
  return authenticated?.workosUser?.email ?? authenticated?.account?.email ?? null;
733
915
  }
@@ -738,10 +920,10 @@ function authenticatedName(authenticated) {
738
920
 
739
921
  function agentNeedsUserAction(sources, opened) {
740
922
  const actions = [];
741
- if (sources.google && opened.includes("google")) actions.push("Complete Google Workspace browser authorization for Gmail, Drive, Docs, and Calendar consent.");
923
+ 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.");
742
924
  if (sources.slack && opened.includes("slack")) actions.push("Complete Slack browser authorization.");
743
925
  if (sources.granola) actions.push("Create/copy a Granola API key from the Granola Mac app.");
744
- if (sources.messages) actions.push("Pass the Messages phone number or Apple ID email collected before starting onboarding.");
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.");
745
927
  return actions;
746
928
  }
747
929
 
@@ -853,7 +1035,7 @@ async function openOrPrint(url, opts) {
853
1035
  }
854
1036
 
855
1037
  async function openGranolaApiKeys(opts = {}) {
856
- const deepLink = "granola://settings/integrations";
1038
+ const deepLink = granolaApiKeysDeepLink();
857
1039
  if (opts.noOpen) {
858
1040
  console.log("Granola API keys: open the Granola desktop app -> Settings -> Connectors -> API keys");
859
1041
  return { opened: false, target: "Granola Settings -> Connectors -> API keys" };
@@ -866,19 +1048,20 @@ async function openGranolaApiKeys(opts = {}) {
866
1048
 
867
1049
  console.log("\nOpening Granola API keys");
868
1050
  const bundleResult = await execFileQuiet("open", ["-b", "com.granola.app"], { ignoreError: true, captureError: true });
869
- await sleep(500);
870
- await execFileQuiet("open", [deepLink], { ignoreError: true, captureError: true });
871
- await sleep(500);
1051
+ await sleep(900);
1052
+ const deepLinkResult = await execFileQuiet("open", ["-u", deepLink], { ignoreError: true, captureError: true });
1053
+ await sleep(700);
1054
+ const deepLinkRetryResult = await execFileQuiet("open", ["-u", deepLink], { ignoreError: true, captureError: true });
1055
+ await sleep(300);
872
1056
  const activateByBundleResult = await execFileQuiet("osascript", [
873
1057
  "-e",
874
1058
  'tell application id "com.granola.app" to activate',
875
1059
  ], { ignoreError: true, captureError: true });
876
1060
  const activateByNameResult = await execFileQuiet("open", ["-a", "Granola"], { ignoreError: true, captureError: true });
877
1061
 
878
- if (bundleResult.error && activateByBundleResult.error && activateByNameResult.error) {
1062
+ if (bundleResult.error && deepLinkResult.error && deepLinkRetryResult.error && activateByBundleResult.error && activateByNameResult.error) {
879
1063
  await execFileQuiet("open", ["-a", "Granola"], { ignoreError: true });
880
1064
  }
881
- await navigateGranolaApiKeysWithAppleScript();
882
1065
 
883
1066
  return {
884
1067
  opened: true,
@@ -888,32 +1071,8 @@ async function openGranolaApiKeys(opts = {}) {
888
1071
  };
889
1072
  }
890
1073
 
891
- async function navigateGranolaApiKeysWithAppleScript() {
892
- const script = `
893
- tell application id "com.granola.app" to activate
894
- delay 0.7
895
- tell application "System Events"
896
- if not (exists process "Granola") then return
897
- tell process "Granola"
898
- set frontmost to true
899
- try
900
- set {x, y} to position of window 1
901
- set {w, h} to size of window 1
902
- click at {x + w - 55, y + 810}
903
- return
904
- end try
905
- try
906
- keystroke "," using command down
907
- delay 0.5
908
- set {x, y} to position of window 1
909
- set {w, h} to size of window 1
910
- click at {x + w - 55, y + 810}
911
- return
912
- end try
913
- end tell
914
- end tell
915
- `;
916
- await execFileQuiet("osascript", ["-e", script], { ignoreError: true });
1074
+ function granolaApiKeysDeepLink() {
1075
+ return `granola://open?path=${encodeURIComponent(GRANOLA_API_KEYS_PATH)}`;
917
1076
  }
918
1077
 
919
1078
  async function postJson(url, body, opts = {}) {
@@ -948,6 +1107,10 @@ async function writeMessagesConfig(input) {
948
1107
  const dir = join(homedir(), ".shepherd", "raw-messages");
949
1108
  await mkdir(dir, { recursive: true });
950
1109
  const path = join(dir, `${input.userId}.json`);
1110
+ const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
1111
+ if (allowedChatIds.length === 0) {
1112
+ throw new Error("Select at least one Messages chat before installing local Messages sync.");
1113
+ }
951
1114
  await writeFile(
952
1115
  path,
953
1116
  JSON.stringify({
@@ -955,6 +1118,8 @@ async function writeMessagesConfig(input) {
955
1118
  userId: input.userId,
956
1119
  agentToken: input.agentToken,
957
1120
  backfillDays: input.backfillDays,
1121
+ allowedChatIds,
1122
+ selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats.map(publicMessageChat) : [],
958
1123
  createdAt: new Date().toISOString(),
959
1124
  }, null, 2),
960
1125
  { mode: 0o600 },
@@ -1019,6 +1184,120 @@ async function installMessagesAgent(configPath, userId) {
1019
1184
  return { label, plistPath, stdoutPath, stderrPath };
1020
1185
  }
1021
1186
 
1187
+ async function selectRecentMessageChats() {
1188
+ const explicitIds = parseMessageChatIdsArg();
1189
+ if (explicitIds.length > 0) {
1190
+ return explicitIds.map((chatId) => ({ chatId, label: chatId, kind: "unknown", participants: [] }));
1191
+ }
1192
+
1193
+ 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>".`);
1195
+ }
1196
+
1197
+ const chats = await listRecentMessageChats({ limit: DEFAULT_RECENT_MESSAGE_CHATS });
1198
+ if (chats.length === 0) {
1199
+ throw new Error("No recent local Messages chats were found on this Mac.");
1200
+ }
1201
+
1202
+ console.log(`\nSelect local Messages chats to sync\n`);
1203
+ console.log("Shepherd will only pull from the chats you select.");
1204
+ for (let i = 0; i < chats.length; i++) {
1205
+ console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
1206
+ }
1207
+
1208
+ const answer = await prompt("\nEnter chat numbers to sync, separated by commas: ");
1209
+ const indexes = parseSelectionIndexes(answer, chats.length);
1210
+ if (indexes.length === 0) throw new Error("Select at least one Messages chat.");
1211
+ return indexes.map((idx) => chats[idx]);
1212
+ }
1213
+
1214
+ async function listRecentMessageChats({ limit }) {
1215
+ if (platform() !== "darwin") {
1216
+ throw new Error("local Messages chat discovery is only supported on macOS");
1217
+ }
1218
+
1219
+ const kit = await import("@photon-ai/imessage-kit");
1220
+ const sdk = new kit.IMessageSDK({ debug: args.debug === true });
1221
+ const contactLookup = buildContactLookup();
1222
+ try {
1223
+ const chats = await sdk.listChats({
1224
+ sortBy: "recent",
1225
+ limit: Math.max(limit, DEFAULT_RECENT_MESSAGE_CHATS),
1226
+ });
1227
+ const visible = chats
1228
+ .filter((chat) => typeof chat.chatId === "string" && chat.chatId.trim())
1229
+ .filter((chat) => chat.kind === "dm" || chat.kind === "group")
1230
+ .slice(0, limit);
1231
+
1232
+ const enriched = [];
1233
+ for (const chat of visible) {
1234
+ enriched.push(await enrichMessageChat(sdk, chat, contactLookup));
1235
+ }
1236
+ return enriched;
1237
+ } finally {
1238
+ await sdk.close?.().catch(() => undefined);
1239
+ }
1240
+ }
1241
+
1242
+ async function enrichMessageChat(sdk, chat, contactLookup) {
1243
+ const recentMessages = await sdk.getMessages({ chatId: chat.chatId, limit: 30 }).catch(() => []);
1244
+ const participants = uniqueParticipants(recentMessages, contactLookup);
1245
+ const dmHandle = chat.kind === "dm"
1246
+ ? participants[0]?.handle ?? parseDmHandleFromChatId(chat.chatId)
1247
+ : null;
1248
+ const dmName = dmHandle ? contactLookup.resolveName(dmHandle) : null;
1249
+ 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");
1254
+
1255
+ return {
1256
+ chatId: chat.chatId,
1257
+ label,
1258
+ kind: chat.kind ?? "unknown",
1259
+ service: chat.service ?? null,
1260
+ lastMessageAt: isoDate(chat.lastMessageAt),
1261
+ participants,
1262
+ };
1263
+ }
1264
+
1265
+ function uniqueParticipants(messages, contactLookup) {
1266
+ const seen = new Set();
1267
+ const participants = [];
1268
+ for (const msg of messages) {
1269
+ const handle = typeof msg.participant === "string" ? msg.participant.trim() : "";
1270
+ if (!handle || contactLookup.isSelfHandle(handle)) continue;
1271
+ const normalized = normalizeHandle(handle);
1272
+ if (seen.has(normalized)) continue;
1273
+ seen.add(normalized);
1274
+ participants.push({
1275
+ handle,
1276
+ name: contactLookup.resolveName(handle),
1277
+ });
1278
+ }
1279
+ return participants;
1280
+ }
1281
+
1282
+ 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}`;
1288
+ }
1289
+
1290
+ function publicMessageChat(chat) {
1291
+ return {
1292
+ chatId: chat.chatId,
1293
+ label: chat.label,
1294
+ kind: chat.kind,
1295
+ service: chat.service ?? null,
1296
+ lastMessageAt: chat.lastMessageAt ?? null,
1297
+ participants: Array.isArray(chat.participants) ? chat.participants : [],
1298
+ };
1299
+ }
1300
+
1022
1301
  async function loadGroupChatNames(sdk, serializer) {
1023
1302
  if (typeof sdk.listChats !== "function") return;
1024
1303
  try {
@@ -1032,36 +1311,50 @@ async function loadGroupChatNames(sdk, serializer) {
1032
1311
  }
1033
1312
  }
1034
1313
 
1035
- async function runMessagesBackfill(sdk, sender, serializer, days) {
1036
- console.log(`Running ${days}-day Messages backfill`);
1314
+ function loadSelectedChatNames(selectedChats, serializer) {
1315
+ if (!Array.isArray(selectedChats)) return;
1316
+ for (const chat of selectedChats) {
1317
+ if (chat && typeof chat.chatId === "string" && typeof chat.label === "string") {
1318
+ serializer.setChatName(chat.chatId, chat.label);
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds) {
1324
+ console.log(`Running ${days}-day Messages backfill for ${allowedChatIds.length} selected chat(s)`);
1037
1325
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
1038
1326
  const pageSize = 1000;
1039
- let offset = 0;
1040
1327
  let totalMessages = 0;
1041
1328
  let totalStored = 0;
1042
1329
 
1043
- while (true) {
1044
- const messages = await sdk.getMessages({ since, limit: pageSize, offset });
1045
- if (!messages.length) break;
1330
+ for (const chatId of allowedChatIds) {
1331
+ let offset = 0;
1332
+ while (true) {
1333
+ const messages = await sdk.getMessages({ chatId, since, limit: pageSize, offset });
1334
+ if (!messages.length) break;
1046
1335
 
1047
- totalMessages += messages.length;
1048
- const result = await sender.send(messages.map((msg) => serializer.serialize(msg)));
1049
- totalStored += result.stored;
1050
- saveMessagesWatermark(sender.userId, maxRowId(messages));
1336
+ totalMessages += messages.length;
1337
+ const result = await sender.send(messages.map((msg) => serializer.serialize(msg)));
1338
+ totalStored += result.stored;
1339
+ saveMessagesWatermark(sender.userId, maxRowId(messages));
1051
1340
 
1052
- if (messages.length < pageSize) break;
1053
- offset += pageSize;
1341
+ if (messages.length < pageSize) break;
1342
+ offset += pageSize;
1343
+ }
1054
1344
  }
1055
1345
 
1056
1346
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
1057
1347
  }
1058
1348
 
1059
- async function gapFillFromWatermark(sdk, sender, serializer, userId) {
1349
+ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds) {
1060
1350
  const lastWatermark = loadMessagesWatermark(userId);
1061
1351
  if (lastWatermark <= 0) return;
1062
1352
 
1063
- const missed = await sdk.getMessages({ limit: 5000 });
1064
- const newMessages = missed.filter((msg) => Number(msg.rowId) > lastWatermark);
1353
+ const missed = [];
1354
+ for (const chatId of allowedChatIds) {
1355
+ missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
1356
+ }
1357
+ const newMessages = missed.filter((msg) => Number(msg.rowId) > lastWatermark && allowedChatIds.includes(msg.chatId));
1065
1358
  if (newMessages.length === 0) return;
1066
1359
 
1067
1360
  const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
@@ -1069,7 +1362,8 @@ async function gapFillFromWatermark(sdk, sender, serializer, userId) {
1069
1362
  console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
1070
1363
  }
1071
1364
 
1072
- async function watchMessages(sdk, sender, serializer, userId) {
1365
+ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
1366
+ const allowed = new Set(allowedChatIds);
1073
1367
  let buffer = [];
1074
1368
  let timer = null;
1075
1369
 
@@ -1089,6 +1383,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
1089
1383
  };
1090
1384
 
1091
1385
  const onMessage = (msg) => {
1386
+ if (!msg.chatId || !allowed.has(msg.chatId)) return;
1092
1387
  buffer.push(msg);
1093
1388
  if (buffer.length >= MAX_BATCH_SIZE) {
1094
1389
  if (timer) clearTimeout(timer);
@@ -1105,7 +1400,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
1105
1400
  onError: (err) => console.error("Messages watcher error:", safeError(err)),
1106
1401
  });
1107
1402
 
1108
- console.log("Watching for new Messages");
1403
+ console.log("Watching for new Messages in selected chats");
1109
1404
 
1110
1405
  const shutdown = async () => {
1111
1406
  if (timer) clearTimeout(timer);
@@ -1118,7 +1413,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
1118
1413
  process.on("SIGTERM", shutdown);
1119
1414
  }
1120
1415
 
1121
- function createMessageSerializer(kit) {
1416
+ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
1122
1417
  const chatNames = new Map();
1123
1418
  const isImageAttachment = kit.isImageAttachment ?? (() => false);
1124
1419
  const isVideoAttachment = kit.isVideoAttachment ?? (() => false);
@@ -1173,11 +1468,213 @@ function createMessageSerializer(kit) {
1173
1468
  isForwarded: Boolean(msg.isForwarded),
1174
1469
  affectedParticipant: msg.affectedParticipant ?? null,
1175
1470
  newGroupName: msg.newGroupName ?? null,
1471
+ _resolved_name: msg.participant ? contactLookup.resolveName(msg.participant) : null,
1472
+ _is_self_handle: msg.participant ? contactLookup.isSelfHandle(msg.participant) : false,
1176
1473
  };
1177
1474
  },
1178
1475
  };
1179
1476
  }
1180
1477
 
1478
+ function buildContactLookup() {
1479
+ const contacts = loadContacts();
1480
+ const myCard = loadMyCard();
1481
+ const handleToName = new Map();
1482
+ const selfHandles = new Set();
1483
+
1484
+ for (const contact of contacts) {
1485
+ for (const phone of contact.phones) {
1486
+ addHandleMapping(handleToName, phone, contact.name);
1487
+ }
1488
+ for (const email of contact.emails) {
1489
+ addHandleMapping(handleToName, email, contact.name);
1490
+ }
1491
+ }
1492
+
1493
+ if (myCard) {
1494
+ for (const phone of myCard.phones) addSelfHandle(selfHandles, phone);
1495
+ for (const email of myCard.emails) addSelfHandle(selfHandles, email);
1496
+ }
1497
+
1498
+ return {
1499
+ resolveName(handle) {
1500
+ const candidates = handleCandidates(handle);
1501
+ for (const candidate of candidates) {
1502
+ const name = handleToName.get(candidate);
1503
+ if (name) return name;
1504
+ }
1505
+ return null;
1506
+ },
1507
+ isSelfHandle(handle) {
1508
+ return handleCandidates(handle).some((candidate) => selfHandles.has(candidate));
1509
+ },
1510
+ };
1511
+ }
1512
+
1513
+ function emptyContactLookup() {
1514
+ return {
1515
+ resolveName() {
1516
+ return null;
1517
+ },
1518
+ isSelfHandle() {
1519
+ return false;
1520
+ },
1521
+ };
1522
+ }
1523
+
1524
+ function loadContacts() {
1525
+ if (platform() !== "darwin") return [];
1526
+ const script = `
1527
+ set output to ""
1528
+ tell application "Contacts"
1529
+ repeat with p in every person
1530
+ set pName to name of p
1531
+ set phList to ""
1532
+ repeat with ph in phones of p
1533
+ if phList is not "" then set phList to phList & ","
1534
+ set phList to phList & (value of ph)
1535
+ end repeat
1536
+ set eList to ""
1537
+ repeat with e in emails of p
1538
+ if eList is not "" then set eList to eList & ","
1539
+ set eList to eList & (value of e)
1540
+ end repeat
1541
+ set output to output & pName & "\\t" & phList & "\\t" & eList & "\\n"
1542
+ end repeat
1543
+ end tell
1544
+ return output`;
1545
+
1546
+ try {
1547
+ const raw = execFileSync("osascript", ["-e", script], {
1548
+ encoding: "utf8",
1549
+ timeout: 30_000,
1550
+ });
1551
+ return parseContacts(raw);
1552
+ } catch {
1553
+ return [];
1554
+ }
1555
+ }
1556
+
1557
+ function loadMyCard() {
1558
+ if (platform() !== "darwin") return null;
1559
+ const script = `
1560
+ tell application "Contacts"
1561
+ set mc to my card
1562
+ set pName to name of mc
1563
+ set phList to ""
1564
+ repeat with ph in phones of mc
1565
+ if phList is not "" then set phList to phList & ","
1566
+ set phList to phList & (value of ph)
1567
+ end repeat
1568
+ set eList to ""
1569
+ repeat with e in emails of mc
1570
+ if eList is not "" then set eList to eList & ","
1571
+ set eList to eList & (value of e)
1572
+ end repeat
1573
+ return pName & "\\t" & phList & "\\t" & eList
1574
+ end tell`;
1575
+
1576
+ try {
1577
+ const raw = execFileSync("osascript", ["-e", script], {
1578
+ encoding: "utf8",
1579
+ timeout: 10_000,
1580
+ });
1581
+ return parseContacts(raw)[0] ?? null;
1582
+ } catch {
1583
+ return null;
1584
+ }
1585
+ }
1586
+
1587
+ function parseContacts(raw) {
1588
+ return String(raw)
1589
+ .split("\n")
1590
+ .filter(Boolean)
1591
+ .map((line) => {
1592
+ const [name, phones, emails] = line.split("\t");
1593
+ return {
1594
+ name: name?.trim() ?? "",
1595
+ phones: phones ? phones.split(",").map((phone) => phone.trim()).filter(Boolean) : [],
1596
+ emails: emails ? emails.split(",").map((email) => email.trim()).filter(Boolean) : [],
1597
+ };
1598
+ })
1599
+ .filter((contact) => contact.name);
1600
+ }
1601
+
1602
+ function addHandleMapping(map, handle, name) {
1603
+ for (const candidate of handleCandidates(handle)) {
1604
+ map.set(candidate, name);
1605
+ }
1606
+ }
1607
+
1608
+ function addSelfHandle(set, handle) {
1609
+ for (const candidate of handleCandidates(handle)) {
1610
+ set.add(candidate);
1611
+ }
1612
+ }
1613
+
1614
+ function handleCandidates(handle) {
1615
+ const raw = String(handle ?? "").trim();
1616
+ if (!raw) return [];
1617
+ const lower = raw.toLowerCase();
1618
+ const normalized = normalizeHandle(raw);
1619
+ const candidates = new Set([raw, lower, normalized]);
1620
+ if (normalized.startsWith("+1") && normalized.length === 12) {
1621
+ candidates.add(normalized.slice(2));
1622
+ }
1623
+ if (/^\d{10}$/.test(normalized)) {
1624
+ candidates.add(`+1${normalized}`);
1625
+ }
1626
+ return [...candidates].filter(Boolean);
1627
+ }
1628
+
1629
+ function normalizeHandle(handle) {
1630
+ const raw = String(handle ?? "").trim();
1631
+ if (raw.includes("@")) return raw.toLowerCase();
1632
+ const compact = raw.replace(/[^\d+]/g, "");
1633
+ if (/^1\d{10}$/.test(compact)) return `+${compact}`;
1634
+ if (/^\d{10}$/.test(compact)) return `+1${compact}`;
1635
+ return compact || raw.toLowerCase();
1636
+ }
1637
+
1638
+ function cleanChatName(name) {
1639
+ if (typeof name !== "string") return null;
1640
+ const trimmed = name.trim();
1641
+ return trimmed || null;
1642
+ }
1643
+
1644
+ function parseDmHandleFromChatId(chatId) {
1645
+ const parts = String(chatId ?? "").split(";");
1646
+ if (parts.length >= 3 && parts[1] === "-") return parts.slice(2).join(";") || null;
1647
+ return null;
1648
+ }
1649
+
1650
+ function parseSelectionIndexes(answer, max) {
1651
+ const indexes = new Set();
1652
+ for (const part of String(answer ?? "").split(/[,\s]+/).map((value) => value.trim()).filter(Boolean)) {
1653
+ const range = part.match(/^(\d+)-(\d+)$/);
1654
+ if (range) {
1655
+ const start = Number(range[1]);
1656
+ const end = Number(range[2]);
1657
+ for (let value = Math.min(start, end); value <= Math.max(start, end); value++) {
1658
+ if (value >= 1 && value <= max) indexes.add(value - 1);
1659
+ }
1660
+ continue;
1661
+ }
1662
+ const value = Number(part);
1663
+ if (Number.isInteger(value) && value >= 1 && value <= max) indexes.add(value - 1);
1664
+ }
1665
+ return [...indexes].sort((a, b) => a - b);
1666
+ }
1667
+
1668
+ function parseMessageChatIdsArg() {
1669
+ return parseAllowedChatIds(args["messages-chat-ids"] ?? args["message-chat-ids"] ?? args["messages-chats"]);
1670
+ }
1671
+
1672
+ function parseAllowedChatIds(value) {
1673
+ if (!value) return [];
1674
+ const raw = Array.isArray(value) ? value : String(value).split(",");
1675
+ return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
1676
+ }
1677
+
1181
1678
  class MessagesBatchSender {
1182
1679
  constructor(apiUrl, agentToken, userId) {
1183
1680
  this.apiUrl = trimTrailingSlash(apiUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shepherd-onboard",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Customer-facing Shepherd raw sync onboarding CLI",
5
5
  "type": "module",
6
6
  "bin": {