@dayofweek/dcli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/dcli.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { DayOfWeekClient } from "../client.js";
4
- import { getToken, getApiUrl, saveConfig, loadConfig } from "../config.js";
5
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
6
- import { join, dirname } from "node:path";
3
+ import { ApiError, DayOfWeekClient } from "../client.js";
4
+ import { getToken, getApiUrl, saveConfig, loadConfig, saveCredential, deleteCredential } from "../config.js";
5
+ import { browserLogin } from "../auth/login.js";
6
+ import { parseBrainResource } from "../uri.js";
7
+ import { accessSync, constants, readFileSync, existsSync, statSync } from "node:fs";
8
+ import { join, basename, resolve, relative, sep } from "node:path";
7
9
  import { homedir } from "node:os";
8
10
  import { createInterface } from "node:readline/promises";
9
- import { fileURLToPath } from "node:url";
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
- const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8"));
11
+ import pkg from "../../package.json" with { type: "json" };
12
+ import { createHash } from "node:crypto";
13
+ import { validateSkillBundle, writeSkillBundle } from "../skills.js";
12
14
  const program = new Command()
13
15
  .name("dcli")
14
16
  .description("CLI for the Day of Week AgTech platform")
@@ -30,25 +32,29 @@ const auth = program.command("auth").description("Authentication commands");
30
32
  auth
31
33
  .command("login")
32
34
  .description("Authenticate via browser")
