@dayofweek/dcli 1.4.0 → 1.6.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/bin/dcli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { ApiError, DayOfWeekClient } from "../client.js";
3
+ import { ApiError, DayOfWeekClient, toArrayBuffer } from "../client.js";
4
4
  import { getToken, getApiUrl, saveConfig, loadConfig, saveCredential, deleteCredential } from "../config.js";
5
5
  import { browserLogin } from "../auth/login.js";
6
6
  import { parseBrainResource } from "../uri.js";
7
- import { accessSync, constants, readFileSync, existsSync, statSync } from "node:fs";
7
+ import { accessSync, constants, mkdirSync, readFileSync, existsSync, statSync, writeFileSync } from "node:fs";
8
8
  import { join, basename, resolve, relative, sep } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { createInterface } from "node:readline/promises";
@@ -240,6 +240,13 @@ brain
240
240
  output(await getClient().listBrainAudit(opts.area, { cursor: opts.cursor, limit: opts.limit }));
241
241
  });
242
242
  const brainSource = brain.command("source").description("Work with original shared files and recordings");
243
+ brainSource
244
+ .command("list")
245
+ .description("List an area's sources, newest first (metadata only)")
246
+ .requiredOption("--area <areaId>", "Area to inventory")
247
+ .action(async (opts) => {
248
+ output(await getClient().listBrainSources(opts.area));
249
+ });
243
250
  brainSource
244
251
  .command("get <uri>")
245
252
  .description("Read source metadata and derived text")
@@ -325,6 +332,7 @@ read
325
332
  .option("--type <entityType>", "Filter by type (Farm, Producer, Restaurant, ...)")
326
333
  .option("--parent <entityId>", "List children of an entity")
327
334
  .option("--limit <count>", "Max results", parseInt)
