@assethub/cli 0.1.3 → 0.1.4

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.
Files changed (3) hide show
  1. package/README.md +23 -5
  2. package/dist/index.js +455 -102
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,11 +6,10 @@ Command line interface for AssetHub.
6
6
  export ASSETHUB_API_KEY=ah_live_xxx
7
7
 
8
8
  assethub models list
9
- assethub models list --domain meshGen --ids-only
10
9
  assethub files import https://example.com/input.png --media-type image
11
10
  assethub source create --file ./input.png --media-type image
12
11
  assethub image generate --prompt "stylized prop concept" --wait
13
- assethub mesh generate --file ./input.png --model-id meshGen.hunyuan31 --wait
12
+ assethub mesh generate --file ./input.png --wait
14
13
  assethub jobs watch job_123 --download --out-dir ./out/job
15
14
  assethub parts split --file ./input.png --part-extractor "V1.5" --wait --all-ready --download --out-dir ./out/parts
16
15
  assethub parts compare --file ./input.png --preprocess-prompt "clean white background, centered product photo" --part-extractor "V1.5" --wait --all-ready
@@ -27,22 +26,41 @@ Agent-friendly login:
27
26
  ```sh
28
27
  printf '%s' "$ASSETHUB_API_KEY" | assethub auth login --api-key-stdin
29
28
  assethub image generate --prompt "small stylized game asset chest" --wait --download --out-dir ./out/image
30
- assethub mesh generate --source-url https://example.com/input.png --model-id meshGen.hunyuan31 --wait --download --out-dir ./out/mesh
29
+ assethub mesh generate --source-url https://example.com/input.png --wait --download --out-dir ./out/mesh
31
30
  cat ./character.png | assethub parts split --stdin --file-name character.png --part-extractor "V1.5" --wait --all-ready --download --out-dir ./out/parts
31
+ base64 -i ./character.png | assethub parts split --stdin-base64 --file-name character.png --content-type image/png --part-extractor "V1.5" --wait --all-ready
32
+ printf '{"image_url":{"url":"data:image/png;base64,..."}}' | assethub parts split --stdin-json --part-extractor "V1.5" --wait --all-ready
33
+ assethub parts split --clipboard --file-name character.png --part-extractor "V1.5" --wait --all-ready
32
34
  cat ./character.png | assethub parts compare --stdin --file-name character.png --preprocess-prompt "clean white background" --part-extractor "V1.5" --wait --all-ready
33
35
  ```
34
36
 
35
37
  Agent-oriented input:
36
38
 
37
39
  - `--file <path>` uploads a local image or mesh through the public file API.
38
- - `--stdin --file-name <name>` accepts piped bytes. Give a file name with an
39
- extension when possible.
40
+ - `--stdin --file-name <name>` accepts raw piped bytes. Give a file name with
41
+ an extension when possible.
42
+ - `--stdin-base64 --file-name <name> --content-type <type>` accepts raw base64
43
+ from stdin. It also accepts a full `data:*;base64,...` value.
44
+ - `--stdin-data-uri` accepts a data URI from stdin.
45
+ - `--stdin-json` accepts common agent payload shapes from stdin, including
46
+ OpenAI-style `{"image_url":{"url":"..."}}`, Anthropic-style
47
+ `{"source":{"type":"base64","media_type":"image/png","data":"..."}}`,
48
+ `resourceId`, `fileRef`, `dataUri`, `base64`, `url`, and local `path`.
49
+ - `--source-json <json|@file|@->` parses the same JSON shapes from an argument,
50
+ a file, or stdin.
40
51
  - `--data-uri <uri>` accepts `data:image/png;base64,...` style inputs and uploads
41
52
  them before running the target command.
53
+ - `--clipboard` reads an image from the macOS clipboard for image inputs. It
54
+ uses `pngpaste` when available and falls back to the system clipboard bridge.
42
55
  - `--source-url`, `--source-id`/`--source-resource-id`, and `--file-ref-json`
43
56
  can be reused across image, mesh, and part extraction commands where the API
44
57
  supports them.
45
58
 
59
+ For Codex or other AI agents, the important contract is: if the runtime can
60
+ expose the attachment as bytes, base64, a data URI, a local path, an
61
+ OpenAI/Anthropic-style JSON object, or a URL, the CLI can consume it directly.
62
+ It does not require writing a temporary file first.
63
+
46
64
  Agent-oriented output:
47
65
 
48
66
  - stdout is one final JSON object.
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
2
+ import { execFile } from 'node:child_process';
3
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
4
+ import { homedir, tmpdir } from 'node:os';
4
5
  import { basename, dirname, extname, join } from 'node:path';
5
6
  import { argv, env, exit, stdin, stderr, stdout } from 'node:process';
7
+ import { promisify } from 'node:util';
6
8
  import { AssetHubApiError, createAssetHubClient, } from '@assethub/api-client';
7
9
  const defaultBaseUrl = 'https://app.assethub.io';
8
10
  const defaultImageModelId = 'imageGen.nanoBanana.pro.openrouter';
@@ -47,6 +49,7 @@ const partExtractorPublicNameByInput = new Map(partExtractorOptions.flatMap(opti
47
49
  ]),
48
50
  ]));
49
51
  const isInternalPartExtractorId = (value) => value.trim().includes('_');
