@dayofweek/dcli 1.5.0 → 1.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/bin/dcli.js CHANGED
@@ -4,13 +4,13 @@ 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, mkdirSync, readFileSync, existsSync, statSync, writeFileSync } from "node:fs";
7
+ import { accessSync, constants, mkdirSync, readdirSync, 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";
11
11
  import pkg from "../../package.json" with { type: "json" };
12
12
  import { createHash } from "node:crypto";
13
- import { validateSkillBundle, writeSkillBundle } from "../skills.js";
13
+ import { readInstalledSkill, validateSkillBundle, writeSkillBundle } from "../skills.js";
14
14
  const program = new Command()
15
15
  .name("dcli")
16
16
  .description("CLI for the Day of Week AgTech platform")
@@ -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);
@@ -755,53 +768,207 @@ function parseTarget(value) {
755
768
  }
756
769
  return v;
757
770
  }
771
+ function sharedToBundle(shared) {
772
+ return {
773
+ bundle: { name: shared.name, version: shared.version, hash: shared.hash, files: shared.files },
774
+ origin: { source: "shared", skillId: shared.id, areaId: shared.areaId, uri: shared.uri },
775
+ };
776
+ }
777
+ async function fetchSkillByOrigin(client, nameOrUri, areaId) {
778
+ if (nameOrUri?.includes("://")) {
779
+ const resource = parseBrainResource(nameOrUri);
780
+ if (resource.resourceType !== "skill" || !resource.resourceId)
781
+ throw new Error("A shared skill URI is required");
782
+ const shared = await client.getSharedSkill(resource.resourceId);
783
+ if (shared.areaId !== resource.areaId)
784
+ throw new Error("Server returned a mismatched area");
785
+ return sharedToBundle(shared);
786
+ }
787
+ if (areaId) {
788
+ if (!nameOrUri)
789
+ throw new Error("A skill name is required together with --area");
790
+ const match = (await client.listSharedSkills()).find((candidate) => candidate.areaId === areaId && candidate.name === nameOrUri);
791
+ if (!match)
792
+ throw new Error("Shared skill not found in that area");
793
+ return sharedToBundle(await client.getSharedSkill(match.id));
794
+ }
795
+ return { bundle: await client.getSkillBundle(nameOrUri), origin: { source: "platform" } };
796
+ }
797
+ async function installOrUpdateSkill(action, name, opts) {
798
+ const client = getClient();
799
+ let resolved;
800
+ if (action === "update" && !name && !opts.area && opts.dir) {
801
+ // Bare `skill update --dir …`: go back to wherever this install came from.
802
+ const installed = readInstalledSkill(opts.dir);
803
+ if (installed) {
804
+ resolved = installed.origin?.source === "shared"
805
+ ? sharedToBundle(await client.getSharedSkill(installed.origin.skillId))
806
+ : { bundle: await client.getSkillBundle(installed.name), origin: { source: "platform" } };
807
+ }
808
+ }
809
+ resolved = resolved ?? await fetchSkillByOrigin(client, name, opts.area);
810
+ const bundle = validateSkillBundle(resolved.bundle);
811
+ const dirs = resolveTargetDirs(parseTarget(opts.target), bundle.name, opts.dir);
812
+ const installations = [];
813
+ for (const dir of dirs) {
814
+ const result = writeSkillBundle(bundle, dir, resolved.origin);
815
+ installations.push({ directory: dir, ...result });
816
+ }
817
+ output({
818
+ action,
819
+ bundle: bundle.name,
820
+ version: bundle.version,
821
+ hash: bundle.hash,
822
+ origin: resolved.origin.source,
823
+ ...(resolved.origin.source === "shared" ? { uri: resolved.origin.uri } : {}),
824
+ installations,
825
+ });
826
+ }
827
+ /** Read a skill directory into bundle files: relative paths, utf-8 content. */
828
+ function collectSkillFiles(dir) {
829
+ const root = resolve(dir);
830
+ if (!existsSync(root) || !statSync(root).isDirectory())
831
+ throw new Error("Skill directory not found");
832
+ const files = [];
833
+ const walk = (current, prefix) => {
834
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
835
+ // Hidden entries (including the .dayofweek-skill.json install manifest)
836
+ // and update-conflict artifacts never belong in a published bundle.
837
+ if (entry.name.startsWith(".") || /\.new(\.\d+)?$/.test(entry.name))
838
+ continue;
839
+ if (entry.isSymbolicLink())
840
+ throw new Error("Symlinks are not allowed in a skill directory");
841
+ const full = join(current, entry.name);
842
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
843
+ if (entry.isDirectory())
844
+ walk(full, relativePath);
845
+ else if (entry.isFile())
846
+ files.push({ path: relativePath, content: readFileSync(full, "utf8") });
847
+ }
848
+ };
849
+ walk(root, "");
850
+ return files.sort((left, right) => left.path.localeCompare(right.path));
851
+ }
758
852
  skill