33
- .action(async () => {
35
+ .option("--scopes <scopes>", "Comma-separated scopes", "brain:read,brain:write")
36
+ .option("--source-app <sourceApp>", "Authorization client", "dcli")
37
+ .action(async (opts) => {
34
38
  const apiUrl = program.opts().apiUrl ?? getApiUrl();
35
- const baseUrl = apiUrl.replace("/api/dcli", "");
36
- const authUrl = `${baseUrl}/dcli/auth`;
37
- console.log(`Opening browser for authentication...`);
38
- console.log(`If the browser doesn't open, visit: ${authUrl}`);
39
- const open = (await import("open")).default;
40
- await open(authUrl);
41
- console.log("\nAfter authenticating, copy the token and run:");
42
- console.log(" export DCLI_AUTH_TOKEN=<your-token>");
43
- console.log(" # or");
44
- console.log(" dcli auth set-token <your-token>");
39
+ const scopes = opts.scopes.split(",").map((scope) => scope.trim()).filter(Boolean);
40
+ if (opts.sourceApp !== "dcli" && opts.sourceApp !== "dayofweek-desktop")
41
+ throw new Error("Invalid authorization client");
42
+ console.error("Opening Day of Week in your browser…");
43
+ const result = await browserLogin({ apiUrl, scopes, sourceApp: opts.sourceApp });
44
+ saveCredential(result.secret);
45
+ output({ authenticated: true, scopes: result.scopes, bootstrap: result.bootstrap });
45
46
  });
46
47
  auth
47
- .command("set-token <token>")
48
- .description("Save a token to local config")
49
- .action((token) => {
50
- saveConfig({ authToken: token });
51
- console.log("Token saved to ~/.config/dayofweek/dcli.json");
48
+ .command("logout")
49
+ .description("Revoke and remove the credential from this device")
50
+ .action(async () => {
51
+ if (program.opts().token || process.env.DCLI_AUTH_TOKEN || process.env.DCLI_TOKEN) {
52
+ throw new Error("Unset the token override before logging out this device");
53
+ }
54
+ const client = new DayOfWeekClient(getToken(), getApiUrl());
55
+ await client.revokeCurrentDevice();
56
+ deleteCredential();
57
+ output({ authenticated: false, loggedOut: true, revoked: true });
52
58
  });
53
59
  auth
54
60
  .command("status")
@@ -56,12 +62,24 @@ auth
56
62
  .action(async () => {
57
63
  try {
58
64
  const client = getClient();
59
- const result = await client.checkAuth();
65
+ let result;
66
+ try {
67
+ result = await client.brainBootstrap();
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof ApiError) || error.code !== "scope_denied")
71
+ throw error;
72
+ result = await client.checkAuth();
73
+ }
60
74
  // Persist isAdmin so the next CLI invocation can register admin
61
75
  // subcommands in --help without a network round-trip. Stale cache
62
76
  // is harmless: admin commands still reject non-admin tokens at the
63
77
  // API boundary, and admin demotion is rare.
64
- if (result.authenticated && typeof result.isAdmin === "boolean") {
78
+ if (typeof result === "object" &&
79
+ result !== null &&
80
+ "authenticated" in result &&
81
+ "isAdmin" in result &&
82
+ typeof result.isAdmin === "boolean") {
65
83
  saveConfig({ isAdmin: result.isAdmin, roleCachedAt: Date.now() });
66
84
  }
67
85
  output(result);
@@ -71,6 +89,208 @@ auth
71
89
  process.exit(1);
72
90
  }
73
91
  });
92
+ program
93
+ .command("doctor")
94
+ .description("Run machine-readable Day of Week health checks")
95
+ .action(async () => {
96
+ const project = inspectWikiProject(process.cwd());
97
+ const checks = [
98
+ { name: "binary", ok: true, detail: pkg.version },
99
+ { name: "credential", ok: false },
100
+ { name: "api", ok: false },
101
+ { name: "login", ok: false },
102
+ { name: "areas", ok: false },
103
+ project.manifest,
104
+ project.launcher,
105
+ project.skills,
106
+ project.write,
107
+ { name: "default_area", ok: false, detail: "manifest or API unavailable" },
108
+ ];
109
+ try {
110
+ getToken();
111
+ checks[1] = { name: "credential", ok: true };
112
+ const bootstrap = await getClient().brainBootstrap();
113
+ checks[2] = { name: "api", ok: true };
114
+ checks[3] = { name: "login", ok: bootstrap.authenticated, detail: bootstrap.scopes.join(",") };
115
+ checks[4] = { name: "areas", ok: bootstrap.areas.length > 0, detail: String(bootstrap.areas.length) };
116
+ const defaultArea = checks.find((check) => check.name === "default_area");
117
+ defaultArea.ok = Boolean(project.defaultAreaId && bootstrap.areas.some((area) => area.id === project.defaultAreaId));
118
+ defaultArea.detail = defaultArea.ok ? project.defaultAreaId : "configured area is no longer accessible";
119
+ output({ ok: checks.every((check) => check.ok), checks, defaultAreaId: bootstrap.defaultAreaId });
120
+ }
121
+ catch (error) {
122
+ output({ ok: false, checks, error: error instanceof Error ? error.message : "Health check failed" });
123
+ process.exitCode = 2;
124
+ }
125
+ });
126
+ // ── Shared Brain Commands ───────────────────────────────────────────────────
127
+ const brain = program.command("brain").description("Work with shared Day of Week knowledge");
128
+ const brainCompany = brain.command("company").description("Connect the idempotent company knowledge space");
129
+ brainCompany
130
+ .command("status")
131
+ .description("Check whether the current hierarchy membership can connect a company space")
132
+ .action(async () => output(await getClient().companyBrainStatus()));
133
+ brainCompany
134
+ .command("ensure")
135
+ .description("Create or connect the one company brain for the current entity")
136
+ .action(async () => output(await getClient().ensureCompanyBrain()));
137
+ brain
138
+ .command("list")
139
+ .description("List accessible company and project spaces")
140
+ .action(async () => output(await getClient().listBrainAreas()));
141
+ brain
142
+ .command("search <query>")
143
+ .description("Search accessible shared knowledge")
144
+ .option("--area <areaId>", "Restrict to one accessible area")
145
+ .option("--limit <count>", "Maximum results", (value) => Number.parseInt(value, 10), 10)
146
+ .action(async (query, opts) => {
147
+ output(await getClient().searchBrain(query, { areaId: opts.area, limit: opts.limit }));
148
+ });
149
+ brain
150
+ .command("get <uri>")
151
+ .description("Resolve a Day of Week brain URI")
152
+ .option("--markdown", "Print note markdown only")
153
+ .action(async (uri, opts) => {
154
+ parseBrainResource(uri);
155
+ const result = await getClient().resolveBrain(uri);
156
+ if (opts.markdown) {
157
+ if (result.resourceType !== "note")
158
+ throw new Error("--markdown requires a note URI");
159
+ process.stdout.write(result.note.markdown);
160
+ if (!result.note.markdown.endsWith("\n"))
161
+ process.stdout.write("\n");
162
+ return;
163
+ }
164
+ output(result);
165
+ });
166
+ brain
167
+ .command("share")
168
+ .description("Explicitly share a markdown note")
169
+ .requiredOption("--area <areaId>", "Destination area")
170
+ .requiredOption("--title <title>", "Shared note title")
171
+ .option("--file <path>", "Markdown file")
172
+ .option("--stdin", "Read markdown from standard input")
173
+ .requiredOption("--intent <intent>", "interactive or autonomous")
174
+ .action(async (opts) => {
175
+ if (Boolean(opts.file) === Boolean(opts.stdin))
176
+ throw new Error("Use exactly one of --file or --stdin");
177
+ if (opts.intent !== "interactive" && opts.intent !== "autonomous")
178
+ throw new Error("Invalid --intent");
179
+ const markdown = opts.stdin ? await readStdin() : readFileSync(opts.file, "utf8");
180
+ output(await getClient().shareBrainNote({
181
+ areaId: opts.area,
182
+ title: opts.title,
183
+ markdown,
184
+ sourceName: opts.file ? basename(opts.file) : undefined,
185
+ intent: opts.intent,
186
+ }));
187
+ });
188
+ brain
189
+ .command("update <uri>")
190
+ .description("Update a shared note with optimistic concurrency")
191
+ .requiredOption("--file <path>", "Markdown file")
192
+ .requiredOption("--if-version <version>", "Expected current version", (value) => Number.parseInt(value, 10))
193
+ .option("--title <title>", "Updated title")
194
+ .action(async (uri, opts) => {
195
+ const parsed = parseBrainResource(uri);
196
+ if (parsed.resourceType !== "note" || !parsed.resourceId)
197
+ throw new Error("A note URI is required");
198
+ const note = await getClient().updateBrainNote(parsed.resourceId, {
199
+ title: opts.title,
200
+ markdown: readFileSync(opts.file, "utf8"),
201
+ expectedVersion: opts.ifVersion,
202
+ });
203
+ if (note.areaId !== parsed.areaId)
204
+ throw new Error("Server returned a mismatched area");
205
+ output(note);
206
+ });
207
+ brain
208
+ .command("archive <uri>")
209
+ .description("Soft-archive a shared note")
210
+ .requiredOption("--if-version <version>", "Expected current version", (value) => Number.parseInt(value, 10))
211
+ .action(async (uri, opts) => {
212
+ const parsed = parseBrainResource(uri);
213
+ if (parsed.resourceType !== "note" || !parsed.resourceId)
214
+ throw new Error("A note URI is required");
215
+ const note = await getClient().archiveBrainNote(parsed.resourceId, opts.ifVersion);
216
+ if (note.areaId !== parsed.areaId)
217
+ throw new Error("Server returned a mismatched area");
218
+ output(note);
219
+ });
220
+ brain
221
+ .command("restore <uri>")
222
+ .description("Restore an archived shared note")
223
+ .requiredOption("--if-version <version>", "Expected current version", (value) => Number.parseInt(value, 10))
224
+ .action(async (uri, opts) => {
225
+ const parsed = parseBrainResource(uri);
226
+ if (parsed.resourceType !== "note" || !parsed.resourceId)
227
+ throw new Error("A note URI is required");
228
+ const note = await getClient().restoreBrainNote(parsed.resourceId, opts.ifVersion);
229
+ if (note.areaId !== parsed.areaId)
230
+ throw new Error("Server returned a mismatched area");
231
+ output(note);
232
+ });
233
+ brain
234
+ .command("audit")
235
+ .description("List bounded audit metadata (area owner and brain:manage scope required)")
236
+ .requiredOption("--area <areaId>", "Area to inspect")
237
+ .option("--cursor <cursor>", "Pagination cursor")
238
+ .option("--limit <count>", "Maximum events", (value) => Number.parseInt(value, 10), 50)
239
+ .action(async (opts) => {
240
+ output(await getClient().listBrainAudit(opts.area, { cursor: opts.cursor, limit: opts.limit }));
241
+ });
242
+ const brainSource = brain.command("source").description("Work with original shared files and recordings");
243
+ brainSource
244
+ .command("get <uri>")
245
+ .description("Read source metadata and derived text")
246
+ .action(async (uri) => {
247
+ const parsed = parseBrainResource(uri);
248
+ if (parsed.resourceType !== "source" || !parsed.resourceId)
249
+ throw new Error("A source URI is required");
250
+ const source = await getClient().getBrainSource(parsed.resourceId);
251
+ if (source.areaId !== parsed.areaId)
252
+ throw new Error("Server returned a mismatched area");
253
+ output(source);
254
+ });
255
+ brainSource
256
+ .command("upload")
257
+ .description("Upload an original file or meeting recording")
258
+ .requiredOption("--area <areaId>", "Destination area")
259
+ .requiredOption("--file <path>", "File to upload")
260
+ .option("--mime <mimeType>", "MIME type; inferred from extension when omitted")
261
+ .option("--meeting", "Mark this source as a recorded meeting")
262
+ .option("--consent-ack", "Confirm recorded participants were informed and consented")
263
+ .action(async (opts) => {
264
+ if (opts.meeting && !opts.consentAck) {
265
+ throw new Error("--consent-ack is required: you are attesting that recorded participants were informed and consented");
266
+ }
267
+ output(await getClient().uploadBrainSource({
268
+ areaId: opts.area,
269
+ path: opts.file,
270
+ mimeType: opts.mime ?? inferMimeType(opts.file),
271
+ isMeeting: opts.meeting ?? false,
272
+ consentAcknowledged: opts.consentAck ?? false,
273
+ }));
274
+ });
275
+ brainSource
276
+ .command("download <uri>")
277
+ .description("Download exact original bytes to an explicit path")
278
+ .requiredOption("--output <path>", "Explicit output file")
279
+ .option("--overwrite", "Replace an existing output file")
280
+ .action(async (uri, opts) => {
281
+ const parsed = parseBrainResource(uri);
282
+ if (parsed.resourceType !== "source" || !parsed.resourceId)
283
+ throw new Error("A source URI is required");
284
+ const source = await getClient().getBrainSource(parsed.resourceId);
285
+ if (source.areaId !== parsed.areaId)
286
+ throw new Error("Server returned a mismatched area");
287
+ output(await getClient().downloadBrainSource({
288
+ sourceId: source.id,
289
+ outputPath: opts.output,
290
+ expectedSha256: source.sha256,
291
+ overwrite: opts.overwrite,
292
+ }));
293
+ });
74
294
  auth
75
295
  .command("devices")
76
296
  .description("List your agent tokens")
@@ -174,6 +394,7 @@ agent
174
394
  .option("--parent <entityId>", "Parent entity ID")
175
395
  .option("--entity-type <type>", "Entity type (Farm, Producer, ...)")
176
396
  .option("--target-id <id>", "Target record ID (for update/delete)")
397
+ .option("--org <slug>", "File against another org (admin tokens only)")
177
398
  .option("--file <path>", "Read payload from JSON file (- for stdin)")
178
399
  .option("--payload <json>", "Inline JSON payload")
179
400
  .action(async (opts) => {
@@ -186,6 +407,7 @@ agent
186
407
  }
187
408
  const client = getClient();
188
409
  const result = await client.submitProposal({
410
+ org: opts.org,
189
411
  operation: opts.op,
190
412
  targetTable: opts.table,
191
413
  title: opts.title,
@@ -205,6 +427,7 @@ agent
205
427
  .description("Submit multiple proposals")
206
428
  .requiredOption("--label <label>", "Batch label")
207
429
  .option("--source <agent>", "Agent identifier")
430
+ .option("--org <slug>", "File against another org (admin tokens only)")
208
431
  .requiredOption("--file <path>", "JSON file with proposals array (- for stdin)")
209
432
  .action(async (opts) => {
210
433
  const content = opts.file === "-"
@@ -213,6 +436,7 @@ agent
213
436
  const proposals = JSON.parse(content);
214
437
  const client = getClient();
215
438
  const result = await client.submitBatch({
439
+ org: opts.org,
216
440
  batchLabel: opts.label,
217
441
  sourceAgent: opts.source,
218
442
  proposals: Array.isArray(proposals) ? proposals : [proposals],
@@ -255,15 +479,88 @@ function resolveTargetDirs(target, bundleName, customDir) {
255
479
  return claudeRootExists ? [agentsDir, claudeDir] : [agentsDir];
256
480
  }
257
481
  }
258
- function writeBundle(bundle, targetDir) {
259
- let filesWritten = 0;
260
- for (const file of bundle.files) {
261
- const filePath = join(targetDir, file.path);
262
- mkdirSync(dirname(filePath), { recursive: true });
263
- writeFileSync(filePath, file.content, "utf-8");
264
- filesWritten++;
482
+ function sha256(value) {
483
+ return createHash("sha256").update(value).digest("hex");
484
+ }
485
+ function inspectWikiProject(directory) {
486
+ const root = resolve(directory);
487
+ const manifestPath = join(root, ".dayofweek", "manifest.json");
488
+ const missing = {
489
+ manifest: { name: "project_manifest", ok: false, detail: "not found in current directory" },
490
+ launcher: { name: "project_launcher", ok: false, detail: "manifest unavailable" },
491
+ skills: { name: "project_skills", ok: false, detail: "manifest unavailable" },
492
+ write: { name: "project_write", ok: false, detail: "manifest unavailable" },
493
+ };
494
+ if (!existsSync(manifestPath))
495
+ return missing;
496
+ try {
497
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
498
+ if (manifest.schemaVersion !== 1 ||
499
+ typeof manifest.dcliRuntimePath !== "string" ||
500
+ !manifest.managedFiles ||
501
+ typeof manifest.managedFiles !== "object") {
502
+ return {
503
+ manifest: { name: "project_manifest", ok: false, detail: "invalid schema" },
504
+ launcher: missing.launcher,
505
+ skills: missing.skills,
506
+ write: missing.write,
507
+ };
508
+ }
509
+ const runtime = resolve(manifest.dcliRuntimePath);
510
+ const runtimeOk = existsSync(runtime) && statSync(runtime).isFile();
511
+ const launcherPaths = process.platform === "win32"
512
+ ? [".dayofweek/bin/dcli.cmd"]
513
+ : [".dayofweek/bin/dcli"];
514
+ const launcherOk = runtimeOk && launcherPaths.every((path) => managedFileMatches(root, path, manifest.managedFiles));
515
+ const skillNames = ["personal-llm-wiki", "dayofweek-brain"];
516
+ const skillPaths = skillNames.flatMap((name) => [
517
+ `.agents/skills/${name}/SKILL.md`,
518
+ `.claude/skills/${name}/SKILL.md`,
519
+ ]);
520
+ const skillsOk = skillPaths.every((path) => managedFileMatches(root, path, manifest.managedFiles));
521
+ let writeOk = false;
522
+ try {
523
+ accessSync(root, constants.W_OK);
524
+ writeOk = true;
525
+ }
526
+ catch {
527
+ writeOk = false;
528
+ }
529
+ return {
530
+ manifest: {
531
+ name: "project_manifest",
532
+ ok: true,
533
+ detail: `schema=1 dcli=${manifest.dcliVersion ?? "unknown"}`,
534
+ },
535
+ launcher: {
536
+ name: "project_launcher",
537
+ ok: launcherOk,
538
+ detail: runtimeOk ? (launcherOk ? "managed launcher verified" : "launcher modified or missing") : "managed runtime missing",
539
+ },
540
+ skills: {
541
+ name: "project_skills",
542
+ ok: skillsOk,
543
+ detail: skillsOk ? Object.entries(manifest.skillVersions ?? {}).map(([name, version]) => `${name}@${version}`).join(",") : "managed skill modified or missing",
544
+ },
545
+ write: { name: "project_write", ok: writeOk, detail: writeOk ? "folder is writable" : "folder is not writable" },
546
+ defaultAreaId: manifest.defaultAreaId,
547
+ };
265
548
  }
266
- return filesWritten;
549
+ catch {
550
+ return {
551
+ manifest: { name: "project_manifest", ok: false, detail: "unreadable or malformed" },
552
+ launcher: missing.launcher,
553
+ skills: missing.skills,
554
+ write: missing.write,
555
+ };
556
+ }
557
+ }
558
+ function managedFileMatches(root, relativePath, managedFiles) {
559
+ const expected = managedFiles[relativePath];
560
+ const target = resolve(root, relativePath);
561
+ const pathFromRoot = relative(root, target);
562
+ const insideRoot = pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`) && !pathFromRoot.startsWith(sep);
563
+ return Boolean(expected && insideRoot && existsSync(target) && sha256(readFileSync(target)) === expected);
267
564
  }
268
565
  function parseTarget(value) {
269
566
  const v = (value ?? "all").toLowerCase();
@@ -274,67 +571,75 @@ function parseTarget(value) {
274
571
  return v;
275
572
  }
276
573
  skill
277
- .command("install")
574
+ .command("list")
575
+ .description("List authenticated named skill bundles")
576
+ .action(async () => output(await getClient().listSkillBundles()));
577
+ skill
578
+ .command("bundle <name>")
579
+ .description("Fetch and verify a named skill bundle for a managed installer")
580
+ .action(async (name) => output(validateSkillBundle(await getClient().getSkillBundle(name))));
581
+ skill
582
+ .command("install [name]")
278
583
  .description("Install the agent skill (requires valid auth)")
279
584
  .option("--dir <path>", "Custom install directory (overrides --target)")
280
585
  .option("--target <target>", "Install target: agents, claude, or all (default: all)")
281
- .action(async (opts) => {
586
+ .action(async (name, opts) => {
282
587
  const client = getClient();
283
- const bundle = await client.getSkillBundle();
588
+ const bundle = await client.getSkillBundle(name);
284
589
  const target = parseTarget(opts.target);
285
590
  const dirs = resolveTargetDirs(target, bundle.name, opts.dir);
591
+ const installations = [];
286
592
  for (const dir of dirs) {
287
- const count = writeBundle(bundle, dir);
288
- console.log(`Installed ${count} files to ${dir}`);
593
+ const result = writeSkillBundle(bundle, dir);
594
+ installations.push({ directory: dir, ...result });
289
595
  }
290
- console.log("Any compatible agent will discover the skill automatically.");
596
+ output({ action: "install", bundle: bundle.name, version: bundle.version, hash: bundle.hash, installations });
291
597
  });
292
598
  skill
293
- .command("update")
599
+ .command("update [name]")
294
600
  .description("Update the skill to the latest version")
295
601
  .option("--dir <path>", "Custom install directory (overrides --target)")
296
602
  .option("--target <target>", "Install target: agents, claude, or all (default: all)")
297
- .action(async (opts) => {
603
+ .action(async (name, opts) => {
298
604
  const client = getClient();
299
- const bundle = await client.getSkillBundle();
605
+ const bundle = await client.getSkillBundle(name);
300
606
  const target = parseTarget(opts.target);
301
607
  const dirs = resolveTargetDirs(target, bundle.name, opts.dir);
608
+ const installations = [];
302
609
  for (const dir of dirs) {
303
- const count = writeBundle(bundle, dir);
304
- console.log(`Updated ${count} files in ${dir}`);
610
+ const result = writeSkillBundle(bundle, dir);
611
+ installations.push({ directory: dir, ...result });
305
612
  }
613
+ output({ action: "update", bundle: bundle.name, version: bundle.version, hash: bundle.hash, installations });
306
614
  });
307
615
  skill
308
- .command("status")
616
+ .command("status [name]")
309
617
  .description("Check if the skill is installed")
310
618
  .option("--dir <path>", "Custom install directory (overrides --target)")
311
619
  .option("--target <target>", "Check target: agents, claude, or all (default: all)")
312
- .action(async (opts) => {
313
- const bundleName = "dayofweek-platform";
620
+ .action(async (name, opts) => {
621
+ const bundleName = name ?? "dayofweek-platform";
314
622
  const target = parseTarget(opts.target);
315
623
  const dirs = opts.dir
316
624
  ? [opts.dir]
317
625
  : target === "all"
318
626
  ? [join(homedir(), ".agents", "skills", bundleName), join(homedir(), ".claude", "skills", bundleName)]
319
627
  : resolveTargetDirs(target, bundleName);
320
- let anyInstalled = false;
628
+ const installations = [];
321
629
  for (const dir of dirs) {
322
630
  const skillPath = join(dir, "SKILL.md");
323
631
  if (!existsSync(skillPath)) {
324
- console.log(`Not installed at: ${dir}`);
632
+ installations.push({ installed: false, directory: dir, name: bundleName });
325
633
  continue;
326
634
  }
327
- anyInstalled = true;
328
635
  const content = readFileSync(skillPath, "utf-8");
329
636
  const versionMatch = content.match(/version:\s*"([^"]+)"/);
330
- console.log(`Installed at: ${dir}`);
331
- console.log(` Version: ${versionMatch?.[1] ?? "unknown"}`);
332
- }
333
- if (!anyInstalled) {
334
- console.log("\nRun: dcli skill install");
335
- process.exit(1);
637
+ installations.push({ installed: true, directory: dir, name: bundleName, version: versionMatch?.[1] ?? "unknown", sha256: sha256(content) });
336
638
  }
337
- console.log("\nTo update: dcli skill update");
639
+ const installed = installations.some((entry) => entry.installed);
640
+ output({ installed, bundle: bundleName, installations });
641
+ if (!installed)
642
+ process.exitCode = 2;
338
643
  });
339
644
  // ── Admin Commands ───────────────────────────────────────────────────────────
340
645
  //
@@ -398,8 +703,36 @@ async function readStdin() {
398
703
  }
399
704
  return chunks.join("\n");
400
705
  }
706
+ function inferMimeType(path) {
707
+ const extension = path.toLowerCase().split(".").at(-1);
708
+ const types = {
709
+ pdf: "application/pdf",
710
+ md: "text/markdown",
711
+ txt: "text/plain",
712
+ csv: "text/csv",
713
+ json: "application/json",
714
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
715
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
716
+ mp3: "audio/mpeg",
717
+ m4a: "audio/mp4",
718
+ wav: "audio/wav",
719
+ mp4: "video/mp4",
720
+ };
721
+ const mime = extension ? types[extension] : undefined;
722
+ if (!mime)
723
+ throw new Error("Could not infer MIME type; pass --mime");
724
+ return mime;
725
+ }
401
726
  // ── Run ──────────────────────────────────────────────────────────────────────
402
727
  program.parseAsync(process.argv).catch((err) => {
403
728
  console.error(err.message ?? err);
404
- process.exit(1);
729
+ if (err instanceof ApiError) {
730
+ const code = err.code;
731
+ process.exit(code === "unauthenticated" ? 2 :
732
+ code === "scope_denied" || code === "not_found" ? 3 :
733
+ code === "conflict" ? 4 :
734
+ code === "quarantined" ? 7 :
735
+ err.status >= 500 ? 6 : 5);
736
+ }
737
+ process.exit(5);
405
738
  });