@assethub/cli 0.1.1 → 0.1.2

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 +24 -5
  2. package/dist/index.js +479 -44
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -8,10 +8,11 @@ export ASSETHUB_API_KEY=ah_live_xxx
8
8
  assethub models list
9
9
  assethub models list --domain meshGen --ids-only
10
10
  assethub files import https://example.com/input.png --media-type image
11
+ assethub source create --file ./input.png --media-type image
11
12
  assethub image generate --prompt "stylized prop concept" --wait
12
13
  assethub mesh generate --file ./input.png --model-id meshGen.hunyuan31 --wait
13
- assethub jobs watch job_123
14
- assethub production analyze --image-url https://example.com/input.png --part-extractor "V1.5"
14
+ assethub jobs watch job_123 --download --out-dir ./out/job
15
+ assethub parts split --file ./input.png --part-extractor "V1.5" --wait --all-ready --download --out-dir ./out/parts
15
16
  ```
16
17
 
17
18
  Configuration:
@@ -24,11 +25,29 @@ Agent-friendly login:
24
25
 
25
26
  ```sh
26
27
  printf '%s' "$ASSETHUB_API_KEY" | assethub auth login --api-key-stdin
27
- assethub image generate --prompt "small stylized game asset chest" --wait --interval-ms 5000 --timeout-ms 900000
28
- assethub mesh generate --source-url https://example.com/input.png --model-id meshGen.hunyuan31 --wait --interval-ms 5000 --timeout-ms 900000
29
- assethub production analyze --image-url https://example.com/input.png --part-extractor "V1.5"
28
+ assethub image generate --prompt "small stylized game asset chest" --wait --download --out-dir ./out/image
29
+ assethub mesh generate --source-url https://example.com/input.png --model-id meshGen.hunyuan31 --wait --download --out-dir ./out/mesh
30
+ cat ./character.png | assethub parts split --stdin --file-name character.png --part-extractor "V1.5" --wait --all-ready --download --out-dir ./out/parts
30
31
  ```
31
32
 
33
+ Agent-oriented input:
34
+
35
+ - `--file <path>` uploads a local image or mesh through the public file API.
36
+ - `--stdin --file-name <name>` accepts piped bytes. Give a file name with an
37
+ extension when possible.
38
+ - `--data-uri <uri>` accepts `data:image/png;base64,...` style inputs and uploads
39
+ them before running the target command.
40
+ - `--source-url`, `--source-id`/`--source-resource-id`, and `--file-ref-json`
41
+ can be reused across image, mesh, and part extraction commands where the API
42
+ supports them.
43
+
44
+ Agent-oriented output:
45
+
46
+ - stdout is one final JSON object.
47
+ - polling progress goes to stderr.
48
+ - `--download --out-dir <dir>` recursively downloads public artifact URLs from
49
+ job or production status payloads and writes a `manifest.json`.
50
+
32
51
  Image generation defaults to the deployed `v1` endpoint. Pass `--api-version v2`
33
52
  only when the `v2` image endpoint is available in the target environment.
34
53
 
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { basename, dirname, join } from 'node:path';
4
+ import { basename, dirname, extname, join } from 'node:path';
5
5
  import { argv, env, exit, stdin, stderr, stdout } from 'node:process';
6
6
  import { AssetHubApiError, createAssetHubClient, } from '@assethub/api-client';
7
7
  const defaultBaseUrl = 'https://app.assethub.io';
@@ -11,32 +11,26 @@ const defaultProfileName = 'default';
11
11
  const defaultPartExtractorName = 'V1.5';
12
12
  const partExtractorOptions = [
13
13
  {
14
- internalId: 'ah_part_extractor_v3',
15
14
  publicName: 'Part Extractor V3',
16
15
  aliases: ['part extractor v3', 'v3'],
17
16
  },
18
17
  {
19
- internalId: 'ah_part_extractor_v5',
20
18
  publicName: 'V1',
21
19
  aliases: ['v1', '1'],
22
20
  },
23
21
  {
24
- internalId: 'ah_part_extractor_v55',
25
22
  publicName: 'V1.5 alpha',
26
23
  aliases: ['v1.5 alpha', 'v1.5-alpha', '1.5 alpha', '1.5-alpha'],
27
24
  },
28
25
  {
29
- internalId: 'ah_part_extractor_v65',
30
26
  publicName: 'V1.5',
31
27
  aliases: ['v1.5', '1.5', 'latest', 'default'],
32
28
  },
33
29
  {
34
- internalId: 'ah_part_extractor_v20_alpha',
35
30
  publicName: 'V2.0 alpha',
36
31
  aliases: ['v2.0 alpha', 'v2 alpha', 'v2.0-alpha', 'v2-alpha', '2.0 alpha'],
37
32
  },
38
33
  {
39
- internalId: 'ah_part_extractor_v21_alpha',
40
34
  publicName: 'V2.1 alpha',
41
35
  aliases: ['v2.1 alpha', 'v2.1-alpha', '2.1 alpha'],
42
36
  },
@@ -45,15 +39,14 @@ const partExtractorPublicNames = partExtractorOptions
45
39
  .map(option => option.publicName)
46
40
  .join(', ');
47
41
  const normalizePartExtractorName = (value) => value.trim().toLowerCase().replace(/\s+/g, ' ');
48
- const publicNameByInternalPartExtractorId = new Map(partExtractorOptions.map(option => [option.internalId, option.publicName]));
49
- const internalPartExtractorIdByPublicName = new Map(partExtractorOptions.flatMap(option => [
50
- [normalizePartExtractorName(option.publicName), option.internalId],
42
+ const partExtractorPublicNameByInput = new Map(partExtractorOptions.flatMap(option => [
43
+ [normalizePartExtractorName(option.publicName), option.publicName],
51
44
  ...option.aliases.map(alias => [
52
45
  normalizePartExtractorName(alias),
53
- option.internalId,
46
+ option.publicName,
54
47
  ]),
55
48
  ]));
56
- const isInternalPartExtractorId = (value) => value === 'nano_bbox_v1' || value.startsWith('ah_part_extractor_');
49
+ const isInternalPartExtractorId = (value) => value.trim().includes('_');
57
50
  const usage = `AssetHub CLI
