argus-media-cli 0.1.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,45 @@ argus-media-cli upload photo.jpg
35
35
  argus-media-cli upload hero.png --project campaign-q2 --tags hero,banner
36
36
  ```
37
37
 
38
+ ### Sync
39
+
40
+ Onboard an entire folder of assets in one shot — ideal for migrating a legacy
41
+ brand folder. `sync` walks the folder recursively, filters out junk and
42
+ unsupported files, dedupes, uploads what's left sequentially, and prints a
43
+ manifest of what was synced, skipped, and failed.
44
+
45
+ ```bash
46
+ argus-media-cli sync ./legacy-brand-folder --dry-run # preview the plan
47
+ argus-media-cli sync ./legacy-brand-folder # upload for real
48
+ argus-media-cli sync ./assets --project brand-2019 --tags legacy,imported
49
+ argus-media-cli sync ./assets --json # structured manifest
50
+ ```
51
+
52
+ **What it uploads:** JPEG, PNG, GIF, WebP, and SVG files up to 20 MB.
53
+
54
+ **What it skips (with a stated reason):**
55
+
56
+ - Junk files (`.DS_Store`, `Thumbs.db`, `desktop.ini`) and hidden dotfiles
57
+ - Unsupported file types
58
+ - Files larger than the 20 MB server cap
59
+ - Duplicates — identical file contents (hashed), and any asset already in the
60
+ target project with the same filename + size
61
+
62
+ **Project:** defaults to the kebab-cased folder name (e.g. `Legacy Brand
63
+ Folder` → `legacy-brand-folder`). Override with `--project`.
64
+
65
+ **Credits:** each analyzable upload costs 1 credit. If the plan exceeds your
66
+ remaining credits, `sync` warns and asks for confirmation; pass `--yes` to skip
67
+ the prompt (uploads past the limit are stored without AI analysis).
68
+
69
+ | Flag | Description |
70
+ |---|---|
71
+ | `--project NAME` | Target project (default: kebab-cased folder name) |
72
+ | `--tags a,b,c` | Tags applied to every synced asset |
73
+ | `--dry-run` | Print the plan without uploading |
74
+ | `--json` | Emit the manifest as structured JSON |
75
+ | `--yes` | Skip the credit-shortfall confirmation prompt |
76
+
38
77
  ### Search
39
78
 
40
79
  ```bash
@@ -64,6 +103,18 @@ argus-media-cli login --key ak_xxx # Non-interactive
64
103
  argus-media-cli login --url http://localhost:8787 # Custom API URL