759
853
  .command("list")
760
- .description("List authenticated named skill bundles")
761
- .action(async () => output(await getClient().listSkillBundles()));
854
+ .description("List installable named skill bundles; --shared adds skills shared with you")
855
+ .option("--shared", "Include skills other users shared with you")
856
+ .action(async (opts) => {
857
+ const client = getClient();
858
+ // The bare listing stays exactly the named bundles: managed installers
859
+ // iterate it and fetch every entry by plain name, so shared skills (which
860
+ // resolve by URI) only appear when explicitly asked for.
861
+ const listings = (await client.listSkillBundles()).map((bundle) => ({
862
+ ...bundle,
863
+ source: "platform",
864
+ }));
865
+ if (opts.shared) {
866
+ try {
867
+ for (const shared of await client.listSharedSkills()) {
868
+ listings.push({
869
+ name: shared.name,
870
+ version: shared.version,
871
+ hash: shared.hash,
872
+ source: "shared",
873
+ areaId: shared.areaId,
874
+ areaName: shared.areaName,
875
+ visibility: shared.visibility,
876
+ description: shared.description,
877
+ uri: shared.uri,
878
+ updatedAt: shared.updatedAt,
879
+ });
880
+ }
881
+ }
882
+ catch (error) {
883
+ // Older servers or tokens without knowledge scopes have no shared-skill
884
+ // surface — the named bundles are still worth listing.
885
+ console.error(`Shared skills unavailable: ${error instanceof Error ? error.message : String(error)}`);
886
+ }
887
+ }
888
+ output(listings.sort((left, right) => left.name.localeCompare(right.name)));
889
+ });
762
890
  skill
763
- .command("bundle <name>")
764
- .description("Fetch and verify a named skill bundle for a managed installer")
765
- .action(async (name) => output(validateSkillBundle(await getClient().getSkillBundle(name))));
891
+ .command("bundle <nameOrUri>")
892
+ .description("Fetch and verify a skill bundle (by name, or shared-skill URI) for a managed installer")
893
+ .action(async (nameOrUri) => {
894
+ if (nameOrUri.includes("://")) {
895
+ const resource = parseBrainResource(nameOrUri);
896
+ if (resource.resourceType !== "skill" || !resource.resourceId)
897
+ throw new Error("A shared skill URI is required");
898
+ const shared = await getClient().getSharedSkill(resource.resourceId);
899
+ if (shared.areaId !== resource.areaId)
900
+ throw new Error("Server returned a mismatched area");
901
+ // Emit the plain bundle shape managed installers expect.
902
+ output(validateSkillBundle({ name: shared.name, version: shared.version, hash: shared.hash, files: shared.files }));
903
+ return;
904
+ }
905
+ output(validateSkillBundle(await getClient().getSkillBundle(nameOrUri)));
906
+ });
766
907
  skill
767
908
  .command("install [name]")
768
- .description("Install the agent skill (requires valid auth)")
909
+ .description("Install a skill by bundle name, shared-skill URI, or name + --area")
769
910
  .option("--dir <path>", "Custom install directory (overrides --target)")
