shepherd-onboard 0.1.9 → 0.1.10

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, 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.
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
@@ -31,7 +31,8 @@ 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
- - 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
 
@@ -45,6 +46,7 @@ The command does not expose Railway, database, Redis, or internal service detail
45
46
  --org <name> Organization name
46
47
  --granola-api-key <key> Granola API key
47
48
  --messages-handle <value> Messages phone number or Apple ID email
49
+ --messages-chat-ids <ids> Comma-separated chat IDs selected from messages-chats
48
50
  --messages-backfill-days Local Messages backfill window, default 30
49
51
  --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar)
50
52
  --no-slack Skip Slack
@@ -54,6 +56,20 @@ The command does not expose Railway, database, Redis, or internal service detail
54
56
  --no-open Print auth URLs instead of opening the browser
55
57
  ```
56
58
 
59
+ ## Messages Chat Selection
60
+
61
+ List recent local Messages chats:
62
+
63
+ ```sh
64
+ npx -y shepherd-onboard@latest messages-chats --json
65
+ ```
66
+
67
+ Pass the selected chat IDs when finishing onboarding:
68
+
69
+ ```sh
70
+ npx -y shepherd-onboard@latest agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"
71
+ ```
72
+
57
73
  ## Granola API Keys
58
74
 
59
75
  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,62 @@ 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 GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
17
+ const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
18
+ "gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
19
+ const GOOGLE_WORKSPACE_DELEGATION_CLIENT_ID = "118363960386741325727";
20
+ const GOOGLE_WORKSPACE_DELEGATION_SCOPES = [
21
+ "https://mail.google.com/",
22
+ "https://www.googleapis.com/auth/gmail.addons.current.action.compose",
23
+ "https://www.googleapis.com/auth/gmail.addons.current.message.action",
24
+ "https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
25
+ "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
26
+ "https://www.googleapis.com/auth/gmail.compose",
27
+ "https://www.googleapis.com/auth/gmail.insert",
28
+ "https://www.googleapis.com/auth/gmail.labels",
29
+ "https://www.googleapis.com/auth/gmail.metadata",
30
+ "https://www.googleapis.com/auth/gmail.modify",
31
+ "https://www.googleapis.com/auth/gmail.readonly",
32
+ "https://www.googleapis.com/auth/gmail.send",
33
+ "https://www.googleapis.com/auth/gmail.settings.basic",
34
+ "https://www.googleapis.com/auth/gmail.settings.sharing",
35
+ "https://www.googleapis.com/auth/calendar",
36
+ "https://www.googleapis.com/auth/calendar.acls",
37
+ "https://www.googleapis.com/auth/calendar.acls.readonly",
38
+ "https://www.googleapis.com/auth/calendar.app.created",
39
+ "https://www.googleapis.com/auth/calendar.calendarlist",
40
+ "https://www.googleapis.com/auth/calendar.calendarlist.readonly",
41
+ "https://www.googleapis.com/auth/calendar.calendars",
42
+ "https://www.googleapis.com/auth/calendar.calendars.readonly",
43
+ "https://www.googleapis.com/auth/calendar.events",
44
+ "https://www.googleapis.com/auth/calendar.events.freebusy",
45
+ "https://www.googleapis.com/auth/calendar.events.owned",
46
+ "https://www.googleapis.com/auth/calendar.events.owned.readonly",
47
+ "https://www.googleapis.com/auth/calendar.events.public.readonly",
48
+ "https://www.googleapis.com/auth/calendar.events.readonly",
49
+ "https://www.googleapis.com/auth/calendar.freebusy",
50
+ "https://www.googleapis.com/auth/calendar.readonly",
51
+ "https://www.googleapis.com/auth/calendar.settings.readonly",
52
+ "https://www.googleapis.com/auth/drive",
53
+ "https://www.googleapis.com/auth/drive.appdata",
54
+ "https://www.googleapis.com/auth/drive.apps.readonly",
55
+ "https://www.googleapis.com/auth/drive.file",
56
+ "https://www.googleapis.com/auth/drive.install",
57
+ "https://www.googleapis.com/auth/drive.meet.readonly",
58
+ "https://www.googleapis.com/auth/drive.metadata",
59
+ "https://www.googleapis.com/auth/drive.metadata.readonly",
60
+ "https://www.googleapis.com/auth/drive.photos.readonly",
61
+ "https://www.googleapis.com/auth/drive.readonly",
62
+ "https://www.googleapis.com/auth/drive.scripts",
63
+ "https://www.googleapis.com/auth/documents",
64
+ "https://www.googleapis.com/auth/spreadsheets",
65
+ "https://www.googleapis.com/auth/presentations",
66
+ "https://www.googleapis.com/auth/tasks",
67
+ "https://www.googleapis.com/auth/contacts.readonly",
68
+ "https://www.googleapis.com/auth/drive.activity.readonly",
69
+ "https://www.googleapis.com/auth/directory.readonly",
70
+ ];
15
71
 
16
72
  const rawArgv = process.argv.slice(2);
17
73
  const command = rawArgv[0] && !rawArgv[0].startsWith("--") ? rawArgv[0] : "onboard";
@@ -34,6 +90,8 @@ async function dispatch() {
34
90
  await runAgentOnboarding();
35
91
  } else if (command === "granola-api-keys") {
36
92
  await openGranolaApiKeys({ noOpen: Boolean(args["no-open"]) });
93
+ } else if (command === "messages-chats") {
94
+ await runMessagesChatsCommand();
37
95
  } else if (command === "messages-agent") {
38
96
  await runMessagesAgent();
39
97
  } else {
@@ -81,10 +139,10 @@ async function runOnboarding() {
81
139
  console.log(`Matched existing organization by ${session.account.organizationMatch.type}.`);
82
140
  }
83
141
 
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.");
142
+ if (sources.google) {
143
+ console.log("\nGoogle Workspace domain-wide delegation");
144
+ printGoogleWorkspaceDelegationSetup(session.googleWorkspaceDelegation);
145
+ await waitForEnter("After the Google Workspace super admin authorizes Shepherd in Admin Console, press Enter.");
88
146
  }
89
147
 
90
148
  if (session.authUrls?.slack) {
@@ -94,6 +152,7 @@ async function runOnboarding() {
94
152
  }
95
153
 
96
154
  const finalizeBody = { sessionToken: session.sessionToken };
155
+ let selectedMessageChats = [];
97
156
 
98
157
  if (sources.granola) {
99
158
  await openGranolaApiKeys({ noOpen });
@@ -103,7 +162,10 @@ async function runOnboarding() {
103
162
 
104
163
  if (sources.messages) {
105
164
  const handle = await valueOrPrompt("messages-handle", "Messages phone number or Apple ID email", { optional: true });
106
- if (handle) finalizeBody.imessage = { handle };
165
+ if (handle) {
166
+ finalizeBody.imessage = { handle };
167
+ selectedMessageChats = await selectRecentMessageChats();
168
+ }
107
169
  }
108
170
 
109
171
  const finalized = await postJson(
@@ -127,6 +189,8 @@ async function runOnboarding() {
127
189
  userId: session.sessionId,
128
190
  agentToken: finalized.connected.messages.agentToken,
129
191
  backfillDays: Number(args["messages-backfill-days"] ?? 30),
192
+ allowedChatIds: selectedMessageChats.map((chat) => chat.chatId),
193
+ selectedChats: selectedMessageChats,
130
194
  });
131
195
 
132
196
  if (!args["no-install-messages-agent"]) {
@@ -219,6 +283,7 @@ async function runAgentOnboarding() {
219
283
  account: session.account,
220
284
  sources,
221
285
  authUrls: session.authUrls ?? {},
286
+ googleWorkspaceDelegation: googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation),
222
287
  workosAuth,
223
288
  createdAt: new Date().toISOString(),
224
289
  });
@@ -226,6 +291,7 @@ async function runAgentOnboarding() {
226
291
  const opened = [];
227
292
  for (const [provider, url] of Object.entries(session.authUrls ?? {})) {
228
293
  if (typeof url !== "string") continue;
294
+ if (provider === "google") continue;
229
295
  if (!noOpen) await openOrPrint(url, { noOpen: false });
230
296
  opened.push(provider);
231
297
  }
@@ -240,9 +306,11 @@ async function runAgentOnboarding() {
240
306
  status: "auth_required",
241
307
  account: publicAgentAccount(session.account),
242
308
  opened,
309
+ googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
243
310
  granolaApiKeyPage,
244
311
  statePath,
245
- nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"`,
312
+ messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats --json` : undefined,
313
+ nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
246
314
  needsUserAction: agentNeedsUserAction(sources, opened),
247
315
  }, null, 2));
248
316
  return;
@@ -257,18 +325,28 @@ async function runAgentOnboarding() {
257
325
  if (opened.length) {
258
326
  console.log(`Opened browser authorization: ${opened.join(", ")}`);
259
327
  }
328
+ if (sources.google) {
329
+ console.log("\nGoogle Workspace domain-wide delegation setup:");
330
+ printGoogleWorkspaceDelegationSetup(session.googleWorkspaceDelegation);
331
+ }
260
332
  if (noOpen) {
261
333
  for (const [provider, url] of Object.entries(session.authUrls ?? {})) {
334
+ if (provider === "google") continue;
262
335
  console.log(`${provider} auth URL: ${url}`);
263
336
  }
264
337
  }
265
338
  console.log(`State saved: ${statePath}`);
266
339
  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>"`);