65
104
  ```
66
105
 
106
+ ### API Keys
107
+
108
+ Manage API keys for the current workspace. Works with either a session (from
109
+ `login`) or an existing API key. Use this to rotate a dead or expired key:
110
+
111
+ ```bash
112
+ argus-media-cli login # Magic-link session (no key needed)
113
+ argus-media-cli keys list # List active keys
114
+ argus-media-cli keys create --name "MCP" # Mint a new key (shown once)
115
+ argus-media-cli keys revoke <id> # Revoke a key (admin only)
116
+ ```
117
+
67
118
  ## Options
68
119
 
69
120
  All commands support `--json` for structured JSON output.
package/dist/index.js CHANGED
@@ -2,7 +2,9 @@
2
2
  import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import { basename, extname, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { createHash } from "node:crypto";
5
6
  import * as readline from "node:readline";
7
+ import { basicSkipReason, buildPlan, deriveProject, formatManifestText, toJsonManifest, walk, } from "./sync.js";
6
8
  // ─── Config ────────────────────────────────────────────────────────────────
7
9
  const CONFIG_DIR = join(homedir(), ".argus");
8
10
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -23,22 +25,27 @@ async function saveConfig(config) {
23
25
  function getBaseUrl(config) {
24
26
  return process.env.ARGUS_BASE_URL || config.baseUrl || "https://argus.build";
25
27
  }
26
- function getApiKey(config) {
27
- const key = process.env.ARGUS_API_KEY || config.apiKey;
28
- if (!key) {
29
- console.error("Error: No API key configured.");
30
- console.error("Run `argus-media-cli login` to authenticate, or set ARGUS_API_KEY.");
31
- process.exit(1);
28
+ function getAuthHeaders(config) {
29
+ // API key takes precedence (CI / backward compat)
30
+ const apiKey = process.env.ARGUS_API_KEY || config.apiKey;
31
+ if (apiKey) {
32
+ return { Authorization: `Bearer ${apiKey}` };
33
+ }
34
+ // Session-based auth (interactive login)
35
+ const sessionId = config.sessionId;
36
+ if (sessionId) {
37
+ return { Cookie: `session=${sessionId}` };
32
38
  }
33
- return key;
39
+ console.error("Error: Not authenticated.");
40
+ console.error("Run `argus-media-cli login` to sign in, or set ARGUS_API_KEY.");
41
+ process.exit(1);
34
42
  }
35
43
  async function apiRequest(config, method, path, body) {
36
44
  const url = `${getBaseUrl(config)}${path}`;
37
- const apiKey = getApiKey(config);
38
45
  const opts = {
39
46
  method,
40
47
  headers: {
41
- Authorization: `Bearer ${apiKey}`,
48
+ ...getAuthHeaders(config),
42
49
  ...(body ? { "Content-Type": "application/json" } : {}),
43
50
  },
44
51
  ...(body ? { body: JSON.stringify(body) } : {}),
@@ -92,6 +99,8 @@ function formatAssetDetail(a) {
92
99
  lines.push(` Project: ${a.project}`);
93
100
  if (a.url)
94
101
  lines.push(` URL: ${a.url}`);
102
+ if (a.displayUrl)
103
+ lines.push(` Display: ${a.displayUrl}`);
95
104
  if (a.tags?.length)
96
105
  lines.push(` Tags: ${a.tags.join(", ")}`);
97
106
  if (a.uploadedBy)
@@ -118,6 +127,7 @@ function formatAssetDetail(a) {
118
127
  // ─── Commands ──────────────────────────────────────────────────────────────
119
128
  async function cmdLogin(args, flags) {
120
129
  const config = await loadConfig();
130
+ // Backward-compat: --key sets API key directly (useful for CI)
121
131
  if (flags.key) {
122
132
  config.apiKey = flags.key;
123
133
  await saveConfig(config);
@@ -132,25 +142,62 @@ async function cmdLogin(args, flags) {
132
142
  }
133
143
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
134
144
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
135
- console.log("Argus CLI Login");
136
- console.log("Get your API key at https://argus.build/ui\n");
137
- const key = await ask("API key: ");
145
+ const baseUrl = getBaseUrl(config);
146
+ console.log("\x1b[1mArgus CLI Login\x1b[0m\n");
147
+ const email = (await ask("Email: ")).trim().toLowerCase();
148
+ if (!email) {
149
+ rl.close();
150
+ console.error("No email provided.");
151
+ process.exit(1);
152
+ }
153
+ // Request magic code
154
+ console.log("Sending login code...");
155
+ const magicRes = await fetch(`${baseUrl}/auth/magic`, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify({ email }),
159
+ });
160
+ if (!magicRes.ok) {
161
+ rl.close();
162
+ const err = await magicRes.json().catch(() => ({ error: "Request failed" }));
163
+ console.error(`Error: ${err.error || "Could not send login code."}`);
164
+ process.exit(1);
165
+ }
166
+ console.log(`\nCheck your email for a 6-digit code.\n`);
167
+ const code = (await ask("Code: ")).trim();
138
168
  rl.close();
139
- if (!key.trim()) {
140
- console.error("No key provided.");
169
+ if (!code) {
170
+ console.error("No code provided.");
171
+ process.exit(1);
172
+ }
173
+ // Verify code and get session
174
+ const verifyRes = await fetch(`${baseUrl}/auth/magic/verify`, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({ email, code, source: "cli" }),
178
+ });
179
+ if (!verifyRes.ok) {
180
+ const err = await verifyRes.json().catch(() => ({ error: "Verification failed" }));
181
+ console.error(`Error: ${err.error || "Invalid or expired code."}`);
182
+ process.exit(1);
183
+ }
184
+ const data = await verifyRes.json();
185
+ if (!data.session) {
186
+ console.error("Error: No session returned from server.");
141
187
  process.exit(1);
142
188
  }
143
- config.apiKey = key.trim();
189
+ config.sessionId = data.session;
190
+ delete config.apiKey; // prefer session auth
144
191
  await saveConfig(config);
145
- // Verify the key works
192
+ console.log("\nAuthenticated! Session saved to ~/.argus/config.json");
193
+ // Show usage summary
146
194
  try {
147
195
  const res = await apiRequest(config, "GET", "/usage");
148
- console.log(`\nAuthenticated! Workspace tier: ${res.tier}`);
196
+ console.log(`Workspace tier: ${res.tier}`);
149
197
  console.log(`Assets: ${res.assets?.used ?? 0}/${res.assets?.limit ?? "unlimited"}`);
150
198
  }
151
- catch (e) {
152
- console.error(`\nWarning: Could not verify key — ${e.message}`);
153
- console.log("Key saved anyway. Check it with `argus-media-cli list`.");
199
+ catch {
200
+ // non-fatal
154
201
  }
155
202
  }
156
203
  async function cmdUpload(args, flags) {
@@ -160,7 +207,6 @@ async function cmdUpload(args, flags) {
160
207
  process.exit(1);
161
208
  }
162
209
  const config = await loadConfig();
163
- const apiKey = getApiKey(config);
164
210
  const baseUrl = getBaseUrl(config);
165
211
  // Read file
166
212
  const fileData = await readFile(filePath);
@@ -177,7 +223,7 @@ async function cmdUpload(args, flags) {
177
223
  form.append("tags", flags.tags);
178
224
  const res = await fetch(`${baseUrl}/assets/upload`, {
179
225
  method: "POST",
180
- headers: { Authorization: `Bearer ${apiKey}` },
226
+ headers: { ...getAuthHeaders(config) },
181
227
  body: form,
182
228
  });
183
229
  if (!res.ok) {
@@ -194,6 +240,8 @@ async function cmdUpload(args, flags) {
194
240
  console.log(`Status: ${asset.status}`);
195
241
  if (asset.url)
196
242
  console.log(`URL: ${asset.url}`);
243
+ if (asset.displayUrl)
244
+ console.log(`Display: ${asset.displayUrl}`);
197
245
  }
198
246
  }
199
247
  async function cmdSearch(args, flags) {
@@ -261,6 +309,439 @@ async function cmdUsage(_args, flags) {
261
309
  console.log(` Storage: ${data.storage?.used != null ? formatBytes(data.storage.used) : "0 B"} / ${data.storage?.limit != null ? formatBytes(data.storage.limit) : "unlimited"}`);
262
310
  }
263
311
  }
312
+ /** Upload a single file. Returns the created asset, or a duplicate marker on 409. */
313
+ async function uploadFile(config, filePath, fileName, project, tags) {
314
+ const baseUrl = getBaseUrl(config);
315
+ const fileData = await readFile(filePath);
316
+ const ext = extname(fileName).toLowerCase();
317
+ const mimeType = MIME_TYPES[ext] || "application/octet-stream";
318
+ const blob = new Blob([fileData], { type: mimeType });
319
+ const form = new FormData();
320
+ form.append("file", blob, fileName);
321
+ if (project)
322
+ form.append("project", project);
323
+ if (tags)
324
+ form.append("tags", tags);
325
+ const res = await fetch(`${baseUrl}/assets/upload`, {
326
+ method: "POST",
327
+ headers: { ...getAuthHeaders(config) },
328
+ body: form,
329
+ });
330
+ // The server enforces workspace-wide exact-content dedup and returns 409 with
331
+ // the pre-existing asset. Surface that as a duplicate skip, not a failure.
332
+ if (res.status === 409) {
333
+ const body = (await res.json().catch(() => ({})));
334
+ const existing = body?.duplicate?.asset;
335
+ return {
336
+ duplicateOf: existing?.filename
337
+ ? `${existing.filename} (${existing.id})`
338
+ : "an existing asset",
339
+ };
340
+ }
341
+ if (!res.ok) {
342
+ const text = await res.text();
343
+ throw new Error(`upload failed (${res.status}): ${text}`);
344
+ }
345
+ const data = (await res.json());
346
+ return { asset: data.asset };
347
+ }
348
+ /** Poll an asset until it reaches a terminal status (ready/error) or times out. */
349
+ async function pollUntilReady(config, id, timeoutMs = 60_000) {
350
+ const deadline = Date.now() + timeoutMs;
351
+ while (Date.now() < deadline) {
352
+ const data = (await apiRequest(config, "GET", `/assets/${id}`));
353
+ const status = data.asset?.status;
354
+ if (status === "ready" || status === "error")
355
+ return status;
356
+ await new Promise((r) => setTimeout(r, 2000));
357
+ }
358
+ return "pending"; // timed out — still queued, not a hard failure
359
+ }
360
+ /** Fetch every existing asset in a project (paginated) for filename+size dedup. */
361
+ async function fetchExistingAssets(config, project) {
362
+ const existing = [];
363
+ const pageSize = 200;
364
+ let offset = 0;
365
+ for (;;) {
366
+ const params = new URLSearchParams({
367
+ project,
368
+ limit: String(pageSize),
369
+ offset: String(offset),
370
+ });
371
+ const data = (await apiRequest(config, "GET", `/assets?${params}`));
372
+ const assets = data.assets ?? [];
373
+ for (const a of assets) {
374
+ if (typeof a.size === "number")
375
+ existing.push({ filename: a.filename, size: a.size });
376
+ }
377
+ if (assets.length < pageSize)
378
+ break;
379
+ offset += pageSize;
380
+ }
381
+ return existing;
382
+ }
383
+ async function confirm(question) {
384
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
385
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
386
+ rl.close();
387
+ return /^y(es)?$/i.test(answer.trim());
388
+ }
389
+ async function cmdSync(args, flags) {
390
+ const folder = args[0];
391
+ if (!folder) {
392
+ console.error("Usage: argus-media-cli sync <folder> [--project NAME] [--tags a,b,c] [--dry-run] [--json] [--yes]");
393
+ process.exit(1);
394
+ }
395
+ const config = await loadConfig();
396
+ const project = flags.project || deriveProject(folder);
397
+ const tags = flags.tags;
398
+ const dryRun = !!flags["dry-run"];
399
+ const asJson = !!flags.json;
400
+ const assumeYes = !!flags.yes;
401
+ // 1. Walk the folder.
402
+ let files;
403
+ try {
404
+ files = await walk(folder);
405
+ }
406
+ catch (err) {
407
+ throw new Error(`could not read folder '${folder}': ${err.message}`);
408
+ }
409
+ // 2. Hash the files that pass the cheap filters, so content-dedup can run.
410
+ const hashed = [];
411
+ for (const f of files) {
412
+ if (basicSkipReason(f)) {
413
+ hashed.push(f); // no hash needed — will be skipped in buildPlan
414
+ continue;
415
+ }
416
+ const buf = await readFile(f.path);
417
+ hashed.push({ ...f, hash: createHash("sha256").update(buf).digest("hex") });
418
+ }
419
+ // 3. Dedup against what's already in the target project.
420
+ const existing = await fetchExistingAssets(config, project);
421
+ const plan = buildPlan(hashed, existing);
422
+ const result = {
423
+ synced: [],
424
+ skipped: plan.skip.map((s) => ({ filename: s.filename, reason: s.reason })),
425
+ failed: [],
426
+ };
427
+ // 4. Dry run: show the plan and stop.
428
+ if (dryRun) {
429
+ result.synced = plan.include.map((f) => ({ id: "", filename: f.name }));
430
+ if (asJson) {
431
+ console.log(JSON.stringify(toJsonManifest(result, { project, dryRun: true }), null, 2));
432
+ }
433
+ else {
434
+ console.log(formatManifestText(result, { project, dryRun: true }));
435
+ }
436
+ return;
437
+ }
438
+ if (plan.include.length === 0) {
439
+ if (asJson) {
440
+ console.log(JSON.stringify(toJsonManifest(result, { project, dryRun: false }), null, 2));
441
+ }
442
+ else {
443
+ console.log(formatManifestText(result, { project, dryRun: false }));
444
+ }
445
+ return;
446
+ }
447
+ // 5. Credit check — each analyzable upload costs 1 credit. The server draws
448
+ // from monthly credits first, then one-time bonus credits, so the real
449
+ // balance is remaining + bonus.
450
+ try {
451
+ const usage = (await apiRequest(config, "GET", "/usage"));
452
+ const available = (usage.credits?.remaining ?? 0) + (usage.credits?.bonus ?? 0);
453
+ if (plan.include.length > available) {
454
+ const msg = `Plan uploads ${plan.include.length} file(s) but only ${available} credit(s) remain; uploads past the limit will store without AI analysis.`;
455
+ if (assumeYes) {
456
+ console.error(`Warning: ${msg}`);
457
+ }
458
+ else if (!asJson) {
459
+ const ok = await confirm(`${msg}\nContinue? [y/N] `);
460
+ if (!ok) {
461
+ console.log("Aborted.");
462
+ return;
463
+ }
464
+ }
465
+ else {
466
+ throw new Error(`${msg} Re-run with --yes to proceed.`);
467
+ }
468
+ }
469
+ }
470
+ catch (err) {
471
+ // Only abort if this was our own credit-guard error; usage lookups are best-effort.
472
+ if (/Re-run with --yes/.test(err.message))
473
+ throw err;
474
+ }
475
+ // 6. Upload sequentially, then poll each to a terminal state.
476
+ for (const f of plan.include) {
477
+ if (!asJson)
478
+ process.stderr.write(`Uploading ${f.name}… `);
479
+ try {
480
+ const outcome = await uploadFile(config, f.path, f.name, project, tags);
481
+ if (outcome.duplicateOf) {
482
+ result.skipped.push({
483
+ filename: f.name,
484
+ reason: `duplicate of ${outcome.duplicateOf}`,
485
+ });
486
+ if (!asJson)
487
+ process.stderr.write("duplicate\n");
488
+ continue;
489
+ }
490
+ const asset = outcome.asset;
491
+ const status = await pollUntilReady(config, asset.id);
492
+ if (status === "error") {
493
+ result.failed.push({ filename: f.name, error: `analysis failed (${asset.id})` });
494
+ if (!asJson)
495
+ process.stderr.write("error\n");
496
+ }
497
+ else {
498
+ result.synced.push({ id: asset.id, filename: f.name });
499
+ if (!asJson)
500
+ process.stderr.write(status === "ready" ? "ready\n" : "queued\n");
501
+ }
502
+ }
503
+ catch (err) {
504
+ result.failed.push({ filename: f.name, error: err.message });
505
+ if (!asJson)
506
+ process.stderr.write("failed\n");
507
+ }
508
+ }
509
+ // 7. Manifest.
510
+ if (asJson) {
511
+ console.log(JSON.stringify(toJsonManifest(result, { project, dryRun: false }), null, 2));
512
+ }
513
+ else {
514
+ console.log("\n" + formatManifestText(result, { project, dryRun: false }));
515
+ }
516
+ }
517
+ // ─── Phase 2: Generative Commands ─────────────────────────────────────────
518
+ async function cmdCreativeDirection(args, flags) {
519
+ const id = args[0];
520
+ if (!id) {
521
+ console.error("Usage: argus-media-cli creative-direction <asset-id> [--purpose TEXT] [--audience TEXT] [--tone TEXT] [--style TEXT]");
522
+ process.exit(1);
523
+ }
524
+ const config = await loadConfig();
525
+ const body = {};
526
+ if (flags.purpose)
527
+ body.purpose = flags.purpose;
528
+ if (flags.audience)
529
+ body.audience = flags.audience;
530
+ if (flags.tone)
531
+ body.tone = flags.tone;
532
+ if (flags.style)
533
+ body.style = flags.style;
534
+ const data = (await apiRequest(config, "POST", `/assets/${id}/creative-direction`, body));
535
+ if (flags.json) {
536
+ console.log(JSON.stringify(data, null, 2));
537
+ return;
538
+ }
539
+ const cd = data.creativeDirection;
540
+ console.log(`\x1b[1mCreative Direction\x1b[0m${data.cached ? " (cached)" : ""}\n`);
541
+ console.log(cd.rationale);
542
+ if (cd.googleFontsUrl)
543
+ console.log(`\nGoogle Fonts: ${cd.googleFontsUrl}`);
544
+ if (cd.typography?.length) {
545
+ console.log(`\n\x1b[1mTypography:\x1b[0m`);
546
+ for (const t of cd.typography) {
547
+ console.log(` ${t.role}: ${t.css.fontFamily} (${t.css.fontWeight}) — ${t.semantic}`);
548
+ }
549
+ }
550
+ if (cd.colors?.length) {
551
+ console.log(`\n\x1b[1mColors:\x1b[0m`);
552
+ for (const c of cd.colors) {
553
+ console.log(` ${c.role}: ${c.css.color} — ${c.semantic}`);
554
+ }
555
+ }
556
+ if (cd.layout?.length) {
557
+ console.log(`\n\x1b[1mLayout:\x1b[0m`);
558
+ for (const l of cd.layout) {
559
+ console.log(` ${l.pattern} — ${l.semantic}`);
560
+ }
561
+ }
562
+ }
563
+ async function cmdTypography(args, flags) {
564
+ const id = args[0];
565
+ if (!id) {
566
+ console.error("Usage: argus-media-cli typography <asset-id> [--purpose TEXT]");
567
+ process.exit(1);
568
+ }
569
+ const config = await loadConfig();
570
+ const body = {};
571
+ if (flags.purpose)
572
+ body.purpose = flags.purpose;
573
+ const data = (await apiRequest(config, "POST", `/assets/${id}/typography`, body));
574
+ if (flags.json) {
575
+ console.log(JSON.stringify(data, null, 2));
576
+ return;
577
+ }
578
+ const typo = data.typography;
579
+ console.log(`\x1b[1mTypography Suggestions\x1b[0m${data.cached ? " (cached)" : ""}\n`);
580
+ if (typo.pairings?.length) {
581
+ for (const t of typo.pairings) {
582
+ console.log(` ${t.role}: ${t.css.fontFamily} (${t.css.fontWeight}) — ${t.semantic}`);
583
+ if (t.googleFontsUrl)
584
+ console.log(` Font: ${t.googleFontsUrl}`);
585
+ }
586
+ }
587
+ if (typo.googleFontsUrl)
588
+ console.log(`\nGoogle Fonts: ${typo.googleFontsUrl}`);
589
+ if (typo.rationale)
590
+ console.log(`\n${typo.rationale}`);
591
+ }
592
+ async function cmdPalette(args, flags) {
593
+ const id = args[0];
594
+ if (!id) {
595
+ console.error("Usage: argus-media-cli palette <asset-id>");
596
+ process.exit(1);
597
+ }
598
+ const config = await loadConfig();
599
+ const data = (await apiRequest(config, "POST", `/assets/${id}/palette`));
600
+ if (flags.json) {
601
+ console.log(JSON.stringify(data, null, 2));
602
+ return;
603
+ }
604
+ const palette = data.palette;
605
+ console.log(`\x1b[1mColor Palette\x1b[0m${data.cached ? " (cached)" : ""}\n`);
606
+ if (palette.colors?.length) {
607
+ for (const c of palette.colors) {
608
+ console.log(` ${c.role}: ${c.css.color} — ${c.semantic}`);
609
+ }
610
+ }
611
+ if (palette.rationale)
612
+ console.log(`\n${palette.rationale}`);
613
+ }
614
+ async function cmdCohesion(args, flags) {
615
+ if (args.length < 2) {
616
+ console.error("Usage: argus-media-cli cohesion <asset-id> <asset-id> [<asset-id>...] [--purpose TEXT] [--brand TEXT]");
617
+ process.exit(1);
618
+ }
619
+ const config = await loadConfig();
620
+ const body = { assetIds: args };
621
+ if (flags.purpose || flags.brand) {
622
+ const context = {};
623
+ if (flags.purpose)
624
+ context.purpose = flags.purpose;
625
+ if (flags.brand)
626
+ context.brandName = flags.brand;
627
+ body.context = context;
628
+ }
629
+ const data = (await apiRequest(config, "POST", "/assets/compare-cohesion", body));
630
+ if (flags.json) {
631
+ console.log(JSON.stringify(data, null, 2));
632
+ return;
633
+ }
634
+ const c = data.cohesion;
635
+ console.log(`\x1b[1mCohesion Report\x1b[0m${data.cached ? " (cached)" : ""}\n`);
636
+ console.log(` Overall: ${c.overallScore}/100`);
637
+ console.log(` Typography: ${c.typography.score}/100 — ${c.typography.consistency}`);
638
+ console.log(` Color: ${c.color.score}/100 — ${c.color.harmony}`);
639
+ console.log(` Mood: ${c.mood.score}/100 — ${c.mood.alignment}`);
640
+ if (c.suggestions?.length) {
641
+ console.log(`\n\x1b[1mSuggestions:\x1b[0m`);
642
+ for (const s of c.suggestions) {
643
+ const sev = s.severity === "high" ? "\x1b[31m" : s.severity === "medium" ? "\x1b[33m" : "\x1b[32m";
644
+ console.log(` ${sev}${s.severity}\x1b[0m ${s.asset.slice(0, 8)}: ${s.suggestion}`);
645
+ }
646
+ }
647
+ if (c.rationale)
648
+ console.log(`\n${c.rationale}`);
649
+ }
650
+ // ─── API Key Management ───────────────────────────────────────────────────
651
+ async function cmdKeys(args, flags) {
652
+ const sub = args[0];
653
+ const rest = args.slice(1);
654
+ if (!sub || sub === "list" || sub === "ls") {
655
+ return cmdKeysList(rest, flags);
656
+ }
657
+ if (sub === "create" || sub === "new") {
658
+ return cmdKeysCreate(rest, flags);
659
+ }
660
+ if (sub === "revoke" || sub === "delete" || sub === "rm") {
661
+ return cmdKeysRevoke(rest, flags);
662
+ }
663
+ console.error(`Unknown keys subcommand: ${sub}`);
664
+ console.error("Usage: argus-media-cli keys <list|create|revoke>");
665
+ process.exit(1);
666
+ }
667
+ async function cmdKeysList(_args, flags) {
668
+ const config = await loadConfig();
669
+ const data = (await apiRequest(config, "GET", "/keys"));
670
+ if (flags.json) {
671
+ console.log(JSON.stringify(data, null, 2));
672
+ return;
673
+ }
674
+ const keys = data.keys || [];
675
+ if (!keys.length) {
676
+ console.log("No API keys found.");
677
+ console.log("Create one with: argus-media-cli keys create --name 'My Key'");
678
+ return;
679
+ }
680
+ console.log(`\x1b[1m${keys.length} API key${keys.length === 1 ? "" : "s"}\x1b[0m\n`);
681
+ for (const k of keys) {
682
+ const status = k.revoked_at ? "\x1b[31mrevoked\x1b[0m" : "\x1b[32mactive\x1b[0m ";
683
+ const lastUsed = k.last_used_at ? `used ${k.last_used_at}` : "never used";
684
+ console.log(` ${status} ${k.key_prefix}… ${k.name}`);
685
+ console.log(` id: ${k.id}`);
686
+ console.log(` created ${k.created_at}, ${lastUsed}`);
687
+ }
688
+ }
689
+ async function cmdKeysCreate(args, flags) {
690
+ const name = flags.name || args[0] || "API Key";
691
+ const config = await loadConfig();
692
+ const data = (await apiRequest(config, "POST", "/keys", { name }));
693
+ if (flags.json) {
694
+ console.log(JSON.stringify(data, null, 2));
695
+ return;
696
+ }
697
+ console.log(`\x1b[1mAPI key created\x1b[0m\n`);
698
+ console.log(` Name: ${data.name}`);
699
+ console.log(` ID: ${data.id}`);
700
+ console.log(` Prefix: ${data.key_prefix}`);
701
+ console.log(`\n\x1b[1m\x1b[33mKey (shown once — store it now):\x1b[0m`);
702
+ console.log(` ${data.key}\n`);
703
+ console.log(`Use it by setting:`);
704
+ console.log(` export ARGUS_API_KEY=${data.key}`);
705
+ }
706
+ async function cmdKeysRevoke(args, flags) {
707
+ const id = args[0];
708
+ if (!id) {
709
+ console.error("Usage: argus-media-cli keys revoke <id>");
710
+ process.exit(1);
711
+ }
712
+ const config = await loadConfig();
713
+ const data = (await apiRequest(config, "DELETE", `/keys/${id}`));
714
+ if (flags.json) {
715
+ console.log(JSON.stringify(data, null, 2));
716
+ return;
717
+ }
718
+ console.log(`Key ${id} revoked.`);
719
+ }
720
+ async function cmdStyleGuide(args, flags) {
721
+ if (args.length < 1) {
722
+ console.error("Usage: argus-media-cli style-guide <asset-id> [<asset-id>...] [--name TEXT] [--guide-id ID]");
723
+ process.exit(1);
724
+ }
725
+ const config = await loadConfig();
726
+ const body = { assetIds: args };
727
+ if (flags.name)
728
+ body.name = flags.name;
729
+ if (flags["guide-id"])
730
+ body.guideId = flags["guide-id"];
731
+ const data = (await apiRequest(config, "POST", "/style-guides", body));
732
+ if (flags.json) {
733
+ console.log(JSON.stringify(data, null, 2));
734
+ return;
735
+ }
736
+ const sg = data.styleGuide;
737
+ console.log(`\x1b[1mStyle Guide: ${sg.name || sg.id}\x1b[0m\n`);
738
+ console.log(` ID: ${sg.id}`);
739
+ console.log(` Assets: ${sg.sourceAssetIds?.length ?? 0}`);
740
+ if (data.googleFontsUrl)
741
+ console.log(` Fonts: ${data.googleFontsUrl}`);
742
+ if (data.rationale)
743
+ console.log(`\n${data.rationale}`);
744
+ }
264
745
  function parseArgs(argv) {
265
746
  const command = argv[0] || "help";
266
747
  const flags = {};
@@ -296,10 +777,19 @@ function printHelp() {
296
777
  \x1b[1mCommands:\x1b[0m
297
778
  login Authenticate with Argus
298
779
  upload <file> Upload a media file
780
+ sync <folder> Onboard a folder of assets in one shot
299
781
  search <query> Search assets by description, tags, mood
300
782
  list List all assets
301
783
  get <id> Get asset details and analysis
302
784
  usage Show workspace usage and limits
785
+ creative-direction <id> Generate creative brief for an asset
786
+ typography <id> Suggest typography pairings for an asset
787
+ palette <id> Generate extended color palette from an asset
788
+ cohesion <id> <id> ... Compare visual cohesion across assets
789
+ style-guide <id> ... Generate a style guide from assets
790
+ keys list List API keys in the current workspace
791
+ keys create [--name] Create a new API key (shown once)
792
+ keys revoke <id> Revoke an API key (admin only)
303
793
 
304
794
  \x1b[1mGlobal Options:\x1b[0m
305
795
  --json Output raw JSON
@@ -308,6 +798,12 @@ function printHelp() {
308
798
  --project NAME Assign to a project
309
799
  --tags a,b,c Comma-separated tags
310
800
 
801
+ \x1b[1mSync Options:\x1b[0m
802
+ --project NAME Target project (default: kebab-cased folder name)
803
+ --tags a,b,c Tags applied to every synced asset
804
+ --dry-run Print the plan without uploading
805
+ --yes Skip the credit-shortfall confirmation prompt
806
+
311
807
  \x1b[1mSearch/List Options:\x1b[0m
312
808
  --project NAME Filter by project
313
809
  --status STATUS Filter by status (pending, ready, error)
@@ -315,10 +811,30 @@ function printHelp() {
315
811
  --limit N Max results (default 50)
316
812
  --offset N Pagination offset
317
813
 
814
+ \x1b[1mCreative Direction Options:\x1b[0m
815
+ --purpose TEXT Intended use (e.g. 'hero banner')
816
+ --audience TEXT Target audience
817
+ --tone TEXT Desired tone (e.g. 'professional')
818
+ --style TEXT Visual style (e.g. 'minimalist')
819
+
820
+ \x1b[1mTypography Options:\x1b[0m
821
+ --purpose TEXT Intended use context
822
+
823
+ \x1b[1mCohesion Options:\x1b[0m
824
+ --purpose TEXT Comparison context
825
+ --brand TEXT Brand name for context
826
+
827
+ \x1b[1mStyle Guide Options:\x1b[0m
828
+ --name TEXT Name for the style guide
829
+ --guide-id ID Existing guide ID to update
830
+
318
831
  \x1b[1mLogin Options:\x1b[0m
319
832
  --key KEY Set API key non-interactively
320
833
  --url URL Set custom API base URL
321
834
 
835
+ \x1b[1mKeys Options:\x1b[0m
836
+ --name TEXT Name for the new key (keys create)
837
+
322
838
  \x1b[1mEnvironment:\x1b[0m
323
839
  ARGUS_API_KEY API key (overrides saved config)
324
840
  ARGUS_BASE_URL API base URL (default: https://argus.build)
@@ -326,28 +842,76 @@ function printHelp() {
326
842
  \x1b[1mExamples:\x1b[0m
327
843
  argus-media-cli login
328
844
  argus-media-cli upload photo.jpg --project campaign-q2 --tags hero,banner
845
+ argus-media-cli sync ./legacy-brand-folder --dry-run
846
+ argus-media-cli sync ./legacy-brand-folder --project brand-2019 --tags legacy
329
847
  argus-media-cli search "sunset landscape" --json
330
848
  argus-media-cli list --project website --limit 20
331
849
  argus-media-cli get abc12345-def6-7890-abcd-ef1234567890
332
850
  `);
333
851
  }
852
+ // ─── Onboarding ───────────────────────────────────────────────────────────
853
+ function isAuthenticated(config) {
854
+ return !!(process.env.ARGUS_API_KEY || config.apiKey || config.sessionId);
855
+ }
856
+ async function cmdDashboard(_args, flags) {
857
+ const config = await loadConfig();
858
+ const res = await apiRequest(config, "GET", "/usage");
859
+ console.log(`\x1b[1mArgus Dashboard\x1b[0m\n`);
860
+ console.log(` Tier: ${res.tier}`);
861
+ console.log(` Assets: ${res.assets?.used ?? 0} / ${res.assets?.limit ?? "unlimited"}`);
862
+ if (res.storage != null) {
863
+ console.log(` Storage: ${res.storage?.used != null ? formatBytes(res.storage.used) : "0 B"} / ${res.storage?.limit != null ? formatBytes(res.storage.limit) : "unlimited"}`);
864
+ }
865
+ console.log(`\nRun \x1b[1margus-media-cli help\x1b[0m for available commands.`);
866
+ }
334
867
  // ─── Main ──────────────────────────────────────────────────────────────────
335
868
  const COMMANDS = {
869
+ dashboard: cmdDashboard,
336
870
  login: cmdLogin,
337
871
  upload: cmdUpload,
872
+ sync: cmdSync,
338
873
  search: cmdSearch,
339
874
  list: cmdList,
340
875
  get: cmdGet,
341
876
  usage: cmdUsage,
877
+ "creative-direction": cmdCreativeDirection,
878
+ typography: cmdTypography,
879
+ palette: cmdPalette,
880
+ cohesion: cmdCohesion,
881
+ "style-guide": cmdStyleGuide,
882
+ keys: cmdKeys,
342
883
  };
343
884
  async function main() {
344
- const { command, args, flags } = parseArgs(process.argv.slice(2));
885
+ const rawArgs = process.argv.slice(2);
886
+ // No args: onboarding — show dashboard or prompt login
887
+ if (rawArgs.length === 0) {
888
+ const config = await loadConfig();
889
+ if (isAuthenticated(config)) {
890
+ try {
891
+ await cmdDashboard([], {});
892
+ }
893
+ catch {
894
+ console.log("\x1b[1mArgus Media CLI\x1b[0m\n");
895
+ console.log("Run \x1b[1margus-media-cli help\x1b[0m for available commands.");
896
+ }
897
+ }
898
+ else {
899
+ console.log("\x1b[1mWelcome to Argus!\x1b[0m\n");
900
+ console.log("Get started by signing in:\n");
901
+ console.log(" argus-media-cli login\n");
902
+ console.log("Or set an API key for CI use:\n");
903
+ console.log(" argus-media-cli login --key <your-key>\n");
904
+ console.log("Run \x1b[1margus-media-cli help\x1b[0m for all commands.");
905
+ }
906
+ return;
907
+ }
908
+ const { command, args, flags } = parseArgs(rawArgs);
345
909
  if (command === "help" || command === "--help" || command === "-h") {
346
910
  printHelp();
347
911
  return;
348
912
  }
349
913
  if (command === "version" || command === "--version" || command === "-v") {
350
- console.log("argus-media-cli 0.1.2");
914
+ console.log("argus-media-cli 0.4.0");
351
915
  return;
352
916
  }
353
917
  const handler = COMMANDS[command];
package/dist/sync.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ /** Server-enforced upload cap. Files larger than this are skipped. */
2
+ export declare const MAX_SYNC_SIZE: number;
3
+ /**
4
+ * File types Argus can ingest and analyze. Mirrors the server's ANALYZABLE_TYPES
5
+ * (JPEG/PNG/GIF/WebP/SVG). Anything else is skipped with an "unsupported type" reason.
6
+ */
7
+ export declare const SYNCABLE_EXTENSIONS: Record<string, string>;
8
+ export interface FileEntry {
9
+ path: string;
10
+ name: string;
11
+ size: number;
12
+ }
13
+ export interface HashedFile extends FileEntry {
14
+ /** sha256 of file contents; undefined for files that never reach the dedup stage. */
15
+ hash?: string;
16
+ }
17
+ export interface ExistingAsset {
18
+ filename: string;
19
+ size: number;
20
+ }
21
+ export interface SyncPlan {
22
+ include: HashedFile[];
23
+ skip: {
24
+ filename: string;
25
+ path: string;
26
+ reason: string;
27
+ }[];
28
+ }
29
+ export interface SyncResult {
30
+ synced: {
31
+ id: string;
32
+ filename: string;
33
+ }[];
34
+ skipped: {
35
+ filename: string;
36
+ reason: string;
37
+ }[];
38
+ failed: {
39
+ filename: string;
40
+ error: string;
41
+ }[];
42
+ }
43
+ export declare function formatBytes(bytes: number): string;
44
+ export declare function kebabCase(input: string): string;
45
+ /** Default project name = kebab-cased basename of the folder. */
46
+ export declare function deriveProject(folder: string): string;
47
+ export declare function mimeForFile(name: string): string | null;
48
+ /**
49
+ * Cheap, hash-free skip checks: junk, hidden, unsupported type, oversize.
50
+ * Returns a human-readable reason, or null if the file passes and should be hashed.
51
+ */
52
+ export declare function basicSkipReason(f: FileEntry): string | null;
53
+ /**
54
+ * Recursively collect files under `dir`. Hidden directories (`.git`, etc.) are
55
+ * skipped entirely; hidden *files* are still returned so they can be reported as
56
+ * skipped-with-reason. Output is sorted by path for deterministic manifests.
57
+ */
58
+ export declare function walk(dir: string): Promise<FileEntry[]>;
59
+ /**
60
+ * Decide which files to upload vs skip.
61
+ *
62
+ * Skip reasons, in priority order:
63
+ * 1. junk / hidden / unsupported / oversize (see basicSkipReason)
64
+ * 2. an existing asset in the target project with the same filename + size
65
+ * 3. an earlier file in this run with identical contents (hash)
66
+ *
67
+ * `files` should carry a `hash` for every entry that passes basicSkipReason;
68
+ * entries without a hash simply never match the content-dedup check.
69
+ */
70
+ export declare function buildPlan(files: HashedFile[], existing: ExistingAsset[]): SyncPlan;
71
+ export interface ManifestMeta {
72
+ project: string;
73
+ dryRun: boolean;
74
+ }
75
+ export declare function toJsonManifest(result: SyncResult, meta: ManifestMeta): object;
76
+ export declare function formatManifestText(result: SyncResult, meta: ManifestMeta): string;
package/dist/sync.js ADDED
@@ -0,0 +1,189 @@
1
+ // Pure, testable logic for the `sync <folder>` command.
2
+ // Kept separate from index.ts so the walk/filter/dedup/manifest behaviour can be
3
+ // unit-tested without spinning up the CLI or hitting the network.
4
+ import { readdir, stat } from "node:fs/promises";
5
+ import { basename, extname, join } from "node:path";
6
+ /** Server-enforced upload cap. Files larger than this are skipped. */
7
+ export const MAX_SYNC_SIZE = 20 * 1024 * 1024; // 20 MB
8
+ /**
9
+ * File types Argus can ingest and analyze. Mirrors the server's ANALYZABLE_TYPES
10
+ * (JPEG/PNG/GIF/WebP/SVG). Anything else is skipped with an "unsupported type" reason.
11
+ */
12
+ export const SYNCABLE_EXTENSIONS = {
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".png": "image/png",
16
+ ".gif": "image/gif",
17
+ ".webp": "image/webp",
18
+ ".svg": "image/svg+xml",
19
+ };
20
+ /** OS/editor cruft that should never be uploaded. */
21
+ const JUNK_FILENAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
22
+ // ─── Formatting ──────────────────────────────────────────────────────────────
23
+ export function formatBytes(bytes) {
24
+ if (bytes < 1024)
25
+ return `${bytes} B`;
26
+ if (bytes < 1024 * 1024)
27
+ return `${(bytes / 1024).toFixed(1)} KB`;
28
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
29
+ }
30
+ // ─── Project derivation ────────────────────────────────────────────────────────
31
+ export function kebabCase(input) {
32
+ return input
33
+ .trim()
34
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase → camel-Case
35
+ .replace(/[\s_]+/g, "-")
36
+ .replace(/[^a-zA-Z0-9-]/g, "-")
37
+ .replace(/-+/g, "-")
38
+ .replace(/^-+|-+$/g, "")
39
+ .toLowerCase();
40
+ }
41
+ /** Default project name = kebab-cased basename of the folder. */
42
+ export function deriveProject(folder) {
43
+ const trimmed = folder.replace(/[/\\]+$/, "");
44
+ const base = basename(trimmed);
45
+ return kebabCase(base) || "imports";
46
+ }
47
+ // ─── Classification ────────────────────────────────────────────────────────────
48
+ export function mimeForFile(name) {
49
+ const ext = extname(name).toLowerCase();
50
+ return SYNCABLE_EXTENSIONS[ext] ?? null;
51
+ }
52
+ /**
53
+ * Cheap, hash-free skip checks: junk, hidden, unsupported type, oversize.
54
+ * Returns a human-readable reason, or null if the file passes and should be hashed.
55
+ */
56
+ export function basicSkipReason(f) {
57
+ if (JUNK_FILENAMES.has(f.name))
58
+ return "junk file";
59
+ if (f.name.startsWith("."))
60
+ return "hidden file";
61
+ const mime = mimeForFile(f.name);
62
+ if (!mime) {
63
+ const ext = extname(f.name).toLowerCase();
64
+ return `unsupported type (${ext || "no extension"})`;
65
+ }
66
+ if (f.size > MAX_SYNC_SIZE) {
67
+ return `exceeds 20 MB limit (${formatBytes(f.size)})`;
68
+ }
69
+ return null;
70
+ }
71
+ // ─── Walk ──────────────────────────────────────────────────────────────────────
72
+ /**
73
+ * Recursively collect files under `dir`. Hidden directories (`.git`, etc.) are
74
+ * skipped entirely; hidden *files* are still returned so they can be reported as
75
+ * skipped-with-reason. Output is sorted by path for deterministic manifests.
76
+ */
77
+ export async function walk(dir) {
78
+ const out = [];
79
+ async function recurse(current) {
80
+ const entries = await readdir(current, { withFileTypes: true });
81
+ for (const entry of entries) {
82
+ const full = join(current, entry.name);
83
+ if (entry.isDirectory()) {
84
+ if (entry.name.startsWith("."))
85
+ continue; // skip .git, .cache, etc.
86
+ await recurse(full);
87
+ }
88
+ else if (entry.isFile()) {
89
+ const s = await stat(full);
90
+ out.push({ path: full, name: entry.name, size: s.size });
91
+ }
92
+ }
93
+ }
94
+ await recurse(dir);
95
+ out.sort((a, b) => a.path.localeCompare(b.path));
96
+ return out;
97
+ }
98
+ // ─── Plan ────────────────────────────────────────────────────────────────────
99
+ /**
100
+ * Decide which files to upload vs skip.
101
+ *
102
+ * Skip reasons, in priority order:
103
+ * 1. junk / hidden / unsupported / oversize (see basicSkipReason)
104
+ * 2. an existing asset in the target project with the same filename + size
105
+ * 3. an earlier file in this run with identical contents (hash)
106
+ *
107
+ * `files` should carry a `hash` for every entry that passes basicSkipReason;
108
+ * entries without a hash simply never match the content-dedup check.
109
+ */
110
+ export function buildPlan(files, existing) {
111
+ const include = [];
112
+ const skip = [];
113
+ const existingKeys = new Set(existing.map((a) => `${a.filename}:${a.size}`));
114
+ const seenHashes = new Map(); // hash → first filename seen
115
+ for (const f of files) {
116
+ const basic = basicSkipReason(f);
117
+ if (basic) {
118
+ skip.push({ filename: f.name, path: f.path, reason: basic });
119
+ continue;
120
+ }
121
+ // Content dedup within the run. The first file with a given hash claims it,
122
+ // even if that first file is then skipped for another reason (e.g. already
123
+ // in the project) — a later identical-content file is still a duplicate.
124
+ if (f.hash && seenHashes.has(f.hash)) {
125
+ skip.push({
126
+ filename: f.name,
127
+ path: f.path,
128
+ reason: `duplicate of ${seenHashes.get(f.hash)}`,
129
+ });
130
+ continue;
131
+ }
132
+ if (f.hash)
133
+ seenHashes.set(f.hash, f.name);
134
+ if (existingKeys.has(`${f.name}:${f.size}`)) {
135
+ skip.push({
136
+ filename: f.name,
137
+ path: f.path,
138
+ reason: "already in project (same name + size)",
139
+ });
140
+ continue;
141
+ }
142
+ include.push(f);
143
+ }
144
+ return { include, skip };
145
+ }
146
+ export function toJsonManifest(result, meta) {
147
+ return {
148
+ project: meta.project,
149
+ dryRun: meta.dryRun,
150
+ summary: {
151
+ synced: result.synced.length,
152
+ skipped: result.skipped.length,
153
+ failed: result.failed.length,
154
+ },
155
+ synced: result.synced,
156
+ skipped: result.skipped,
157
+ failed: result.failed,
158
+ };
159
+ }
160
+ const BOLD = "\x1b[1m";
161
+ const DIM = "\x1b[2m";
162
+ const GREEN = "\x1b[32m";
163
+ const YELLOW = "\x1b[33m";
164
+ const RED = "\x1b[31m";
165
+ const RESET = "\x1b[0m";
166
+ export function formatManifestText(result, meta) {
167
+ const lines = [];
168
+ const header = meta.dryRun ? "Sync plan (dry run)" : "Sync complete";
169
+ lines.push(`${BOLD}${header}${RESET} — project ${BOLD}${meta.project}${RESET}\n`);
170
+ const syncedLabel = meta.dryRun ? "Would sync" : "Synced";
171
+ lines.push(`${GREEN}${syncedLabel}: ${result.synced.length}${RESET}`);
172
+ for (const s of result.synced) {
173
+ const id = s.id ? `${DIM}${s.id}${RESET} ` : "";
174
+ lines.push(` ${id}${s.filename}`);
175
+ }
176
+ if (result.skipped.length) {
177
+ lines.push(`\n${YELLOW}Skipped: ${result.skipped.length}${RESET}`);
178
+ for (const s of result.skipped) {
179
+ lines.push(` ${s.filename} ${DIM}— ${s.reason}${RESET}`);
180
+ }
181
+ }
182
+ if (result.failed.length) {
183
+ lines.push(`\n${RED}Failed: ${result.failed.length}${RESET}`);
184
+ for (const f of result.failed) {
185
+ lines.push(` ${f.filename} ${DIM}— ${f.error}${RESET}`);
186
+ }
187
+ }
188
+ return lines.join("\n");
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argus-media-cli",
3
- "version": "0.1.2",
3
+ "version": "0.4.1",
4
4
  "description": "CLI for Argus — AI-native media asset management. Upload, search, analyze, and manage media assets from the terminal.",
5
5
  "keywords": [
6
6
  "argus",
@@ -29,12 +29,14 @@
29
29
  "README.md"
30
30
  ],
31
31
  "scripts": {
32
- "build": "tsc",
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "test": "vitest run",
33
34
  "prepublishOnly": "npm run build"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/node": "^20.0.0",
37
- "typescript": "^5.4.0"
38
+ "typescript": "^5.4.0",
39
+ "vitest": "^4.1.2"
38
40
  },
39
41
  "engines": {
40
42
  "node": ">=18"