770
911
  .option("--target <target>", "Install target: agents, claude, or all (default: all)")
771
- .action(async (name, opts) => {
772
- const client = getClient();
773
- const bundle = await client.getSkillBundle(name);
774
- const target = parseTarget(opts.target);
775
- const dirs = resolveTargetDirs(target, bundle.name, opts.dir);
776
- const installations = [];
777
- for (const dir of dirs) {
778
- const result = writeSkillBundle(bundle, dir);
779
- installations.push({ directory: dir, ...result });
780
- }
781
- output({ action: "install", bundle: bundle.name, version: bundle.version, hash: bundle.hash, installations });
782
- });
912
+ .option("--area <areaId>", "Install a shared skill from this area")
913
+ .action(async (name, opts) => installOrUpdateSkill("install", name, opts));
783
914
  skill
784
915
  .command("update [name]")
785
- .description("Update the skill to the latest version")
916
+ .description("Update a skill to the latest version from its origin")
786
917
  .option("--dir <path>", "Custom install directory (overrides --target)")
787
918
  .option("--target <target>", "Install target: agents, claude, or all (default: all)")
788
- .action(async (name, opts) => {
789
- const client = getClient();
790
- const bundle = await client.getSkillBundle(name);
791
- const target = parseTarget(opts.target);
792
- const dirs = resolveTargetDirs(target, bundle.name, opts.dir);
793
- const installations = [];
794
- for (const dir of dirs) {
795
- const result = writeSkillBundle(bundle, dir);
796
- installations.push({ directory: dir, ...result });
919
+ .option("--area <areaId>", "Update a shared skill from this area")
920
+ .action(async (name, opts) => installOrUpdateSkill("update", name, opts));
921
+ skill
922
+ .command("publish")
923
+ .description("Share a skill directory to a knowledge area so teammates can install it")
924
+ .requiredOption("--area <areaId>", "Owning area (see: dcli brain list)")
925
+ .requiredOption("--dir <path>", "Skill directory containing SKILL.md")
926
+ .option("--name <name>", "Skill name (default: SKILL.md frontmatter, else the directory name)")
927
+ .option("--skill-version <version>", "Version string (default: SKILL.md frontmatter version)")
928
+ .option("--visibility <visibility>", "Who can discover it: area (members only), company, or global (admins; every user)")
929
+ .option("--description <text>", "Short description shown in listings")
930
+ .action(async (opts) => {
931
+ const visibility = (opts.visibility ?? "area").toLowerCase();
932
+ if (visibility !== "area" && visibility !== "company" && visibility !== "global") {
933
+ throw new Error("Invalid --visibility: use area, company, or global");
797
934
  }
798
- output({ action: "update", bundle: bundle.name, version: bundle.version, hash: bundle.hash, installations });
935
+ const files = collectSkillFiles(opts.dir);
936
+ const skillMd = files.find((file) => file.path === "SKILL.md");
937
+ if (!skillMd)
938
+ throw new Error("The skill directory must contain SKILL.md");
939
+ const name = opts.name
940
+ ?? skillMd.content.match(/^name:\s*"?([a-z0-9][a-z0-9-]*)"?\s*$/m)?.[1]
941
+ ?? basename(resolve(opts.dir));
942
+ const version = opts.skillVersion ?? skillMd.content.match(/version:\s*"([^"]+)"/)?.[1] ?? "1.0.0";
943
+ validateSkillBundle({ name, version, files });
944
+ const result = await getClient().publishSharedSkill({
945
+ areaId: opts.area,
946
+ name,
947
+ version,
948
+ description: opts.description,
949
+ visibility,
950
+ files,
951
+ });
952
+ output({ action: "publish", ...result });
953
+ });
954
+ skill
955
+ .command("archive <uri>")
956
+ .description("Archive a shared skill you own (removes it from listing and install)")
957
+ .action(async (uri) => {
958
+ const resource = parseBrainResource(uri);
959
+ if (resource.resourceType !== "skill" || !resource.resourceId)
960
+ throw new Error("A shared skill URI is required");
961
+ const result = await getClient().archiveSharedSkill(resource.resourceId);
962
+ if (result.areaId !== resource.areaId)
963
+ throw new Error("Server returned a mismatched area");
964
+ output({ action: "archive", ...result });
799
965
  });
800
966
  skill
801
967
  .command("status [name]")
802
- .description("Check if the skill is installed")
968
+ .description("Check if the skill is installed; --check compares against its origin")
803
969
  .option("--dir <path>", "Custom install directory (overrides --target)")
804
970
  .option("--target <target>", "Check target: agents, claude, or all (default: all)")
971
+ .option("--check", "Also ask the server whether a newer version exists")
805
972
  .action(async (name, opts) => {
806
973
  const bundleName = name ?? "dayofweek-platform";
807
974
  const target = parseTarget(opts.target);
@@ -810,6 +977,7 @@ skill
810
977
  : target === "all"
811
978
  ? [join(homedir(), ".agents", "skills", bundleName), join(homedir(), ".claude", "skills", bundleName)]
812
979
  : resolveTargetDirs(target, bundleName);
980
+ let platformBundles;
813
981
  const installations = [];
814
982
  for (const dir of dirs) {
815
983
  const skillPath = join(dir, "SKILL.md");
@@ -817,15 +985,154 @@ skill
817
985
  installations.push({ installed: false, directory: dir, name: bundleName });
818
986
  continue;
819
987
  }
988
+ const metadata = readInstalledSkill(dir);
820
989
  const content = readFileSync(skillPath, "utf-8");
821
990
  const versionMatch = content.match(/version:\s*"([^"]+)"/);
822
- installations.push({ installed: true, directory: dir, name: bundleName, version: versionMatch?.[1] ?? "unknown", sha256: sha256(content) });
991
+ const entry = {
992
+ installed: true,
993
+ directory: dir,
994
+ name: metadata?.name ?? bundleName,
995
+ version: metadata?.version ?? versionMatch?.[1] ?? "unknown",
996
+ sha256: sha256(content),
997
+ origin: metadata?.origin?.source ?? "platform",
998
+ };
999
+ if (opts.check) {
1000
+ try {
1001
+ if (metadata?.origin?.source === "shared") {
1002
+ const latest = await getClient().getSharedSkill(metadata.origin.skillId);
1003
+ entry.latestVersion = latest.version;
1004
+ entry.upToDate = metadata.hash === latest.hash;
1005
+ }
1006
+ else {
1007
+ platformBundles ??= await getClient().listSkillBundles();
1008
+ const latest = platformBundles.find((candidate) => candidate.name === entry.name);
1009
+ if (latest) {
1010
+ entry.latestVersion = latest.version;
1011
+ // Pre-origin installs have no recorded manifest hash; fall back
1012
+ // to comparing the declared versions.
1013
+ entry.upToDate = metadata ? metadata.hash === latest.hash : latest.version === entry.version;
1014
+ }
1015
+ }
1016
+ }
1017
+ catch (error) {
1018
+ entry.checkError = error instanceof Error ? error.message : String(error);
1019
+ }
1020
+ }
1021
+ installations.push(entry);
823
1022
  }
824
1023
  const installed = installations.some((entry) => entry.installed);
825
1024
  output({ installed, bundle: bundleName, installations });
826
1025
  if (!installed)
827
1026
  process.exitCode = 2;
828
1027
  });
