@dayofweek/dcli 1.5.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/README.md CHANGED
@@ -111,6 +111,55 @@ immediately, so treat them as something you do when a person has asked for that
111
111
  specific document — not as the normal path. The server rejects them for
112
112
  non-admin tokens.
113
113
 
114
+ ## Datasets
115
+
116
+ The platform offers additional read-only datasets beyond entities and
117
+ knowledge. The catalog is server-owned and discovered at runtime — what
118
+ `data list` returns is exactly what your credential may read.
119
+
120
+ ```bash
121
+ dcli data list --json
122
+ dcli data get <dataset> --limit 100 --json
123
+ ```
124
+
125
+ Responses are `{ dataset, total, truncated, rows }`. The CLI has no built-in
126
+ dataset names; new datasets appear in the listing without a CLI update.
127
+
128
+ ## Feedback backlog
129
+
130
+ The customer feedback backlog that humans and coding agents work together.
131
+
132
+ ```bash
133
+ dcli feedback next --json # what should I work on next
134
+ dcli feedback list --status backlog --json
135
+ dcli feedback show <itemId> --json
136
+ dcli feedback claim <itemId> --json # signal that you picked it up
137
+ dcli feedback comment <itemId> --body "Fixed in #482"
138
+ dcli feedback status <itemId> --status shipped
139
+ ```
140
+
141
+ Access follows the token's scopes: `read:feedback` for the reads,
142
+ `write:feedback` to comment, `admin:feedback` for claim/status/priority.
143
+ Day of Week staff hold all three implicitly.
144
+
145
+ ## Customer emails (staff)
146
+
147
+ Mail sent to a customer's own inbox address becomes a thread you can answer.
148
+ The commands appear once `dcli auth status` has cached your admin role.
149
+
150
+ ```bash
151
+ dcli emails list --status needs_reply --json
152
+ dcli emails show <threadKey> --json
153
+ dcli emails reply <threadKey> --message "..." --approved
154
+ dcli emails compose --entity <entityId> --to person@example.com \
155
+ --subject "..." --message "..." --approved
156
+ ```
157
+
158
+ `reply` and `compose` refuse to run without `--approved`. Sending mail as a
159
+ customer is irreversible and outward-facing, so it cannot happen as a side
160
+ effect of reading the inbox — a person approves the exact text first, and
161
+ `--approved` records that they did.
162
+
114
163
  ## Legacy platform commands
115
164
 
116
165
  Existing read/proposal workflows remain compatible:
@@ -135,11 +184,31 @@ Development commands:
135
184
  ```bash
136
185
  npm install
137
186
  npm test
138
- npm run build
187
+ npm run build # tsc + the dependency-free bundle
188
+ npm run build:bundle # just dist/bundle/dcli.cjs
139
189
  npm run standalone:build
140
190
  npm run standalone:smoke
141
191
  ```
142
192
 
193
+ ### What gets published
194
+
195
+ `npm run build` produces two runnable forms, and both ship:
196
+
197
+ - **`dist/bin/dcli.js`** — the normal entry point, the one `bin` points at. It
198
+ imports `commander` and `open` from `node_modules`, which is exactly right
199
+ when npm installed the package.
200
+ - **`dist/bundle/dcli.cjs`** — the same CLI with its dependencies compiled in,
201
+ runnable straight from an unpacked tarball.
202
+
203
+ The bundle exists for consumers that unpack the tarball themselves instead of
204
+ installing it, the Day of Week desktop app being the one that matters: it fetches
205
+ the published package and runs it with Electron's Node, so a customer with no
206
+ `node` and no `npm` still gets a working `dcli`. Without the bundle that install
207
+ starts and immediately fails on a missing `commander`.
208
+
209
+ Keep both. Dropping `dist/bundle/` from `files` silently breaks the desktop app's
210
+ dcli updates.
211
+
143
212
  ## License
144
213
 
145
214
  MIT
package/dist/bin/dcli.js CHANGED
@@ -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);
@@ -826,6 +839,114 @@ skill
826
839
  if (!installed)
827
840
  process.exitCode = 2;
828
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
+ });
829
950
  // ── Admin Commands ───────────────────────────────────────────────────────────
830
951
  //
831
952
  // These are admin-only. They're registered as hidden subcommands when the
@@ -877,6 +998,224 @@ function registerAdminCommands() {
877
998
  });
878
999
  output(result);
879
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
+ });
880
1219
  }
881
1220
  registerAdminCommands();
882
1221
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -888,6 +1227,13 @@ async function readStdin() {
888
1227
  }
889
1228
  return chunks.join("\n");
890
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
+ }
891
1237
  function inferMimeType(path) {
892
1238
  const extension = path.toLowerCase().split(".").at(-1);
893
1239
  const types = {