340
+ if (sources.google) {
341
+ console.log("1. Ask the user's Google Workspace super admin to authorize Shepherd in Google Admin Console with the Client ID and scopes above.");
342
+ }
343
+ if (sources.slack) console.log("2. Ask the user to finish the opened Slack browser authorization.");
344
+ if (sources.granola) console.log("3. Ask the user for their Granola API key from the Granola Mac app.");
345
+ if (sources.messages) {
346
+ console.log(`4. Run ${agentCommand()} messages-chats --json, ask the user which recent chats to sync, and keep those chat IDs.`);
347
+ }
348
+ console.log("5. Run:");
349
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
272
350
  console.log(" Omit either optional flag if that source is not being connected.");
273
351
  }
274
352
 
@@ -354,8 +432,12 @@ async function continueAgentOnboarding() {
354
432
  const body = { sessionToken: state.sessionToken };
355
433
  const granolaApiKey = stringArg("granola-api-key");
356
434
  const messagesHandle = stringArg("messages-handle");
435
+ const selectedMessageChatIds = parseMessageChatIdsArg();
357
436
  if (granolaApiKey) body.granolaApiKey = granolaApiKey;
358
437
  if (messagesHandle) body.imessage = { handle: messagesHandle };
438
+ if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
439
+ 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>".`);
440
+ }
359
441
 