58
51
 
59
52
  Usage:
@@ -65,15 +58,17 @@ Usage:
65
58
  assethub models get <model-id>
66
59
  assethub files import <url> --media-type image|mesh
67
60
  assethub files upload <file> --media-type image|mesh
68
- assethub image generate --prompt <text> [--file <path> | --source-url <url>] [--model-id <id>] [--batch-size <n>] [--wait] [--api-version v1|v2]
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]
69
63
  assethub image generate --prompt <text> --source-resource-id <id> --api-version v2 [--model-id <id>] [--batch-size <n>] [--wait]
70
- assethub mesh generate (--file <path> | --source-url <url> | --source-resource-id <id> | --file-ref-json <json>) [--model-id <id>] [--name <name>] [--wait]
71
- assethub mesh retopo (--source-resource-id <id> | --source-url <url> | --file-ref-json <json>) --model-id <id> [--wait]
72
- assethub jobs get <job-id>
73
- assethub jobs watch <job-id> [--interval-ms <ms>] [--timeout-ms <ms>]
74
- assethub production analyze --image-url <url> [--part-extractor <name>] [--name <name>]
75
- assethub production status <order-id>
76
- assethub production execute --order-id <id> --mission-id <id> --task-id <id> [--task-id <id>...] [--part-extractor <name>] [--part-extraction-mode fast|high_quality]
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>]
66
+ assethub jobs get <job-id> [--download --out-dir <dir>]
67
+ 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>]
69
+ assethub production status <order-id> [--wait] [--download --out-dir <dir>]
70
+ 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>]
77
72
 
78
73
  Global options:
79
74
  --api-key <key> Overrides saved auth and ASSETHUB_API_KEY.
@@ -87,9 +82,12 @@ Examples:
87
82
  assethub auth login --api-key-stdin
88
83
  assethub models list --domain meshGen --ids-only
89
84
  assethub files upload ./input.png --media-type image
85
+ assethub source create --file ./input.png --media-type image
86
+ cat ./input.png | assethub parts split --stdin --file-name input.png --part-extractor "V1.5" --wait
90
87
  assethub image generate --prompt "stylized prop concept" --wait
91
88
  assethub mesh generate --file ./input.png --model-id meshGen.hunyuan31 --wait
