@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/README.md +98 -1
- package/dist/bin/dcli.js +569 -37
- package/dist/bundle/dcli.cjs +6166 -0
- package/dist/client.d.ts +140 -2
- package/dist/client.js +156 -4
- package/dist/skills.d.ts +24 -1
- package/dist/skills.js +17 -2
- package/dist/uri.d.ts +1 -1
- package/dist/uri.js +1 -1
- package/package.json +5 -3
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
|
-
.
|
|
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
|
-
.
|
|
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
|
|
761
|
-
.
|
|
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 <
|
|
764
|
-
.description("Fetch and verify a
|
|
765
|
-
.action(async (
|
|
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
|
|
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
|
-
.
|
|
772
|
-
|
|
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
|
|
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
|
-
.
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|