1028
+ // ── Data Commands ────────────────────────────────────────────────────────────
1029
+ //
1030
+ // Generic, name-blind reads of platform datasets. The server's catalog decides
1031
+ // what exists and what the caller may see — this CLI ships no dataset names,
1032
+ // so new platform surfaces appear in `data list` without a CLI release.
1033
+ const data = program.command("data").description("Read platform datasets the server offers you");
1034
+ data
1035
+ .command("list")
1036
+ .description("List the datasets your credential may read")
1037
+ .option("--org <org>", "Organization slug or id (admin only)")
1038
+ .action(async (opts) => {
1039
+ const client = getClient();
1040
+ output(await client.listDatasets(opts.org));
1041
+ });
1042
+ data
1043
+ .command("get <dataset>")
1044
+ .description("Read one dataset's rows (names come from `data list`)")
1045
+ .option("--limit <count>", "Max rows (default 50, max 200)", parseInt)
1046
+ .option("--org <org>", "Organization slug or id (admin only)")
1047
+ .action(async (dataset, opts) => {
1048
+ const client = getClient();
1049
+ output(await client.readDataset(dataset, { limit: opts.limit, org: opts.org }));
1050
+ });
1051
+ // ── Feedback Commands ────────────────────────────────────────────────────────
1052
+ //
1053
+ // The customer feedback backlog. Scope-gated rather than admin-gated:
1054
+ // read:feedback for the reads, write:feedback to comment, admin:feedback for
1055
+ // claim/status/priority. backOffice users implicitly hold every scope, so the
1056
+ // group stays visible and the endpoint decides what the caller may do.
1057
+ const feedback = program
1058
+ .command("feedback")
1059
+ .description("Read and work the customer feedback backlog");
1060
+ feedback
1061
+ .command("list")
1062
+ .description("List backlog items")
1063
+ .option("--status <status>", "backlog | planned | in_progress | shipped | rejected | new")
1064
+ .option("--priority <priority>", "urgent | high | medium | low")
1065
+ .option("--category <category>", "Filter by category, e.g. studio")
1066
+ .option("--limit <count>", "Max results", parseInt)
1067
+ .action(async (opts) => {
1068
+ const client = getClient();
1069
+ output(await client.listFeedback({
1070
+ status: opts.status,
1071
+ priority: opts.priority,
1072
+ category: opts.category,
1073
+ limit: opts.limit,
1074
+ }));
1075
+ });
1076
+ feedback
1077
+ .command("next")
1078
+ .description("What should I work on next — the prioritizer's top picks")
1079
+ .option("--limit <count>", "How many recommendations", parseInt)
1080
+ .action(async (opts) => {
1081
+ const client = getClient();
1082
+ output(await client.feedbackRecommendations(opts.limit));
1083
+ });
1084
+ feedback
1085
+ .command("show <itemId>")
1086
+ .description("Show one backlog item in full")
1087
+ .action(async (itemId) => {
1088
+ const client = getClient();
1089
+ output(await client.getFeedbackItem(itemId));
1090
+ });
1091
+ feedback
1092
+ .command("claim <itemId>")
1093
+ .description("Claim an item so humans see it is being worked on")
1094
+ .action(async (itemId) => {
1095
+ const client = getClient();
1096
+ output(await client.claimFeedbackItem(itemId));
1097
+ });
1098
+ feedback
1099
+ .command("comment <itemId>")
1100
+ .description("Add a comment to a backlog item")
1101
+ .option("--body <text>", "Comment text")
1102
+ .option("--file <path>", "Read the comment from a file (- for stdin)")
1103
+ .action(async (itemId, opts) => {
1104
+ let body;
1105
+ if (opts.body) {
1106
+ body = String(opts.body);
1107
+ }
1108
+ else if (opts.file) {
1109
+ body = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1110
+ }
1111
+ else {
1112
+ throw new Error("Provide --body or --file");
1113
+ }
1114
+ if (!body.trim())
1115
+ throw new Error("Comment is empty");
1116
+ const client = getClient();
1117
+ output(await client.commentOnFeedbackItem(itemId, body));
1118
+ });
1119
+ feedback
1120
+ .command("status <itemId>")
1121
+ .description("Set status and/or priority on a backlog item")
1122
+ .option("--status <status>", "backlog | planned | in_progress | shipped | rejected")
1123
+ .option("--priority <priority>", "urgent | high | medium | low")
1124
+ .option("--rejected-reason <text>", "Why it was rejected (with --status rejected)")
1125
+ .action(async (itemId, opts) => {
1126
+ if (!opts.status && !opts.priority) {
1127
+ throw new Error("Provide --status and/or --priority");
1128
+ }
1129
+ const client = getClient();
1130
+ output(await client.updateFeedbackItem(itemId, {
1131
+ status: opts.status,
1132
+ priority: opts.priority,
1133
+ rejectedReason: opts.rejectedReason,
1134
+ }));
1135
+ });
829
1136
  // ── Admin Commands ───────────────────────────────────────────────────────────