92
- assethub production analyze --image-url https://example.com/input.png --part-extractor "V1.5"
89
+ assethub production analyze --file ./input.png --part-extractor "V1.5" --wait
90
+ assethub parts split --file ./input.png --part-extractor "V1.5" --wait --all-ready
93
91
  `;
94
92
  const print = (value) => {
95
93
  stdout.write(`${JSON.stringify(value, null, 2)}\n`);
@@ -162,23 +160,22 @@ const requireFlag = (flags, name) => {
162
160
  const getPartExtractorFlag = (flags) => getFlag(flags, 'part-extractor') ??
163
161
  getFlag(flags, 'part-extractor-version') ??
164
162
  getFlag(flags, 'agent-version');
165
- const resolvePartExtractorInternalId = (value) => {
163
+ const resolvePartExtractorPublicName = (value) => {
166
164
  if (isInternalPartExtractorId(value)) {
167
165
  throw new Error(`Internal part extractor IDs are not accepted by the public CLI. Use one of: ${partExtractorPublicNames}.`);
168
166
  }
169
- const internalId = internalPartExtractorIdByPublicName.get(normalizePartExtractorName(value));
170
- if (internalId == null) {
167
+ const publicName = partExtractorPublicNameByInput.get(normalizePartExtractorName(value));
168
+ if (publicName == null) {
171
169
  throw new Error(`Unknown part extractor: ${value}. Use one of: ${partExtractorPublicNames}.`);
172
170
  }
173
- return internalId;
171
+ return publicName;
174
172
  };
175
- const resolvePartExtractorForAnalyze = (flags) => resolvePartExtractorInternalId(getPartExtractorFlag(flags) ?? defaultPartExtractorName);
173
+ const resolvePartExtractorForAnalyze = (flags) => resolvePartExtractorPublicName(getPartExtractorFlag(flags) ?? defaultPartExtractorName);
176
174
  const publicPartExtractorName = (value) => {
177
175
  if (value == null) {
178
176
  return null;
179
177
  }
180
- return (publicNameByInternalPartExtractorId.get(value) ??
181
- (isInternalPartExtractorId(value) ? 'Unknown' : value));
178
+ return (isInternalPartExtractorId(value) ? 'Unknown' : value);
182
179
  };
183
180
  const sanitizeProductionOutput = (value) => {
184
181
  if (Array.isArray(value)) {
@@ -224,6 +221,10 @@ const parseMediaType = (value) => {
224
221
  }
225
222
  throw new Error('--media-type must be "image" or "mesh"');
226
223
  };
224
+ const defaultFileNameForMediaType = (mediaType) => mediaType === 'image' ? 'stdin.png' : 'stdin.glb';
225
+ const getSourceResourceIdFlag = (flags) => getFlag(flags, 'source-resource-id') ??
226
+ getFlag(flags, 'source-id') ??
227
+ getFlag(flags, 'resource-id');
227
228
  const getProfileName = (flags) => getFlag(flags, 'profile') ?? defaultProfileName;
228
229
  const getConfigPath = (flags) => getFlag(flags, 'config') ??
229
230
  env.ASSETHUB_CLI_CONFIG ??
@@ -265,6 +266,18 @@ const readStdin = async () => await new Promise((resolve, reject) => {
265
266
  stdin.on('end', () => resolve(value));
266
267
  stdin.on('error', reject);
267
268
  });
269
+ const readStdinBlob = async (type) => await new Promise((resolve, reject) => {
270
+ const chunks = [];
271
+ stdin.on('data', chunk => {
272
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
273
+ });
274
+ stdin.on('end', () => {
275
+ const buffer = Buffer.concat(chunks);
276
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
277
+ resolve(new Blob([arrayBuffer], type == null ? undefined : { type }));
278
+ });
279
+ stdin.on('error', reject);
280
+ });
268
281
  const resolveAuth = async (flags) => {
269
282
  const profile = getProfileName(flags);
270
283
  const baseUrlFromFlags = getFlag(flags, 'base-url');
@@ -323,8 +336,46 @@ const readBlob = async (filePath) => {
323
336
  const buffer = await readFile(filePath);
324
337
  return new Blob([new Uint8Array(buffer)]);
325
338
  };
339
+ const blobFromDataUri = (value) => {
340
+ const match = value.match(/^data:([^;,]+)?(;base64)?,(.*)$/s);
341
+ if (match == null) {
342
+ throw new Error('--data-uri must be a valid data URI.');
343
+ }
344
+ const contentType = match[1] === '' ? undefined : match[1];
345
+ const isBase64 = match[2] != null;
346
+ const payload = match[3] ?? '';
347
+ const buffer = isBase64
348
+ ? Buffer.from(payload, 'base64')
349
+ : Buffer.from(decodeURIComponent(payload), 'utf8');
350
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
351
+ return {
352
+ blob: new Blob([arrayBuffer], contentType == null ? undefined : { type: contentType }),
353
+ contentType,
354
+ };
355
+ };
356
+ const extensionFromMimeType = (mimeType, mediaType) => {
357
+ if (mimeType === 'image/png')
358
+ return 'png';
359
+ if (mimeType === 'image/jpeg')
360
+ return 'jpg';
361
+ if (mimeType === 'image/webp')
362
+ return 'webp';
363
+ if (mimeType === 'image/gif')
364
+ return 'gif';
365
+ if (mimeType === 'model/gltf-binary')
366
+ return 'glb';
367
+ if (mimeType === 'model/gltf+json')
368
+ return 'gltf';
369
+ return mediaType === 'image' ? 'png' : 'glb';
370
+ };
371
+ const ensureFileNameExtension = (fileName, mediaType, mimeType) => {
372
+ if (extname(fileName) !== '') {
373
+ return fileName;
374
+ }
375
+ return `${fileName}.${extensionFromMimeType(mimeType, mediaType)}`;
376
+ };
326
377
  const resolveSource = async ({ flags, client, fileMediaType, }) => {
327
- const sourceResourceId = getFlag(flags, 'source-resource-id');
378
+ const sourceResourceId = getSourceResourceIdFlag(flags);
328
379
  if (sourceResourceId != null) {
329
380
  return { resourceId: sourceResourceId };
330
381
  }
@@ -345,12 +396,32 @@ const resolveSource = async ({ flags, client, fileMediaType, }) => {
345
396
  });
346
397
  return { fileRef: fileRef.fileRef };
347
398
  }
399
+ const dataUri = getFlag(flags, 'data-uri');
400
+ if (dataUri != null) {
401
+ const { blob, contentType } = blobFromDataUri(dataUri);
402
+ const fileName = ensureFileNameExtension(getFlag(flags, 'file-name') ?? 'data-uri', fileMediaType, contentType);
403
+ const fileRef = await client.v2.uploadFile({
404
+ file: blob,
405
+ fileName,
406
+ mediaType: fileMediaType,
407
+ });
408
+ return { fileRef: fileRef.fileRef };
409
+ }
410
+ if (hasFlag(flags, 'stdin')) {
411
+ const fileName = ensureFileNameExtension(getFlag(flags, 'file-name') ?? defaultFileNameForMediaType(fileMediaType), fileMediaType);
412
+ const fileRef = await client.v2.uploadFile({
413
+ file: await readStdinBlob(),
414
+ fileName,
415
+ mediaType: fileMediaType,
416
+ });
417
+ return { fileRef: fileRef.fileRef };
418
+ }
348
419
  return undefined;
349
420
  };
350
421
  const requireSource = async (options) => {
351
422
  const source = await resolveSource(options);
352
423
  if (source == null) {
353
- throw new Error('Missing source. Pass --file, --source-url, --source-resource-id, or --file-ref-json.');
424
+ throw new Error('Missing source. Pass --file, --source-url, --source-resource-id, --source-id, --file-ref-json, --stdin, or --data-uri.');
354
425
  }
355
426
  return source;
356
427
  };
@@ -366,6 +437,44 @@ const sourceToV1ImageInput = (source) => {
366
437
  }
367
438
  throw new Error('v1 image generation does not accept source-resource-id. Use --source-url, --file, or --api-version v2.');
368
439
  };
440
+ const resolveProductionAnalyzeSource = async (ctx) => {
441
+ const imageUrl = getFlag(ctx.flags, 'image-url');
442
+ if (imageUrl != null) {
443
+ return { imageUrl };
444
+ }
445
+ const source = await requireSource({
446
+ flags: ctx.flags,
447
+ client: ctx.client,
448
+ fileMediaType: 'image',
449
+ });
450
+ if (source.resourceId != null) {
451
+ return { imageAssetId: source.resourceId };
452
+ }
453
+ if (source.url != null) {
454
+ return { imageUrl: source.url };
455
+ }
456
+ if (source.fileRef == null) {
457
+ throw new Error('Missing source. Pass --file, --source-url, --source-id, --file-ref-json, --stdin, or --data-uri.');
458
+ }
459
+ return { imageBlobLocation: source.fileRef };
460
+ };
461
+ const productionAnalyzeTerminalStatuses = new Set(['ready', 'failed']);
462
+ const productionExecuteTerminalStatuses = new Set(['completed', 'failed']);
463
+ const pollProductionStatus = async ({ ctx, orderId, terminalStatuses, }) => {
464
+ const intervalMs = parsePositiveIntegerFlag(ctx.flags, 'interval-ms', 5000);
465
+ const timeoutMs = parsePositiveIntegerFlag(ctx.flags, 'timeout-ms', 900000);
466
+ const startedAt = Date.now();
467
+ while (Date.now() - startedAt <= timeoutMs) {
468
+ const status = await ctx.client.v1.getProductionStatus(orderId);
469
+ const orderStatus = status.order.status;
470
+ stderr.write(`[poll] order=${orderId} status=${orderStatus}\n`);
471
+ if (terminalStatuses.has(orderStatus)) {
472
+ return status;
473
+ }
474
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
475
+ }
476
+ throw new Error(`Polling timeout: order=${orderId} did not reach terminal state within ${timeoutMs}ms`);
477
+ };
369
478
  const pollIfRequested = async ({ ctx, jobId, apiVersion = 'v2', }) => {
370
479
  if (!hasFlag(ctx.flags, 'wait')) {
371
480
  return undefined;
@@ -388,6 +497,60 @@ const requirePositional = (positionals, index, label) => {
388
497
  }
389
498
  return value;
390
499
  };
500
+ const commandSource = async (subcommand, ctx) => {
501
+ if (subcommand !== 'create') {
502
+ throw new Error('Unknown source command. Use "source create".');
503
+ }
504
+ const mediaType = parseMediaType(requireFlag(ctx.flags, 'media-type'));
505
+ const sourceUrl = getFlag(ctx.flags, 'source-url') ?? getFlag(ctx.flags, 'image-url');
506
+ const sourceResourceId = getSourceResourceIdFlag(ctx.flags);
507
+ const filePath = getFlag(ctx.flags, 'file');
508
+ const useStdin = hasFlag(ctx.flags, 'stdin');
509
+ const dataUri = getFlag(ctx.flags, 'data-uri');
510
+ const provided = [
511
+ sourceUrl != null,
512
+ sourceResourceId != null,
513
+ filePath != null,
514
+ useStdin,
515
+ dataUri != null,
516
+ ].filter(Boolean);
517
+ if (provided.length !== 1) {
518
+ throw new Error('Pass exactly one source input: --file, --source-url, --source-id, --source-resource-id, --stdin, or --data-uri.');
519
+ }
520
+ if (sourceUrl != null) {
521
+ print({ source: { url: sourceUrl }, url: sourceUrl, mediaType });
522
+ return;
523
+ }
524
+ if (sourceResourceId != null) {
525
+ print({
526
+ source: { resourceId: sourceResourceId },
527
+ resourceId: sourceResourceId,
528
+ mediaType,
529
+ });
530
+ return;
531
+ }
532
+ const upload = filePath != null
533
+ ? await ctx.client.v2.uploadFile({
534
+ file: await readBlob(filePath),
535
+ fileName: basename(filePath),
536
+ mediaType,
537
+ })
538
+ : dataUri != null
539
+ ? await (async () => {
540
+ const { blob, contentType } = blobFromDataUri(dataUri);
541
+ return await ctx.client.v2.uploadFile({
542
+ file: blob,
543
+ fileName: ensureFileNameExtension(getFlag(ctx.flags, 'file-name') ?? 'data-uri', mediaType, contentType),
544
+ mediaType,
545
+ });
546
+ })()
547
+ : await ctx.client.v2.uploadFile({
548
+ file: await readStdinBlob(),
549
+ fileName: ensureFileNameExtension(getFlag(ctx.flags, 'file-name') ?? defaultFileNameForMediaType(mediaType), mediaType),
550
+ mediaType,
551
+ });
552
+ print({ source: { fileRef: upload.fileRef }, fileRef: upload.fileRef, mediaType });
553
+ };
391
554
  const commandModels = async (subcommand, positionals, ctx) => {
392
555
  if (subcommand === 'list') {
393
556
  const domain = getFlag(ctx.flags, 'domain');
@@ -533,7 +696,7 @@ const commandImage = async (subcommand, ctx) => {
533
696
  source,
534
697
  });
535
698
  const job = await pollIfRequested({ ctx, jobId: queued.jobId, apiVersion });
536
- print({ queued, job, apiVersion });
699
+ print(await withOptionalDownloads(ctx.flags, { queued, job, apiVersion }));
537
700
  };
538
701
  const commandMesh = async (subcommand, ctx) => {
539
702
  if (subcommand === 'generate') {
@@ -551,7 +714,7 @@ const commandMesh = async (subcommand, ctx) => {
551
714
  isLowPoly: parseBooleanFlag(ctx.flags, 'low-poly'),
552
715
  });
553
716
  const job = await pollIfRequested({ ctx, jobId: queued.jobId });
554
- print({ queued, job });
717
+ print(await withOptionalDownloads(ctx.flags, { queued, job }));
555
718
  return;
556
719
  }
557
720
  if (subcommand === 'retopo') {
@@ -567,7 +730,7 @@ const commandMesh = async (subcommand, ctx) => {
567
730
  polygonType: getFlag(ctx.flags, 'polygon-type'),
568
731
  });
569
732
  const job = await pollIfRequested({ ctx, jobId: queued.jobId });
570
- print({ queued, job });
733
+ print(await withOptionalDownloads(ctx.flags, { queued, job }));
571
734
  return;
572
735
  }
573
736
  throw new Error('Unknown mesh command. Use "mesh generate" or "mesh retopo".');
@@ -575,7 +738,8 @@ const commandMesh = async (subcommand, ctx) => {
575
738
  const commandJobs = async (subcommand, positionals, ctx) => {
576
739
  const jobId = requirePositional(positionals, 2, 'job-id');
577
740
  if (subcommand === 'get') {
578
- print({ job: await ctx.client.v2.getJob(jobId) });
741
+ const job = await ctx.client.v2.getJob(jobId);
742
+ print(await withOptionalDownloads(ctx.flags, { job }));
579
743
  return;
580
744
  }
581
745
  if (subcommand === 'watch') {
@@ -586,7 +750,7 @@ const commandJobs = async (subcommand, positionals, ctx) => {
586
750
  stderr.write(`[poll] job=${current.id} status=${current.status}\n`);
587
751
  },
588
752
  });
589
- print({ job });
753
+ print(await withOptionalDownloads(ctx.flags, { job }));
590
754
  return;
591
755
  }
592
756
  throw new Error('Unknown jobs command. Use "jobs get" or "jobs watch".');
@@ -599,22 +763,200 @@ const resolvePartExtractorFromMission = async (ctx, orderId, missionId) => {
599
763
  partExtractorPublicNames);
600
764
  }
601
765
  if (isInternalPartExtractorId(mission.agentVersion)) {
602
- return mission.agentVersion;
766
+ throw new Error('Could not infer the public part extractor from the mission. Pass --part-extractor with one of: ' +
767
+ partExtractorPublicNames);
768
+ }
769
+ return resolvePartExtractorPublicName(mission.agentVersion);
770
+ };
771
+ const redactUrl = (value) => {
772
+ try {
773
+ const url = new URL(value);
774
+ url.search = '';
775
+ url.hash = '';
776
+ return url.toString();
777
+ }
778
+ catch {
779
+ return value;
780
+ }
781
+ };
782
+ const collectHttpUrls = (value, urls = new Set()) => {
783
+ if (typeof value === 'string') {
784
+ try {
785
+ const url = new URL(value);
786
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
787
+ urls.add(value);
788
+ }
789
+ }
790
+ catch {
791
+ // Ignore non-URL strings.
792
+ }
793
+ return urls;
794
+ }
795
+ if (Array.isArray(value)) {
796
+ for (const item of value) {
797
+ collectHttpUrls(item, urls);
798
+ }
799
+ return urls;
800
+ }
801
+ if (value != null && typeof value === 'object') {
802
+ for (const item of Object.values(value)) {
803
+ collectHttpUrls(item, urls);
804
+ }
603
805
  }
604
- return resolvePartExtractorInternalId(mission.agentVersion);
806
+ return urls;
807
+ };
808
+ const extensionFromContentType = (contentType) => {
809
+ if (contentType == null) {
810
+ return '';
811
+ }
812
+ const clean = contentType.split(';')[0]?.trim().toLowerCase();
813
+ switch (clean) {
814
+ case 'image/png':
815
+ return '.png';
816
+ case 'image/jpeg':
817
+ return '.jpg';
818
+ case 'image/webp':
819
+ return '.webp';
820
+ case 'image/gif':
821
+ return '.gif';
822
+ case 'model/gltf-binary':
823
+ return '.glb';
824
+ case 'model/gltf+json':
825
+ return '.gltf';
826
+ case 'application/octet-stream':
827
+ default:
828
+ return '';
829
+ }
830
+ };
831
+ const safeDownloadFileName = (value, index, contentType) => {
832
+ let name = `asset-${index + 1}`;
833
+ try {
834
+ const url = new URL(value);
835
+ const fromPath = basename(url.pathname);
836
+ if (fromPath !== '' && fromPath !== '/' && fromPath !== '.') {
837
+ name = fromPath;
838
+ }
839
+ }
840
+ catch {
841
+ // Keep fallback name.
842
+ }
843
+ name = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^_+/, '');
844
+ if (name === '' || name === '.' || name === '..') {
845
+ name = `asset-${index + 1}`;
846
+ }
847
+ if (extname(name) === '') {
848
+ name = `${name}${extensionFromContentType(contentType)}`;
849
+ }
850
+ return `${String(index + 1).padStart(3, '0')}-${name}`;
851
+ };
852
+ const maybeDownloadUrls = async (flags, value) => {
853
+ if (!hasFlag(flags, 'download')) {
854
+ return undefined;
855
+ }
856
+ const outDir = getFlag(flags, 'out-dir') ?? getFlag(flags, 'out');
857
+ if (outDir == null || outDir === 'true') {
858
+ throw new Error('Missing download output directory. Pass --out-dir <dir> or --out <dir>.');
859
+ }
860
+ await mkdir(outDir, { recursive: true });
861
+ const urls = [...collectHttpUrls(value)];
862
+ const files = [];
863
+ for (const [index, url] of urls.entries()) {
864
+ const response = await fetch(url);
865
+ if (!response.ok) {
866
+ throw new Error(`Download failed: ${redactUrl(url)} returned HTTP ${response.status}`);
867
+ }
868
+ const contentType = response.headers.get('content-type');
869
+ const bytes = new Uint8Array(await response.arrayBuffer());
870
+ const fileName = safeDownloadFileName(url, index, contentType);
871
+ const filePath = join(outDir, fileName);
872
+ await writeFile(filePath, bytes);
873
+ files.push({
874
+ url: redactUrl(url),
875
+ path: filePath,
876
+ contentType,
877
+ bytes: bytes.byteLength,
878
+ });
879
+ }
880
+ const manifest = { generatedAt: new Date().toISOString(), files };
881
+ await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
882
+ return { outDir, files };
883
+ };
884
+ const withOptionalDownloads = async (flags, value) => {
885
+ const downloads = await maybeDownloadUrls(flags, value);
886
+ return downloads == null ? value : { ...value, downloads };
887
+ };
888
+ const readyTaskIdsFromStatus = (status) => {
889
+ const missions = status != null && typeof status === 'object' && 'missions' in status
890
+ ? status.missions
891
+ : undefined;
892
+ if (!Array.isArray(missions)) {
893
+ return [];
894
+ }
895
+ return missions.flatMap(mission => {
896
+ if (mission == null || typeof mission !== 'object' || !('tasks' in mission)) {
897
+ return [];
898
+ }
899
+ const tasks = mission.tasks;
900
+ if (!Array.isArray(tasks)) {
901
+ return [];
902
+ }
903
+ return tasks.flatMap(task => {
904
+ if (task == null || typeof task !== 'object') {
905
+ return [];
906
+ }
907
+ const id = task.id;
908
+ const taskStatus = task.status;
909
+ return typeof id === 'string' &&
910
+ typeof taskStatus === 'string' &&
911
+ ['ready', 'pending', 'pending_review'].includes(taskStatus)
912
+ ? [id]
913
+ : [];
914
+ });
915
+ });
916
+ };
917
+ const singleMissionIdFromStatus = (status) => {
918
+ const missions = status != null && typeof status === 'object' && 'missions' in status
919
+ ? status.missions
920
+ : undefined;
921
+ if (!Array.isArray(missions) || missions.length !== 1) {
922
+ throw new Error('Pass --mission-id when the production order does not have exactly one mission.');
923
+ }
924
+ const missionId = missions[0].id;
925
+ if (typeof missionId !== 'string' || missionId.trim() === '') {
926
+ throw new Error('Could not infer mission id. Pass --mission-id.');
927
+ }
928
+ return missionId;
605
929
  };
606
930
  const commandProduction = async (subcommand, positionals, ctx) => {
607
931
  if (subcommand === 'analyze') {
608
- print(sanitizeProductionOutput(await ctx.client.v1.analyzeProduction({
609
- imageUrl: requireFlag(ctx.flags, 'image-url'),
932
+ const analyzed = await ctx.client.v1.analyzeProduction({
933
+ ...(await resolveProductionAnalyzeSource(ctx)),
610
934
  agentVersion: resolvePartExtractorForAnalyze(ctx.flags),
611
935
  name: getFlag(ctx.flags, 'name'),
612
- })));
936
+ });
937
+ const status = hasFlag(ctx.flags, 'wait')
938
+ ? await pollProductionStatus({
939
+ ctx,
940
+ orderId: analyzed.orderId,
941
+ terminalStatuses: productionAnalyzeTerminalStatuses,
942
+ })
943
+ : undefined;
944
+ print(await withOptionalDownloads(ctx.flags, sanitizeProductionOutput({ analyzed, status })));
613
945
  return;
614
946
  }
615
947
  if (subcommand === 'status') {
616
948
  const orderId = requirePositional(positionals, 2, 'order-id');
617
- print(sanitizeProductionOutput(await ctx.client.v1.getProductionStatus(orderId)));
949
+ const status = hasFlag(ctx.flags, 'wait')
950
+ ? await pollProductionStatus({
951
+ ctx,
952
+ orderId,
953
+ terminalStatuses: new Set([
954
+ ...productionAnalyzeTerminalStatuses,
955
+ ...productionExecuteTerminalStatuses,
956
+ ]),
957
+ })
958
+ : await ctx.client.v1.getProductionStatus(orderId);
959
+ print(await withOptionalDownloads(ctx.flags, sanitizeProductionOutput({ status })));
618
960
  return;
619
961
  }
620
962
  if (subcommand === 'execute') {
@@ -633,18 +975,105 @@ const commandProduction = async (subcommand, positionals, ctx) => {
633
975
  const partExtractorFlag = getPartExtractorFlag(ctx.flags);
634
976
  const agentVersion = partExtractorFlag == null
635
977
  ? await resolvePartExtractorFromMission(ctx, orderId, missionId)
636
- : resolvePartExtractorInternalId(partExtractorFlag);
637
- print(sanitizeProductionOutput(await ctx.client.v1.executeProduction({
978
+ : resolvePartExtractorPublicName(partExtractorFlag);
979
+ const executed = await ctx.client.v1.executeProduction({
638
980
  orderId,
639
981
  missionId,
640
982
  confirmedTaskIds,
641
983
  agentVersion,
642
984
  partExtractionMode,
643
- })));
985
+ });
986
+ const status = hasFlag(ctx.flags, 'wait')
987
+ ? await pollProductionStatus({
988
+ ctx,
989
+ orderId,
990
+ terminalStatuses: productionExecuteTerminalStatuses,
991
+ })
992
+ : undefined;
993
+ print(await withOptionalDownloads(ctx.flags, sanitizeProductionOutput({ executed, status })));
644
994
  return;
645
995
  }
646
996
  throw new Error('Unknown production command. Use "production analyze", "production status", or "production execute".');
647
997
  };
998
+ const commandParts = async (subcommand, ctx) => {
999
+ if (subcommand !== 'split') {
1000
+ throw new Error('Unknown parts command. Use "parts split".');
1001
+ }
1002
+ let orderId = getFlag(ctx.flags, 'order-id');
1003
+ let analyzed;
1004
+ let status;
1005
+ if (orderId == null) {
1006
+ const result = await ctx.client.v1.analyzeProduction({
1007
+ ...(await resolveProductionAnalyzeSource(ctx)),
1008
+ agentVersion: resolvePartExtractorForAnalyze(ctx.flags),
1009
+ name: getFlag(ctx.flags, 'name'),
1010
+ });
1011
+ analyzed = result;
1012
+ orderId = result.orderId;
1013
+ if (hasFlag(ctx.flags, 'wait') || hasFlag(ctx.flags, 'all-ready')) {
1014
+ status = await pollProductionStatus({
1015
+ ctx,
1016
+ orderId,
1017
+ terminalStatuses: productionAnalyzeTerminalStatuses,
1018
+ });
1019
+ }
1020
+ }
1021
+ else if (hasFlag(ctx.flags, 'wait') || hasFlag(ctx.flags, 'all-ready')) {
1022
+ status = await pollProductionStatus({
1023
+ ctx,
1024
+ orderId,
1025
+ terminalStatuses: productionAnalyzeTerminalStatuses,
1026
+ });
1027
+ }
1028
+ let taskIds = getFlagValues(ctx.flags, 'task-id');
1029
+ if (hasFlag(ctx.flags, 'all-ready')) {
1030
+ if (status == null) {
1031
+ status = await ctx.client.v1.getProductionStatus(orderId);
1032
+ }
1033
+ taskIds = readyTaskIdsFromStatus(status);
1034
+ if (taskIds.length === 0) {
1035
+ throw new Error('No ready tasks were found for --all-ready.');
1036
+ }
1037
+ }
1038
+ let executed;
1039
+ let executionStatus;
1040
+ if (taskIds.length > 0) {
1041
+ if (status == null) {
1042
+ status = await ctx.client.v1.getProductionStatus(orderId);
1043
+ }
1044
+ const missionId = getFlag(ctx.flags, 'mission-id') ?? singleMissionIdFromStatus(status);
1045
+ const partExtractorFlag = getPartExtractorFlag(ctx.flags);
1046
+ const agentVersion = partExtractorFlag == null
1047
+ ? await resolvePartExtractorFromMission(ctx, orderId, missionId)
1048
+ : resolvePartExtractorPublicName(partExtractorFlag);
1049
+ const partExtractionMode = getFlag(ctx.flags, 'part-extraction-mode');
1050
+ if (partExtractionMode != null &&
1051
+ partExtractionMode !== 'fast' &&
1052
+ partExtractionMode !== 'high_quality') {
1053
+ throw new Error('--part-extraction-mode must be "fast" or "high_quality"');
1054
+ }
1055
+ executed = await ctx.client.v1.executeProduction({
1056
+ orderId,
1057
+ missionId,
1058
+ confirmedTaskIds: taskIds,
1059
+ agentVersion,
1060
+ partExtractionMode,
1061
+ });
1062
+ if (hasFlag(ctx.flags, 'wait')) {
1063
+ executionStatus = await pollProductionStatus({
1064
+ ctx,
1065
+ orderId,
1066
+ terminalStatuses: productionExecuteTerminalStatuses,
1067
+ });
1068
+ }
1069
+ }
1070
+ print(await withOptionalDownloads(ctx.flags, sanitizeProductionOutput({
1071
+ analyzed,
1072
+ status,
1073
+ executed,
1074
+ executionStatus,
1075
+ })));
1076
+ };
648
1077
  const run = async () => {
649
1078
  const parsed = parseArgs(argv.slice(2));
650
1079
  if (hasFlag(parsed.flags, 'help') || parsed.positionals.length === 0) {
@@ -664,6 +1093,9 @@ const run = async () => {
664
1093
  case 'files':
665
1094
  await commandFiles(subcommand, parsed.positionals, ctx);
666
1095
  return;
1096
+ case 'source':
1097
+ await commandSource(subcommand, ctx);
1098
+ return;
667
1099
  case 'image':
668
1100
  await commandImage(subcommand, ctx);
669
1101
  return;
@@ -676,6 +1108,9 @@ const run = async () => {
676
1108
  case 'production':
677
1109
  await commandProduction(subcommand, parsed.positionals, ctx);
678
1110
  return;
1111
+ case 'parts':
1112
+ await commandParts(subcommand, ctx);
1113
+ return;
679
1114
  default:
680
1115
  throw new Error(`Unknown command: ${command}`);
681
1116
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@assethub/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Command line interface for AssetHub.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "assethub": "./dist/index.js"
7
+ "assethub": "dist/index.js"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -20,19 +20,19 @@
20
20
  "README.md",
21
21
  "LICENSE"
22
22
  ],
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
25
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
26
+ },
23
27
  "license": "MIT",
24
28
  "publishConfig": {
25
29
  "access": "public"
26
30
  },
27
31
  "dependencies": {
28
- "@assethub/api-client": "^0.1.0"
32
+ "@assethub/api-client": "^0.1.1"
29
33
  },
30
34
  "devDependencies": {
31
35
  "@types/node": "24.10.1",
32
36
  "typescript": "5.9.3"
33
- },
34
- "scripts": {
35
- "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
36
- "typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
37
37
  }
38
- }
38
+ }