@persistmemory/cli 0.6.0 → 0.7.0

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // node_modules/@persistmemory/sdk/dist/index.js
1
+ // ../sdk-js/dist/index.js
2
2
  function encodeQuery(params) {
3
3
  if (!params) return "";
4
4
  const search = new URLSearchParams();
@@ -232,6 +232,27 @@ var HttpClient = class {
232
232
  ...options ? { options } : {}
233
233
  });
234
234
  }
235
+ /** POST with a file as the body. The type describes the bytes, not JSON. */
236
+ async postBytes(path, bytes, contentType, query, options) {
237
+ return this.#request({
238
+ method: "POST",
239
+ path,
240
+ rawBody: bytes,
241
+ contentType,
242
+ ...query ? { query } : {},
243
+ ...options ? { options } : {}
244
+ });
245
+ }
246
+ /** GET that returns bytes rather than JSON, for downloading a file. */
247
+ async getBytes(path, query, options) {
248
+ return this.#request({
249
+ method: "GET",
250
+ path,
251
+ rawResponse: true,
252
+ ...query ? { query } : {},
253
+ ...options ? { options } : {}
254
+ });
255
+ }
235
256
  async patch(path, body, options) {
236
257
  return this.#request({
237
258
  method: "PATCH",
@@ -284,11 +305,20 @@ var HttpClient = class {
284
305
  this.#fetch(url, {
285
306
  method: request.method,
286
307
  headers: this.#headers(request),
287
- ...request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
308
+ ...request.rawBody !== void 0 ? { body: request.rawBody } : request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
288
309
  signal: deadline.signal
289
310
  }),
290
311
  deadline.signal
291
312
  );
313
+ if (request.rawResponse && response.ok) {
314
+ const disposition = response.headers.get("content-disposition") ?? "";
315
+ const named = /filename="([^"]+)"/.exec(disposition)?.[1];
316
+ return {
317
+ bytes: new Uint8Array(await response.arrayBuffer()),
318
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
319
+ ...named ? { filename: named } : {}
320
+ };
321
+ }
292
322
  const payload = await readBody(response);
293
323
  if (!response.ok) throw errorFromResponse(response.status, payload, response.headers);
294
324
  return payload;
@@ -309,9 +339,11 @@ var HttpClient = class {
309
339
  return {
310
340
  // The only place the key is ever read.
311
341
  authorization: `Bearer ${this.#apiKey}`,
312
- accept: "application/json",
342
+ // A download route answers with the file's own type, so `*/*` rather
343
+ // than a promise to accept only JSON that the server would have to break.
344
+ accept: request.rawResponse ? "*/*" : "application/json",
313
345
  "user-agent": this.#userAgent,
314
- ...request.body !== void 0 ? { "content-type": "application/json" } : {},
346
+ ...request.rawBody !== void 0 ? { "content-type": request.contentType ?? "application/octet-stream" } : request.body !== void 0 ? { "content-type": "application/json" } : {},
315
347
  ...request.options?.idempotencyKey ? { "idempotency-key": request.options.idempotencyKey } : {}
316
348
  };
317
349
  }
@@ -615,17 +647,22 @@ var Spaces = class {
615
647
  /**
616
648
  * The memories filed in a Space.
617
649
  *
618
- * This endpoint answers `{ data, pagination: { limit } }` with no cursor: it
619
- * returns the first `limit` members and stops. Wrapped in a `Paginated`
620
- * anyway so it reads like every other list, and it simply yields one page -
621
- * a caller who needs more should filter `memories.list` by `spaceIds`, which
622
- * is the endpoint that actually pages.
650
+ * The cursor is PASSED. This fetch used to ignore the paginator's cursor
651
+ * on the stale belief that the endpoint had none the server has minted
652
+ * `pagination.nextCursor` since it started paging, and its own comment
653
+ * says "both SDKs iterate by reading pagination". Ignoring it meant every
654
+ * page request was identical: the loop guard saw a non-advancing fetch and
655
+ * stopped silently, so `all()` returned the first page twice and dropped
656
+ * everything after it — duplicated AND truncated data, with no error.
623
657
  */
624
658
  memories(id, params = {}, options) {
625
659
  return new Paginated(
626
- () => this.#http.get(
660
+ (cursor) => this.#http.get(
627
661
  `/api/v1/spaces/${encodeURIComponent(id)}/memories`,
628
- { ...params.limit !== void 0 ? { limit: params.limit } : {} },
662
+ {
663
+ ...params.limit !== void 0 ? { limit: params.limit } : {},
664
+ ...cursor !== void 0 ? { cursor } : {}
665
+ },
629
666
  options
630
667
  )
631
668
  );
@@ -651,6 +688,94 @@ var Spaces = class {
651
688
  options
652
689
  );
653
690
  }
