shepherd-onboard 0.1.10 → 0.1.12
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 +25 -7
- package/bin/shepherd-onboard.js +422 -49
- package/package.json +1 -1
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. If Messages is selected, the agent runs `messages-chats
|
|
13
|
+
The command prints the exact prompt the agent should follow, then the exact follow-up commands to open Shepherd WorkOS login/signup, guide Google Workspace Admin Console delegation, open source auth for non-Google sources, open Granola's API key page, finalize, start cloud raw polling/backfills, and install local Messages sync.
|
|
14
|
+
The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. If Messages is selected, the agent runs `messages-chats`, opens a minimal local webpage with the 20 most recent local Messages chats, and has the user select which contacts/groups to sync. Account creation/relinking always starts with Shepherd WorkOS auth.
|
|
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,17 +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
|
-
-
|
|
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
|
-
-
|
|
34
|
+
- opens a minimal local webpage showing the 20 most recent local Messages chats with contact/group names and only syncs the chats selected by the user
|
|
35
35
|
- sets up local macOS Messages raw sync with a background LaunchAgent scoped to the selected chats
|
|
36
36
|
- starts raw polling/backfill for connected sources
|
|
37
37
|
- does not start wiki generation, memory compilation, or doc summaries
|
|
38
38
|
|
|
39
39
|
The command does not expose Railway, database, Redis, or internal service details to the user.
|
|
40
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
|
+
|
|
41
57
|
## Options
|
|
42
58
|
|
|
43
59
|
```sh
|
|
@@ -48,7 +64,7 @@ The command does not expose Railway, database, Redis, or internal service detail
|
|
|
48
64
|
--messages-handle <value> Messages phone number or Apple ID email
|
|
49
65
|
--messages-chat-ids <ids> Comma-separated chat IDs selected from messages-chats
|
|
50
66
|
--messages-backfill-days Local Messages backfill window, default 30
|
|
51
|
-
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar)
|
|
67
|
+
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)
|
|
52
68
|
--no-slack Skip Slack
|
|
53
69
|
--no-granola Skip Granola
|
|
54
70
|
--no-open-granola Do not open the Granola API key screen
|
|
@@ -58,12 +74,14 @@ The command does not expose Railway, database, Redis, or internal service detail
|
|
|
58
74
|
|
|
59
75
|
## Messages Chat Selection
|
|
60
76
|
|
|
61
|
-
|
|
77
|
+
Open the local Messages chat selector:
|
|
62
78
|
|
|
63
79
|
```sh
|
|
64
|
-
npx -y shepherd-onboard@latest messages-chats
|
|
80
|
+
npx -y shepherd-onboard@latest messages-chats
|
|
65
81
|
```
|
|
66
82
|
|
|
83
|
+
Use `--json` for machine-readable chat metadata, or `--text` for a terminal list.
|
|
84
|
+
|
|
67
85
|
Pass the selected chat IDs when finishing onboarding:
|
|
68
86
|
|
|
69
87
|
```sh
|
package/bin/shepherd-onboard.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createServer } from "node:http";
|
|
5
6
|
import { homedir, platform } from "node:os";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import readline from "node:readline";
|
|
@@ -13,6 +14,7 @@ const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-ag
|
|
|
13
14
|
const MAX_BATCH_SIZE = 50;
|
|
14
15
|
const MAX_QUEUE_MESSAGES = 10_000;
|
|
15
16
|
const DEFAULT_RECENT_MESSAGE_CHATS = 20;
|
|
17
|
+
const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
|
|
16
18
|
const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
|
|
17
19
|
const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
|
|
18
20
|
"gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
|
|
@@ -283,7 +285,9 @@ async function runAgentOnboarding() {
|
|
|
283
285
|
account: session.account,
|
|
284
286
|
sources,
|
|
285
287
|
authUrls: session.authUrls ?? {},
|
|
286
|
-
googleWorkspaceDelegation:
|
|
288
|
+
googleWorkspaceDelegation: sources.google
|
|
289
|
+
? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation)
|
|
290
|
+
: undefined,
|
|
287
291
|
workosAuth,
|
|
288
292
|
createdAt: new Date().toISOString(),
|
|
289
293
|
});
|
|
@@ -309,7 +313,7 @@ async function runAgentOnboarding() {
|
|
|
309
313
|
googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
|
|
310
314
|
granolaApiKeyPage,
|
|
311
315
|
statePath,
|
|
312
|
-
messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats
|
|
316
|
+
messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
|
|
313
317
|
nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
|
|
314
318
|
needsUserAction: agentNeedsUserAction(sources, opened),
|
|
315
319
|
}, null, 2));
|
|
@@ -337,15 +341,16 @@ async function runAgentOnboarding() {
|
|
|
337
341
|
}
|
|
338
342
|
console.log(`State saved: ${statePath}`);
|
|
339
343
|
console.log("\nCoding agent next steps:");
|
|
344
|
+
let step = 1;
|
|
340
345
|
if (sources.google) {
|
|
341
|
-
console.log(
|
|
346
|
+
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.`);
|
|
342
347
|
}
|
|
343
|
-
if (sources.slack) console.log(
|
|
344
|
-
if (sources.granola) console.log(
|
|
348
|
+
if (sources.slack) console.log(`${step++}. Ask the user to finish the opened Slack browser authorization.`);
|
|
349
|
+
if (sources.granola) console.log(`${step++}. Ask the user for their Granola API key from the Granola Mac app.`);
|
|
345
350
|
if (sources.messages) {
|
|
346
|
-
console.log(
|
|
351
|
+
console.log(`${step++}. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, and keep the printed chat IDs.`);
|
|
347
352
|
}
|
|
348
|
-
console.log(
|
|
353
|
+
console.log(`${step++}. Run:`);
|
|
349
354
|
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
|
|
350
355
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
351
356
|
}
|
|
@@ -436,7 +441,7 @@ async function continueAgentOnboarding() {
|
|
|
436
441
|
if (granolaApiKey) body.granolaApiKey = granolaApiKey;
|
|
437
442
|
if (messagesHandle) body.imessage = { handle: messagesHandle };
|
|
438
443
|
if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
|
|
439
|
-
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats
|
|
444
|
+
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
|
|
440
445
|
}
|
|
441
446
|
|
|
442
447
|
const finalized = await postJson(
|
|
@@ -518,6 +523,16 @@ async function runMessagesChatsCommand() {
|
|
|
518
523
|
return;
|
|
519
524
|
}
|
|
520
525
|
|
|
526
|
+
if (!args.text && !args.list) {
|
|
527
|
+
const selected = await selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
|
|
528
|
+
const selectedIds = selected.map((chat) => chat.chatId).join(",");
|
|
529
|
+
console.log(`\nSelected ${selected.length} Messages chat(s).`);
|
|
530
|
+
console.log(`messages-chat-ids=${selectedIds}`);
|
|
531
|
+
console.log("\nContinue with:");
|
|
532
|
+
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "${selectedIds}"`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
521
536
|
console.log(`\nRecent local Messages chats (${chats.length})\n`);
|
|
522
537
|
for (let i = 0; i < chats.length; i++) {
|
|
523
538
|
console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
|
|
@@ -598,7 +613,7 @@ Usage:
|
|
|
598
613
|
npx -y ${PACKAGE_NAME}@latest agent
|
|
599
614
|
npx -y ${PACKAGE_NAME}@latest agent --login
|
|
600
615
|
npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
|
|
601
|
-
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
616
|
+
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
602
617
|
npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
|
|
603
618
|
npx -y ${PACKAGE_NAME}@latest agent --status
|
|
604
619
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
@@ -633,6 +648,8 @@ Usage:
|
|
|
633
648
|
|
|
634
649
|
Options:
|
|
635
650
|
--limit <n> Number of recent chats to show. Defaults to ${DEFAULT_RECENT_MESSAGE_CHATS}.
|
|
651
|
+
--text Print a terminal list instead of opening the selector page.
|
|
652
|
+
--no-open Print the local selector URL instead of opening it.
|
|
636
653
|
--json Print machine-readable chat IDs and labels.
|
|
637
654
|
--help Show this help.
|
|
638
655
|
`);
|
|
@@ -735,7 +752,7 @@ function printAgentContract() {
|
|
|
735
752
|
"--granola-api-key \"<granola_key>\" if Granola is being connected",
|
|
736
753
|
],
|
|
737
754
|
statusCommand: `${command} agent --status`,
|
|
738
|
-
messagesChatsCommand: `${command} messages-chats
|
|
755
|
+
messagesChatsCommand: `${command} messages-chats`,
|
|
739
756
|
googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
|
|
740
757
|
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.",
|
|
741
758
|
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.",
|
|
@@ -758,7 +775,7 @@ Ask with short interactive prompts, not as one pasted checklist.
|
|
|
758
775
|
|
|
759
776
|
Start with selection questions to determine intent:
|
|
760
777
|
1. Organization: Join existing org, or Create new org.
|
|
761
|
-
2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar), Slack, Granola, Messages. Allow multi-select if your interface supports it.
|
|
778
|
+
2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages. Allow multi-select if your interface supports it.
|
|
762
779
|
3. Messages, if selected: Skip Messages, or Provide handle.
|
|
763
780
|
|
|
764
781
|
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.
|
|
@@ -767,6 +784,7 @@ Before source setup, always run:
|
|
|
767
784
|
${payload.loginCommand}
|
|
768
785
|
|
|
769
786
|
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.
|
|
787
|
+
Do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace. WorkOS login is only Shepherd account identity.
|
|
770
788
|
|
|
771
789
|
Ask the user for:
|
|
772
790
|
1. Full name
|
|
@@ -780,7 +798,7 @@ If they are joining an existing org, ask for the org name they believe they belo
|
|
|
780
798
|
If Messages is selected, run:
|
|
781
799
|
${payload.messagesChatsCommand}
|
|
782
800
|
|
|
783
|
-
|
|
801
|
+
This opens a minimal local webpage with the 20 recent local Messages chats. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. When the command returns, keep the printed comma-separated chat IDs.
|
|
784
802
|
|
|
785
803
|
Then run:
|
|
786
804
|
${payload.startCommand}
|
|
@@ -791,8 +809,21 @@ Add skip flags for sources the user did not select:
|
|
|
791
809
|
- --no-granola
|
|
792
810
|
- --no-messages
|
|
793
811
|
|
|
794
|
-
That command creates/reuses the customer user and org,
|
|
795
|
-
|
|
812
|
+
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.
|
|
813
|
+
|
|
814
|
+
If Google Workspace is selected, show this Admin Console setup to the user and have their Google Workspace super admin authorize it:
|
|
815
|
+
|
|
816
|
+
App name: ${payload.googleWorkspaceDelegation.appName}
|
|
817
|
+
Service account email: ${payload.googleWorkspaceDelegation.serviceAccountEmail}
|
|
818
|
+
Domain-wide delegation OAuth Client ID: ${payload.googleWorkspaceDelegation.clientId}
|
|
819
|
+
|
|
820
|
+
Scopes:
|
|
821
|
+
${payload.googleWorkspaceDelegation.scopes.join("\n")}
|
|
822
|
+
|
|
823
|
+
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.
|
|
824
|
+
Shepherd must still enforce selected users and groups internally before polling or impersonating any employee email.
|
|
825
|
+
|
|
826
|
+
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.
|
|
796
827
|
|
|
797
828
|
If Granola is selected, it also opens the Granola desktop app. If your local app automation can navigate it, go to:
|
|
798
829
|
Settings -> Connectors -> API keys
|
|
@@ -802,7 +833,7 @@ If Granola did not come forward, run:
|
|
|
802
833
|
${payload.granolaApiKeyCommand}
|
|
803
834
|
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.
|
|
804
835
|
|
|
805
|
-
After Google Workspace and Slack browser auth
|
|
836
|
+
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:
|
|
806
837
|
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"
|
|
807
838
|
|
|
808
839
|
Omit either optional flag if that source is not being connected.
|
|
@@ -905,7 +936,7 @@ function agentNeedsUserAction(sources, opened) {
|
|
|
905
936
|
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.");
|
|
906
937
|
if (sources.slack && opened.includes("slack")) actions.push("Complete Slack browser authorization.");
|
|
907
938
|
if (sources.granola) actions.push("Create/copy a Granola API key from the Granola Mac app.");
|
|
908
|
-
if (sources.messages) actions.push("Run messages-chats,
|
|
939
|
+
if (sources.messages) actions.push("Run messages-chats, have the user select local Messages contacts/groups in the browser, then pass the printed chat IDs with the Messages handle.");
|
|
909
940
|
return actions;
|
|
910
941
|
}
|
|
911
942
|
|
|
@@ -1017,7 +1048,7 @@ async function openOrPrint(url, opts) {
|
|
|
1017
1048
|
}
|
|
1018
1049
|
|
|
1019
1050
|
async function openGranolaApiKeys(opts = {}) {
|
|
1020
|
-
const deepLink =
|
|
1051
|
+
const deepLink = granolaApiKeysDeepLink();
|
|
1021
1052
|
if (opts.noOpen) {
|
|
1022
1053
|
console.log("Granola API keys: open the Granola desktop app -> Settings -> Connectors -> API keys");
|
|
1023
1054
|
return { opened: false, target: "Granola Settings -> Connectors -> API keys" };
|
|
@@ -1030,19 +1061,20 @@ async function openGranolaApiKeys(opts = {}) {
|
|
|
1030
1061
|
|
|
1031
1062
|
console.log("\nOpening Granola API keys");
|
|
1032
1063
|
const bundleResult = await execFileQuiet("open", ["-b", "com.granola.app"], { ignoreError: true, captureError: true });
|
|
1033
|
-
await sleep(
|
|
1034
|
-
await execFileQuiet("open", [deepLink], { ignoreError: true, captureError: true });
|
|
1035
|
-
await sleep(
|
|
1064
|
+
await sleep(900);
|
|
1065
|
+
const deepLinkResult = await execFileQuiet("open", ["-u", deepLink], { ignoreError: true, captureError: true });
|
|
1066
|
+
await sleep(700);
|
|
1067
|
+
const deepLinkRetryResult = await execFileQuiet("open", ["-u", deepLink], { ignoreError: true, captureError: true });
|
|
1068
|
+
await sleep(300);
|
|
1036
1069
|
const activateByBundleResult = await execFileQuiet("osascript", [
|
|
1037
1070
|
"-e",
|
|
1038
1071
|
'tell application id "com.granola.app" to activate',
|
|
1039
1072
|
], { ignoreError: true, captureError: true });
|
|
1040
1073
|
const activateByNameResult = await execFileQuiet("open", ["-a", "Granola"], { ignoreError: true, captureError: true });
|
|
1041
1074
|
|
|
1042
|
-
if (bundleResult.error && activateByBundleResult.error && activateByNameResult.error) {
|
|
1075
|
+
if (bundleResult.error && deepLinkResult.error && deepLinkRetryResult.error && activateByBundleResult.error && activateByNameResult.error) {
|
|
1043
1076
|
await execFileQuiet("open", ["-a", "Granola"], { ignoreError: true });
|
|
1044
1077
|
}
|
|
1045
|
-
await navigateGranolaApiKeysWithAppleScript();
|
|
1046
1078
|
|
|
1047
1079
|
return {
|
|
1048
1080
|
opened: true,
|
|
@@ -1052,32 +1084,8 @@ async function openGranolaApiKeys(opts = {}) {
|
|
|
1052
1084
|
};
|
|
1053
1085
|
}
|
|
1054
1086
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
tell application id "com.granola.app" to activate
|
|
1058
|
-
delay 0.7
|
|
1059
|
-
tell application "System Events"
|
|
1060
|
-
if not (exists process "Granola") then return
|
|
1061
|
-
tell process "Granola"
|
|
1062
|
-
set frontmost to true
|
|
1063
|
-
try
|
|
1064
|
-
set {x, y} to position of window 1
|
|
1065
|
-
set {w, h} to size of window 1
|
|
1066
|
-
click at {x + w - 55, y + 810}
|
|
1067
|
-
return
|
|
1068
|
-
end try
|
|
1069
|
-
try
|
|
1070
|
-
keystroke "," using command down
|
|
1071
|
-
delay 0.5
|
|
1072
|
-
set {x, y} to position of window 1
|
|
1073
|
-
set {w, h} to size of window 1
|
|
1074
|
-
click at {x + w - 55, y + 810}
|
|
1075
|
-
return
|
|
1076
|
-
end try
|
|
1077
|
-
end tell
|
|
1078
|
-
end tell
|
|
1079
|
-
`;
|
|
1080
|
-
await execFileQuiet("osascript", ["-e", script], { ignoreError: true });
|
|
1087
|
+
function granolaApiKeysDeepLink() {
|
|
1088
|
+
return `granola://open?path=${encodeURIComponent(GRANOLA_API_KEYS_PATH)}`;
|
|
1081
1089
|
}
|
|
1082
1090
|
|
|
1083
1091
|
async function postJson(url, body, opts = {}) {
|
|
@@ -1196,7 +1204,7 @@ async function selectRecentMessageChats() {
|
|
|
1196
1204
|
}
|
|
1197
1205
|
|
|
1198
1206
|
if (!process.stdin.isTTY) {
|
|
1199
|
-
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats
|
|
1207
|
+
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>".`);
|
|
1200
1208
|
}
|
|
1201
1209
|
|
|
1202
1210
|
const chats = await listRecentMessageChats({ limit: DEFAULT_RECENT_MESSAGE_CHATS });
|
|
@@ -1204,6 +1212,10 @@ async function selectRecentMessageChats() {
|
|
|
1204
1212
|
throw new Error("No recent local Messages chats were found on this Mac.");
|
|
1205
1213
|
}
|
|
1206
1214
|
|
|
1215
|
+
if (!args.text && !args.list) {
|
|
1216
|
+
return selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1207
1219
|
console.log(`\nSelect local Messages chats to sync\n`);
|
|
1208
1220
|
console.log("Shepherd will only pull from the chats you select.");
|
|
1209
1221
|
for (let i = 0; i < chats.length; i++) {
|
|
@@ -1216,6 +1228,356 @@ async function selectRecentMessageChats() {
|
|
|
1216
1228
|
return indexes.map((idx) => chats[idx]);
|
|
1217
1229
|
}
|
|
1218
1230
|
|
|
1231
|
+
async function selectChatsInBrowser(chats, opts = {}) {
|
|
1232
|
+
if (!chats.length) throw new Error("No recent local Messages chats were found on this Mac.");
|
|
1233
|
+
|
|
1234
|
+
const token = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
1235
|
+
let settled = false;
|
|
1236
|
+
let server;
|
|
1237
|
+
|
|
1238
|
+
return new Promise((resolve, reject) => {
|
|
1239
|
+
const timeout = setTimeout(() => {
|
|
1240
|
+
if (settled) return;
|
|
1241
|
+
settled = true;
|
|
1242
|
+
server?.close();
|
|
1243
|
+
reject(new Error("Messages chat selection timed out."));
|
|
1244
|
+
}, 20 * 60 * 1000);
|
|
1245
|
+
|
|
1246
|
+
server = createServer(async (req, res) => {
|
|
1247
|
+
try {
|
|
1248
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
1249
|
+
|
|
1250
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
1251
|
+
sendHtml(res, renderMessagesSelectorPage(chats, token));
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (req.method === "POST" && url.pathname === "/select") {
|
|
1256
|
+
const body = await readRequestBody(req);
|
|
1257
|
+
const form = new URLSearchParams(body);
|
|
1258
|
+
if (form.get("token") !== token) {
|
|
1259
|
+
sendHtml(res, renderMessagesDonePage("Invalid selection session.", true), 403);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const selectedIds = form.getAll("chatId").filter(Boolean);
|
|
1263
|
+
const selectedSet = new Set(selectedIds);
|
|
1264
|
+
const selected = chats.filter((chat) => selectedSet.has(chat.chatId));
|
|
1265
|
+
if (selected.length === 0) {
|
|
1266
|
+
sendHtml(res, renderMessagesSelectorPage(chats, token, "Select at least one chat."));
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
sendHtml(res, renderMessagesDonePage(`${selected.length} chat${selected.length === 1 ? "" : "s"} selected.`));
|
|
1271
|
+
if (!settled) {
|
|
1272
|
+
settled = true;
|
|
1273
|
+
clearTimeout(timeout);
|
|
1274
|
+
setTimeout(() => server.close(), 100);
|
|
1275
|
+
resolve(selected);
|
|
1276
|
+
}
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
sendHtml(res, renderMessagesDonePage("Not found.", true), 404);
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
if (!settled) {
|
|
1283
|
+
settled = true;
|
|
1284
|
+
clearTimeout(timeout);
|
|
1285
|
+
server?.close();
|
|
1286
|
+
reject(err);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
server.on("error", (err) => {
|
|
1292
|
+
if (settled) return;
|
|
1293
|
+
settled = true;
|
|
1294
|
+
clearTimeout(timeout);
|
|
1295
|
+
reject(err);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
1299
|
+
const address = server.address();
|
|
1300
|
+
const port = typeof address === "object" && address ? address.port : null;
|
|
1301
|
+
if (!port) {
|
|
1302
|
+
settled = true;
|
|
1303
|
+
clearTimeout(timeout);
|
|
1304
|
+
server.close();
|
|
1305
|
+
reject(new Error("Could not start local Messages selector."));
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
1309
|
+
console.log(`\nOpening Messages chat selector: ${url}`);
|
|
1310
|
+
await openOrPrint(url, { noOpen: Boolean(opts.noOpen) });
|
|
1311
|
+
console.log("Select the Messages chats to sync in the browser.");
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function renderMessagesSelectorPage(chats, token, error = "") {
|
|
1317
|
+
const rows = chats.map((chat) => `
|
|
1318
|
+
<label class="chat-row">
|
|
1319
|
+
<input type="checkbox" name="chatId" value="${htmlAttr(chat.chatId)}">
|
|
1320
|
+
<span class="box" aria-hidden="true"></span>
|
|
1321
|
+
<span class="chat-main">
|
|
1322
|
+
<span class="chat-top">
|
|
1323
|
+
<span class="chat-name">${html(chat.label)}</span>
|
|
1324
|
+
<span class="chat-kind">${html(chat.kind === "group" ? "Group" : chat.kind === "dm" ? "Contact" : "Chat")}</span>
|
|
1325
|
+
</span>
|
|
1326
|
+
${renderChatPeople(chat)}
|
|
1327
|
+
<span class="chat-meta">${html(formatMessageChatMeta(chat))}</span>
|
|
1328
|
+
</span>
|
|
1329
|
+
</label>`).join("");
|
|
1330
|
+
|
|
1331
|
+
return `<!doctype html>
|
|
1332
|
+
<html lang="en">
|
|
1333
|
+
<head>
|
|
1334
|
+
<meta charset="utf-8">
|
|
1335
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1336
|
+
<title>Select Messages Chats</title>
|
|
1337
|
+
<style>
|
|
1338
|
+
:root {
|
|
1339
|
+
color-scheme: light dark;
|
|
1340
|
+
--bg: #FCFCFC;
|
|
1341
|
+
--fg: #111111;
|
|
1342
|
+
--muted: #6D726D;
|
|
1343
|
+
--line: #E8ECE8;
|
|
1344
|
+
--panel: #FFFFFF;
|
|
1345
|
+
--button: #136033;
|
|
1346
|
+
--button-text: #FFFFFF;
|
|
1347
|
+
--link: #136033;
|
|
1348
|
+
--radius: 10px;
|
|
1349
|
+
}
|
|
1350
|
+
@media (prefers-color-scheme: dark) {
|
|
1351
|
+
:root {
|
|
1352
|
+
--bg: #000000;
|
|
1353
|
+
--fg: #F8F8F8;
|
|
1354
|
+
--muted: #A2A8A2;
|
|
1355
|
+
--line: #202520;
|
|
1356
|
+
--panel: #070907;
|
|
1357
|
+
--button: #FFFFFF;
|
|
1358
|
+
--button-text: #000000;
|
|
1359
|
+
--link: #136033;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
* { box-sizing: border-box; }
|
|
1363
|
+
body {
|
|
1364
|
+
margin: 0;
|
|
1365
|
+
background: var(--bg);
|
|
1366
|
+
color: var(--fg);
|
|
1367
|
+
font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1368
|
+
letter-spacing: 0;
|
|
1369
|
+
}
|
|
1370
|
+
main {
|
|
1371
|
+
width: min(760px, calc(100vw - 32px));
|
|
1372
|
+
margin: 40px auto;
|
|
1373
|
+
}
|
|
1374
|
+
header {
|
|
1375
|
+
display: flex;
|
|
1376
|
+
justify-content: space-between;
|
|
1377
|
+
gap: 16px;
|
|
1378
|
+
align-items: end;
|
|
1379
|
+
padding-bottom: 18px;
|
|
1380
|
+
border-bottom: 1px solid var(--line);
|
|
1381
|
+
}
|
|
1382
|
+
h1 {
|
|
1383
|
+
margin: 0;
|
|
1384
|
+
font-size: 24px;
|
|
1385
|
+
line-height: 1.1;
|
|
1386
|
+
font-weight: 650;
|
|
1387
|
+
}
|
|
1388
|
+
.count {
|
|
1389
|
+
color: var(--muted);
|
|
1390
|
+
font-size: 13px;
|
|
1391
|
+
white-space: nowrap;
|
|
1392
|
+
}
|
|
1393
|
+
form {
|
|
1394
|
+
margin-top: 18px;
|
|
1395
|
+
}
|
|
1396
|
+
.error {
|
|
1397
|
+
margin: 0 0 12px;
|
|
1398
|
+
color: #9B1C1C;
|
|
1399
|
+
font-size: 14px;
|
|
1400
|
+
}
|
|
1401
|
+
.chat-list {
|
|
1402
|
+
display: grid;
|
|
1403
|
+
gap: 8px;
|
|
1404
|
+
margin: 0 0 18px;
|
|
1405
|
+
}
|
|
1406
|
+
.chat-row {
|
|
1407
|
+
display: grid;
|
|
1408
|
+
grid-template-columns: 24px 1fr;
|
|
1409
|
+
gap: 12px;
|
|
1410
|
+
align-items: start;
|
|
1411
|
+
padding: 13px 14px;
|
|
1412
|
+
background: var(--panel);
|
|
1413
|
+
border: 1px solid var(--line);
|
|
1414
|
+
border-radius: var(--radius);
|
|
1415
|
+
cursor: pointer;
|
|
1416
|
+
}
|
|
1417
|
+
.chat-row:hover {
|
|
1418
|
+
border-color: color-mix(in srgb, var(--link) 45%, var(--line));
|
|
1419
|
+
}
|
|
1420
|
+
input[type="checkbox"] {
|
|
1421
|
+
position: absolute;
|
|
1422
|
+
opacity: 0;
|
|
1423
|
+
pointer-events: none;
|
|
1424
|
+
}
|
|
1425
|
+
.box {
|
|
1426
|
+
width: 18px;
|
|
1427
|
+
height: 18px;
|
|
1428
|
+
margin-top: 2px;
|
|
1429
|
+
border: 1.5px solid var(--muted);
|
|
1430
|
+
border-radius: 5px;
|
|
1431
|
+
display: inline-grid;
|
|
1432
|
+
place-items: center;
|
|
1433
|
+
}
|
|
1434
|
+
input[type="checkbox"]:checked + .box {
|
|
1435
|
+
background: var(--button);
|
|
1436
|
+
border-color: var(--button);
|
|
1437
|
+
}
|
|
1438
|
+
input[type="checkbox"]:checked + .box::after {
|
|
1439
|
+
content: "";
|
|
1440
|
+
width: 7px;
|
|
1441
|
+
height: 4px;
|
|
1442
|
+
border-left: 2px solid var(--button-text);
|
|
1443
|
+
border-bottom: 2px solid var(--button-text);
|
|
1444
|
+
transform: rotate(-45deg) translateY(-1px);
|
|
1445
|
+
}
|
|
1446
|
+
.chat-main {
|
|
1447
|
+
min-width: 0;
|
|
1448
|
+
display: grid;
|
|
1449
|
+
gap: 4px;
|
|
1450
|
+
}
|
|
1451
|
+
.chat-top {
|
|
1452
|
+
display: flex;
|
|
1453
|
+
gap: 10px;
|
|
1454
|
+
align-items: center;
|
|
1455
|
+
min-width: 0;
|
|
1456
|
+
}
|
|
1457
|
+
.chat-name {
|
|
1458
|
+
overflow: hidden;
|
|
1459
|
+
text-overflow: ellipsis;
|
|
1460
|
+
white-space: nowrap;
|
|
1461
|
+
font-size: 15px;
|
|
1462
|
+
font-weight: 600;
|
|
1463
|
+
}
|
|
1464
|
+
.chat-kind {
|
|
1465
|
+
color: var(--link);
|
|
1466
|
+
font-size: 12px;
|
|
1467
|
+
flex: none;
|
|
1468
|
+
}
|
|
1469
|
+
.chat-people,
|
|
1470
|
+
.chat-meta {
|
|
1471
|
+
color: var(--muted);
|
|
1472
|
+
font-size: 13px;
|
|
1473
|
+
line-height: 1.35;
|
|
1474
|
+
overflow-wrap: anywhere;
|
|
1475
|
+
}
|
|
1476
|
+
.actions {
|
|
1477
|
+
display: flex;
|
|
1478
|
+
justify-content: flex-end;
|
|
1479
|
+
position: sticky;
|
|
1480
|
+
bottom: 0;
|
|
1481
|
+
padding: 14px 0 0;
|
|
1482
|
+
background: linear-gradient(to top, var(--bg) 70%, transparent);
|
|
1483
|
+
}
|
|
1484
|
+
button {
|
|
1485
|
+
appearance: none;
|
|
1486
|
+
border: 0;
|
|
1487
|
+
border-radius: var(--radius);
|
|
1488
|
+
background: var(--button);
|
|
1489
|
+
color: var(--button-text);
|
|
1490
|
+
padding: 10px 14px;
|
|
1491
|
+
font: inherit;
|
|
1492
|
+
font-weight: 620;
|
|
1493
|
+
cursor: pointer;
|
|
1494
|
+
}
|
|
1495
|
+
a { color: var(--link); }
|
|
1496
|
+
</style>
|
|
1497
|
+
</head>
|
|
1498
|
+
<body>
|
|
1499
|
+
<main>
|
|
1500
|
+
<header>
|
|
1501
|
+
<h1>Select Messages chats</h1>
|
|
1502
|
+
<div class="count">${chats.length} recent chats</div>
|
|
1503
|
+
</header>
|
|
1504
|
+
<form method="post" action="/select">
|
|
1505
|
+
<input type="hidden" name="token" value="${htmlAttr(token)}">
|
|
1506
|
+
${error ? `<p class="error">${html(error)}</p>` : ""}
|
|
1507
|
+
<div class="chat-list">${rows}</div>
|
|
1508
|
+
<div class="actions">
|
|
1509
|
+
<button type="submit">Use selected chats</button>
|
|
1510
|
+
</div>
|
|
1511
|
+
</form>
|
|
1512
|
+
</main>
|
|
1513
|
+
</body>
|
|
1514
|
+
</html>`;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function renderMessagesDonePage(message, isError = false) {
|
|
1518
|
+
return `<!doctype html>
|
|
1519
|
+
<html lang="en">
|
|
1520
|
+
<head>
|
|
1521
|
+
<meta charset="utf-8">
|
|
1522
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1523
|
+
<title>Messages Selection</title>
|
|
1524
|
+
<style>
|
|
1525
|
+
:root { color-scheme: light dark; --bg: #FCFCFC; --fg: #111; --muted: #6D726D; --button: #136033; --button-text: #FFFFFF; --radius: 10px; }
|
|
1526
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #000; --fg: #F8F8F8; --muted: #A2A8A2; --button: #FFFFFF; --button-text: #000; } }
|
|
1527
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; }
|
|
1528
|
+
main { width: min(420px, calc(100vw - 32px)); }
|
|
1529
|
+
h1 { margin: 0 0 8px; font-size: 24px; line-height: 1.1; font-weight: 650; }
|
|
1530
|
+
p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.45; }
|
|
1531
|
+
.mark { width: 34px; height: 34px; border-radius: var(--radius); display: grid; place-items: center; margin-bottom: 14px; background: var(--button); color: var(--button-text); font-weight: 700; }
|
|
1532
|
+
</style>
|
|
1533
|
+
</head>
|
|
1534
|
+
<body>
|
|
1535
|
+
<main>
|
|
1536
|
+
<div class="mark">${isError ? "!" : "OK"}</div>
|
|
1537
|
+
<h1>${html(message)}</h1>
|
|
1538
|
+
<p>${isError ? "Return to the terminal and retry." : "You can close this tab and return to the terminal."}</p>
|
|
1539
|
+
</main>
|
|
1540
|
+
</body>
|
|
1541
|
+
</html>`;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function renderChatPeople(chat) {
|
|
1545
|
+
const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
|
|
1546
|
+
if (!names.length) return "";
|
|
1547
|
+
return `<span class="chat-people">${html(names.slice(0, 6).join(", "))}</span>`;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function formatMessageChatMeta(chat) {
|
|
1551
|
+
const parts = [];
|
|
1552
|
+
if (chat.service) parts.push(chat.service);
|
|
1553
|
+
if (chat.lastMessageAt) parts.push(new Date(chat.lastMessageAt).toLocaleString());
|
|
1554
|
+
return parts.join(" · ");
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function sendHtml(res, body, status = 200) {
|
|
1558
|
+
res.writeHead(status, {
|
|
1559
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1560
|
+
"Cache-Control": "no-store",
|
|
1561
|
+
});
|
|
1562
|
+
res.end(body);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function readRequestBody(req) {
|
|
1566
|
+
return new Promise((resolve, reject) => {
|
|
1567
|
+
let body = "";
|
|
1568
|
+
req.setEncoding("utf8");
|
|
1569
|
+
req.on("data", (chunk) => {
|
|
1570
|
+
body += chunk;
|
|
1571
|
+
if (body.length > 64_000) {
|
|
1572
|
+
reject(new Error("Request body too large."));
|
|
1573
|
+
req.destroy();
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
req.on("end", () => resolve(body));
|
|
1577
|
+
req.on("error", reject);
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1219
1581
|
async function listRecentMessageChats({ limit }) {
|
|
1220
1582
|
if (platform() !== "darwin") {
|
|
1221
1583
|
throw new Error("local Messages chat discovery is only supported on macOS");
|
|
@@ -1680,6 +2042,17 @@ function parseAllowedChatIds(value) {
|
|
|
1680
2042
|
return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
|
|
1681
2043
|
}
|
|
1682
2044
|
|
|
2045
|
+
function html(value) {
|
|
2046
|
+
return String(value ?? "")
|
|
2047
|
+
.replace(/&/g, "&")
|
|
2048
|
+
.replace(/</g, "<")
|
|
2049
|
+
.replace(/>/g, ">");
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function htmlAttr(value) {
|
|
2053
|
+
return html(value).replace(/"/g, """);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1683
2056
|
class MessagesBatchSender {
|
|
1684
2057
|
constructor(apiUrl, agentToken, userId) {
|
|
1685
2058
|
this.apiUrl = trimTrailingSlash(apiUrl);
|