360
442
  const finalized = await postJson(
361
443
  `${state.apiUrl}/onboarding/raw/session/${encodeURIComponent(state.sessionId)}/finalize`,
@@ -369,6 +451,7 @@ async function continueAgentOnboarding() {
369
451
  userId: state.sessionId,
370
452
  agentToken: finalized.connected.messages.agentToken,
371
453
  backfillDays: Number(args["messages-backfill-days"] ?? 30),
454
+ allowedChatIds: selectedMessageChatIds,
372
455
  });
373
456
 
374
457
  if (!args["no-install-messages-agent"]) {
@@ -390,7 +473,7 @@ async function continueAgentOnboarding() {
390
473
  status: errors ? "waiting" : "completed",
391
474
  connected: Object.keys(finalized.connected ?? {}),
392
475
  errors: errors ? safeErrorRecord(errors) : undefined,
393
- nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"` : undefined,
476
+ 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
477
  }, null, 2));
395
478
  return;
396
479
  }
@@ -401,7 +484,7 @@ async function continueAgentOnboarding() {
401
484
  console.log(`- ${source}: ${safeError(message)}`);
402
485
  }
403
486
  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>"`);
487
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
405
488
  console.log(" Omit either optional flag if that source is not being connected.");
406
489
  return;
407
490
  }
@@ -425,6 +508,25 @@ async function printAgentStatus() {
425
508
  }, null, 2));
426
509
  }
427
510
 
511
+ async function runMessagesChatsCommand() {
512
+ const chats = await listRecentMessageChats({
513
+ limit: clampInt(Number(args.limit ?? DEFAULT_RECENT_MESSAGE_CHATS), 1, 100),
514
+ });
515
+
516
+ if (args.json) {
517
+ console.log(JSON.stringify({ chats }, null, 2));
518
+ return;
519
+ }
520
+
521
+ console.log(`\nRecent local Messages chats (${chats.length})\n`);
522
+ for (let i = 0; i < chats.length; i++) {
523
+ console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
524
+ console.log(` ${chats[i].chatId}`);
525
+ }
526
+ console.log("\nPass selected IDs to:");
527
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"`);
528
+ }
529
+
428
530
  async function runMessagesAgent() {
429
531
  const configPath = stringArg("config");
430
532
  if (!configPath) throw new Error("messages-agent requires --config <path>");
@@ -434,23 +536,30 @@ async function runMessagesAgent() {
434
536
  const userId = requiredConfigString(config.userId, "userId");
435
537
  const agentToken = requiredConfigString(config.agentToken, "agentToken");
436
538
  const backfillDays = clampInt(Number(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays ?? 30), 0, 3650);
539
+ const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
540
+ if (allowedChatIds.length === 0) {
541
+ throw new Error("Messages config must include selected chat IDs. Re-run onboarding and select one or more recent Messages chats.");
542
+ }
437
543
 
438
544
  const kit = await import("@photon-ai/imessage-kit");
439
545
  const sdk = new kit.IMessageSDK({ debug: args.debug === true });
440
546
  const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
441
- const serializer = createMessageSerializer(kit);
547
+ const contactLookup = buildContactLookup();
548
+ const serializer = createMessageSerializer(kit, contactLookup);
442
549
 
443
550
  console.log("Shepherd Messages raw sync starting");
551
+ console.log(`Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
444
552
 
445
553
  try {
446
554
  await loadGroupChatNames(sdk, serializer);
555
+ loadSelectedChatNames(config.selectedChats, serializer);
447
556
 
448
557
  if (backfillDays > 0) {
449
- await runMessagesBackfill(sdk, sender, serializer, backfillDays);
558
+ await runMessagesBackfill(sdk, sender, serializer, backfillDays, allowedChatIds);
450
559
  }
451
560
 
452
- await gapFillFromWatermark(sdk, sender, serializer, userId);
453
- await watchMessages(sdk, sender, serializer, userId);
561
+ await gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds);
562
+ await watchMessages(sdk, sender, serializer, userId, allowedChatIds);
454
563
  } catch (err) {
455
564
  await sdk.close?.().catch(() => undefined);
456
565
  throw err;
@@ -489,12 +598,13 @@ Usage:
489
598
  npx -y ${PACKAGE_NAME}@latest agent
490
599
  npx -y ${PACKAGE_NAME}@latest agent --login
491
600
  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>
601
+ npx -y ${PACKAGE_NAME}@latest messages-chats --json
602
+ npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
493
603
  npx -y ${PACKAGE_NAME}@latest agent --status
494
604
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
495
605
 
496
606
  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.
607
+ 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
608
  `);
499
609
  return;
500
610
  }
@@ -514,11 +624,27 @@ Options:
514
624
  return;
515
625
  }
516
626
 
