askshepherd 0.1.39 → 0.1.40

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.
@@ -2,7 +2,7 @@
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { constants as fsConstants, existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
5
- import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import { access, chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
6
6
  import { createServer } from "node:http";
7
7
  import { homedir, platform } from "node:os";
8
8
  import { basename, dirname, join } from "node:path";
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
12
12
  const DEFAULT_API_URL = "https://brain-api-customer-facing.up.railway.app";
13
13
  const PACKAGE_NAME = "askshepherd";
14
14
  const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
15
- const PACKAGE_VERSION = "0.1.37";
15
+ const PACKAGE_VERSION = "0.1.40";
16
16
  const MCP_SERVER_NAME = "shepherd";
17
17
  const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
18
18
  const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
@@ -133,6 +133,12 @@ async function dispatch() {
133
133
  await runMessagesChatsCommand();
134
134
  } else if (command === "messages-agent") {
135
135
  await runMessagesAgent();
136
+ } else if (command === "write-agent-state") {
137
+ await runWriteAgentState();
138
+ } else if (command === "write-messages-config") {
139
+ await runWriteMessagesConfig();
140
+ } else if (command === "install-messages-agent") {
141
+ await runInstallMessagesAgent();
136
142
  } else if (command === "coding-sessions-agent") {
137
143
  await runCodingSessionsAgent();
138
144
  } else if (command === "coding-sessions-status") {
@@ -923,9 +929,18 @@ async function writeMcpState(state) {
923
929
  const path = mcpStatePath();
924
930
  await mkdir(dirname(path), { recursive: true });
925
931
  await writeFile(path, JSON.stringify(state, null, 2), { mode: 0o600 });
932
+ await chmod(path, 0o600);
926
933
  return path;
927
934
  }
928
935
 
936
+ function sanitizeUserFileId(userId) {
937
+ const safeId = String(userId ?? "").replace(/[^a-zA-Z0-9._-]/g, "-");
938
+ if (!safeId || safeId === "." || safeId === ".." || /^\.+$/.test(safeId)) {
939
+ throw new Error("Onboarding returned an invalid user ID for local Messages config.");
940
+ }
941
+ return safeId;
942
+ }
943
+
929
944
  function mcpStatePath() {
930
945
  return expandHomePath(stringArg("state") ?? DEFAULT_MCP_STATE_PATH);
931
946
  }
@@ -1345,6 +1360,96 @@ function renderLocalCodingSessionsStatus(status) {
1345
1360
  return lines;
1346
1361
  }
1347
1362
 
1363
+ async function runWriteAgentState() {
1364
+ const input = await readJsonInput();
1365
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
1366
+ throw new Error("write-agent-state expects a JSON object on stdin.");
1367
+ }
1368
+ const previous = await readOptionalAgentState();
1369
+ const statePath = await writeAgentState({ ...(previous ?? {}), ...input });
1370
+ console.log(JSON.stringify({ statePath }, null, 2));
1371
+ }
1372
+
1373
+ async function runWriteMessagesConfig() {
1374
+ const input = await readJsonInput();
1375
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
1376
+ throw new Error("write-messages-config expects a JSON object on stdin.");
1377
+ }
1378
+ const configPath = await writeMessagesConfig({
1379
+ apiUrl: trimTrailingSlash(requiredConfigString(input.apiUrl, "apiUrl")),
1380
+ userId: requiredConfigString(input.userId, "userId"),
1381
+ agentToken: requiredConfigString(input.agentToken, "agentToken"),
1382
+ backfillDays: parseBackfillDays(input.backfillDays, null),
1383
+ allowedChatIds: input.allowedChatIds,
1384
+ selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats : [],
1385
+ });
1386
+ console.log(JSON.stringify({ configPath }, null, 2));
1387
+ }
1388
+
1389
+ async function runInstallMessagesAgent() {
1390
+ const configPath = stringArg("config");
1391
+ if (!configPath) throw new Error("install-messages-agent requires --config <path>");
1392
+ let config;
1393
+ try {
1394
+ config = JSON.parse(await readFile(configPath, "utf8"));
1395
+ } catch (err) {
1396
+ if (err && typeof err === "object" && "code" in err) throw err;
1397
+ throw new Error(`install-messages-agent: config file at ${configPath} does not contain valid JSON.`);
1398
+ }
1399
+ const userId = stringArg("user-id") ?? requiredConfigString(config.userId, "userId");
1400
+ const overrides = {
1401
+ programArguments: parseJsonArrayArg("program"),
1402
+ environment: parseJsonObjectArg("env"),
1403
+ };
1404
+
1405
+ if (args["dry-run"]) {
1406
+ console.log(JSON.stringify(buildMessagesAgentInstall(configPath, userId, overrides), null, 2));
1407
+ return;
1408
+ }
1409
+
1410
+ const install = await installMessagesAgent(configPath, userId, overrides);
1411
+ console.log(JSON.stringify(install, null, 2));
1412
+ }
1413
+
1414
+ async function readJsonInput() {
1415
+ const chunks = [];
1416
+ for await (const chunk of process.stdin) chunks.push(chunk);
1417
+ const text = Buffer.concat(chunks).toString("utf8").trim();
1418
+ if (!text) throw new Error("Expected JSON input on stdin.");
1419
+ return JSON.parse(text);
1420
+ }
1421
+
1422
+ function parseJsonArrayArg(name) {
1423
+ const raw = stringArg(name);
1424
+ if (!raw) return undefined;
1425
+ let parsed;
1426
+ try {
1427
+ parsed = JSON.parse(raw);
1428
+ } catch {
1429
+ throw new Error(`--${name} must be a JSON array of strings.`);
1430
+ }
1431
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) {
1432
+ throw new Error(`--${name} must be a JSON array of strings.`);
1433
+ }
1434
+ return parsed;
1435
+ }
1436
+
1437
+ function parseJsonObjectArg(name) {
1438
+ const raw = stringArg(name);
1439
+ if (!raw) return undefined;
1440
+ let parsed;
1441
+ try {
1442
+ parsed = JSON.parse(raw);
1443
+ } catch {
1444
+ throw new Error(`--${name} must be a JSON object of string values.`);
1445
+ }
1446
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)
1447
+ || Object.values(parsed).some((value) => typeof value !== "string")) {
1448
+ throw new Error(`--${name} must be a JSON object of string values.`);
1449
+ }
1450
+ return parsed;
1451
+ }
1452
+
1348
1453
  async function runMessagesChatsCommand() {
1349
1454
  await ensureMessagesReadPermission({ noOpen: Boolean(args["no-open"]) });
1350
1455
  const chats = await listRecentMessageChats({
@@ -1759,6 +1864,31 @@ Options:
1759
1864
  return;
1760
1865
  }
1761
1866
 
1867
+ if (which === "write-agent-state" || which === "write-messages-config" || which === "install-messages-agent") {
1868
+ console.log(`Shepherd onboarding engine commands
1869
+
1870
+ These non-interactive commands are used by GUI onboarding apps (for example the
1871
+ Shepherd macOS app) so that this CLI stays the single owner of Shepherd state
1872
+ file schemas and the launchd install flow.
1873
+
1874
+ Usage:
1875
+ shepherd-onboard write-agent-state JSON object on stdin is merged into ~/.shepherd/raw-onboarding-agent.json. Prints {statePath}.
1876
+ shepherd-onboard write-messages-config JSON object on stdin ({apiUrl, userId, agentToken, backfillDays?, allowedChatIds, selectedChats?}) is written to ~/.shepherd/raw-messages/<userId>.json. Prints {configPath}.
1877
+ shepherd-onboard install-messages-agent --config <path>
1878
+ Installs and verifies the Messages launchd agent for an existing config. Prints install metadata.
1879
+
1880
+ install-messages-agent options:
1881
+ --config <path> Messages agent config created by onboarding. Required.
1882
+ --user-id <id> Override the user ID. Defaults to the config's userId.
1883
+ --program <json_array> Replace the default npx launcher with custom ProgramArguments (e.g. a signed app binary). --config <path> is appended.
1884
+ --env <json_object> Extra EnvironmentVariables merged into the launchd plist (e.g. ELECTRON_RUN_AS_NODE).
1885
+ --dry-run Print the launchd plist and paths without writing or loading anything.
1886
+ --no-permission-prompt Fail instead of prompting when Full Disk Access is missing.
1887
+ --help Show this help.
1888
+ `);
1889
+ return;
1890
+ }
1891
+
1762
1892
  if (which === "mcp") {
1763
1893
  console.log(`Shepherd MCP stdio proxy
1764
1894
 
@@ -2149,6 +2279,9 @@ async function writeAgentState(state) {
2149
2279
  const path = agentStatePath();
2150
2280
  await mkdir(dirname(path), { recursive: true });
2151
2281
  await writeFile(path, JSON.stringify(state, null, 2), { mode: 0o600 });
2282
+ // writeFile's mode only applies on creation; an existing file keeps its
2283
+ // permissions, so enforce them on every token write.
2284
+ await chmod(path, 0o600);
2152
2285
  return path;
2153
2286
  }
2154
2287
 
@@ -2660,7 +2793,11 @@ function headers(token) {
2660
2793
  async function writeMessagesConfig(input) {
2661
2794
  const dir = join(homedir(), ".shepherd", "raw-messages");
2662
2795
  await mkdir(dir, { recursive: true });
2663
- const path = join(dir, `${input.userId}.json`);
2796
+ // The userId reaches this path from the network (server-issued session id)
2797
+ // and, via write-messages-config, from stdin; sanitize it like the launchd
2798
+ // label does so it can never traverse outside the raw-messages directory.
2799
+ const safeId = sanitizeUserFileId(input.userId);
2800
+ const path = join(dir, `${safeId}.json`);
2664
2801
  const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
2665
2802
  const allChats = selectedChatIdsIncludeAll(allowedChatIds);
2666
2803
  if (!allChats && allowedChatIds.length === 0) {
@@ -2681,25 +2818,34 @@ async function writeMessagesConfig(input) {
2681
2818
  }, null, 2),
2682
2819
  { mode: 0o600 },
2683
2820
  );
2821
+ await chmod(path, 0o600);
2684
2822
  return path;
2685
2823
  }
2686
2824
 
2687
- async function installMessagesAgent(configPath, userId) {
2688
- if (platform() !== "darwin") {
2689
- throw new Error("automatic local Messages sync is only supported on macOS");
2690
- }
2691
-
2825
+ function buildMessagesAgentInstall(configPath, userId, overrides = {}) {
2692
2826
  const safeId = userId.replace(/[^a-zA-Z0-9.-]/g, "-");
2693
2827
  const label = `ai.shepherd.raw-messages.${safeId}`;
2694
2828
  const rawDir = join(homedir(), ".shepherd", "raw-messages");
2695
2829
  const agentsDir = join(homedir(), "Library", "LaunchAgents");
2696
- await mkdir(rawDir, { recursive: true });
2697
- await mkdir(agentsDir, { recursive: true });
2698
-
2699
2830
  const plistPath = join(agentsDir, `${label}.plist`);
2700
2831
  const stdoutPath = join(rawDir, `${safeId}.out.log`);
2701
2832
  const stderrPath = join(rawDir, `${safeId}.err.log`);
2702
- const launchPath = launchAgentPath();
2833
+
2834
+ const programPrefix = Array.isArray(overrides.programArguments) && overrides.programArguments.length > 0
2835
+ ? overrides.programArguments
2836
+ : ["/usr/bin/env", "npx", "-y", PACKAGE_SPEC, "messages-agent"];
2837
+ const programArguments = [...programPrefix, "--config", configPath];
2838
+ const environment = {
2839
+ PATH: launchAgentPath(),
2840
+ ...stringRecord(overrides.environment),
2841
+ };
2842
+
2843
+ const programArgumentsXml = programArguments
2844
+ .map((value) => ` <string>${xmlEscape(value)}</string>`)
2845
+ .join("\n");
2846
+ const environmentXml = Object.entries(environment)
2847
+ .map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
2848
+ .join("\n");
2703
2849
 
2704
2850
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
2705
2851
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -2709,32 +2855,43 @@ async function installMessagesAgent(configPath, userId) {
2709
2855
  <string>${xmlEscape(label)}</string>
2710
2856
  <key>ProgramArguments</key>
2711
2857
  <array>
2712
- <string>/usr/bin/env</string>
2713
- <string>npx</string>
2714
- <string>-y</string>
2715
- <string>${PACKAGE_SPEC}</string>
2716
- <string>messages-agent</string>
2717
- <string>--config</string>
2718
- <string>${xmlEscape(configPath)}</string>
2858
+ ${programArgumentsXml}
2719
2859
  </array>
2720
2860
  <key>KeepAlive</key>
2721
2861
  <true/>
2722
2862
  <key>RunAtLoad</key>
2723
2863
  <true/>
2864
+ <key>ThrottleInterval</key>
2865
+ <integer>10</integer>
2866
+ <key>WorkingDirectory</key>
2867
+ <string>${xmlEscape(rawDir)}</string>
2724
2868
  <key>StandardOutPath</key>
2725
2869
  <string>${xmlEscape(stdoutPath)}</string>
2726
2870
  <key>StandardErrorPath</key>
2727
2871
  <string>${xmlEscape(stderrPath)}</string>
2728
2872
  <key>EnvironmentVariables</key>
2729
2873
  <dict>
2730
- <key>PATH</key>
2731
- <string>${xmlEscape(launchPath)}</string>
2874
+ ${environmentXml}
2732
2875
  </dict>
2733
2876
  </dict>
2734
2877
  </plist>
2735
2878
  `;
2736
2879
 
2880
+ return { label, rawDir, agentsDir, plistPath, stdoutPath, stderrPath, programArguments, environment, plist };
2881
+ }
2882
+
2883
+ async function installMessagesAgent(configPath, userId, overrides = {}) {
2884
+ if (platform() !== "darwin") {
2885
+ throw new Error("automatic local Messages sync is only supported on macOS");
2886
+ }
2887
+
2888
+ const install = buildMessagesAgentInstall(configPath, userId, overrides);
2889
+ const { label, rawDir, agentsDir, plistPath, stdoutPath, stderrPath, plist } = install;
2890
+ await mkdir(rawDir, { recursive: true });
2891
+ await mkdir(agentsDir, { recursive: true });
2892
+
2737
2893
  await writeFile(plistPath, plist, { mode: 0o600 });
2894
+ await chmod(plistPath, 0o600);
2738
2895
  while (true) {
2739
2896
  const stdoutOffset = await fileLength(stdoutPath);
2740
2897
  const stderrOffset = await fileLength(stderrPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askshepherd",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Customer-facing Shepherd production onboarding and MCP CLI",
5
5
  "type": "module",
6
6
  "bin": {