52
+ const execFileAsync = promisify(execFile);
50
53
  const usage = `AssetHub CLI
51
54
 
52
55
  Usage:
@@ -58,18 +61,18 @@ Usage:
58
61
  assethub models get <model-id>
59
62
  assethub files import <url> --media-type image|mesh
60
63
  assethub files upload <file> --media-type image|mesh
61
- assethub source create (--file <path> | --source-url <url> | --source-id <id> | --stdin | --data-uri <uri>) --media-type image|mesh
62
- assethub image generate --prompt <text> [--file <path> | --source-url <url> | --stdin | --data-uri <uri>] [--model-id <id>] [--batch-size <n>] [--wait] [--download --out-dir <dir>] [--api-version v1|v2]
64
+ assethub source create (--file <path> | --source-url <url> | --source-id <id> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri> | --clipboard) --media-type image|mesh [--file-name <name>] [--content-type <type>]
65
+ assethub image generate --prompt <text> [--file <path> | --source-url <url> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri> | --clipboard] [--file-name <name>] [--content-type <type>] [--model-id <id>] [--batch-size <n>] [--wait] [--download --out-dir <dir>] [--api-version v1|v2]
63
66
  assethub image generate --prompt <text> --source-resource-id <id> --api-version v2 [--model-id <id>] [--batch-size <n>] [--wait]
64
- assethub mesh generate (--file <path> | --source-url <url> | --source-resource-id <id> | --file-ref-json <json> | --stdin | --data-uri <uri>) [--model-id <id>] [--name <name>] [--wait] [--download --out-dir <dir>]
65
- assethub mesh retopo (--source-resource-id <id> | --source-url <url> | --file-ref-json <json> | --stdin | --data-uri <uri>) --model-id <id> [--wait] [--download --out-dir <dir>]
67
+ assethub mesh generate (--file <path> | --source-url <url> | --source-resource-id <id> | --file-ref-json <json> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri>) [--file-name <name>] [--content-type <type>] [--model-id <id>] [--name <name>] [--wait] [--download --out-dir <dir>]
68
+ assethub mesh retopo (--source-resource-id <id> | --source-url <url> | --file-ref-json <json> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri>) --model-id <id> [--file-name <name>] [--content-type <type>] [--wait] [--download --out-dir <dir>]
66
69
  assethub jobs get <job-id> [--download --out-dir <dir>]
67
70
  assethub jobs watch <job-id> [--interval-ms <ms>] [--timeout-ms <ms>] [--download --out-dir <dir>]
68
- assethub production analyze (--file <path> | --source-url <url> | --source-id <id> | --image-url <url> | --file-ref-json <json> | --stdin | --data-uri <uri>) [--part-extractor <name>] [--name <name>] [--wait] [--download --out-dir <dir>]
71
+ assethub production analyze (--file <path> | --source-url <url> | --source-id <id> | --image-url <url> | --file-ref-json <json> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri> | --clipboard) [--file-name <name>] [--content-type <type>] [--part-extractor <name>] [--name <name>] [--wait] [--download --out-dir <dir>]
69
72
  assethub production status <order-id> [--wait] [--download --out-dir <dir>]
70
73
  assethub production execute --order-id <id> --mission-id <id> --task-id <id> [--task-id <id>...] [--part-extractor <name>] [--part-extraction-mode fast|high_quality] [--wait] [--download --out-dir <dir>]
71
- assethub parts split (--file <path> | --source-url <url> | --source-id <id> | --image-url <url> | --file-ref-json <json> | --stdin | --data-uri <uri> | --order-id <id>) [--part-extractor <name>] [--wait] [--task-id <id>...] [--mission-id <id>] [--all-ready] [--download --out-dir <dir>]
72
- assethub parts compare (--file <path> | --source-url <url> | --source-id <id> | --image-url <url> | --file-ref-json <json> | --stdin | --data-uri <uri>) [--preprocess-prompt <text>] [--preprocess-model-id <id>] [--fail-on-preprocess-error] [--part-extractor <name>] [--wait] [--task-id <id>...] [--mission-id <id>] [--all-ready] [--download --out-dir <dir>]
74
+ assethub parts split (--file <path> | --source-url <url> | --source-id <id> | --image-url <url> | --file-ref-json <json> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri> | --clipboard | --order-id <id>) [--file-name <name>] [--content-type <type>] [--part-extractor <name>] [--wait] [--task-id <id>...] [--mission-id <id>] [--all-ready] [--download --out-dir <dir>]
75
+ assethub parts compare (--file <path> | --source-url <url> | --source-id <id> | --image-url <url> | --file-ref-json <json> | --stdin | --stdin-base64 | --stdin-data-uri | --stdin-json | --source-json <json|@file|@-> | --data-uri <uri> | --clipboard) [--file-name <name>] [--content-type <type>] [--preprocess-prompt <text>] [--preprocess-model-id <id>] [--fail-on-preprocess-error] [--part-extractor <name>] [--wait] [--task-id <id>...] [--mission-id <id>] [--all-ready] [--download --out-dir <dir>]
73
76
 
74
77
  Global options:
75
78
  --api-key <key> Overrides saved auth and ASSETHUB_API_KEY.
@@ -81,12 +84,16 @@ Global options:
81
84
  Examples:
82
85
  ASSETHUB_API_KEY=ah_live_xxx assethub models list
83
86
  assethub auth login --api-key-stdin
84
- assethub models list --domain meshGen --ids-only
87
+ assethub models list --ids-only
85
88
  assethub files upload ./input.png --media-type image
86
89
  assethub source create --file ./input.png --media-type image
90
+ pbpaste | assethub source create --stdin-data-uri --media-type image
91
+ assethub source create --clipboard --media-type image --file-name clipboard.png
92
+ printf '{"image_url":{"url":"data:image/png;base64,..."}}' | assethub parts split --stdin-json --part-extractor "V1.5" --wait
93
+ base64 -i ./input.png | assethub parts split --stdin-base64 --file-name input.png --content-type image/png --part-extractor "V1.5" --wait
87
94
  cat ./input.png | assethub parts split --stdin --file-name input.png --part-extractor "V1.5" --wait
88
95
  assethub image generate --prompt "stylized prop concept" --wait
89
- assethub mesh generate --file ./input.png --model-id meshGen.hunyuan31 --wait
96
+ assethub mesh generate --file ./input.png --wait
90
97
  assethub production analyze --file ./input.png --part-extractor "V1.5" --wait
91
98
  assethub parts split --file ./input.png --part-extractor "V1.5" --wait --all-ready
92
99
  assethub parts compare --file ./input.png --preprocess-prompt "clean white background, centered product photo" --part-extractor "V1.5" --wait --all-ready
@@ -327,31 +334,73 @@ const createContext = async (flags) => {
327
334
  }),
328
335
  };
329
336
  };
337
+ const isRecord = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
338
+ const isBlobLocation = (value) => {
339
+ if (!isRecord(value) || typeof value.type !== 'string') {
340
+ return false;
341
+ }
342
+ if (value.type === 'supabase' &&
343
+ typeof value.bucket === 'string' &&
344
+ typeof value.path === 'string') {
345
+ return true;
346
+ }
347
+ if (value.type === 'signedSupabase' &&
348
+ typeof value.signedUrl === 'string') {
349
+ return true;
350
+ }
351
+ if (value.type === 'asset' && typeof value.assetId === 'string') {
352
+ return true;
353
+ }
354
+ return false;
355
+ };
330
356
  const parseFileRef = (value) => {
331
357
  const parsed = JSON.parse(value);
332
- if (parsed == null || typeof parsed !== 'object' || !('type' in parsed)) {
358
+ if (!isBlobLocation(parsed)) {
333
359
  throw new Error('--file-ref-json must be a BlobLocation JSON object');
334
360
  }
335
361
  return parsed;
336
362
  };
337
- const readBlob = async (filePath) => {
338
- const buffer = await readFile(filePath);
339
- return new Blob([new Uint8Array(buffer)]);
363
+ const blobFromBuffer = (buffer, type) => {
364
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
365
+ return new Blob([arrayBuffer], type == null ? undefined : { type });
340
366
  };
341
- const blobFromDataUri = (value) => {
342
- const match = value.match(/^data:([^;,]+)?(;base64)?,(.*)$/s);
367
+ const readBlob = async (filePath, type) => blobFromBuffer(await readFile(filePath), type);
368
+ const getContentTypeFlag = (flags) => getFlag(flags, 'content-type') ?? getFlag(flags, 'mime-type');
369
+ const blobFromDataUri = (value, flagName = '--data-uri') => {
370
+ const trimmed = value.trim();
371
+ const match = trimmed.match(/^data:([^;,]+)?(;base64)?,(.*)$/s);
343
372
  if (match == null) {
344
- throw new Error('--data-uri must be a valid data URI.');
373
+ throw new Error(`${flagName} must be a valid data URI.`);
345
374
  }
346
375
  const contentType = match[1] === '' ? undefined : match[1];
347
376
  const isBase64 = match[2] != null;
348
- const payload = match[3] ?? '';
377
+ const payload = (match[3] ?? '').replace(/\s+/g, '');
349
378
  const buffer = isBase64
350
379
  ? Buffer.from(payload, 'base64')
351
380
  : Buffer.from(decodeURIComponent(payload), 'utf8');
352
- const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
353
381
  return {
354
- blob: new Blob([arrayBuffer], contentType == null ? undefined : { type: contentType }),
382
+ blob: blobFromBuffer(buffer, contentType),
383
+ contentType,
384
+ };
385
+ };
386
+ const blobFromBase64 = (value, contentType, flagName) => {
387
+ const trimmed = value.trim();
388
+ if (trimmed.startsWith('data:')) {
389
+ return blobFromDataUri(trimmed, flagName);
390
+ }
391
+ const normalized = trimmed.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
392
+ if (normalized === '') {
393
+ throw new Error(`${flagName} input is empty.`);
394
+ }
395
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) {
396
+ throw new Error(`${flagName} must contain base64 data or a data URI.`);
397
+ }
398
+ const buffer = Buffer.from(normalized, 'base64');
399
+ if (buffer.byteLength === 0) {
400
+ throw new Error(`${flagName} did not decode to any bytes.`);
401
+ }
402
+ return {
403
+ blob: blobFromBuffer(buffer, contentType),
355
404
  contentType,
356
405
  };
357
406
  };
@@ -376,54 +425,392 @@ const ensureFileNameExtension = (fileName, mediaType, mimeType) => {
376
425
  }
377
426
  return `${fileName}.${extensionFromMimeType(mimeType, mediaType)}`;
378
427
  };
428
+ const missingSourceMessage = 'Missing source. Pass --file, --source-url, --source-resource-id, --source-id, --file-ref-json, --stdin, --stdin-base64, --stdin-data-uri, --stdin-json, --source-json, --data-uri, or --clipboard.';
429
+ const exactlyOneSourceMessage = 'Pass exactly one source input: --file, --source-url/--image-url, --source-id/--source-resource-id, --file-ref-json, --stdin, --stdin-base64, --stdin-data-uri, --stdin-json, --source-json, --data-uri, or --clipboard.';
430
+ const getSourceUrlFlag = (flags) => getFlag(flags, 'source-url') ?? getFlag(flags, 'image-url') ?? getFlag(flags, 'url');
431
+ const assertFlagHasValue = (value, flagDescription) => {
432
+ if (value === 'true' || value.trim() === '') {
433
+ throw new Error(`Missing required flag value: ${flagDescription}`);
434
+ }
435
+ return value;
436
+ };
437
+ const sourceInputCount = (flags) => [
438
+ getSourceResourceIdFlag(flags) != null,
439
+ getSourceUrlFlag(flags) != null,
440
+ getFlag(flags, 'file-ref-json') != null,
441
+ getFlag(flags, 'file') != null,
442
+ getFlag(flags, 'data-uri') != null,
443
+ hasFlag(flags, 'stdin'),
444
+ hasFlag(flags, 'stdin-base64'),
445
+ hasFlag(flags, 'stdin-data-uri'),
446
+ hasFlag(flags, 'stdin-json'),
447
+ getFlag(flags, 'source-json') != null,
448
+ hasFlag(flags, 'clipboard'),
449
+ ].filter(Boolean).length;
450
+ const sourceFromString = (value) => {
451
+ const trimmed = value.trim();
452
+ if (trimmed === '' || trimmed.startsWith('data:')) {
453
+ return undefined;
454
+ }
455
+ try {
456
+ const url = new URL(trimmed);
457
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
458
+ return { url: trimmed };
459
+ }
460
+ }
461
+ catch {
462
+ // Ignore non-URL strings.
463
+ }
464
+ return undefined;
465
+ };
466
+ const getFirstString = (record, keys) => {
467
+ for (const key of keys) {
468
+ const value = record[key];
469
+ if (typeof value === 'string' && value.trim() !== '') {
470
+ return value;
471
+ }
472
+ }
473
+ return undefined;
474
+ };
475
+ const stringFromJsonImageUrl = (value) => {
476
+ if (typeof value === 'string' && value.trim() !== '') {
477
+ return value;
478
+ }
479
+ if (isRecord(value)) {
480
+ return getFirstString(value, ['url']);
481
+ }
482
+ return undefined;
483
+ };
484
+ const descriptorFromDataUri = (value, mediaType, fileName, flagName) => {
485
+ const { blob, contentType } = blobFromDataUri(value, flagName);
486
+ return {
487
+ kind: 'blob',
488
+ blob,
489
+ fileName: ensureFileNameExtension(fileName, mediaType, contentType),
490
+ contentType,
491
+ };
492
+ };
493
+ const descriptorFromBase64 = ({ value, contentType, fileName, mediaType, flagName, }) => {
494
+ const decoded = blobFromBase64(value, contentType, flagName);
495
+ return {
496
+ kind: 'blob',
497
+ blob: decoded.blob,
498
+ fileName: ensureFileNameExtension(fileName, mediaType, decoded.contentType),
499
+ contentType: decoded.contentType,
500
+ };
501
+ };
502
+ const sourceDescriptorFromJson = (value, options) => {
503
+ if (typeof value === 'string') {
504
+ const trimmed = value.trim();
505
+ if (trimmed === '') {
506
+ return undefined;
507
+ }
508
+ if (trimmed.startsWith('data:')) {
509
+ return descriptorFromDataUri(trimmed, options.mediaType, options.fallbackFileName, '--source-json');
510
+ }
511
+ const source = sourceFromString(trimmed);
512
+ return source == null ? undefined : { kind: 'source', source };
513
+ }
514
+ if (Array.isArray(value)) {
515
+ for (const item of value) {
516
+ const found = sourceDescriptorFromJson(item, options);
517
+ if (found != null) {
518
+ return found;
519
+ }
520
+ }
521
+ return undefined;
522
+ }
523
+ if (!isRecord(value)) {
524
+ return undefined;
525
+ }
526
+ const nestedSource = value.source;
527
+ if (nestedSource != null) {
528
+ const found = sourceDescriptorFromJson(nestedSource, options);
529
+ if (found != null) {
530
+ return found;
531
+ }
532
+ }
533
+ const imageUrl = stringFromJsonImageUrl(value.image_url ?? value.imageUrl);
534
+ if (imageUrl != null) {
535
+ if (imageUrl.trim().startsWith('data:')) {
536
+ return descriptorFromDataUri(imageUrl, options.mediaType, options.fallbackFileName, '--source-json');
537
+ }
538
+ return { kind: 'source', source: { url: imageUrl } };
539
+ }
540
+ const resourceId = getFirstString(value, [
541
+ 'resourceId',
542
+ 'resource_id',
543
+ 'sourceResourceId',
544
+ 'source_resource_id',
545
+ 'sourceId',
546
+ 'source_id',
547
+ 'imageAssetId',
548
+ 'image_asset_id',
549
+ 'assetId',
550
+ 'asset_id',
551
+ ]);
552
+ if (resourceId != null) {
553
+ return { kind: 'source', source: { resourceId } };
554
+ }
555
+ const fileRefValue = value.fileRef ?? value.file_ref ?? value.imageBlobLocation ?? value.image_blob_location ?? value.blobLocation ?? value.blob_location;
556
+ if (isBlobLocation(fileRefValue)) {
557
+ return { kind: 'source', source: { fileRef: fileRefValue } };
558
+ }
559
+ const dataUri = getFirstString(value, ['dataUri', 'data_uri']);
560
+ if (dataUri != null) {
561
+ return descriptorFromDataUri(dataUri, options.mediaType, getFirstString(value, ['fileName', 'file_name', 'name']) ??
562
+ options.fallbackFileName, '--source-json');
563
+ }
564
+ const contentType = getFirstString(value, [
565
+ 'contentType',
566
+ 'content_type',
567
+ 'mimeType',
568
+ 'mime_type',
569
+ 'media_type',
570
+ ]);
571
+ const base64Value = getFirstString(value, ['base64', 'data', 'content']);
572
+ if (base64Value != null && (value.type === 'base64' || contentType != null)) {
573
+ return descriptorFromBase64({
574
+ value: base64Value,
575
+ contentType,
576
+ fileName: getFirstString(value, ['fileName', 'file_name', 'name']) ??
577
+ options.fallbackFileName,
578
+ mediaType: options.mediaType,
579
+ flagName: '--source-json',
580
+ });
581
+ }
582
+ const url = getFirstString(value, ['sourceUrl', 'source_url', 'url', 'publicUrl', 'public_url']);
583
+ if (url != null) {
584
+ const source = sourceFromString(url);
585
+ if (source != null) {
586
+ return { kind: 'source', source };
587
+ }
588
+ }
589
+ const path = getFirstString(value, ['path', 'file', 'filePath', 'file_path']);
590
+ if (path != null) {
591
+ return { kind: 'file', path };
592
+ }
593
+ const preferredKeys = [
594
+ 'input',
595
+ 'image',
596
+ 'asset',
597
+ 'file',
598
+ 'message',
599
+ 'messages',
600
+ 'content',
601
+ 'attachments',
602
+ ];
603
+ for (const key of preferredKeys) {
604
+ const found = sourceDescriptorFromJson(value[key], options);
605
+ if (found != null) {
606
+ return found;
607
+ }
608
+ }
609
+ for (const entry of Object.values(value)) {
610
+ const found = sourceDescriptorFromJson(entry, options);
611
+ if (found != null) {
612
+ return found;
613
+ }
614
+ }
615
+ return undefined;
616
+ };
617
+ const parseJsonValue = (raw, sourceName) => {
618
+ try {
619
+ return JSON.parse(raw);
620
+ }
621
+ catch (error) {
622
+ throw new Error(`${sourceName} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
623
+ }
624
+ };
625
+ const readSourceJsonFlag = async (flags) => {
626
+ const value = getFlag(flags, 'source-json');
627
+ if (value == null || value === 'true' || value.trim() === '') {
628
+ throw new Error('Missing required flag value: --source-json <json|@file|@->');
629
+ }
630
+ if (value === '@-' || value === '-') {
631
+ return await readStdin();
632
+ }
633
+ if (value.startsWith('@')) {
634
+ return await readFile(value.slice(1), 'utf8');
635
+ }
636
+ return value;
637
+ };
638
+ const readClipboardImage = async () => {
639
+ const filePath = join(tmpdir(), `assethub-clipboard-${process.pid}-${Date.now()}.png`);
640
+ const readTempFile = async () => await readBlob(filePath, 'image/png');
641
+ try {
642
+ await execFileAsync('pngpaste', [filePath], { timeout: 10000 });
643
+ return await readTempFile();
644
+ }
645
+ catch (pngpasteError) {
646
+ const quoteAppleScriptString = (value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
647
+ const pngClass = `${String.fromCharCode(0x00ab)}class PNGf${String.fromCharCode(0x00bb)}`;
648
+ const script = [
649
+ `set outPath to ${quoteAppleScriptString(filePath)}`,
650
+ `set pngData to the clipboard as ${pngClass}`,
651
+ 'set fileRef to open for access (POSIX file outPath) with write permission',
652
+ 'try',
653
+ 'set eof fileRef to 0',
654
+ 'write pngData to fileRef',
655
+ 'close access fileRef',
656
+ 'on error errMsg',
657
+ 'try',
658
+ 'close access fileRef',
659
+ 'end try',
660
+ 'error errMsg',
661
+ 'end try',
662
+ ].join('\n');
663
+ try {
664
+ await execFileAsync('osascript', ['-e', script], { timeout: 10000 });
665
+ return await readTempFile();
666
+ }
667
+ catch (osascriptError) {
668
+ const details = osascriptError instanceof Error
669
+ ? osascriptError.message
670
+ : pngpasteError instanceof Error
671
+ ? pngpasteError.message
672
+ : 'unknown error';
673
+ throw new Error(`Could not read an image from the clipboard. Copy an image, install pngpaste, or pass bytes through --stdin/--stdin-base64/--stdin-json. Details: ${details}`);
674
+ }
675
+ }
676
+ finally {
677
+ await unlink(filePath).catch(() => undefined);
678
+ }
679
+ };
680
+ const uploadSourceDescriptor = async ({ descriptor, client, mediaType, }) => {
681
+ if (descriptor.kind === 'source') {
682
+ return descriptor.source;
683
+ }
684
+ const file = descriptor.kind === 'file'
685
+ ? await readBlob(descriptor.path, descriptor.contentType)
686
+ : descriptor.blob;
687
+ const fileName = descriptor.kind === 'file'
688
+ ? basename(descriptor.path)
689
+ : descriptor.fileName;
690
+ const upload = await client.v2.uploadFile({
691
+ file,
692
+ fileName: ensureFileNameExtension(fileName, mediaType, descriptor.contentType),
693
+ mediaType,
694
+ });
695
+ return { fileRef: upload.fileRef };
696
+ };
379
697
  const resolveSource = async ({ flags, client, fileMediaType, }) => {
698
+ const providedCount = sourceInputCount(flags);
699
+ if (providedCount === 0) {
700
+ return undefined;
701
+ }
702
+ if (providedCount > 1) {
703
+ throw new Error(exactlyOneSourceMessage);
704
+ }
380
705
  const sourceResourceId = getSourceResourceIdFlag(flags);
381
706
  if (sourceResourceId != null) {
382
- return { resourceId: sourceResourceId };
707
+ return {
708
+ resourceId: assertFlagHasValue(sourceResourceId, '--source-id/--source-resource-id <id>'),
709
+ };
383
710
  }
384
- const sourceUrl = getFlag(flags, 'source-url');
711
+ const sourceUrl = getSourceUrlFlag(flags);
385
712
  if (sourceUrl != null) {
386
- return { url: sourceUrl };
713
+ return {
714
+ url: assertFlagHasValue(sourceUrl, '--source-url/--image-url <url>'),
715
+ };
387
716
  }
388
717
  const fileRefJson = getFlag(flags, 'file-ref-json');
389
718
  if (fileRefJson != null) {
390
- return { fileRef: parseFileRef(fileRefJson) };
719
+ return {
720
+ fileRef: parseFileRef(assertFlagHasValue(fileRefJson, '--file-ref-json <json>')),
721
+ };
391
722
  }
392
723
  const filePath = getFlag(flags, 'file');
393
724
  if (filePath != null) {
394
- const fileRef = await client.v2.uploadFile({
395
- file: await readBlob(filePath),
396
- fileName: basename(filePath),
725
+ return await uploadSourceDescriptor({
726
+ descriptor: { kind: 'file', path: assertFlagHasValue(filePath, '--file <path>') },
727
+ client,
397
728
  mediaType: fileMediaType,
398
729
  });
399
- return { fileRef: fileRef.fileRef };
400
730
  }
401
731
  const dataUri = getFlag(flags, 'data-uri');
402
732
  if (dataUri != null) {
403
- const { blob, contentType } = blobFromDataUri(dataUri);
404
- const fileName = ensureFileNameExtension(getFlag(flags, 'file-name') ?? 'data-uri', fileMediaType, contentType);
405
- const fileRef = await client.v2.uploadFile({
406
- file: blob,
407
- fileName,
733
+ return await uploadSourceDescriptor({
734
+ descriptor: descriptorFromDataUri(assertFlagHasValue(dataUri, '--data-uri <uri>'), fileMediaType, getFlag(flags, 'file-name') ?? 'data-uri', '--data-uri'),
735
+ client,
736
+ mediaType: fileMediaType,
737
+ });
738
+ }
739
+ if (getFlag(flags, 'source-json') != null) {
740
+ const parsed = parseJsonValue(await readSourceJsonFlag(flags), '--source-json');
741
+ const descriptor = sourceDescriptorFromJson(parsed, {
742
+ mediaType: fileMediaType,
743
+ fallbackFileName: getFlag(flags, 'file-name') ?? defaultFileNameForMediaType(fileMediaType),
744
+ });
745
+ if (descriptor == null) {
746
+ throw new Error('--source-json did not contain a usable source. Expected url, image_url.url, resourceId, fileRef, data URI, base64 data, or path.');
747
+ }
748
+ return await uploadSourceDescriptor({ descriptor, client, mediaType: fileMediaType });
749
+ }
750
+ if (hasFlag(flags, 'stdin-json')) {
751
+ const parsed = parseJsonValue(await readStdin(), '--stdin-json');
752
+ const descriptor = sourceDescriptorFromJson(parsed, {
753
+ mediaType: fileMediaType,
754
+ fallbackFileName: getFlag(flags, 'file-name') ?? defaultFileNameForMediaType(fileMediaType),
755
+ });
756
+ if (descriptor == null) {
757
+ throw new Error('--stdin-json did not contain a usable source. Expected url, image_url.url, resourceId, fileRef, data URI, base64 data, or path.');
758
+ }
759
+ return await uploadSourceDescriptor({ descriptor, client, mediaType: fileMediaType });
760
+ }
761
+ if (hasFlag(flags, 'stdin-data-uri')) {
762
+ return await uploadSourceDescriptor({
763
+ descriptor: descriptorFromDataUri(await readStdin(), fileMediaType, getFlag(flags, 'file-name') ?? 'stdin-data-uri', '--stdin-data-uri'),
764
+ client,
765
+ mediaType: fileMediaType,
766
+ });
767
+ }
768
+ if (hasFlag(flags, 'stdin-base64')) {
769
+ return await uploadSourceDescriptor({
770
+ descriptor: descriptorFromBase64({
771
+ value: await readStdin(),
772
+ contentType: getContentTypeFlag(flags),
773
+ fileName: getFlag(flags, 'file-name') ?? defaultFileNameForMediaType(fileMediaType),
774
+ mediaType: fileMediaType,
775
+ flagName: '--stdin-base64',
776
+ }),
777
+ client,
778
+ mediaType: fileMediaType,
779
+ });
780
+ }
781
+ if (hasFlag(flags, 'clipboard')) {
782
+ if (fileMediaType !== 'image') {
783
+ throw new Error('--clipboard is only supported for image inputs.');
784
+ }
785
+ return await uploadSourceDescriptor({
786
+ descriptor: {
787
+ kind: 'blob',
788
+ blob: await readClipboardImage(),
789
+ fileName: ensureFileNameExtension(getFlag(flags, 'file-name') ?? 'clipboard.png', fileMediaType, 'image/png'),
790
+ contentType: 'image/png',
791
+ },
792
+ client,
408
793
  mediaType: fileMediaType,
409
794
  });
410
- return { fileRef: fileRef.fileRef };
411
795
  }
412
796
  if (hasFlag(flags, 'stdin')) {
413
- const fileName = ensureFileNameExtension(getFlag(flags, 'file-name') ?? defaultFileNameForMediaType(fileMediaType), fileMediaType);
414
- const fileRef = await client.v2.uploadFile({
415
- file: await readStdinBlob(),
416
- fileName,
797
+ return await uploadSourceDescriptor({
798
+ descriptor: {
799
+ kind: 'blob',
800
+ blob: await readStdinBlob(getContentTypeFlag(flags)),
801
+ fileName: ensureFileNameExtension(getFlag(flags, 'file-name') ?? defaultFileNameForMediaType(fileMediaType), fileMediaType, getContentTypeFlag(flags)),
802
+ contentType: getContentTypeFlag(flags),
803
+ },
804
+ client,
417
805
  mediaType: fileMediaType,
418
806
  });
419
- return { fileRef: fileRef.fileRef };
420
807
  }
421
808
  return undefined;
422
809
  };
423
810
  const requireSource = async (options) => {
424
811
  const source = await resolveSource(options);
425
812
  if (source == null) {
426
- throw new Error('Missing source. Pass --file, --source-url, --source-resource-id, --source-id, --file-ref-json, --stdin, or --data-uri.');
813
+ throw new Error(missingSourceMessage);
427
814
  }
428
815
  return source;
429
816
  };
@@ -440,10 +827,6 @@ const sourceToV1ImageInput = (source) => {
440
827
  throw new Error('v1 image generation does not accept source-resource-id. Use --source-url, --file, or --api-version v2.');
441
828
  };
442
829
  const resolveProductionAnalyzeSource = async (ctx) => {
443
- const imageUrl = getFlag(ctx.flags, 'image-url');
444
- if (imageUrl != null) {
445
- return { imageUrl };
446
- }
447
830
  const source = await requireSource({
448
831
  flags: ctx.flags,
449
832
  client: ctx.client,
@@ -456,7 +839,7 @@ const resolveProductionAnalyzeSource = async (ctx) => {
456
839
  return { imageUrl: source.url };
457
840
  }
458
841
  if (source.fileRef == null) {
459
- throw new Error('Missing source. Pass --file, --source-url, --source-id, --file-ref-json, --stdin, or --data-uri.');
842
+ throw new Error(missingSourceMessage);
460
843
  }
461
844
  return { imageBlobLocation: source.fileRef };
462
845
  };
@@ -522,54 +905,20 @@ const commandSource = async (subcommand, ctx) => {
522
905
  throw new Error('Unknown source command. Use "source create".');
523
906
  }
524
907
  const mediaType = parseMediaType(requireFlag(ctx.flags, 'media-type'));
525
- const sourceUrl = getFlag(ctx.flags, 'source-url') ?? getFlag(ctx.flags, 'image-url');
526
- const sourceResourceId = getSourceResourceIdFlag(ctx.flags);
527
- const filePath = getFlag(ctx.flags, 'file');
528
- const useStdin = hasFlag(ctx.flags, 'stdin');
529
- const dataUri = getFlag(ctx.flags, 'data-uri');
530
- const provided = [
531
- sourceUrl != null,
532
- sourceResourceId != null,
533
- filePath != null,
534
- useStdin,
535
- dataUri != null,
536
- ].filter(Boolean);
537
- if (provided.length !== 1) {
538
- throw new Error('Pass exactly one source input: --file, --source-url, --source-id, --source-resource-id, --stdin, or --data-uri.');
539
- }
540
- if (sourceUrl != null) {
541
- print({ source: { url: sourceUrl }, url: sourceUrl, mediaType });
908
+ const source = await requireSource({
909
+ flags: ctx.flags,
910
+ client: ctx.client,
911
+ fileMediaType: mediaType,
912
+ });
913
+ if (source.url != null) {
914
+ print({ source, url: source.url, mediaType });
542
915
  return;
543
916
  }
544
- if (sourceResourceId != null) {
545
- print({
546
- source: { resourceId: sourceResourceId },
547
- resourceId: sourceResourceId,
548
- mediaType,
549
- });
917
+ if (source.resourceId != null) {
918
+ print({ source, resourceId: source.resourceId, mediaType });
550
919
  return;
551
920
  }
552
- const upload = filePath != null
553
- ? await ctx.client.v2.uploadFile({
554
- file: await readBlob(filePath),
555
- fileName: basename(filePath),
556
- mediaType,
557
- })
558
- : dataUri != null
559
- ? await (async () => {
560
- const { blob, contentType } = blobFromDataUri(dataUri);
561
- return await ctx.client.v2.uploadFile({
562
- file: blob,
563
- fileName: ensureFileNameExtension(getFlag(ctx.flags, 'file-name') ?? 'data-uri', mediaType, contentType),
564
- mediaType,
565
- });
566
- })()
567
- : await ctx.client.v2.uploadFile({
568
- file: await readStdinBlob(),
569
- fileName: ensureFileNameExtension(getFlag(ctx.flags, 'file-name') ?? defaultFileNameForMediaType(mediaType), mediaType),
570
- mediaType,
571
- });
572
- print({ source: { fileRef: upload.fileRef }, fileRef: upload.fileRef, mediaType });
921
+ print({ source, fileRef: source.fileRef, mediaType });
573
922
  };
574
923
  const commandModels = async (subcommand, positionals, ctx) => {
575
924
  if (subcommand === 'list') {
@@ -1118,6 +1467,9 @@ const commandProduction = async (subcommand, positionals, ctx) => {
1118
1467
  const commandParts = async (subcommand, ctx) => {
1119
1468
  const runPartSplit = async (source) => {
1120
1469
  let orderId = source == null ? getFlag(ctx.flags, 'order-id') : undefined;
1470
+ if (orderId != null) {
1471
+ orderId = assertFlagHasValue(orderId, '--order-id <id>');
1472
+ }
1121
1473
  let analyzed;
1122
1474
  let status;
1123
1475
  if (orderId == null) {
@@ -1193,12 +1545,15 @@ const commandParts = async (subcommand, ctx) => {
1193
1545
  });
1194
1546
  };
1195
1547
  if (subcommand === 'split') {
1548
+ if (getFlag(ctx.flags, 'order-id') != null && sourceInputCount(ctx.flags) > 0) {
1549
+ throw new Error('Pass either --order-id or one image source, not both.');
1550
+ }
1196
1551
  print(await withOptionalDownloads(ctx.flags, await runPartSplit()));
1197
1552
  return;
1198
1553
  }
1199
1554
  if (subcommand === 'compare') {
1200
1555
  if (getFlag(ctx.flags, 'order-id') != null) {
1201
- throw new Error('parts compare requires an image source. Use --file, --source-url, --source-id, --image-url, --file-ref-json, --stdin, or --data-uri.');
1556
+ throw new Error('parts compare requires an image source. Use --file, --source-url, --source-id, --image-url, --file-ref-json, --stdin, --stdin-base64, --stdin-data-uri, --stdin-json, --source-json, --data-uri, or --clipboard.');
1202
1557
  }
1203
1558
  const preprocessPrompt = getFlag(ctx.flags, 'preprocess-prompt');
1204
1559
  if (preprocessPrompt === 'true') {
@@ -1210,17 +1565,15 @@ const commandParts = async (subcommand, ctx) => {
1210
1565
  if (preprocessPrompt != null && getFlag(ctx.flags, 'mission-id') != null) {
1211
1566
  throw new Error('parts compare preprocessing creates a second order. Omit --mission-id and let the CLI infer it.');
1212
1567
  }
1213
- const imageUrlFlag = getFlag(ctx.flags, 'image-url');
1214
- if (imageUrlFlag === 'true') {
1215
- throw new Error('Missing required flag value: --image-url <url>');
1568
+ const preprocessModelId = getFlag(ctx.flags, 'preprocess-model-id');
1569
+ if (preprocessModelId === 'true') {
1570
+ throw new Error('Missing required flag value: --preprocess-model-id <id>');
1216
1571
  }
1217
- const inputSource = imageUrlFlag == null
1218
- ? productionAnalyzeSourceFromSource(await requireSource({
1219
- flags: ctx.flags,
1220
- client: ctx.client,
1221
- fileMediaType: 'image',
1222
- }))
1223
- : { imageUrl: imageUrlFlag };
1572
+ const inputSource = productionAnalyzeSourceFromSource(await requireSource({
1573
+ flags: ctx.flags,
1574
+ client: ctx.client,
1575
+ fileMediaType: 'image',
1576
+ }));
1224
1577
  const direct = await runPartSplit(inputSource);
1225
1578
  let preprocessed = null;
1226
1579
  if (preprocessPrompt != null && preprocessPrompt !== 'true') {
@@ -1228,7 +1581,7 @@ const commandParts = async (subcommand, ctx) => {
1228
1581
  const queued = await ctx.client.v2.generateImage({
1229
1582
  source: sourceFromProductionAnalyzeSource(inputSource),
1230
1583
  prompt: preprocessPrompt,
1231
- modelId: getFlag(ctx.flags, 'preprocess-model-id') ?? defaultImageModelId,
1584
+ modelId: preprocessModelId ?? defaultImageModelId,
1232
1585
  batchSize: 1,
1233
1586
  negativePrompt: getFlag(ctx.flags, 'preprocess-negative-prompt'),
1234
1587
  seed: getFlag(ctx.flags, 'preprocess-seed') == null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assethub/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Command line interface for AssetHub.",
5
5
  "type": "module",
6
6
  "bin": {