627
+ if (which === "messages-chats") {
628
+ console.log(`Shepherd Messages recent chat selector
629
+
630
+ Usage:
631
+ npx -y ${PACKAGE_NAME}@latest messages-chats
632
+ npx -y ${PACKAGE_NAME}@latest messages-chats --json
633
+
634
+ Options:
635
+ --limit <n> Number of recent chats to show. Defaults to ${DEFAULT_RECENT_MESSAGE_CHATS}.
636
+ --json Print machine-readable chat IDs and labels.
637
+ --help Show this help.
638
+ `);
639
+ return;
640
+ }
641
+
517
642
  console.log(`Shepherd raw sync onboarding
518
643
 
519
644
  Usage:
520
645
  npx -y ${PACKAGE_NAME}@latest
521
646
  npx -y ${PACKAGE_NAME}@latest agent
647
+ npx -y ${PACKAGE_NAME}@latest messages-chats
522
648
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
523
649
 
524
650
  Options:
@@ -527,9 +653,10 @@ Options:
527
653
  --org <name> Organization name.
528
654
  --granola-api-key <key> Granola API key.
529
655
  --messages-handle <value> Messages phone number or Apple ID email.
656
+ --messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats.
530
657
  --messages-backfill-days <days>
531
658
  Local Messages backfill window. Defaults to 30.
532
- --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar).
659
+ --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts).
533
660
  --no-slack Skip Slack.
534
661
  --no-granola Skip Granola.
535
662
  --no-open-granola Do not open the Granola API key screen.
@@ -552,10 +679,13 @@ function printAgentContract() {
552
679
  "Start with selection questions to determine what the user wants connected.",
553
680
  "Ask whether they are joining an existing organization or creating a new one.",
554
681
  "Run Shepherd WorkOS login/signup before source setup. Do not ask whether they already have an account.",
682
+ "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
683
  "Collect Full name and Organization name as direct text prompts after those choices. The email comes from WorkOS auth.",
556
684
  "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.",
685
+ "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
686
  "Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
558
687
  "If the user chooses Provide handle, ask for the phone number or Apple ID email.",
688
+ "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
689
  ],
560
690
  selectionQuestions: [
561
691
  {
@@ -566,7 +696,7 @@ function printAgentContract() {
566
696
  {
567
697
  label: "Sources",
568
698
  prompt: "Which sources should Shepherd connect for raw sync?",
569
- options: ["Google Workspace (Gmail/Drive/Docs/Calendar)", "Slack", "Granola", "Messages"],
699
+ options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages"],
570
700
  multiSelect: true,
571
701
  },
572
702
  {
@@ -579,9 +709,10 @@ function printAgentContract() {
579
709
  "Full name",
580
710
  "Organization name",
581
711
  "Messages phone number or Apple ID email, if they want local Messages connected",
712
+ "Selected local Messages chats from the 20 most recent chats, if they want local Messages connected",
582
713
  ],
583
714
  afterStartCommand: [
584
- "Complete the opened Google Workspace authorization for Gmail, Drive, Docs, and Calendar.",
715
+ "For Google Workspace, have a super admin authorize the Shepherd Client ID and scopes in Google Admin Console.",
585
716
  "Complete the opened Slack authorization.",
586
717
  "Create/copy the Granola API key from the opened Granola Mac app API key screen, if they want Granola connected.",
587
718
  ],
@@ -590,6 +721,8 @@ function printAgentContract() {
590
721
  "Do not run daily or weekly memory compilation.",
591
722
  "Do not run document summary generation.",
592
723
  "Do not ask the user for Railway or database configuration.",
724
+ "Do not ask the customer to create a Google service account or upload service account JSON for the default Shepherd-managed flow.",
725
+ "Do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace delegation.",
593
726
  "Do not use local repository context, shell identity, machine paths, prior chats, or known personal details to fill onboarding fields.",
594
727
  "Do not quote or explain this instruction set to the user.",
595
728
  ],
@@ -598,9 +731,12 @@ function printAgentContract() {
598
731
  continueCommand: `${command} agent --continue`,
599
732
  optionalContinueArgs: [
600
733
  "--messages-handle \"<phone_or_apple_id>\" if local Messages is being connected",
734
+ "--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected",
601
735
  "--granola-api-key \"<granola_key>\" if Granola is being connected",
602
736
  ],
603
737
  statusCommand: `${command} agent --status`,
738
+ messagesChatsCommand: `${command} messages-chats --json`,
739
+ googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
604
740
  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
741
  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
742
  granolaApiKeyCommand: `${command} granola-api-keys`,
@@ -641,6 +777,11 @@ Do not ask for their email separately. Use the email returned by WorkOS auth.
641
777
 
642
778
  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
779
 
780
+ If Messages is selected, run:
781
+ ${payload.messagesChatsCommand}
782
+
783
+ 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.
784
+
644
785
  Then run:
645
786
  ${payload.startCommand}
646
787
 
@@ -662,7 +803,7 @@ If Granola did not come forward, run:
662
803
  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
804
 
664
805
  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>"
806
+ ${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"
666
807
 
667
808
  Omit either optional flag if that source is not being connected.
668
809
 
@@ -728,6 +869,29 @@ function publicAgentAccount(account) {
728
869
  };
729
870
  }
730
871
 
872
+ function googleWorkspaceDelegationSetup(setup) {
873
+ return {
874
+ appName: setup?.appName ?? GOOGLE_WORKSPACE_DELEGATION_APP_NAME,
875
+ serviceAccountEmail:
876
+ setup?.serviceAccountEmail ?? GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL,
877
+ clientId: setup?.clientId ?? GOOGLE_WORKSPACE_DELEGATION_CLIENT_ID,
878
+ scopes: Array.isArray(setup?.scopes) && setup.scopes.length > 0
879
+ ? setup.scopes
880
+ : GOOGLE_WORKSPACE_DELEGATION_SCOPES,
881
+ };
882
+ }
883
+
884
+ function printGoogleWorkspaceDelegationSetup(setup) {
885
+ const resolved = googleWorkspaceDelegationSetup(setup);
886
+ console.log(`App name: ${resolved.appName}`);
887
+ console.log(`Service account email: ${resolved.serviceAccountEmail}`);
888
+ console.log(`Domain-wide delegation OAuth Client ID: ${resolved.clientId}`);
889
+ console.log("Customer action: in Google Admin Console, add the Client ID above and paste these scopes.");
890
+ console.log("Customers do not create a service account or upload service account JSON; Shepherd stores its private service account JSON server-side.");
891
+ console.log("\nScopes:");
892
+ for (const scope of resolved.scopes) console.log(scope);
893
+ }
894
+
731
895
  function authenticatedEmail(authenticated) {
732
896
  return authenticated?.workosUser?.email ?? authenticated?.account?.email ?? null;
733
897
  }
@@ -738,10 +902,10 @@ function authenticatedName(authenticated) {
738
902
 
739
903
  function agentNeedsUserAction(sources, opened) {
740
904
  const actions = [];
741
- if (sources.google && opened.includes("google")) actions.push("Complete Google Workspace browser authorization for Gmail, Drive, Docs, and Calendar consent.");
905
+ 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
906
  if (sources.slack && opened.includes("slack")) actions.push("Complete Slack browser authorization.");
743
907
  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.");
908
+ 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
909
  return actions;
746
910
  }
747
911
 
@@ -948,6 +1112,10 @@ async function writeMessagesConfig(input) {
948
1112
  const dir = join(homedir(), ".shepherd", "raw-messages");
949
1113
  await mkdir(dir, { recursive: true });
950
1114
  const path = join(dir, `${input.userId}.json`);
1115
+ const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
1116
+ if (allowedChatIds.length === 0) {
1117
+ throw new Error("Select at least one Messages chat before installing local Messages sync.");
1118
+ }
951
1119
  await writeFile(
952
1120
  path,
953
1121
  JSON.stringify({
@@ -955,6 +1123,8 @@ async function writeMessagesConfig(input) {
955
1123
  userId: input.userId,
956
1124
  agentToken: input.agentToken,
957
1125
  backfillDays: input.backfillDays,
1126
+ allowedChatIds,
1127
+ selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats.map(publicMessageChat) : [],
958
1128
  createdAt: new Date().toISOString(),
959
1129
  }, null, 2),
960
1130
  { mode: 0o600 },
@@ -1019,6 +1189,120 @@ async function installMessagesAgent(configPath, userId) {
1019
1189
  return { label, plistPath, stdoutPath, stderrPath };
1020
1190
  }
1021
1191
 
1192
+ async function selectRecentMessageChats() {
1193
+ const explicitIds = parseMessageChatIdsArg();
1194
+ if (explicitIds.length > 0) {
1195
+ return explicitIds.map((chatId) => ({ chatId, label: chatId, kind: "unknown", participants: [] }));
1196
+ }
1197
+
1198
+ if (!process.stdin.isTTY) {
1199
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json and pass --messages-chat-ids "<id1>,<id2>".`);
1200
+ }
1201
+
1202
+ const chats = await listRecentMessageChats({ limit: DEFAULT_RECENT_MESSAGE_CHATS });
1203
+ if (chats.length === 0) {
1204
+ throw new Error("No recent local Messages chats were found on this Mac.");
1205
+ }
1206
+
1207
+ console.log(`\nSelect local Messages chats to sync\n`);
1208
+ console.log("Shepherd will only pull from the chats you select.");
1209
+ for (let i = 0; i < chats.length; i++) {
1210
+ console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
1211
+ }
1212
+
1213
+ const answer = await prompt("\nEnter chat numbers to sync, separated by commas: ");
1214
+ const indexes = parseSelectionIndexes(answer, chats.length);
1215
+ if (indexes.length === 0) throw new Error("Select at least one Messages chat.");
1216
+ return indexes.map((idx) => chats[idx]);
1217
+ }
1218
+
1219
+ async function listRecentMessageChats({ limit }) {
1220
+ if (platform() !== "darwin") {
1221
+ throw new Error("local Messages chat discovery is only supported on macOS");
1222
+ }
1223
+
1224
+ const kit = await import("@photon-ai/imessage-kit");
1225
+ const sdk = new kit.IMessageSDK({ debug: args.debug === true });
1226
+ const contactLookup = buildContactLookup();
1227
+ try {
1228
+ const chats = await sdk.listChats({
1229
+ sortBy: "recent",
1230
+ limit: Math.max(limit, DEFAULT_RECENT_MESSAGE_CHATS),
1231
+ });
1232
+ const visible = chats
1233
+ .filter((chat) => typeof chat.chatId === "string" && chat.chatId.trim())
1234
+ .filter((chat) => chat.kind === "dm" || chat.kind === "group")
1235
+ .slice(0, limit);
1236
+
1237
+ const enriched = [];
1238
+ for (const chat of visible) {
1239
+ enriched.push(await enrichMessageChat(sdk, chat, contactLookup));
1240
+ }
1241
+ return enriched;
1242
+ } finally {
1243
+ await sdk.close?.().catch(() => undefined);
1244
+ }
1245
+ }
1246
+
1247
+ async function enrichMessageChat(sdk, chat, contactLookup) {
1248
+ const recentMessages = await sdk.getMessages({ chatId: chat.chatId, limit: 30 }).catch(() => []);
1249
+ const participants = uniqueParticipants(recentMessages, contactLookup);
1250
+ const dmHandle = chat.kind === "dm"
1251
+ ? participants[0]?.handle ?? parseDmHandleFromChatId(chat.chatId)
1252
+ : null;
1253
+ const dmName = dmHandle ? contactLookup.resolveName(dmHandle) : null;
1254
+ const groupNames = participants.map((participant) => participant.name ?? participant.handle).filter(Boolean);
1255
+ const label = cleanChatName(chat.name)
1256
+ ?? (chat.kind === "dm" ? dmName ?? dmHandle : null)
1257
+ ?? (chat.kind === "group" && groupNames.length ? groupNames.slice(0, 4).join(", ") : null)
1258
+ ?? (chat.kind === "group" ? "Group chat" : "Direct message");
1259
+
1260
+ return {
1261
+ chatId: chat.chatId,
1262
+ label,
1263
+ kind: chat.kind ?? "unknown",
1264
+ service: chat.service ?? null,
1265
+ lastMessageAt: isoDate(chat.lastMessageAt),
1266
+ participants,
1267
+ };
1268
+ }
1269
+
1270
+ function uniqueParticipants(messages, contactLookup) {
1271
+ const seen = new Set();
1272
+ const participants = [];
1273
+ for (const msg of messages) {
1274
+ const handle = typeof msg.participant === "string" ? msg.participant.trim() : "";
1275
+ if (!handle || contactLookup.isSelfHandle(handle)) continue;
1276
+ const normalized = normalizeHandle(handle);
1277
+ if (seen.has(normalized)) continue;
1278
+ seen.add(normalized);
1279
+ participants.push({
1280
+ handle,
1281
+ name: contactLookup.resolveName(handle),
1282
+ });
1283
+ }
1284
+ return participants;
1285
+ }
1286
+
1287
+ function formatMessageChatOption(chat) {
1288
+ const kind = chat.kind === "group" ? "group" : chat.kind === "dm" ? "dm" : "chat";
1289
+ const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
1290
+ const people = names.length ? ` · ${names.slice(0, 4).join(", ")}` : "";
1291
+ const when = chat.lastMessageAt ? ` · ${new Date(chat.lastMessageAt).toLocaleString()}` : "";
1292
+ return `${chat.label} (${kind})${people}${when}`;
1293
+ }
1294
+
1295
+ function publicMessageChat(chat) {
1296
+ return {
1297
+ chatId: chat.chatId,
1298
+ label: chat.label,
1299
+ kind: chat.kind,
1300
+ service: chat.service ?? null,
1301
+ lastMessageAt: chat.lastMessageAt ?? null,
1302
+ participants: Array.isArray(chat.participants) ? chat.participants : [],
1303
+ };
1304
+ }
1305
+
1022
1306
  async function loadGroupChatNames(sdk, serializer) {
1023
1307
  if (typeof sdk.listChats !== "function") return;
1024
1308
  try {
@@ -1032,36 +1316,50 @@ async function loadGroupChatNames(sdk, serializer) {
1032
1316
  }
1033
1317
  }
1034
1318
 
1035
- async function runMessagesBackfill(sdk, sender, serializer, days) {
1036
- console.log(`Running ${days}-day Messages backfill`);
1319
+ function loadSelectedChatNames(selectedChats, serializer) {
1320
+ if (!Array.isArray(selectedChats)) return;
1321
+ for (const chat of selectedChats) {
1322
+ if (chat && typeof chat.chatId === "string" && typeof chat.label === "string") {
1323
+ serializer.setChatName(chat.chatId, chat.label);
1324
+ }
1325
+ }
1326
+ }
1327
+
1328
+ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds) {
1329
+ console.log(`Running ${days}-day Messages backfill for ${allowedChatIds.length} selected chat(s)`);
1037
1330
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
1038
1331
  const pageSize = 1000;
1039
- let offset = 0;
1040
1332
  let totalMessages = 0;
1041
1333
  let totalStored = 0;
1042
1334
 
1043
- while (true) {
1044
- const messages = await sdk.getMessages({ since, limit: pageSize, offset });
1045
- if (!messages.length) break;
1335
+ for (const chatId of allowedChatIds) {
1336
+ let offset = 0;
1337
+ while (true) {
1338
+ const messages = await sdk.getMessages({ chatId, since, limit: pageSize, offset });
1339
+ if (!messages.length) break;
1046
1340
 
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));
1341
+ totalMessages += messages.length;
1342
+ const result = await sender.send(messages.map((msg) => serializer.serialize(msg)));
1343
+ totalStored += result.stored;
1344
+ saveMessagesWatermark(sender.userId, maxRowId(messages));
1051
1345
 
1052
- if (messages.length < pageSize) break;
1053
- offset += pageSize;
1346
+ if (messages.length < pageSize) break;
1347
+ offset += pageSize;
1348
+ }
1054
1349
  }
1055
1350
 
1056
1351
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
1057
1352
  }