335
+ .option("--org <org>", "Organization slug or id (admin only)")
328
336
  .action(async (opts) => {
329
337
  const client = getClient();
330
338
  const result = await client.listEntities(opts);
@@ -333,9 +341,10 @@ read
333
341
  read
334
342
  .command("entity <entityId>")
335
343
  .description("Get entity details")
336
- .action(async (entityId) => {
344
+ .option("--org <org>", "Organization slug or id (admin only)")
345
+ .action(async (entityId, opts) => {
337
346
  const client = getClient();
338
- const result = await client.getEntity(entityId);
347
+ const result = await client.getEntity(entityId, opts.org);
339
348
  output(result);
340
349
  });
341
350
  read
@@ -343,6 +352,7 @@ read
343
352
  .description("List produce profiles")
344
353
  .option("--entity <entityId>", "Filter by entity")
345
354
  .option("--limit <count>", "Max results", parseInt)
355
+ .option("--org <org>", "Organization slug or id (admin only)")
346
356
  .action(async (opts) => {
347
357
  const client = getClient();
348
358
  const result = await client.listProduce(opts);
@@ -353,6 +363,7 @@ read
353
363
  .description("List contacts and memberships")
354
364
  .option("--entity <entityId>", "Filter by entity")
355
365
  .option("--limit <count>", "Max results", parseInt)
366
+ .option("--org <org>", "Organization slug or id (admin only)")
356
367
  .action(async (opts) => {
357
368
  const client = getClient();
358
369
  const result = await client.listContacts(opts);
@@ -361,9 +372,10 @@ read
361
372
  read
362
373
  .command("entity-types")
363
374
  .description("List available entity types")
364
- .action(async () => {
375
+ .option("--org <org>", "Organization slug or id (admin only)")
376
+ .action(async (opts) => {
365
377
  const client = getClient();
366
- const result = await client.listEntityTypes();
378
+ const result = await client.listEntityTypes(opts.org);
367
379
  output(result);
368
380
  });
369
381
  read
@@ -374,6 +386,7 @@ read
374
386
  .option("--type <nodeType>", "Filter by type (category, produce, variety)")
375
387
  .option("--include-categories", "Include non-selectable categories in results")
376
388
  .option("--limit <count>", "Max results", parseInt)
389
+ .option("--org <org>", "Organization slug or id (admin only)")
377
390
  .action(async (opts) => {
378
391
  const client = getClient();
379
392
  const result = await client.searchCatalog(opts);
@@ -462,6 +475,191 @@ agent
462
475
  const result = await client.getProposal(proposalId);
463
476
  output(result);
464
477
  });
478
+ // ── Knowledge Commands ───────────────────────────────────────────────────────
479
+ const MIME_BY_EXT = {
480
+ ".pdf": "application/pdf",
481
+ ".md": "text/markdown",
482
+ ".txt": "text/plain",
483
+ ".csv": "text/csv",
484
+ ".json": "application/json",
485
+ ".html": "text/html",
486
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
487
+ ".doc": "application/msword",
488
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
489
+ ".xls": "application/vnd.ms-excel",
490
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
491
+ };
492
+ function guessMimeType(path) {
493
+ const dot = path.lastIndexOf(".");
494
+ const ext = dot === -1 ? "" : path.slice(dot).toLowerCase();
495
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
496
+ }
497
+ /** Filesystem-safe name for an exported document, keeping it recognisable. */
498
+ function exportFileName(doc) {
499
+ const base = (doc.fileName || doc.title || doc.id || "document").trim();
500
+ const stripped = base.replace(/\.(md|txt)$/i, "");
501
+ const safe = stripped.replace(/[/\\:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120);
502
+ return `${safe || doc.id || "document"}.md`;
503
+ }
504
+ const knowledge = program
505
+ .command("knowledge")
506
+ .description("Read, add and export entity knowledge documents");
507
+ knowledge
508
+ .command("list")
509
+ .description("List knowledge documents on an entity")
510
+ .requiredOption("--entity <entityId>", "Target entity id")
511
+ .option("--full", "Include each document's full content, not an excerpt")
512
+ .option("--org <org>", "Organization (admin only)")
513
+ .action(async (opts) => {
514
+ const client = getClient();
515
+ output(await client.listKnowledge({ entity: opts.entity, full: opts.full, org: opts.org }));
516
+ });
517
+ knowledge
518
+ .command("get <documentId>")
519
+ .description("Show one knowledge document with full content")
520
+ .option("--org <org>", "Organization (admin only)")
521
+ .action(async (documentId, opts) => {
522
+ const client = getClient();
523
+ output(await client.getKnowledge(documentId, opts.org));
524
+ });
525
+ knowledge
526
+ .command("search <query>")
527
+ .description("Semantic search across knowledge documents")
528
+ .option("--entity <entityId>", "Scope to one entity's org tree")
529
+ .option("--types <types>", "Comma-separated sourceType filter")
530
+ .option("--limit <count>", "Max hits", parseInt)
531
+ .option("--all-orgs", "Search every org (admin only)")
532
+ .option("--org <org>", "Organization (admin only)")
533
+ .action(async (query, opts) => {
534
+ const client = getClient();
535
+ output(await client.searchKnowledge({
536
+ query,
537
+ entity: opts.entity,
538
+ types: opts.types
539
+ ? String(opts.types).split(",").map((t) => t.trim()).filter(Boolean)
540
+ : undefined,
541
+ limit: opts.limit,
542
+ allOrgs: opts.allOrgs,
543
+ org: opts.org,
544
+ }));
545
+ });
546
+ knowledge
547
+ .command("add")
548
+ .description("Add a markdown knowledge note (proposal by default)")
549
+ .requiredOption("--entity <entityId>", "Target entity id")
550
+ .requiredOption("--title <title>", "Document title")
551
+ .option("--file <path>", "Markdown file to read (- for stdin)")
552
+ .option("--content <text>", "Inline content instead of --file")
553
+ .option("--source-type <type>", "research | website | competitive_intel | …", "research")
554
+ .option("--source-url <url>", "Where the content came from")
555
+ .option("--source-description <text>", "Short provenance note")
556
+ .option("--confidence <n>", "0-1, helps reviewers prioritize", parseFloat)
557
+ .option("--source-agent <name>", "Attribution for the submitting agent")
558
+ .option("--direct", "Write immediately instead of proposing. Admin only — ask the operator first")
559
+ .option("--org <org>", "Organization (admin only)")
560
+ .action(async (opts) => {
561
+ let content;
562
+ if (opts.content) {
563
+ content = String(opts.content);
564
+ }
565
+ else if (opts.file) {
566
+ content = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
567
+ }
568
+ else {
569
+ throw new Error("Provide --file or --content");
570
+ }
571
+ if (!content.trim())
572
+ throw new Error("Content is empty");
573
+ const client = getClient();
574
+ output(await client.addKnowledge({
575
+ entityId: opts.entity,
576
+ title: opts.title,
577
+ content,
578
+ sourceType: opts.sourceType,
579
+ sourceUrl: opts.sourceUrl,
580
+ sourceDescription: opts.sourceDescription,
581
+ confidence: opts.confidence,
582
+ sourceAgent: opts.sourceAgent,
583
+ direct: Boolean(opts.direct),
584
+ org: opts.org,
585
+ }));
586
+ });
587
+ knowledge
588
+ .command("attach")
589
+ .description("Attach a file (PDF, DOCX, XLSX …) as a source. Admin only")
590
+ .requiredOption("--entity <entityId>", "Target entity id")
591
+ .requiredOption("--file <path>", "File to upload")
592
+ .option("--name <fileName>", "Stored file name (defaults to the file's own)")
593
+ .option("--mime <mimeType>", "Content type (guessed from the extension)")
594
+ .option("--source-type <type>", "research | website | contract | …", "other")
595
+ .option("--source-url <url>", "Where the file came from")
596
+ .option("--source-description <text>", "Short provenance note")
597
+ .option("--org <org>", "Organization (admin only)")
598
+ .action(async (opts) => {
599
+ if (!existsSync(opts.file))
600
+ throw new Error(`File not found: ${opts.file}`);
601
+ const data = readFileSync(opts.file);
602
+ if (data.byteLength === 0)
603
+ throw new Error("File is empty");
604
+ const client = getClient();
605
+ output(await client.attachKnowledgeFile({
606
+ entityId: opts.entity,
607
+ data: toArrayBuffer(data),
608
+ fileName: opts.name ?? basename(opts.file),
609
+ mimeType: opts.mime ?? guessMimeType(opts.file),
610
+ sourceType: opts.sourceType,
611
+ sourceUrl: opts.sourceUrl,
612
+ sourceDescription: opts.sourceDescription,
613
+ org: opts.org,
614
+ }));
615
+ });
616
+ knowledge
617
+ .command("export")
618
+ .description("Write an entity's knowledge documents to disk as markdown")
619
+ .requiredOption("--entity <entityId>", "Source entity id")
620
+ .requiredOption("--out <dir>", "Directory to write into (created if missing)")
621
+ .option("--org <org>", "Organization (admin only)")
622
+ .action(async (opts) => {
623
+ const client = getClient();
624
+ const docs = await client.listKnowledge({ entity: opts.entity, full: true, org: opts.org });
625
+ mkdirSync(opts.out, { recursive: true });
626
+ const written = [];
627
+ const skipped = [];
628
+ for (const doc of docs) {
629
+ const body = typeof doc.content === "string" ? doc.content : "";
630
+ if (!body) {
631
+ // A binary source whose text extraction hasn't finished has nothing to
632
+ // mirror yet. Report it rather than writing an empty file.
633
+ skipped.push({
634
+ id: doc.id,
635
+ title: doc.title,
636
+ reason: doc.processingStatus && doc.processingStatus !== "completed"
637
+ ? `processingStatus=${doc.processingStatus}`
638
+ : "no extracted content",
639
+ });
640
+ continue;
641
+ }
642
+ const frontMatter = [
643
+ "---",
644
+ `document_id: ${doc.id}`,
645
+ `title: ${JSON.stringify(doc.title ?? "")}`,
646
+ `source_type: ${doc.sourceType ?? "other"}`,
647
+ ...(doc.sourceUrl ? [`source_url: ${doc.sourceUrl}`] : []),
648
+ ...(doc.sourceDescription
649
+ ? [`source_description: ${JSON.stringify(doc.sourceDescription)}`]
650
+ : []),
651
+ `entity_id: ${opts.entity}`,
652
+ ...(doc.createdAt ? [`created_at: ${new Date(doc.createdAt).toISOString()}`] : []),
653
+ "exported_from: dayofweek-platform",
654
+ "---",
655
+ "",
656
+ ].join("\n");
657
+ const target = join(opts.out, exportFileName(doc));
658
+ writeFileSync(target, `${frontMatter}${body}\n`, "utf8");
659
+ written.push({ id: doc.id, file: target, bytes: Buffer.byteLength(body, "utf8") });
660
+ }
661
+ output({ entity: opts.entity, out: opts.out, total: docs.length, written, skipped });
662
+ });
465
663
  // ── Skill Commands ───────────────────────────────────────────────────────────
466
664
  const skill = program.command("skill").description("Manage the Day of Week agent skill");
467
665
  function resolveTargetDirs(target, bundleName, customDir) {
@@ -641,6 +839,114 @@ skill
641
839
  if (!installed)
642
840
  process.exitCode = 2;
643
841
  });
842
+ // ── Data Commands ────────────────────────────────────────────────────────────
843
+ //
844
+ // Generic, name-blind reads of platform datasets. The server's catalog decides
845
+ // what exists and what the caller may see — this CLI ships no dataset names,
846
+ // so new platform surfaces appear in `data list` without a CLI release.
847
+ const data = program.command("data").description("Read platform datasets the server offers you");
848
+ data
849
+ .command("list")
850
+ .description("List the datasets your credential may read")
851
+ .option("--org <org>", "Organization slug or id (admin only)")
852
+ .action(async (opts) => {
853
+ const client = getClient();
854
+ output(await client.listDatasets(opts.org));
855
+ });
856
+ data
857
+ .command("get <dataset>")
858
+ .description("Read one dataset's rows (names come from `data list`)")
859
+ .option("--limit <count>", "Max rows (default 50, max 200)", parseInt)
860
+ .option("--org <org>", "Organization slug or id (admin only)")
861
+ .action(async (dataset, opts) => {
862
+ const client = getClient();
863
+ output(await client.readDataset(dataset, { limit: opts.limit, org: opts.org }));
864
+ });
865
+ // ── Feedback Commands ────────────────────────────────────────────────────────
866
+ //
867
+ // The customer feedback backlog. Scope-gated rather than admin-gated:
868
+ // read:feedback for the reads, write:feedback to comment, admin:feedback for
869
+ // claim/status/priority. backOffice users implicitly hold every scope, so the
870
+ // group stays visible and the endpoint decides what the caller may do.
871
+ const feedback = program
872
+ .command("feedback")
873
+ .description("Read and work the customer feedback backlog");
874
+ feedback
875
+ .command("list")
876
+ .description("List backlog items")
877
+ .option("--status <status>", "backlog | planned | in_progress | shipped | rejected | new")
878
+ .option("--priority <priority>", "urgent | high | medium | low")
879
+ .option("--category <category>", "Filter by category, e.g. studio")
880
+ .option("--limit <count>", "Max results", parseInt)
881
+ .action(async (opts) => {
882
+ const client = getClient();
883
+ output(await client.listFeedback({
884
+ status: opts.status,
885
+ priority: opts.priority,
886
+ category: opts.category,
887
+ limit: opts.limit,
888
+ }));
889
+ });
890
+ feedback
891
+ .command("next")
892
+ .description("What should I work on next — the prioritizer's top picks")
893
+ .option("--limit <count>", "How many recommendations", parseInt)
894
+ .action(async (opts) => {
895
+ const client = getClient();
896
+ output(await client.feedbackRecommendations(opts.limit));
897
+ });
898
+ feedback
899
+ .command("show <itemId>")
900
+ .description("Show one backlog item in full")
901
+ .action(async (itemId) => {
902
+ const client = getClient();
903
+ output(await client.getFeedbackItem(itemId));
904
+ });
905
+ feedback
906
+ .command("claim <itemId>")
907
+ .description("Claim an item so humans see it is being worked on")
908
+ .action(async (itemId) => {
909
+ const client = getClient();
910
+ output(await client.claimFeedbackItem(itemId));
911
+ });
912
+ feedback
913
+ .command("comment <itemId>")
914
+ .description("Add a comment to a backlog item")
915
+ .option("--body <text>", "Comment text")
916
+ .option("--file <path>", "Read the comment from a file (- for stdin)")
917
+ .action(async (itemId, opts) => {
918
+ let body;
919
+ if (opts.body) {
920
+ body = String(opts.body);
921
+ }
922
+ else if (opts.file) {
923
+ body = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
924
+ }
925
+ else {
926
+ throw new Error("Provide --body or --file");
927
+ }
928
+ if (!body.trim())
929
+ throw new Error("Comment is empty");
930
+ const client = getClient();
931
+ output(await client.commentOnFeedbackItem(itemId, body));
932
+ });
933
+ feedback
934
+ .command("status <itemId>")
935
+ .description("Set status and/or priority on a backlog item")
936
+ .option("--status <status>", "backlog | planned | in_progress | shipped | rejected")
937
+ .option("--priority <priority>", "urgent | high | medium | low")
938
+ .option("--rejected-reason <text>", "Why it was rejected (with --status rejected)")
939
+ .action(async (itemId, opts) => {
940
+ if (!opts.status && !opts.priority) {
941
+ throw new Error("Provide --status and/or --priority");
942
+ }
943
+ const client = getClient();
944
+ output(await client.updateFeedbackItem(itemId, {
945
+ status: opts.status,
946
+ priority: opts.priority,
947
+ rejectedReason: opts.rejectedReason,
948
+ }));
949
+ });
644
950
  // ── Admin Commands ───────────────────────────────────────────────────────────
645
951
  //
646
952
  // These are admin-only. They're registered as hidden subcommands when the
@@ -692,6 +998,224 @@ function registerAdminCommands() {
692
998
  });
693
999
  output(result);
694
1000
  });
1001
+ // ── Assortment import ──────────────────────────────────────────────────────
1002
+ //
1003
+ // These write real Creator rows rather than proposals, so the operator
1004
+ // approves the parsed result first. --dry-run rehearses the whole import
1005
+ // server-side and rolls it back; that response is what the review is built
1006
+ // from. Writing requires --approved for the same reason email does.
1007
+ const produce = program
1008
+ .command("produce", { hidden: true })
1009
+ .description("Assortment import and recipe refinement (DoW staff)");
1010
+ produce
1011
+ .command("import")
1012
+ .description("Import a producer's items into Creator. Requires --dry-run or --approved")
1013
+ .requiredOption("--file <path>", "JSON payload: { entityId, items[] } (- for stdin)")
1014
+ .option("--dry-run", "Rehearse server-side and roll back — build the review from this")
1015
+ .option("--skip-existing-names", "Skip items whose displayName already exists")
1016
+ .option("--approved", "The operator approved the dry-run result")
1017
+ .option("--org <org>", "Organization slug or id")
1018
+ .action(async (opts) => {
1019
+ if (!opts.dryRun && !opts.approved) {
1020
+ throw new Error("Refusing to write: run with --dry-run first, show the operator the result, " +
1021
+ "then repeat with --approved. This creates real Creator rows, not proposals.");
1022
+ }
1023
+ const raw = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1024
+ const payload = JSON.parse(raw);
1025
+ if (!payload?.entityId || !Array.isArray(payload.items)) {
1026
+ throw new Error("Payload needs { entityId, items: [...] }");
1027
+ }
1028
+ const client = getClient();
1029
+ output(await client.importProduce({
1030
+ entityId: payload.entityId,
1031
+ items: payload.items,
1032
+ dryRun: Boolean(opts.dryRun),
1033
+ skipExistingNames: opts.skipExistingNames ?? payload.skipExistingNames,
1034
+ org: opts.org ?? payload.org,
1035
+ }));
1036
+ });
1037
+ produce
1038
+ .command("add-concepts")
1039
+ .description("Add concepts to the shared produce catalog. Requires --approved")
1040
+ .requiredOption("--file <path>", "JSON: { concepts: [...] } or a bare array (- for stdin)")
1041
+ .option("--approved", "The operator approved these concepts")
1042
+ .option("--org <org>", "Organization slug or id")
1043
+ .action(async (opts) => {
1044
+ if (!opts.approved) {
1045
+ throw new Error("Refusing to write: the produce catalog is shared by every customer. " +
1046
+ "Show the operator the concepts you want to add, then repeat with --approved.");
1047
+ }
1048
+ const raw = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1049
+ const parsed = JSON.parse(raw);
1050
+ const concepts = Array.isArray(parsed) ? parsed : parsed?.concepts;
1051
+ if (!Array.isArray(concepts) || concepts.length === 0) {
1052
+ throw new Error("Payload needs a non-empty `concepts` array");
1053
+ }
1054
+ const client = getClient();
1055
+ output(await client.createCatalogConcepts({ concepts, org: opts.org ?? parsed?.org }));
1056
+ });
1057
+ produce
1058
+ .command("ingredients")
1059
+ .description("Recipe ingredients still standing in for something vaguer")
1060
+ .requiredOption("--entity <entityId>", "Producer entity")
1061
+ .option("--org <org>", "Organization slug or id")
1062
+ .action(async (opts) => {
1063
+ const client = getClient();
1064
+ output(await client.listImpreciseIngredients(opts.entity, opts.org));
1065
+ });
1066
+ produce
1067
+ .command("refine")
1068
+ .description("Point an imprecise ingredient at what it actually is")
1069
+ .requiredOption("--process-input <id>", "processInputId from `produce ingredients`")
1070
+ .option("--concept <catalogConceptId>", "What it actually is")
1071
+ .option("--material <materialId>", "Material node instead of a catalog concept")
1072
+ .option("--role <role>", "e.g. seasoning, base")
1073
+ .option("--qty <n>", "Quantity", parseFloat)
1074
+ .option("--unit <unitCode>", "Unit code")
1075
+ .option("--still-imprecise", "Narrowed but not resolved — keeps it on the worklist")
1076
+ .option("--org <org>", "Organization slug or id")
1077
+ .action(async (opts) => {
1078
+ if (!opts.concept && !opts.material) {
1079
+ throw new Error("Provide --concept or --material");
1080
+ }
1081
+ const client = getClient();
1082
+ output(await client.refineIngredient({
1083
+ processInputId: opts.processInput,
1084
+ catalogConceptId: opts.concept,
1085
+ materialId: opts.material,
1086
+ role: opts.role,
1087
+ qty: opts.qty,
1088
+ unitCode: opts.unit,
1089
+ stillImprecise: Boolean(opts.stillImprecise),
1090
+ org: opts.org,
1091
+ }));
1092
+ });
1093
+ admin
1094
+ .command("customers-only")
1095
+ .description("Entities with a customer role and no investor/partner/producer role")
1096
+ .action(async () => {
1097
+ const client = getClient();
1098
+ output(await client.adminCustomersOnly());
1099
+ });
1100
+ admin
1101
+ .command("press-inbox")
1102
+ .description("Tips mailed to press@mail.dayofweek.com (Press Radar)")
1103
+ .option("--since <date>", "ISO date or epoch ms (default 45 days back)")
1104
+ .option("--limit <count>", "Max emails (max 200)", parseInt)
1105
+ .action(async (opts) => {
1106
+ const client = getClient();
1107
+ output(await client.adminPressInbox({ since: opts.since, limit: opts.limit }));
1108
+ });
1109
+ // ── Customer emails ────────────────────────────────────────────────────────
1110
+ //
1111
+ // Mail sent to a customer's own inbox address (<slug>@mail.dayofweek.com)
1112
+ // becomes a thread the agent can answer. Reading is free; sending is not:
1113
+ // `reply` and `compose` refuse to run without --approved, so an agent cannot
1114
+ // mail a customer as a side effect of "check the inbox". The human approves
1115
+ // the exact text first — that rule lives in the skill; --approved is the
1116
+ // mechanical backstop for it.
1117
+ const emails = program
1118
+ .command("emails", { hidden: true })
1119
+ .description("Customer email threads — read, reply, compose (DoW staff)");
1120
+ const APPROVAL_REQUIRED = "Refusing to send: pass --approved once the human has approved this exact text. " +
1121
+ "Never send an email the human has not seen.";
1122
+ emails
1123
+ .command("list")
1124
+ .description("List customer email threads")
1125
+ .option("--status <status>", "needs_reply | manual_review | all (default all)")
1126
+ .option("--since <date>", "ISO date (2026-07-01) or epoch ms; default 30 days back")
1127
+ .option("--customer <entityId>", "One customer only")
1128
+ .option("--limit <count>", "Max threads (default 50, max 200)", parseInt)
1129
+ .option("--org <org>", "Organization slug or id")
1130
+ .action(async (opts) => {
1131
+ const client = getClient();
1132
+ output(await client.listEmailThreads({
1133
+ status: opts.status,
1134
+ since: opts.since,
1135
+ customer: opts.customer,
1136
+ limit: opts.limit,
1137
+ org: opts.org,
1138
+ }));
1139
+ });
1140
+ emails
1141
+ .command("show <threadKey>")
1142
+ .description("Full thread: messages, attachments, and reply hints")
1143
+ .action(async (threadKey) => {
1144
+ const client = getClient();
1145
+ output(await client.getEmailThread(threadKey));
1146
+ });
1147
+ emails
1148
+ .command("reply <threadKey>")
1149
+ .description("Reply in a thread. Requires --approved")
1150
+ .option("--message <text>", "Plain-text reply body")
1151
+ .option("--file <path>", "Read the body from a file (- for stdin)")
1152
+ .option("--subject <text>", "Override the subject (defaults to Re: …)")
1153
+ .option("--to <address>", "Override the recipient")
1154
+ .option("--cc <addresses>", "Comma-separated cc list")
1155
+ .option("--no-quote", "Do not quote the original underneath")
1156
+ .option("--approved", "The human approved this exact text")
1157
+ .action(async (threadKey, opts) => {
1158
+ if (!opts.approved)
1159
+ throw new Error(APPROVAL_REQUIRED);
1160
+ let message;
1161
+ if (opts.message) {
1162
+ message = String(opts.message);
1163
+ }
1164
+ else if (opts.file) {
1165
+ message = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1166
+ }
1167
+ else {
1168
+ throw new Error("Provide --message or --file");
1169
+ }
1170
+ if (!message.trim())
1171
+ throw new Error("Message is empty");
1172
+ const client = getClient();
1173
+ output(await client.replyToEmailThread(threadKey, {
1174
+ message,
1175
+ subject: opts.subject,
1176
+ to: opts.to,
1177
+ cc: splitList(opts.cc),
1178
+ quote: opts.quote,
1179
+ }));
1180
+ });
1181
+ emails
1182
+ .command("compose")
1183
+ .description("Start a new thread from a customer inbox. Requires --approved")
1184
+ .option("--entity <entityId>", "Customer entity whose inbox sends the mail")
1185
+ .option("--inbox <address>", "Inbox address instead of --entity")
1186
+ .requiredOption("--to <address>", "Recipient")
1187
+ .option("--cc <addresses>", "Comma-separated cc list")
1188
+ .requiredOption("--subject <text>", "Subject line")
1189
+ .option("--message <text>", "Plain-text body")
1190
+ .option("--file <path>", "Read the body from a file (- for stdin)")
1191
+ .option("--approved", "The human approved this exact text")
1192
+ .action(async (opts) => {
1193
+ if (!opts.approved)
1194
+ throw new Error(APPROVAL_REQUIRED);
1195
+ if (!opts.entity && !opts.inbox)
1196
+ throw new Error("Provide --entity or --inbox");
1197
+ let message;
1198
+ if (opts.message) {
1199
+ message = String(opts.message);
1200
+ }
1201
+ else if (opts.file) {
1202
+ message = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1203
+ }
1204
+ else {
1205
+ throw new Error("Provide --message or --file");
1206
+ }
1207
+ if (!message.trim())
1208
+ throw new Error("Message is empty");
1209
+ const client = getClient();
1210
+ output(await client.composeEmail({
1211
+ entityId: opts.entity,
1212
+ inbox: opts.inbox,
1213
+ to: opts.to,
1214
+ cc: splitList(opts.cc),
1215
+ subject: opts.subject,
1216
+ message,
1217
+ }));
1218
+ });
695
1219
  }
696
1220
  registerAdminCommands();
697
1221
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -703,6 +1227,13 @@ async function readStdin() {
703
1227
  }
704
1228
  return chunks.join("\n");
705
1229
  }
1230
+ /** Parse a comma-separated CLI option into a trimmed list, or undefined. */
1231
+ function splitList(value) {
1232
+ if (!value)
1233
+ return undefined;
1234
+ const items = String(value).split(",").map((entry) => entry.trim()).filter(Boolean);
1235
+ return items.length ? items : undefined;
1236
+ }
706
1237
  function inferMimeType(path) {
707
1238
  const extension = path.toLowerCase().split(".").at(-1);
708
1239
  const types = {