argus-media-cli 0.3.0 → 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
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");
@@ -307,6 +309,211 @@ async function cmdUsage(_args, flags) {
307
309
  console.log(` Storage: ${data.storage?.used != null ? formatBytes(data.storage.used) : "0 B"} / ${data.storage?.limit != null ? formatBytes(data.storage.limit) : "unlimited"}`);
308
310
  }
309
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
+ }
310
517
  // ─── Phase 2: Generative Commands ─────────────────────────────────────────
311
518
  async function cmdCreativeDirection(args, flags) {
312
519
  const id = args[0];
@@ -570,6 +777,7 @@ function printHelp() {
570
777
  \x1b[1mCommands:\x1b[0m
571
778
  login Authenticate with Argus
572
779
  upload <file> Upload a media file
780
+ sync <folder> Onboard a folder of assets in one shot
573
781
  search <query> Search assets by description, tags, mood
574
782
  list List all assets
575
783
  get <id> Get asset details and analysis
@@ -590,6 +798,12 @@ function printHelp() {
590
798
  --project NAME Assign to a project
591
799
  --tags a,b,c Comma-separated tags
592
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
+
593
807
  \x1b[1mSearch/List Options:\x1b[0m
594
808
  --project NAME Filter by project
595
809
  --status STATUS Filter by status (pending, ready, error)
@@ -628,6 +842,8 @@ function printHelp() {
628
842
  \x1b[1mExamples:\x1b[0m
629
843
  argus-media-cli login
630
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
631
847
  argus-media-cli search "sunset landscape" --json
632
848
  argus-media-cli list --project website --limit 20
633
849
  argus-media-cli get abc12345-def6-7890-abcd-ef1234567890
@@ -653,6 +869,7 @@ const COMMANDS = {
653
869
  dashboard: cmdDashboard,
654
870
  login: cmdLogin,
655
871
  upload: cmdUpload,
872
+ sync: cmdSync,
656
873
  search: cmdSearch,
657
874
  list: cmdList,
658
875
  get: cmdGet,
@@ -694,7 +911,7 @@ async function main() {
694
911
  return;
695
912
  }
696
913
  if (command === "version" || command === "--version" || command === "-v") {
697
- console.log("argus-media-cli 0.3.0");
914
+ console.log("argus-media-cli 0.4.0");
698
915
  return;
699
916
  }
700
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.3.0",
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"