1058
1353
 
1059
- async function gapFillFromWatermark(sdk, sender, serializer, userId) {
1354
+ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds) {
1060
1355
  const lastWatermark = loadMessagesWatermark(userId);
1061
1356
  if (lastWatermark <= 0) return;
1062
1357
 
1063
- const missed = await sdk.getMessages({ limit: 5000 });
1064
- const newMessages = missed.filter((msg) => Number(msg.rowId) > lastWatermark);
1358
+ const missed = [];
1359
+ for (const chatId of allowedChatIds) {
1360
+ missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
1361
+ }
1362
+ const newMessages = missed.filter((msg) => Number(msg.rowId) > lastWatermark && allowedChatIds.includes(msg.chatId));
1065
1363
  if (newMessages.length === 0) return;
1066
1364
 
1067
1365
  const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
@@ -1069,7 +1367,8 @@ async function gapFillFromWatermark(sdk, sender, serializer, userId) {
1069
1367
  console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
1070
1368
  }
1071
1369
 
1072
- async function watchMessages(sdk, sender, serializer, userId) {
1370
+ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
1371
+ const allowed = new Set(allowedChatIds);
1073
1372
  let buffer = [];
1074
1373
  let timer = null;
1075
1374
 
@@ -1089,6 +1388,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
1089
1388
  };
1090
1389
 
1091
1390
  const onMessage = (msg) => {
1391
+ if (!msg.chatId || !allowed.has(msg.chatId)) return;
1092
1392
  buffer.push(msg);
1093
1393
  if (buffer.length >= MAX_BATCH_SIZE) {
1094
1394
  if (timer) clearTimeout(timer);
@@ -1105,7 +1405,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
1105
1405
  onError: (err) => console.error("Messages watcher error:", safeError(err)),
1106
1406
  });
1107
1407
 
1108
- console.log("Watching for new Messages");
1408
+ console.log("Watching for new Messages in selected chats");
1109
1409
 
1110
1410
  const shutdown = async () => {
1111
1411
  if (timer) clearTimeout(timer);
@@ -1118,7 +1418,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
1118
1418
  process.on("SIGTERM", shutdown);
1119
1419
  }
1120
1420
 
1121
- function createMessageSerializer(kit) {
1421
+ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
1122
1422
  const chatNames = new Map();
1123
1423
  const isImageAttachment = kit.isImageAttachment ?? (() => false);
1124
1424
  const isVideoAttachment = kit.isVideoAttachment ?? (() => false);
@@ -1173,11 +1473,213 @@ function createMessageSerializer(kit) {
1173
1473
  isForwarded: Boolean(msg.isForwarded),
1174
1474
  affectedParticipant: msg.affectedParticipant ?? null,
1175
1475
  newGroupName: msg.newGroupName ?? null,
1476
+ _resolved_name: msg.participant ? contactLookup.resolveName(msg.participant) : null,
1477
+ _is_self_handle: msg.participant ? contactLookup.isSelfHandle(msg.participant) : false,
1176
1478
  };
1177
1479
  },
1178
1480
  };