830
1137
  //
831
1138
  // These are admin-only. They're registered as hidden subcommands when the
@@ -877,6 +1184,224 @@ function registerAdminCommands() {
877
1184
  });
878
1185
  output(result);
879
1186
  });
1187
+ // ── Assortment import ──────────────────────────────────────────────────────
1188
+ //
1189
+ // These write real Creator rows rather than proposals, so the operator
1190
+ // approves the parsed result first. --dry-run rehearses the whole import
1191
+ // server-side and rolls it back; that response is what the review is built
1192
+ // from. Writing requires --approved for the same reason email does.
1193
+ const produce = program
1194
+ .command("produce", { hidden: true })
1195
+ .description("Assortment import and recipe refinement (DoW staff)");
1196
+ produce
1197
+ .command("import")
1198
+ .description("Import a producer's items into Creator. Requires --dry-run or --approved")
1199
+ .requiredOption("--file <path>", "JSON payload: { entityId, items[] } (- for stdin)")
1200
+ .option("--dry-run", "Rehearse server-side and roll back — build the review from this")
1201
+ .option("--skip-existing-names", "Skip items whose displayName already exists")
1202
+ .option("--approved", "The operator approved the dry-run result")
1203
+ .option("--org <org>", "Organization slug or id")
1204
+ .action(async (opts) => {
1205
+ if (!opts.dryRun && !opts.approved) {
1206
+ throw new Error("Refusing to write: run with --dry-run first, show the operator the result, " +
1207
+ "then repeat with --approved. This creates real Creator rows, not proposals.");
1208
+ }
1209
+ const raw = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1210
+ const payload = JSON.parse(raw);
1211
+ if (!payload?.entityId || !Array.isArray(payload.items)) {
1212
+ throw new Error("Payload needs { entityId, items: [...] }");
1213
+ }
1214
+ const client = getClient();
1215
+ output(await client.importProduce({
1216
+ entityId: payload.entityId,
1217
+ items: payload.items,
1218
+ dryRun: Boolean(opts.dryRun),
1219
+ skipExistingNames: opts.skipExistingNames ?? payload.skipExistingNames,
1220
+ org: opts.org ?? payload.org,
1221
+ }));
1222
+ });
1223
+ produce
1224
+ .command("add-concepts")
1225
+ .description("Add concepts to the shared produce catalog. Requires --approved")
1226
+ .requiredOption("--file <path>", "JSON: { concepts: [...] } or a bare array (- for stdin)")
1227
+ .option("--approved", "The operator approved these concepts")
1228
+ .option("--org <org>", "Organization slug or id")
1229
+ .action(async (opts) => {
1230
+ if (!opts.approved) {
1231
+ throw new Error("Refusing to write: the produce catalog is shared by every customer. " +
1232
+ "Show the operator the concepts you want to add, then repeat with --approved.");
1233
+ }
1234
+ const raw = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1235
+ const parsed = JSON.parse(raw);
1236
+ const concepts = Array.isArray(parsed) ? parsed : parsed?.concepts;
1237
+ if (!Array.isArray(concepts) || concepts.length === 0) {
1238
+ throw new Error("Payload needs a non-empty `concepts` array");
1239
+ }
1240
+ const client = getClient();
1241
+ output(await client.createCatalogConcepts({ concepts, org: opts.org ?? parsed?.org }));
1242
+ });
1243
+ produce
1244
+ .command("ingredients")
1245
+ .description("Recipe ingredients still standing in for something vaguer")
1246
+ .requiredOption("--entity <entityId>", "Producer entity")
1247
+ .option("--org <org>", "Organization slug or id")
1248
+ .action(async (opts) => {
1249
+ const client = getClient();
1250
+ output(await client.listImpreciseIngredients(opts.entity, opts.org));
1251
+ });
1252
+ produce
1253
+ .command("refine")
1254
+ .description("Point an imprecise ingredient at what it actually is")
1255
+ .requiredOption("--process-input <id>", "processInputId from `produce ingredients`")
1256
+ .option("--concept <catalogConceptId>", "What it actually is")
1257
+ .option("--material <materialId>", "Material node instead of a catalog concept")
1258
+ .option("--role <role>", "e.g. seasoning, base")
1259
+ .option("--qty <n>", "Quantity", parseFloat)
1260
+ .option("--unit <unitCode>", "Unit code")
1261
+ .option("--still-imprecise", "Narrowed but not resolved — keeps it on the worklist")
1262
+ .option("--org <org>", "Organization slug or id")
1263
+ .action(async (opts) => {
1264
+ if (!opts.concept && !opts.material) {
1265
+ throw new Error("Provide --concept or --material");
1266
+ }
1267
+ const client = getClient();
1268
+ output(await client.refineIngredient({
1269
+ processInputId: opts.processInput,
1270
+ catalogConceptId: opts.concept,
1271
+ materialId: opts.material,
1272
+ role: opts.role,
1273
+ qty: opts.qty,
1274
+ unitCode: opts.unit,
1275
+ stillImprecise: Boolean(opts.stillImprecise),
1276
+ org: opts.org,
1277
+ }));
1278
+ });
1279
+ admin
1280
+ .command("customers-only")
1281
+ .description("Entities with a customer role and no investor/partner/producer role")
1282
+ .action(async () => {
1283
+ const client = getClient();
1284
+ output(await client.adminCustomersOnly());
1285
+ });
1286
+ admin
1287
+ .command("press-inbox")
1288
+ .description("Tips mailed to press@mail.dayofweek.com (Press Radar)")
1289
+ .option("--since <date>", "ISO date or epoch ms (default 45 days back)")
1290
+ .option("--limit <count>", "Max emails (max 200)", parseInt)
1291
+ .action(async (opts) => {
1292
+ const client = getClient();
1293
+ output(await client.adminPressInbox({ since: opts.since, limit: opts.limit }));
1294
+ });
1295
+ // ── Customer emails ────────────────────────────────────────────────────────
1296
+ //
1297
+ // Mail sent to a customer's own inbox address (<slug>@mail.dayofweek.com)
1298
+ // becomes a thread the agent can answer. Reading is free; sending is not:
1299
+ // `reply` and `compose` refuse to run without --approved, so an agent cannot
1300
+ // mail a customer as a side effect of "check the inbox". The human approves
1301
+ // the exact text first — that rule lives in the skill; --approved is the
1302
+ // mechanical backstop for it.
1303
+ const emails = program
1304
+ .command("emails", { hidden: true })
1305
+ .description("Customer email threads — read, reply, compose (DoW staff)");
1306
+ const APPROVAL_REQUIRED = "Refusing to send: pass --approved once the human has approved this exact text. " +
1307
+ "Never send an email the human has not seen.";
1308
+ emails
1309
+ .command("list")
1310
+ .description("List customer email threads")
1311
+ .option("--status <status>", "needs_reply | manual_review | all (default all)")
1312
+ .option("--since <date>", "ISO date (2026-07-01) or epoch ms; default 30 days back")
1313
+ .option("--customer <entityId>", "One customer only")
1314
+ .option("--limit <count>", "Max threads (default 50, max 200)", parseInt)
1315
+ .option("--org <org>", "Organization slug or id")
1316
+ .action(async (opts) => {
1317
+ const client = getClient();
1318
+ output(await client.listEmailThreads({
1319
+ status: opts.status,
1320
+ since: opts.since,
1321
+ customer: opts.customer,
1322
+ limit: opts.limit,
1323
+ org: opts.org,
1324
+ }));
1325
+ });
1326
+ emails
1327
+ .command("show <threadKey>")
1328
+ .description("Full thread: messages, attachments, and reply hints")
1329
+ .action(async (threadKey) => {
1330
+ const client = getClient();
1331
+ output(await client.getEmailThread(threadKey));
1332
+ });
1333
+ emails
1334
+ .command("reply <threadKey>")
1335
+ .description("Reply in a thread. Requires --approved")
1336
+ .option("--message <text>", "Plain-text reply body")
1337
+ .option("--file <path>", "Read the body from a file (- for stdin)")
1338
+ .option("--subject <text>", "Override the subject (defaults to Re: …)")
1339
+ .option("--to <address>", "Override the recipient")
1340
+ .option("--cc <addresses>", "Comma-separated cc list")
1341
+ .option("--no-quote", "Do not quote the original underneath")
1342
+ .option("--approved", "The human approved this exact text")
1343
+ .action(async (threadKey, opts) => {
1344
+ if (!opts.approved)
1345
+ throw new Error(APPROVAL_REQUIRED);
1346
+ let message;
1347
+ if (opts.message) {
1348
+ message = String(opts.message);
1349
+ }
1350
+ else if (opts.file) {
1351
+ message = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1352
+ }
1353
+ else {
1354
+ throw new Error("Provide --message or --file");
1355
+ }
1356
+ if (!message.trim())
1357
+ throw new Error("Message is empty");
1358
+ const client = getClient();
1359
+ output(await client.replyToEmailThread(threadKey, {
1360
+ message,
1361
+ subject: opts.subject,
1362
+ to: opts.to,
1363
+ cc: splitList(opts.cc),
1364
+ quote: opts.quote,
1365
+ }));
1366
+ });
1367
+ emails
1368
+ .command("compose")
1369
+ .description("Start a new thread from a customer inbox. Requires --approved")
1370
+ .option("--entity <entityId>", "Customer entity whose inbox sends the mail")
1371
+ .option("--inbox <address>", "Inbox address instead of --entity")
1372
+ .requiredOption("--to <address>", "Recipient")
1373
+ .option("--cc <addresses>", "Comma-separated cc list")
1374
+ .requiredOption("--subject <text>", "Subject line")
1375
+ .option("--message <text>", "Plain-text body")
1376
+ .option("--file <path>", "Read the body from a file (- for stdin)")
1377
+ .option("--approved", "The human approved this exact text")
1378
+ .action(async (opts) => {
1379
+ if (!opts.approved)
1380
+ throw new Error(APPROVAL_REQUIRED);
1381
+ if (!opts.entity && !opts.inbox)
1382
+ throw new Error("Provide --entity or --inbox");
1383
+ let message;
1384
+ if (opts.message) {
1385
+ message = String(opts.message);
1386
+ }
1387
+ else if (opts.file) {
1388
+ message = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1389
+ }
1390
+ else {
1391
+ throw new Error("Provide --message or --file");
1392
+ }
1393
+ if (!message.trim())
1394
+ throw new Error("Message is empty");
1395
+ const client = getClient();
1396
+ output(await client.composeEmail({
1397
+ entityId: opts.entity,
1398
+ inbox: opts.inbox,
1399
+ to: opts.to,
1400
+ cc: splitList(opts.cc),
1401
+ subject: opts.subject,
1402
+ message,
1403
+ }));
1404
+ });
880
1405
  }
881
1406
  registerAdminCommands();
882
1407
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -888,6 +1413,13 @@ async function readStdin() {
888
1413
  }
889
1414
  return chunks.join("\n");
890
1415
  }
1416
+ /** Parse a comma-separated CLI option into a trimmed list, or undefined. */
1417
+ function splitList(value) {
1418
+ if (!value)
1419
+ return undefined;
1420
+ const items = String(value).split(",").map((entry) => entry.trim()).filter(Boolean);
1421
+ return items.length ? items : undefined;
1422
+ }
891
1423
  function inferMimeType(path) {
892
1424
  const extension = path.toLowerCase().split(".").at(-1);
893
1425
  const types = {