691
+ /* ----------------------- who else can see it ----------------------- */
692
+ /**
693
+ * Who can see this Space, including invitations nobody has accepted.
694
+ *
695
+ * A DIFFERENT EDGE from `memories()` next door, and the difference is worth
696
+ * holding on to: that one maps a MEMORY to a Space, this one maps a PERSON
697
+ * to a Space. The server keeps them in two tables with two names for exactly
698
+ * that reason.
699
+ *
700
+ * Read `acceptedAt` before you render a row. An invitation grants nothing
701
+ * until it is accepted, so a list that draws invited and accepted people the
702
+ * same way tells its user somebody is reading their memories when nobody is.
703
+ *
704
+ * Paginated like every other list here. A Space has a handful of
705
+ * collaborators rather than thousands, so this will usually be one page -
706
+ * which costs a caller nothing and means the shape does not change if a
707
+ * Space ever has an organisation on it.
708
+ */
709
+ collaborators(id, params = {}, options) {
710
+ return new Paginated(
711
+ (cursor) => this.#http.get(
712
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
713
+ {
714
+ ...params.limit !== void 0 ? { limit: params.limit } : {},
715
+ ...cursor !== void 0 ? { cursor } : {}
716
+ },
717
+ options
718
+ )
719
+ );
720
+ }
721
+ /**
722
+ * Offers somebody sight of a Space. Answers with the invitation.
723
+ *
724
+ * AN OFFER, NOT A GRANT, and the returned `acceptedAt` will be absent to
725
+ * prove it. The recipient has to accept before they can see anything, which
726
+ * is the property that keeps "nothing enters your memory without you" true
727
+ * even when somebody else starts the sharing. Do not tell your user their
728
+ * Space "has been shared" on the strength of a 2xx here.
729
+ *
730
+ * WHAT THEY GET IS THE WHOLE SPACE: every memory already filed in it and
731
+ * every memory that lands in it afterwards. There is no narrower grant, and
732
+ * `role` does not make one - it decides what they may do BESIDES read.
733
+ *
734
+ * Worth an idempotency key when a person is behind it. A double-clicked
735
+ * "share" is two invitations to the same address, and the second one is a
736
+ * second email arriving at somebody who has already been asked.
737
+ */
738
+ async share(id, params, options) {
739
+ return this.#http.post(
740
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
741
+ params,
742
+ options
743
+ );
744
+ }
745
+ /**
746
+ * Ends somebody's access, or withdraws an invitation they never accepted.
747
+ *
748
+ * Nothing was ever copied into their account - a collaborator SEES the
749
+ * owner's memories rather than holding a duplicate - so this is one write
750
+ * and not a cascade, and there is no orphaned copy left behind.
751
+ *
752
+ * A body on a DELETE, matching `removeMemories` above. The alternative is an
753
+ * address in a path segment, where every `.`, `+` and `@` is a chance for a
754
+ * proxy or a router to normalise somebody else's email into the one that
755
+ * gets revoked.
756
+ */
757
+ async unshare(id, email, options) {
758
+ return this.#http.delete(
759
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
760
+ { email },
761
+ options
762
+ );
763
+ }
764
+ /**
765
+ * Changes what an existing collaborator may do. Never invites anybody.
766
+ *
767
+ * The quiet one. Moving somebody from `viewer` to `owner` sends no
768
+ * invitation and needs no acceptance, and afterwards they can share the
769
+ * Space onward and revoke the person who promoted them. Show your user what
770
+ * `owner` means before you send this, not after.
771
+ */
772
+ async setRole(id, params, options) {
773
+ return this.#http.patch(
774
+ `/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
775
+ params,
776
+ options
777
+ );
778
+ }
654
779
  };
655
780
  var Sources = class {
656
781
  #http;
@@ -888,6 +1013,119 @@ var Conversations = class {
888
1013
  );
889
1014
  }
890
1015
  };
1016
+ var Google = class {
1017
+ #http;
1018
+ constructor(http) {
1019
+ this.#http = http;
1020
+ }
1021
+ /** Files by name, newest first. Omit the query for recently changed ones. */
1022
+ async searchDrive(params = {}, options) {
1023
+ return this.#http.get(
1024
+ "/api/v1/google/drive/files",
1025
+ {
1026
+ ...params.query !== void 0 ? { query: params.query } : {},
1027
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1028
+ },
1029
+ options
1030
+ );
1031
+ }
1032
+ async getDriveFile(fileId, options) {
1033
+ return this.#http.get(
1034
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}`,
1035
+ void 0,
1036
+ options
1037
+ );
1038
+ }
1039
+ /**
1040
+ * The bytes of a Drive file.
1041
+ *
1042
+ * A Google Doc, Sheet or Slide holds no bytes of its own and is exported on
1043
+ * the way - a document as PDF, a spreadsheet as CSV - so `filename` comes
1044
+ * back describing what it BECAME. Writing it under the id instead produces a
1045
+ * file nothing will open.
1046
+ */
1047
+ async downloadDriveFile(fileId, options) {
1048
+ return this.#http.getBytes(
1049
+ `/api/v1/google/drive/files/${encodeURIComponent(fileId)}/content`,
1050
+ void 0,
1051
+ options
1052
+ );
1053
+ }
1054
+ /**
1055
+ * Writes a file into the user's Drive.
1056
+ *
1057
+ * Needs one of the Drive write permissions on their connection. A read-only
1058
+ * grant is refused by Google, and the error names the missing permission
1059
+ * rather than reporting a failed upload - one is fixed with a checkbox and
1060
+ * the other sends somebody looking for a bug.
1061
+ */
1062
+ async saveToDrive(params, options) {
1063
+ return this.#http.postBytes(
1064
+ "/api/v1/google/drive/files",
1065
+ params.bytes,
1066
+ params.contentType ?? "application/octet-stream",
1067
+ {
1068
+ name: params.name,
1069
+ ...params.folderId !== void 0 ? { folderId: params.folderId } : {}
1070
+ },
1071
+ options
1072
+ );
1073
+ }
1074
+ /**
1075
+ * Recent messages - senders, subjects and a one-line preview, never bodies.
1076
+ *
1077
+ * `query` is Gmail's own syntax passed through as written: `from:priya`,
1078
+ * `has:attachment`, `newer_than:7d`. It selects within the connected mailbox
1079
+ * and cannot reach another one.
1080
+ */
1081
+ async searchMail(params = {}, options) {
1082
+ return this.#http.get(
1083
+ "/api/v1/google/mail",
1084
+ {
1085
+ ...params.query !== void 0 ? { query: params.query } : {},
1086
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1087
+ },
1088
+ options
1089
+ );
1090
+ }
1091
+ /** One message, with its body and the names of what is attached. */
1092
+ async readMail(messageId, options) {
1093
+ return this.#http.get(
1094
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}`,
1095
+ void 0,
1096
+ options
1097
+ );
1098
+ }
1099
+ /**
1100
+ * The bytes of one attachment.
1101
+ *
1102
+ * Separate from `readMail` so listing a mailbox never drags attachments
1103
+ * across the network: a message with a 40 MB deck should not cost 40 MB to
1104
+ * summarise.
1105
+ */
1106
+ async downloadAttachment(messageId, attachmentId, options) {
1107
+ return this.#http.getBytes(
1108
+ `/api/v1/google/mail/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
1109
+ void 0,
1110
+ options
1111
+ );
1112
+ }
1113
+ /** Sends as the connected account. Needs the send permission. */
1114
+ async sendMail(params, options) {
1115
+ return this.#http.post("/api/v1/google/mail/send", params, options);
1116
+ }
1117
+ /** People in the user's contacts. Omit the query to list them. */
1118
+ async contacts(params = {}, options) {
1119
+ return this.#http.get(
1120
+ "/api/v1/google/contacts",
1121
+ {
1122
+ ...params.query !== void 0 ? { query: params.query } : {},
1123
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
1124
+ },
1125
+ options
1126
+ );
1127
+ }
1128
+ };
891
1129
  var Integrations = class {
892
1130
  #http;
893
1131
  constructor(http) {
@@ -1018,6 +1256,8 @@ var PersistMemory = class {
1018
1256
  conflicts;
1019
1257
  conversations;
1020
1258
  integrations;
1259
+ /** Drive, mail and contacts on the user's connected Google account. */
1260
+ google;
1021
1261
  health;
1022
1262
  agent;
1023
1263
  #http;
@@ -1034,6 +1274,7 @@ var PersistMemory = class {
1034
1274
  this.conflicts = new Conflicts(this.#http);
1035
1275
  this.conversations = new Conversations(this.#http);
1036
1276
  this.integrations = new Integrations(this.#http);
1277
+ this.google = new Google(this.#http);
1037
1278
  this.health = new Health(this.#http);
1038
1279
  this.agent = new Agent(this.#http);
1039
1280
  }
@@ -1291,6 +1532,23 @@ function maskToken(token) {
1291
1532
  return `${token.slice(0, 4)}\u2026${token.slice(-4)}`;
1292
1533
  }
1293
1534
 
1535
+ // src/display-safe.ts
1536
+ var NEUTRALISED = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff\ufff9-\ufffb]|[\u{e0000}-\u{e007f}]/gu;
1537
+ var LINE_BREAKS = /\r\n|[\r\u2028\u2029]/g;
1538
+ function displaySafe(text) {
1539
+ return text.replace(LINE_BREAKS, "\n").replace(NEUTRALISED, "");
1540
+ }
1541
+ function jsonSafe(value) {
1542
+ return JSON.stringify(value, void 0, 2).replace(
1543
+ NEUTRALISED,
1544
+ (match) => (
1545
+ // Per UTF-16 unit, so a tags-block codepoint becomes its surrogate pair
1546
+ // rather than one escape JSON cannot represent.
1547
+ match.split("").map((unit) => "\\u" + unit.charCodeAt(0).toString(16).padStart(4, "0")).join("")
1548
+ )
1549
+ );
1550
+ }
1551
+
1294
1552
  // src/output.ts
1295
1553
  var OUTPUT_FORMATS = ["table", "json", "yaml", "csv", "tsv"];
1296
1554
  function isOutputFormat(value) {
@@ -1299,7 +1557,7 @@ function isOutputFormat(value) {
1299
1557
  function render(rows, columns, options) {
1300
1558
  switch (options.format) {
1301
1559
  case "json":
1302
- return JSON.stringify(rows, null, 2);
1560
+ return jsonSafe(rows);
1303
1561
  case "yaml":
1304
1562
  return toYaml(rows);
1305
1563
  case "csv":
@@ -1311,13 +1569,13 @@ function render(rows, columns, options) {
1311
1569
  }
1312
1570
  }
1313
1571
  function renderOne(row, fields, options) {
1314
- if (options.format === "json") return JSON.stringify(row, null, 2);
1572
+ if (options.format === "json") return jsonSafe(row);
1315
1573
  if (options.format === "yaml") return toYaml(row);
1316
1574
  if (options.format === "csv" || options.format === "tsv") {
1317
1575
  return delimited([row], fields, options.format === "csv" ? "," : " ");
1318
1576
  }
1319
1577
  const width = Math.max(...fields.map((field) => field.header.length));
1320
- return fields.map((field) => `${field.header.padEnd(width)} ${field.value(row)}`).join("\n");
1578
+ return fields.map((field) => `${field.header.padEnd(width)} ${displaySafe(field.value(row))}`).join("\n");
1321
1579
  }
1322
1580
  function table(rows, columns, width = process.stdout.columns || 120) {
1323
1581
  if (rows.length === 0) return "";
@@ -1377,7 +1635,8 @@ function isBlock(value) {
1377
1635
  if (Array.isArray(value)) return value.length > 0;
1378
1636
  return typeof value === "object" && value !== null && Object.keys(value).length > 0;
1379
1637
  }
1380
- function yamlString(value) {
1638
+ function yamlString(text) {
1639
+ const value = displaySafe(text);
1381
1640
  if (value === "") return '""';
1382
1641
  if (value.includes("\n")) {
1383
1642
  return `|-
@@ -1389,7 +1648,7 @@ ${value.split("\n").map((line) => ` ${line}`).join("\n")}`;
1389
1648
  return ambiguous ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : value;
1390
1649
  }
1391
1650
  function oneLine(value) {
1392
- return value.replace(/\s*\n\s*/g, " ").trim();
1651
+ return displaySafe(value).replace(/\s*\n\s*/g, " ").trim();
1393
1652
  }
1394
1653
  function clip(value, width) {
1395
1654
  if (value.length <= width) return value;
@@ -1402,7 +1661,7 @@ function shortDate(iso) {
1402
1661
  }
1403
1662
 
1404
1663
  // src/help.ts
1405
- var VERSION = true ? "0.6.0" : versionFromManifest();
1664
+ var VERSION = true ? "0.7.0" : versionFromManifest();
1406
1665
  var PACKAGE = "@persistmemory/cli";
1407
1666
  var HELP = `
1408
1667
  pm \u2014 PersistMemory from your terminal
@@ -1440,6 +1699,15 @@ var HELP = `
1440
1699
  to what is in it
1441
1700
  spaces merge "A" "B" --name "C" a new Space holding both, originals kept
1442
1701
 
1702
+ spaces sharing "Acme" who can see it, and who has accepted
1703
+ spaces share "Acme" <email> --role viewer|editor
1704
+ offer somebody sight of EVERYTHING in it,
1705
+ now and later. You must say the role.
1706
+ They see nothing until they accept.
1707
+ spaces unshare "Acme" <email> end their access
1708
+ spaces role "Acme" <email> editor
1709
+ change what an existing collaborator may do
1710
+
1443
1711
  remember <text> capture text
1444
1712
  remember - capture whatever is piped in
1445
1713
  remember --file <path> capture a file's contents
@@ -2181,9 +2449,26 @@ ${head.join("\n")}`;
2181
2449
  // src/commands/run-command.ts
2182
2450
  import { spawn as spawn2 } from "node:child_process";
2183
2451
  import { existsSync as existsSync4, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
2184
- import { isAbsolute as isAbsolute2, join as join4, resolve as resolvePath } from "node:path";
2452
+ import { isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolvePath } from "node:path";
2185
2453
  var CLASSES = new Map([
2186
- // Reads. Report on the filesystem and change nothing.
2454
+ /*
2455
+ Reads. Report on the machine and change nothing.
2456
+
2457
+ Several of these are reads only until a particular flag is passed —
2458
+ `find -exec` runs any program, `sort -o` writes a file, `dmesg -C` empties
2459
+ the kernel buffer — and the name alone cannot see that. `FLAG_CHANGES_CLASS`
2460
+ below is where those come back out again; a name in this list is the
2461
+ starting point, not the verdict.
2462
+
2463
+ `journalctl`, `dmesg` and `zcat` are here because of what people actually
2464
+ ask for. "Check the logs on my server" is `journalctl -n 200 -u nginx`,
2465
+ `dmesg -T`, or `zcat` on a rotated `.gz`, and leaving all three in
2466
+ `unknown` bought nothing: every command already waits for a person, so the
2467
+ only thing an honest `read` label changes is that it stops being a lie.
2468
+ `zcat` earns it by only ever writing to stdout — `gunzip` and `gzip -d`
2469
+ delete their input, which is why neither is here — and the decompression
2470
+ bomb it can be handed is bounded by the output cap rather than by trust.
2471
+ */
2187
2472
  ...[
2188
2473
  "ls",
2189
2474
  "cat",
@@ -2211,7 +2496,10 @@ var CLASSES = new Map([
2211
2496
  "dirname",
2212
2497
  "realpath",
2213
2498
  "ps",
2214
- "env"
2499
+ "env",
2500
+ "journalctl",
2501
+ "dmesg",
2502
+ "zcat"
2215
2503
  ].map((name) => [name, "read"]),
2216
2504
  // Writes. Recoverable or not, they change the machine.
2217
2505
  ...[
@@ -2258,16 +2546,159 @@ var SUBCOMMAND_READS = /* @__PURE__ */ new Map([
2258
2546
  "blame",
2259
2547
  "shortlog",
2260
2548
  "ls-files",
2261
- "rev-parse",
2262
- "config"
2549
+ "rev-parse"
2550
+ ])
2551
+ ],
2552
+ ["npm", /* @__PURE__ */ new Set(["ls", "list", "view", "outdated", "why"])],
2553
+ ["yarn", /* @__PURE__ */ new Set(["list", "why", "info"])],
2554
+ /*
2555
+ `docker logs` STAYS a read, having been looked at again.
2556
+
2557
+ The case against it is real: every docker subcommand is a request to a
2558
+ daemon running as root, so "it only reads" is a statement about the
2559
+ subcommand and not about the socket it is sent down. The case for keeping
2560
+ it is that `logs` cannot start, stop or change anything — it prints what a
2561
+ container already wrote to its own stdout — and it is one of the three
2562
+ things anybody means by "check the logs on my server".
2563
+
2564
+ What actually needed fixing was not the label but the flags in front of
2565
+ it: see `REMOTE_FLAGS`. The residue this leaves is `docker --config=<dir>
2566
+ logs x`, which could load a CLI plugin from a directory the argv chose;
2567
+ `logs` is built in rather than a plugin, and a plugin has to be planted on
2568
+ the disk first, so it is left standing rather than papered over here.
2569
+ */
2570
+ ["docker", /* @__PURE__ */ new Set(["ps", "images", "logs", "inspect"])],
2571
+ /*
2572
+ `systemctl`, which was in no table at all and so was `unknown` whole.
2573
+
2574
+ It is the same shape as `git` and the reason this map exists: `systemctl
2575
+ status nginx` asks the manager a question over its bus, and `systemctl
2576
+ stop nginx` takes somebody's website down. One name, two powers, and the
2577
+ first non-flag word is the whole difference. Everything not listed —
2578
+ start, stop, restart, enable, mask, daemon-reload — lands in `write`,
2579
+ which is where it belongs and where it already effectively was.
2580
+
2581
+ `-H user@host` is not here because it is not a subcommand: it makes
2582
+ systemctl talk to ANOTHER machine over ssh, which is `REMOTE_FLAGS` below
2583
+ and a refusal, not a class.
2584
+ */
2585
+ [
2586
+ "systemctl",
2587
+ /* @__PURE__ */ new Set([
2588
+ "status",
2589
+ "show",
2590
+ "cat",
2591
+ "list-units",
2592
+ "list-unit-files",
2593
+ "list-timers",
2594
+ "list-sockets",
2595
+ "list-dependencies",
2596
+ "list-jobs",
2597
+ "list-machines",
2598
+ "is-active",
2599
+ "is-enabled",
2600
+ "is-failed",
2601
+ "is-system-running",
2602
+ "get-default",
2603
+ "show-environment"
2604
+ ])
2605
+ ]
2606
+ ]);
2607
+ var FLAG_CHANGES_CLASS = /* @__PURE__ */ new Map([
2608
+ [
2609
+ "find",
2610
+ new Map([
2611
+ // Runs anything, once per file found. A model that cannot get `bash`
2612
+ // past this file can get `find . -name x -exec bash {} ;` past it.
2613
+ ...["-exec", "-execdir", "-ok", "-okdir"].map(
2614
+ (flag) => [flag, "interpreter"]
2615
+ ),
2616
+ ...["-delete", "-fprint", "-fprint0", "-fprintf", "-fls"].map(
2617
+ (flag) => [flag, "write"]
2618
+ )
2263
2619
  ])
2264
2620
  ],
2265
- ["npm", /* @__PURE__ */ new Set(["ls", "list", "view", "outdated", "why", "config"])],
2266
- ["yarn", /* @__PURE__ */ new Set(["list", "why", "info", "config"])],
2267
- ["docker", /* @__PURE__ */ new Set(["ps", "images", "logs", "inspect"])]
2621
+ ["sort", /* @__PURE__ */ new Map([["-o", "write"], ["--output", "write"]])],
2622
+ /*
2623
+ `fd`, `rg` and `tree` were left in the read table with no entry here, and
2624
+ the first two run arbitrary programs exactly the way `find -exec` does.
2625
+
2626
+ `fd -x` IS `find -exec` under a newer name, and `rg --pre` runs a program
2627
+ per file to decode it. Both were classified `read` and therefore ran
2628
+ automatically: `fd --exec curl http://…` reached the network, which is the
2629
+ one refusal nothing is supposed to override. They were refused only when
2630
+ the helper was spelled with a slash — `pathOutsideRoots` catching
2631
+ `/bin/sh` — so naming it `sh`, or putting it inside the roots, evaporated
2632
+ the refusal. That is not a boundary, it is a coincidence about spelling.
2633
+
2634
+ `tree -o` writes a file, the same shape as `sort -o`.
2635
+ */
2636
+ [
2637
+ "fd",
2638
+ new Map(
2639
+ ["-x", "--exec", "-X", "--exec-batch"].map(
2640
+ (flag) => [flag, "interpreter"]
2641
+ )
2642
+ )
2643
+ ],
2644
+ [
2645
+ "rg",
2646
+ new Map(
2647
+ ["--pre", "--hostname-bin"].map((flag) => [flag, "interpreter"])
2648
+ )
2649
+ ],
2650
+ ["tree", /* @__PURE__ */ new Map([["-o", "write"]])],
2651
+ [
2652
+ "journalctl",
2653
+ new Map(
2654
+ [
2655
+ "--vacuum-size",
2656
+ "--vacuum-time",
2657
+ "--vacuum-files",
2658
+ "--rotate",
2659
+ "--flush",
2660
+ "--sync",
2661
+ "--relinquish-var",
2662
+ "--smart-relinquish-var",
2663
+ "--setup-keys",
2664
+ "--update-catalog"
2665
+ ].map((flag) => [flag, "write"])
2666
+ )
2667
+ ],
2668
+ [
2669
+ "dmesg",
2670
+ new Map(
2671
+ // `-c` reads AND clears, which is the one that costs somebody the
2672
+ // evidence they were reading the log to find.
2673
+ [
2674
+ "-C",
2675
+ "--clear",
2676
+ "-c",
2677
+ "--read-clear",
2678
+ "-D",
2679
+ "--console-off",
2680
+ "-E",
2681
+ "--console-on",
2682
+ "-n",
2683
+ "--console-level"
2684
+ ].map((flag) => [flag, "write"])
2685
+ )
2686
+ ]
2687
+ ]);
2688
+ var REMOTE_FLAGS = /* @__PURE__ */ new Map([
2689
+ ["docker", /* @__PURE__ */ new Set(["-H", "--host", "--context"])],
2690
+ ["systemctl", /* @__PURE__ */ new Set(["-H", "--host"])]
2691
+ ]);
2692
+ var ENDLESS_FLAGS = /* @__PURE__ */ new Map([
2693
+ ["tail", /* @__PURE__ */ new Set(["-f", "-F", "--follow"])],
2694
+ ["journalctl", /* @__PURE__ */ new Set(["-f", "--follow"])],
2695
+ ["dmesg", /* @__PURE__ */ new Set(["-w", "--follow", "-W", "--follow-new"])],
2696
+ ["docker logs", /* @__PURE__ */ new Set(["-f", "--follow"])],
2697
+ ["kubectl logs", /* @__PURE__ */ new Set(["-f", "--follow"])]
2268
2698
  ]);
2269
2699
  var MAX_OUTPUT_BYTES = 256 * 1024;
2270
2700
  var TIMEOUT_MS = 2e4;
2701
+ var AFTER_KILL_MS = 1e3;
2271
2702
  function describe2(argv) {
2272
2703
  return argv.join(" ");
2273
2704
  }
@@ -2275,15 +2706,73 @@ function programOf(argv) {
2275
2706
  const first = argv[0] ?? "";
2276
2707
  return first.split("/").pop() ?? first;
2277
2708
  }
2709
+ function subcommandOf(argv) {
2710
+ return argv.slice(1).find((one) => !one.startsWith("-"));
2711
+ }
2712
+ function flagsIn(argv) {
2713
+ const found = /* @__PURE__ */ new Set();
2714
+ for (const argument of argv.slice(1)) {
2715
+ if (argument === "--") break;
2716
+ if (argument === "-" || !argument.startsWith("-")) continue;
2717
+ const name = argument.split("=")[0] ?? argument;
2718
+ found.add(name);
2719
+ if (!name.startsWith("--")) {
2720
+ for (const letter of name.slice(1)) found.add(`-${letter}`);
2721
+ }
2722
+ }
2723
+ return found;
2724
+ }
2725
+ function anyFlag(argv, flags) {
2726
+ if (!flags) return void 0;
2727
+ for (const flag of flagsIn(argv)) {
2728
+ if (flags.has(flag)) return flag;
2729
+ }
2730
+ return void 0;
2731
+ }
2732
+ function keysFor(argv) {
2733
+ const program = programOf(argv);
2734
+ const sub = subcommandOf(argv);
2735
+ return sub ? [program, `${program} ${sub}`] : [program];
2736
+ }
2737
+ function remoteFlag(argv) {
2738
+ return anyFlag(argv, REMOTE_FLAGS.get(programOf(argv)));
2739
+ }
2740
+ function endlessFlag(argv) {
2741
+ for (const key of keysFor(argv)) {
2742
+ const found = anyFlag(argv, ENDLESS_FLAGS.get(key));
2743
+ if (found) return found;
2744
+ }
2745
+ return void 0;
2746
+ }
2747
+ function flagClass(argv) {
2748
+ for (const key of keysFor(argv)) {
2749
+ const table2 = FLAG_CHANGES_CLASS.get(key);
2750
+ if (!table2) continue;
2751
+ for (const flag of flagsIn(argv)) {
2752
+ const found = table2.get(flag);
2753
+ if (found) return found;
2754
+ }
2755
+ }
2756
+ return void 0;
2757
+ }
2758
+ function runsAnotherProgram(argv) {
2759
+ if (programOf(argv) !== "env") return false;
2760
+ return argv.slice(1).some((one) => !one.startsWith("-") && !one.includes("="));
2761
+ }
2278
2762
  function classify(argv, policy) {
2279
2763
  const program = programOf(argv);
2764
+ if (remoteFlag(argv)) return "network";
2765
+ const imposed = flagClass(argv);
2766
+ if (imposed === "interpreter" || runsAnotherProgram(argv)) return "interpreter";
2767
+ const known = CLASSES.get(program);
2768
+ if (known === "network" || known === "interpreter") return known;
2280
2769
  if (policy.allow.includes(program)) return "read";
2281
2770
  const reads = SUBCOMMAND_READS.get(program);
2282
2771
  if (reads) {
2283
- const sub = argv.slice(1).find((one) => !one.startsWith("-"));
2772
+ const sub = subcommandOf(argv);
2284
2773
  return sub && reads.has(sub) ? "read" : "write";
2285
2774
  }
2286
- return CLASSES.get(program) ?? "unknown";
2775
+ return imposed ?? known ?? "unknown";
2287
2776
  }
2288
2777
  function judge(argv, policy) {
2289
2778
  const program = programOf(argv);
@@ -2300,21 +2789,32 @@ function judge(argv, policy) {
2300
2789
  }
2301
2790
  const commandClass = classify(argv, policy);
2302
2791
  if (commandClass === "network") {
2792
+ const remote = remoteFlag(argv);
2303
2793
  return {
2304
2794
  commandClass,
2305
2795
  automatic: false,
2306
- refusal: `"${program}" can reach the network. This machine will not run it, and no approval enables it: a command that reads private files and can also send them is the one combination nobody can review by looking at it.`,
2796
+ refusal: (remote ? `"${program} ${remote}" points at another machine, so it can reach the network. ` : `"${program}" can reach the network. `) + "This machine will not run it, and no approval enables it: a command that reads private files and can also send them is the one combination nobody can review by looking at it.",
2307
2797
  reason: "reads private data and has a way out"
2308
2798
  };
2309
2799
  }
2310
2800
  if (commandClass === "interpreter") {
2801
+ const language = CLASSES.get(program) === "interpreter";
2311
2802
  return {
2312
2803
  commandClass,
2313
2804
  automatic: false,
2314
- refusal: `"${program}" runs a language, which is every command at once. Ask for the specific command instead.`,
2805
+ refusal: language ? `"${program}" runs a language, which is every command at once. Ask for the specific command instead.` : `"${describe2(argv)}" runs a program of its own, which is every command at once. Ask for the specific command instead.`,
2315
2806
  reason: "an interpreter is not one command"
2316
2807
  };
2317
2808
  }
2809
+ const endless = endlessFlag(argv);
2810
+ if (endless) {
2811
+ return {
2812
+ commandClass,
2813
+ automatic: false,
2814
+ refusal: `"${program} ${endless}" follows the log and never finishes, and nothing here streams \u2014 the reply is sent when the command exits. Ask for the end of the log instead: \`tail -n 500 <file>\`, \`journalctl -n 500 -u <unit>\`, \`docker logs --tail 500 <container>\`.`,
2815
+ reason: "a follow never produces an answer"
2816
+ };
2817
+ }
2318
2818
  const outside = pathOutsideRoots(argv, policy.roots);
2319
2819
  if (outside) {
2320
2820
  return {
@@ -2338,35 +2838,47 @@ function judge(argv, policy) {
2338
2838
  };
2339
2839
  }
2340
2840
  function pathOutsideRoots(argv, roots) {
2841
+ const realRoots = roots.map((root) => realLocation(resolvePath(root)));
2341
2842
  for (const argument of argv.slice(1)) {
2342
- if (argument.startsWith("-")) continue;
2343
- if (!argument.startsWith("/") && !argument.startsWith("~") && !argument.startsWith(".") && !argument.includes("/")) {
2843
+ const attached = /^-[A-Za-z]([/~.].*)$/.exec(argument)?.[1];
2844
+ const value = argument.startsWith("-") ? argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : attached ?? "" : argument;
2845
+ if (argument.startsWith("-") && !argument.includes("=") && attached === void 0) continue;
2846
+ if (!value.startsWith("/") && !value.startsWith("~") && !value.startsWith(".") && !value.includes("/")) {
2344
2847
  continue;
2345
2848
  }
2346
- const resolved = expand(argument, roots[0] ?? process.cwd());
2347
- if (!roots.some((root) => resolved === root || resolved.startsWith(`${root}/`))) {
2849
+ const real = realLocation(expand(value, roots[0] ?? process.cwd()));
2850
+ if (!realRoots.some((root) => contains(root, real))) {
2348
2851
  return argument;
2349
2852
  }
2350
2853
  }
2351
2854
  return void 0;
2352
2855
  }
2856
+ function contains(root, path) {
2857
+ const rel = relative2(root, path);
2858
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
2859
+ }
2353
2860
  function expand(argument, base) {
2354
2861
  const home = process.env["HOME"] ?? "";
2355
2862
  const withHome = argument.startsWith("~") ? join4(home, argument.slice(1)) : argument;
2356
2863
  return isAbsolute2(withHome) ? resolvePath(withHome) : resolvePath(base, withHome);
2357
2864
  }
2358
- async function runCommand(argv, policy) {
2865
+ function refusal(text) {
2866
+ return { ok: false, text, bytes: 0 };
2867
+ }
2868
+ async function runCommand(argv, policy, limits = {}) {
2869
+ const maxOutputBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES;
2870
+ const timeoutMs = limits.timeoutMs ?? TIMEOUT_MS;
2359
2871
  const verdict = judge(argv, policy);
2360
- if (verdict.refusal) return { ok: false, text: verdict.refusal };
2872
+ if (verdict.refusal) return refusal(verdict.refusal);
2361
2873
  if (policy.mode === "plan") {
2362
- return { ok: false, text: `Plan mode: this machine did not run \`${describe2(argv)}\`.` };
2874
+ return refusal(`Plan mode: this machine did not run \`${describe2(argv)}\`.`);
2363
2875
  }
2364
2876
  const cwd = policy.roots[0];
2365
- if (!cwd) return { ok: false, text: "This machine has no folders it may read." };
2877
+ if (!cwd) return refusal("This machine has no folders it may read.");
2366
2878
  try {
2367
- if (!statSync2(cwd).isDirectory()) return { ok: false, text: `${cwd} is not a folder.` };
2879
+ if (!statSync2(cwd).isDirectory()) return refusal(`${cwd} is not a folder.`);
2368
2880
  } catch {
2369
- return { ok: false, text: `${cwd} does not exist.` };
2881
+ return refusal(`${cwd} does not exist.`);
2370
2882
  }
2371
2883
  return new Promise((resolve8) => {
2372
2884
  const child = spawn2(argv[0], argv.slice(1), {
@@ -2374,6 +2886,33 @@ async function runCommand(argv, policy) {
2374
2886
  // NO shell. With one, every character a model can produce is a character
2375
2887
  // the shell can act on, and the argument list stops meaning anything.
2376
2888
  shell: false,
2889
+ /*
2890
+ ITS OWN PROCESS GROUP, so the timeout can kill everything it started.
2891
+
2892
+ A signal to one pid stops one process. `zcat` is a shell wrapper around
2893
+ `gzip` on most systems, a script spawns what it likes, and killing the
2894
+ parent leaves the child running and holding the stdout pipe — which is
2895
+ what `close` waits for, so the promise below waited too, and the loop
2896
+ with it. A negative pid signals the group.
2897
+
2898
+ The cost is that this child no longer sees the Ctrl-C that stops the
2899
+ agent, which is the right way round: the loop finishes the request it
2900
+ is holding, and a half-killed command is not a better answer.
2901
+
2902
+ Not on Windows, which has no process groups to signal; there the
2903
+ backstop below is the whole guarantee.
2904
+ */
2905
+ detached: process.platform !== "win32",
2906
+ /*
2907
+ NO STDIN, which is a bound as much as the timeout is.
2908
+
2909
+ The default is a pipe nobody ever writes to, so anything that reads
2910
+ standard input — `cat` with no file, `grep` with a pattern and no path,
2911
+ a program that stops to ask something — waited for the full twenty
2912
+ seconds and came back empty, indistinguishable from a hang. There is
2913
+ nobody at a keyboard here. Closed, so those read EOF and exit at once.
2914
+ */
2915
+ stdio: ["ignore", "pipe", "pipe"],
2377
2916
  /*
2378
2917
  A bare environment.
2379
2918
 
@@ -2387,38 +2926,84 @@ async function runCommand(argv, policy) {
2387
2926
  LANG: process.env["LANG"] ?? "C"
2388
2927
  }
2389
2928
  });
2390
- let output = "";
2391
- let truncated = false;
2929
+ const started = Date.now();
2930
+ const chunks = [];
2931
+ let bytes = 0;
2932
+ let lastOutput;
2933
+ let stopped;
2934
+ let settled = false;
2935
+ let backstop;
2936
+ const kill = () => {
2937
+ try {
2938
+ if (child.pid !== void 0 && process.platform !== "win32") {
2939
+ process.kill(-child.pid, "SIGKILL");
2940
+ } else {
2941
+ child.kill("SIGKILL");
2942
+ }
2943
+ } catch {
2944
+ }
2945
+ };
2946
+ const finished = (code) => {
2947
+ const elapsed = (Date.now() - started) / 1e3;
2948
+ const notes = [];
2949
+ if (stopped === "output") {
2950
+ notes.push(
2951
+ `[CUT OFF. This is the first ${bytes} bytes and this machine stopped the command there \u2014 there was more, and it is not below. For a log, ask for the end of it instead: \`tail -n 500 <file>\`, \`journalctl -n 500 -u <unit>\`.]`
2952
+ );
2953
+ }
2954
+ if (stopped === "time") {
2955
+ notes.push(
2956
+ `[STOPPED after ${elapsed.toFixed(1)}s. ` + (lastOutput === void 0 ? "It had produced nothing at all in that time" : `It had produced ${bytes} bytes, the last of them ${((lastOutput - started) / 1e3).toFixed(1)}s in`) + ", so this is a fragment of the answer rather than the answer.]"
2957
+ );
2958
+ }
2959
+ if (code !== null && code !== 0) notes.push(`[exit code ${code}]`);
2960
+ const head = notes.length > 0 ? `${notes.join("\n")}
2961
+
2962
+ ` : "";
2963
+ const body = bytes === 0 ? "(no output)\n" : Buffer.concat(chunks).toString("utf8");
2964
+ return {
2965
+ // See `ok` on CommandOutcome: a non-zero exit with something to say is
2966
+ // an answer, and a silent failure is a sentence.
2967
+ ok: bytes > 0 || code === 0,
2968
+ text: `$ ${describe2(argv)}
2969
+
2970
+ ${head}${body}`,
2971
+ bytes,
2972
+ ...code !== null ? { exitCode: code } : {},
2973
+ ...stopped ? { stopped } : {}
2974
+ };
2975
+ };
2976
+ const settle = (outcome) => {
2977
+ if (settled) return;
2978
+ settled = true;
2979
+ clearTimeout(timer);
2980
+ if (backstop) clearTimeout(backstop);
2981
+ resolve8(outcome);
2982
+ };
2983
+ const stop = (why) => {
2984
+ if (stopped) return;
2985
+ stopped = why;
2986
+ kill();
2987
+ backstop = setTimeout(() => settle(finished(null)), AFTER_KILL_MS);
2988
+ };
2392
2989
  const collect = (chunk) => {
2393
- if (truncated) return;
2394
- output += chunk.toString("utf8");
2395
- if (output.length > MAX_OUTPUT_BYTES) {
2396
- output = output.slice(0, MAX_OUTPUT_BYTES);
2397
- truncated = true;
2398
- child.kill("SIGKILL");
2990
+ if (stopped) return;
2991
+ lastOutput = Date.now();
2992
+ const room = maxOutputBytes - bytes;
2993
+ if (chunk.length > room) {
2994
+ chunks.push(chunk.subarray(0, room));
2995
+ bytes += room;
2996
+ stop("output");
2997
+ return;
2399
2998
  }
2999
+ chunks.push(chunk);
3000
+ bytes += chunk.length;
2400
3001
  };
2401
3002
  child.stdout.on("data", collect);
2402
3003
  child.stderr.on("data", collect);
2403
- const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
2404
- child.on("error", (error) => {
2405
- clearTimeout(timer);
2406
- resolve8({ ok: false, text: `Could not run it: ${error.message}` });
2407
- });
2408
- child.on("close", (code) => {
2409
- clearTimeout(timer);
2410
- const notes = [
2411
- truncated ? `
2412
-
2413
- [output cut off at ${MAX_OUTPUT_BYTES} bytes]` : "",
2414
- code !== 0 && code !== null ? `
2415
-
2416
- [exit code ${code}]` : ""
2417
- ].join("");
2418
- resolve8({ ok: code === 0, text: `$ ${describe2(argv)}
2419
-
2420
- ${output}${notes}` });
2421
- });
3004
+ const timer = setTimeout(() => stop("time"), timeoutMs);
3005
+ child.on("error", (error) => settle(refusal(`Could not run it: ${error.message}`)));
3006
+ child.on("close", (code) => settle(finished(code)));
2422
3007
  });
2423
3008
  }
2424
3009
 
@@ -2519,19 +3104,7 @@ ${downloaded.length} bytes
2519
3104
  try {
2520
3105
  const stats = statSync3(located);
2521
3106
  if (!stats.isDirectory()) return { ok: false, error: `${request.path} is not a folder.` };
2522
- const entries = readdirSync(located, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")).slice(0, MAX_LISTED).map((entry) => {
2523
- if (entry.isDirectory()) return `${entry.name}/`;
2524
- try {
2525
- return `${entry.name} ${sizeOf(join5(located, entry.name))}`;
2526
- } catch {
2527
- return entry.name;
2528
- }
2529
- }).sort();
2530
- const listing = entries.length > 0 ? entries.join("\n") : "(empty)";
2531
- bytes = Buffer.from(`${request.path}
2532
-
2533
- ${listing}
2534
- `, "utf8");
3107
+ bytes = Buffer.from(folderListing(request.path, located), "utf8");
2535
3108
  filename = `${request.path.split("/").filter(Boolean).pop() ?? "listing"}.txt`;
2536
3109
  const grant = await upload(apiUrl, token, filename, bytes);
2537
3110
  return grant;
@@ -2583,6 +3156,25 @@ async function upload(apiUrl, token, filename, bytes) {
2583
3156
  return { ok: true, attachToken: stored.attachToken, bytes: bytes.length };
2584
3157
  }
2585
3158
  var MAX_LISTED = 200;
3159
+ function folderListing(requested, located) {
3160
+ const all = readdirSync(located, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")).sort((a, b) => a.name.localeCompare(b.name));
3161
+ const shown = all.slice(0, MAX_LISTED).map((entry) => {
3162
+ if (entry.isDirectory()) return `${entry.name}/`;
3163
+ try {
3164
+ return `${entry.name} ${sizeOf(join5(located, entry.name))}`;
3165
+ } catch {
3166
+ return entry.name;
3167
+ }
3168
+ });
3169
+ const listing = shown.length > 0 ? shown.join("\n") : "(empty)";
3170
+ const rest = all.length > shown.length ? `
3171
+
3172
+ [\u2026${all.length - shown.length} more entries not listed: this is the first ${MAX_LISTED} by name, not the whole folder]` : "";
3173
+ return `${requested}
3174
+
3175
+ ${listing}${rest}
3176
+ `;
3177
+ }
2586
3178
  function sizeOf(path) {
2587
3179
  const size = statSync3(path).size;
2588
3180
  if (size < 1024) return `${size} B`;
@@ -2678,7 +3270,9 @@ async function agentCommand(context) {
2678
3270
  } else {
2679
3271
  const { items } = await claimed.json();
2680
3272
  for (const request of items) {
2681
- context.print(`Reading ${request.path}`);
3273
+ context.print(
3274
+ request.kind === "run_command" ? `Running ${request.path}` : `Reading ${request.path}`
3275
+ );
2682
3276
  const outcome = await answer(context, apiUrl, await authorization(), roots, request);
2683
3277
  const done = await call(
2684
3278
  `complete/${encodeURIComponent(request.id)}`,
@@ -2864,7 +3458,7 @@ async function mailCommand(context) {
2864
3458
 
2865
3459
  // src/commands/requests.ts
2866
3460
  import { existsSync as existsSync6, writeFileSync as writeFileSync6 } from "node:fs";
2867
- import { resolve as resolve5 } from "node:path";
3461
+ import { basename as basename3, resolve as resolve5 } from "node:path";
2868
3462
  async function requestsCommand(context) {
2869
3463
  if (context.args.words[1] === "get") return collectCommand(context);
2870
3464
  const credential = context.resolved.credential;
@@ -2931,8 +3525,7 @@ async function collectCommand(context) {
2931
3525
  context.error(`The download refused it (${file.status}). Links expire in minutes \u2014 try again.`);
2932
3526
  return 1;
2933
3527
  }
2934
- const name = stringFlag(context.args, "output", "o") ?? filename;
2935
- const target = resolve5(name);
3528
+ const target = downloadTarget(filename, stringFlag(context.args, "output", "o"));
2936
3529
  if (existsSync6(target)) {
2937
3530
  context.error(`${target} already exists. Pass --output to write somewhere else.`);
2938
3531
  return 1;
@@ -2941,6 +3534,9 @@ async function collectCommand(context) {
2941
3534
  context.print(`Wrote ${target}`);
2942
3535
  return 0;
2943
3536
  }
3537
+ function downloadTarget(filename, output) {
3538
+ return resolve5(output ?? (basename3(filename) || "file"));
3539
+ }
2944
3540
 
2945
3541
  // src/workspace.ts
2946
3542
  import { existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "node:fs";
@@ -3358,7 +3954,7 @@ function processPath() {
3358
3954
  // src/commands/session.ts
3359
3955
  import { createInterface as createInterface2 } from "node:readline";
3360
3956
  import { randomUUID } from "node:crypto";
3361
- import { relative as relative2 } from "node:path";
3957
+ import { relative as relative3 } from "node:path";
3362
3958
 
3363
3959
  // src/events.ts
3364
3960
  import { appendFileSync, existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8 } from "node:fs";
@@ -3530,11 +4126,11 @@ async function handleInput(args) {
3530
4126
  path: file.path,
3531
4127
  bytes: file.bytes
3532
4128
  });
3533
- context.print(` read ${relative2(root, file.path)} (${Math.round(file.bytes / 1024)} KB)`);
4129
+ context.print(` read ${relative3(root, file.path)} (${Math.round(file.bytes / 1024)} KB)`);
3534
4130
  if (command === "capture") {
3535
4131
  const client = await context.client();
3536
4132
  const result = await client.memories.remember(
3537
- { text: file.text, title: relative2(root, file.path) },
4133
+ { text: file.text, title: relative3(root, file.path) },
3538
4134
  { idempotencyKey: `cli:capture:${file.path}:${file.bytes}` }
3539
4135
  );
3540
4136
  log.append({
@@ -3638,7 +4234,185 @@ async function write2(args) {
3638
4234
  return;
3639
4235
  }
3640
4236
  commitWrite(proposed, true);
3641
- context.print(` Wrote ${relative2(root, proposed.path)}.`);
4237
+ context.print(` Wrote ${relative3(root, proposed.path)}.`);
4238
+ }
4239
+
4240
+ // src/commands/sharing.ts
4241
+ var collaboratorColumns = [
4242
+ { header: "email", value: (one) => one.email },
4243
+ { header: "name", value: (one) => one.name ?? "" },
4244
+ { header: "role", value: (one) => one.role },
4245
+ // The column that matters. See `standing`.
4246
+ { header: "access", value: (one) => standing(one) },
4247
+ { header: "invited by", value: (one) => one.invitedBy ?? "" }
4248
+ ];
4249
+ function standing(one) {
4250
+ return one.acceptedAt ? `yes, since ${one.acceptedAt.slice(0, 10)}` : "no \u2014 invitation not accepted";
4251
+ }
4252
+ function sharingArgs(context) {
4253
+ const words = context.args.words.slice(2);
4254
+ const at = words.findIndex((one) => one.includes("@"));
4255
+ const email = at >= 0 ? words[at] ?? "" : "";
4256
+ const named = at > 0 ? words.slice(0, at).join(" ") : "";
4257
+ const trailing = at >= 0 ? words[at + 1] : void 0;
4258
+ return { named, email, role: stringFlag(context.args, "role") ?? trailing };
4259
+ }
4260
+ var ROLES = ["viewer", "editor"];
4261
+ function isRole(value) {
4262
+ return value !== void 0 && ROLES.includes(value);
4263
+ }
4264
+ async function resolveSpace(context, named) {
4265
+ const client = await context.client();
4266
+ const { data } = await client.spaces.list({ limit: 200 }).first();
4267
+ if (named.startsWith("space_")) {
4268
+ const byId = data.find((one) => one.id === named);
4269
+ if (byId) return byId;
4270
+ context.error(`No Space with id ${named}. Run \`pm spaces list\` to see yours.`);
4271
+ return 1;
4272
+ }
4273
+ const wanted2 = named.trim().toLowerCase();
4274
+ const matches = data.filter((one) => one.name.trim().toLowerCase() === wanted2);
4275
+ const only = matches[0];
4276
+ if (matches.length === 1 && only) return only;
4277
+ if (matches.length === 0) {
4278
+ context.error(`No Space called "${named}". Run \`pm spaces list\` to see yours.`);
4279
+ return 1;
4280
+ }
4281
+ context.error(
4282
+ `More than one Space is called "${named}": ${matches.map((one) => one.id).join(", ")}.`
4283
+ );
4284
+ context.error("Name it by id \u2014 sharing the wrong one gives somebody the wrong memories.");
4285
+ return 1;
4286
+ }
4287
+ async function shareSpaceCommand(context) {
4288
+ const { named, email, role } = sharingArgs(context);
4289
+ if (named === "" || email === "") {
4290
+ context.error("Which Space, and who?");
4291
+ context.error("");
4292
+ context.error(' pm spaces share "Work" priya@example.com --role viewer');
4293
+ return 2;
4294
+ }
4295
+ if (!isRole(role)) {
4296
+ context.error("Say what they may do \u2014 there is no default:");
4297
+ context.error("");
4298
+ context.error(` pm spaces share "${named}" ${email} --role viewer read everything in it`);
4299
+ context.error(` pm spaces share "${named}" ${email} --role editor read it, and add to it`);
4300
+ if (role !== void 0) {
4301
+ context.error("");
4302
+ context.error(
4303
+ role.toLowerCase() === "owner" ? "A Space cannot be handed over. Its owner is whoever created it." : `Roles are viewer and editor. "${role}" is neither.`
4304
+ );
4305
+ }
4306
+ return 2;
4307
+ }
4308
+ const space = await resolveSpace(context, named);
4309
+ if (typeof space === "number") return space;
4310
+ const client = await context.client();
4311
+ const invitation = await client.spaces.share(
4312
+ space.id,
4313
+ { email, role },
4314
+ {
4315
+ /*
4316
+ Derived from what is being shared, not random.
4317
+
4318
+ The SDK will not retry a POST without a key, and a random one per
4319
+ attempt would defeat the point: a share that timed out after the
4320
+ server recorded it would send a second invitation to a real person's
4321
+ inbox. Same Space, same address, one invitation.
4322
+ */
4323
+ idempotencyKey: `cli:share:${space.id}:${email}`
4324
+ }
4325
+ );
4326
+ if (context.flags.output !== "table") {
4327
+ context.print(renderOne(invitation, collaboratorColumns, { format: context.flags.output }));
4328
+ return 0;
4329
+ }
4330
+ context.print(`Invited ${invitation.email} to "${space.name}" as ${invitation.role}.`);
4331
+ context.print("");
4332
+ context.print(
4333
+ `They will see everything filed in "${space.name}" \u2014 including memories added later.`
4334
+ );
4335
+ context.print("Nothing is shared yet: they have to accept the invitation first.");
4336
+ if (invitation.role === "owner") {
4337
+ context.print("");
4338
+ context.print("As an owner they can share it onward and revoke anybody, including you.");
4339
+ }
4340
+ context.print("");
4341
+ context.print(`Undo it with: pm spaces unshare "${space.name}" ${invitation.email}`);
4342
+ return 0;
4343
+ }
4344
+ async function unshareSpaceCommand(context) {
4345
+ const { named, email } = sharingArgs(context);
4346
+ if (named === "" || email === "") {
4347
+ context.error("Which Space, and who?");
4348
+ context.error("");
4349
+ context.error(' pm spaces unshare "Work" priya@example.com');
4350
+ return 2;
4351
+ }
4352
+ const space = await resolveSpace(context, named);
4353
+ if (typeof space === "number") return space;
4354
+ const client = await context.client();
4355
+ const { email: ended } = await client.spaces.unshare(space.id, email);
4356
+ if (context.flags.output !== "table") {
4357
+ context.print(JSON.stringify({ spaceId: space.id, email: ended }, void 0, 2));
4358
+ return 0;
4359
+ }
4360
+ context.print(`${ended} can no longer see "${space.name}".`);
4361
+ context.print("Nothing of it was ever copied into their account, so nothing of it remains.");
4362
+ return 0;
4363
+ }
4364
+ async function spaceRoleCommand(context) {
4365
+ const { named, email, role } = sharingArgs(context);
4366
+ if (named === "" || email === "" || !isRole(role)) {
4367
+ context.error("Which Space, who, and what to:");
4368
+ context.error("");
4369
+ context.error(' pm spaces role "Work" priya@example.com editor');
4370
+ context.error("");
4371
+ context.error(`Roles: ${ROLES.join(", ")}. This changes somebody who already has access \u2014`);
4372
+ context.error("use `pm spaces share` to invite a new person.");
4373
+ return 2;
4374
+ }
4375
+ const space = await resolveSpace(context, named);
4376
+ if (typeof space === "number") return space;
4377
+ const client = await context.client();
4378
+ const changed = await client.spaces.setRole(space.id, { email, role });
4379
+ if (context.flags.output !== "table") {
4380
+ context.print(renderOne(changed, collaboratorColumns, { format: context.flags.output }));
4381
+ return 0;
4382
+ }
4383
+ context.print(`${changed.email} is now ${changed.role} on "${space.name}".`);
4384
+ if (changed.role === "owner") {
4385
+ context.print("As an owner they can share it onward and revoke anybody, including you.");
4386
+ }
4387
+ return 0;
4388
+ }
4389
+ async function spaceSharingCommand(context) {
4390
+ const named = context.args.words.slice(2).join(" ").trim();
4391
+ if (named === "") {
4392
+ context.error('Which Space? Try `pm spaces sharing "Work"`.');
4393
+ return 2;
4394
+ }
4395
+ const space = await resolveSpace(context, named);
4396
+ if (typeof space === "number") return space;
4397
+ const client = await context.client();
4398
+ const { data } = await client.spaces.collaborators(space.id, { limit: 200 }).first();
4399
+ if (context.flags.output !== "table") {
4400
+ context.print(render(data, collaboratorColumns, { format: context.flags.output }));
4401
+ return 0;
4402
+ }
4403
+ if (data.length === 0) {
4404
+ context.print(`Nobody else can see "${space.name}". It has never been shared.`);
4405
+ return 0;
4406
+ }
4407
+ context.print(render(data, collaboratorColumns, { format: context.flags.output }));
4408
+ const waiting = data.filter((one) => one.acceptedAt === void 0).length;
4409
+ if (waiting > 0) {
4410
+ context.print("");
4411
+ context.print(
4412
+ `${waiting} ${waiting === 1 ? "invitation has" : "invitations have"} not been accepted. ${waiting === 1 ? "That person can" : "Those people can"} see nothing yet.`
4413
+ );
4414
+ }
4415
+ return 0;
3642
4416
  }
3643
4417
 
3644
4418
  // src/commands/memory.ts
@@ -4110,8 +4884,14 @@ async function dispatch(context) {
4110
4884
  if (noun === "create" || noun === "new") return createSpaceCommand(context);
4111
4885
  if (noun === "delete" || noun === "remove") return deleteSpaceCommand(context);
4112
4886
  if (noun === "merge") return mergeSpacesCommand(context);
4887
+ if (noun === "share") return shareSpaceCommand(context);
4888
+ if (noun === "unshare") return unshareSpaceCommand(context);
4889
+ if (noun === "role") return spaceRoleCommand(context);
4890
+ if (noun === "sharing") return spaceSharingCommand(context);
4113
4891
  if (noun === void 0 || noun === "list") return listSpacesCommand(context);
4114
- context.error(`Cannot "pm spaces ${noun}". Try list, create, delete or merge.`);
4892
+ context.error(
4893
+ `Cannot "pm spaces ${noun}". Try list, create, delete, merge, share, unshare, role or sharing.`
4894
+ );
4115
4895
  return 2;
4116
4896
  case "list":
4117
4897
  if (noun === "memories" || noun === "memory") return listMemoriesCommand(context);