1179
1481
  }
1180
1482
 
1483
+ function buildContactLookup() {
1484
+ const contacts = loadContacts();
1485
+ const myCard = loadMyCard();
1486
+ const handleToName = new Map();
1487
+ const selfHandles = new Set();
1488
+
1489
+ for (const contact of contacts) {
1490
+ for (const phone of contact.phones) {
1491
+ addHandleMapping(handleToName, phone, contact.name);
1492
+ }
1493
+ for (const email of contact.emails) {
1494
+ addHandleMapping(handleToName, email, contact.name);
1495
+ }
1496
+ }
1497
+
1498
+ if (myCard) {
1499
+ for (const phone of myCard.phones) addSelfHandle(selfHandles, phone);
1500
+ for (const email of myCard.emails) addSelfHandle(selfHandles, email);
1501
+ }
1502
+
1503
+ return {
1504
+ resolveName(handle) {
1505
+ const candidates = handleCandidates(handle);
1506
+ for (const candidate of candidates) {
1507
+ const name = handleToName.get(candidate);
1508
+ if (name) return name;
1509
+ }
1510
+ return null;
1511
+ },
1512
+ isSelfHandle(handle) {
1513
+ return handleCandidates(handle).some((candidate) => selfHandles.has(candidate));
1514
+ },
1515
+ };
1516
+ }
1517
+
1518
+ function emptyContactLookup() {
1519
+ return {
1520
+ resolveName() {
1521
+ return null;
1522
+ },
1523
+ isSelfHandle() {
1524
+ return false;
1525
+ },
1526
+ };
1527
+ }
1528
+
1529
+ function loadContacts() {
1530
+ if (platform() !== "darwin") return [];
1531
+ const script = `
1532
+ set output to ""
1533
+ tell application "Contacts"
1534
+ repeat with p in every person
1535
+ set pName to name of p
1536
+ set phList to ""
1537
+ repeat with ph in phones of p
1538
+ if phList is not "" then set phList to phList & ","
1539
+ set phList to phList & (value of ph)
1540
+ end repeat
1541
+ set eList to ""
1542
+ repeat with e in emails of p
1543
+ if eList is not "" then set eList to eList & ","
1544
+ set eList to eList & (value of e)
1545
+ end repeat
1546
+ set output to output & pName & "\\t" & phList & "\\t" & eList & "\\n"
1547
+ end repeat
1548
+ end tell
1549
+ return output`;
1550
+
1551
+ try {
1552
+ const raw = execFileSync("osascript", ["-e", script], {
1553
+ encoding: "utf8",
1554
+ timeout: 30_000,
1555
+ });
1556
+ return parseContacts(raw);
1557
+ } catch {
1558
+ return [];
1559
+ }
1560
+ }
1561
+
1562
+ function loadMyCard() {
1563
+ if (platform() !== "darwin") return null;
1564
+ const script = `
1565
+ tell application "Contacts"
1566
+ set mc to my card
1567
+ set pName to name of mc
1568
+ set phList to ""
1569
+ repeat with ph in phones of mc
1570
+ if phList is not "" then set phList to phList & ","
1571
+ set phList to phList & (value of ph)
1572
+ end repeat
1573
+ set eList to ""
1574
+ repeat with e in emails of mc
1575
+ if eList is not "" then set eList to eList & ","
1576
+ set eList to eList & (value of e)
1577
+ end repeat
1578
+ return pName & "\\t" & phList & "\\t" & eList
1579
+ end tell`;
1580
+
1581
+ try {
1582
+ const raw = execFileSync("osascript", ["-e", script], {
1583
+ encoding: "utf8",
1584
+ timeout: 10_000,
1585
+ });
1586
+ return parseContacts(raw)[0] ?? null;
1587
+ } catch {
1588
+ return null;
1589
+ }
1590
+ }
1591
+
1592
+ function parseContacts(raw) {
1593
+ return String(raw)
1594
+ .split("\n")
1595
+ .filter(Boolean)
1596
+ .map((line) => {
1597
+ const [name, phones, emails] = line.split("\t");
1598
+ return {
1599
+ name: name?.trim() ?? "",
1600
+ phones: phones ? phones.split(",").map((phone) => phone.trim()).filter(Boolean) : [],
1601
+ emails: emails ? emails.split(",").map((email) => email.trim()).filter(Boolean) : [],
1602
+ };
1603
+ })
1604
+ .filter((contact) => contact.name);
1605
+ }
1606
+
1607
+ function addHandleMapping(map, handle, name) {
1608
+ for (const candidate of handleCandidates(handle)) {
1609
+ map.set(candidate, name);
1610
+ }
1611
+ }
1612
+
1613
+ function addSelfHandle(set, handle) {
1614
+ for (const candidate of handleCandidates(handle)) {
1615
+ set.add(candidate);
1616
+ }
1617
+ }
1618
+
1619
+ function handleCandidates(handle) {
1620
+ const raw = String(handle ?? "").trim();
1621
+ if (!raw) return [];
1622
+ const lower = raw.toLowerCase();
1623
+ const normalized = normalizeHandle(raw);
1624
+ const candidates = new Set([raw, lower, normalized]);
1625
+ if (normalized.startsWith("+1") && normalized.length === 12) {
1626
+ candidates.add(normalized.slice(2));
1627
+ }
1628
+ if (/^\d{10}$/.test(normalized)) {
1629
+ candidates.add(`+1${normalized}`);
1630
+ }
1631
+ return [...candidates].filter(Boolean);
1632
+ }
1633
+
1634
+ function normalizeHandle(handle) {
1635
+ const raw = String(handle ?? "").trim();
1636
+ if (raw.includes("@")) return raw.toLowerCase();
1637
+ const compact = raw.replace(/[^\d+]/g, "");
1638
+ if (/^1\d{10}$/.test(compact)) return `+${compact}`;
1639
+ if (/^\d{10}$/.test(compact)) return `+1${compact}`;
1640
+ return compact || raw.toLowerCase();
1641
+ }
1642
+
1643
+ function cleanChatName(name) {
1644
+ if (typeof name !== "string") return null;
1645
+ const trimmed = name.trim();
1646
+ return trimmed || null;
1647
+ }
1648
+
1649
+ function parseDmHandleFromChatId(chatId) {
1650
+ const parts = String(chatId ?? "").split(";");
1651
+ if (parts.length >= 3 && parts[1] === "-") return parts.slice(2).join(";") || null;
1652
+ return null;
1653
+ }
1654
+
1655
+ function parseSelectionIndexes(answer, max) {
1656
+ const indexes = new Set();
1657
+ for (const part of String(answer ?? "").split(/[,\s]+/).map((value) => value.trim()).filter(Boolean)) {
1658
+ const range = part.match(/^(\d+)-(\d+)$/);
1659
+ if (range) {
1660
+ const start = Number(range[1]);
1661
+ const end = Number(range[2]);
1662
+ for (let value = Math.min(start, end); value <= Math.max(start, end); value++) {
1663
+ if (value >= 1 && value <= max) indexes.add(value - 1);
1664
+ }
1665
+ continue;
1666
+ }
1667
+ const value = Number(part);
1668
+ if (Number.isInteger(value) && value >= 1 && value <= max) indexes.add(value - 1);
1669
+ }
1670
+ return [...indexes].sort((a, b) => a - b);
1671
+ }
1672
+
1673
+ function parseMessageChatIdsArg() {
1674
+ return parseAllowedChatIds(args["messages-chat-ids"] ?? args["message-chat-ids"] ?? args["messages-chats"]);
1675
+ }
1676
+
1677
+ function parseAllowedChatIds(value) {
1678
+ if (!value) return [];
1679
+ const raw = Array.isArray(value) ? value : String(value).split(",");
1680
+ return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
1681
+ }
1682
+
1181
1683
  class MessagesBatchSender {
1182
1684
  constructor(apiUrl, agentToken, userId) {
1183
1685
  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.10",
4
4
  "description": "Customer-facing Shepherd raw sync onboarding CLI",
5
5
  "type": "module",
6
6
  "bin": {