@assethub/cli 0.1.0 → 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 +28 -4
  2. package/dist/index.js +563 -28
  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 --agent-version ah_part_extractor_v65
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,12 +25,35 @@ 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
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
29
31
  ```
30
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
+
31
51
  Image generation defaults to the deployed `v1` endpoint. Pass `--api-version v2`
32
52
  only when the `v2` image endpoint is available in the target environment.
33
53
 
54
+ Part extraction uses the same public names shown in the AssetHub product:
55
+ `V1`, `V1.5 alpha`, `V1.5`, `V2.0 alpha`, and `V2.1 alpha`. Internal agent
56
+ IDs are intentionally not part of the public CLI interface.
57
+
34
58
  The CLI only uses AssetHub's public API surface and is designed so this package
35
59
  can be published independently from the private application monorepo.
package/dist/index.js CHANGED
@@ -1,13 +1,52 @@
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';
8
8
  const defaultImageModelId = 'imageGen.nanoBanana.pro.openrouter';
9
9
  const defaultMeshModelId = 'meshGen.hunyuan31';
10
10
  const defaultProfileName = 'default';
11
+ const defaultPartExtractorName = 'V1.5';
12
+ const partExtractorOptions = [
13
+ {
14
+ publicName: 'Part Extractor V3',
15
+ aliases: ['part extractor v3', 'v3'],
16
+ },
17
+ {
18
+ publicName: 'V1',
19
+ aliases: ['v1', '1'],
20
+ },
21
+ {
22
+ publicName: 'V1.5 alpha',
23
+ aliases: ['v1.5 alpha', 'v1.5-alpha', '1.5 alpha', '1.5-alpha'],
24
+ },
25
+ {
26
+ publicName: 'V1.5',
27
+ aliases: ['v1.5', '1.5', 'latest', 'default'],
28
+ },
29
+ {
30
+ publicName: 'V2.0 alpha',
31
+ aliases: ['v2.0 alpha', 'v2 alpha', 'v2.0-alpha', 'v2-alpha', '2.0 alpha'],
32
+ },
33
+ {
34
+ publicName: 'V2.1 alpha',
35
+ aliases: ['v2.1 alpha', 'v2.1-alpha', '2.1 alpha'],
36
+ },
37
+ ];
38
+ const partExtractorPublicNames = partExtractorOptions
39
+ .map(option => option.publicName)
40
+ .join(', ');
41
+ const normalizePartExtractorName = (value) => value.trim().toLowerCase().replace(/\s+/g, ' ');
42
+ const partExtractorPublicNameByInput = new Map(partExtractorOptions.flatMap(option => [
43
+ [normalizePartExtractorName(option.publicName), option.publicName],
44
+ ...option.aliases.map(alias => [
45
+ normalizePartExtractorName(alias),
46
+ option.publicName,
47
+ ]),
48
+ ]));
49
+ const isInternalPartExtractorId = (value) => value.trim().includes('_');
11
50
  const usage = `AssetHub CLI
12
51
 
13
52
  Usage:
@@ -19,15 +58,17 @@ Usage:
19
58
  assethub models get <model-id>
20
59
  assethub files import <url> --media-type image|mesh
21
60
  assethub files upload <file> --media-type image|mesh
22
- 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]
23
63
  assethub image generate --prompt <text> --source-resource-id <id> --api-version v2 [--model-id <id>] [--batch-size <n>] [--wait]
24
- assethub mesh generate (--file <path> | --source-url <url> | --source-resource-id <id> | --file-ref-json <json>) [--model-id <id>] [--name <name>] [--wait]
25
- assethub mesh retopo (--source-resource-id <id> | --source-url <url> | --file-ref-json <json>) --model-id <id> [--wait]
26
- assethub jobs get <job-id>
27
- assethub jobs watch <job-id> [--interval-ms <ms>] [--timeout-ms <ms>]
28
- assethub production analyze --image-url <url> --agent-version <id> [--name <name>]
29
- assethub production status <order-id>
30
- assethub production execute --order-id <id> --mission-id <id> --task-id <id> [--task-id <id>...] --agent-version <id> [--part-extraction-mode fast|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>]
31
72
 
32
73
  Global options:
33
74
  --api-key <key> Overrides saved auth and ASSETHUB_API_KEY.
@@ -41,8 +82,12 @@ Examples:
41
82
  assethub auth login --api-key-stdin
42
83
  assethub models list --domain meshGen --ids-only
43
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
44
87
  assethub image generate --prompt "stylized prop concept" --wait
45
88
  assethub mesh generate --file ./input.png --model-id meshGen.hunyuan31 --wait
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
46
91
  `;
47
92
  const print = (value) => {
48
93
  stdout.write(`${JSON.stringify(value, null, 2)}\n`);
@@ -112,6 +157,40 @@ const requireFlag = (flags, name) => {
112
157
  }
113
158
  return value;
114
159
  };
160
+ const getPartExtractorFlag = (flags) => getFlag(flags, 'part-extractor') ??
161
+ getFlag(flags, 'part-extractor-version') ??
162
+ getFlag(flags, 'agent-version');
163
+ const resolvePartExtractorPublicName = (value) => {
164
+ if (isInternalPartExtractorId(value)) {
165
+ throw new Error(`Internal part extractor IDs are not accepted by the public CLI. Use one of: ${partExtractorPublicNames}.`);
166
+ }
167
+ const publicName = partExtractorPublicNameByInput.get(normalizePartExtractorName(value));
168
+ if (publicName == null) {
169
+ throw new Error(`Unknown part extractor: ${value}. Use one of: ${partExtractorPublicNames}.`);
170
+ }
171
+ return publicName;
172
+ };
173
+ const resolvePartExtractorForAnalyze = (flags) => resolvePartExtractorPublicName(getPartExtractorFlag(flags) ?? defaultPartExtractorName);
174
+ const publicPartExtractorName = (value) => {
175
+ if (value == null) {
176
+ return null;
177
+ }
178
+ return (isInternalPartExtractorId(value) ? 'Unknown' : value);
179
+ };
180
+ const sanitizeProductionOutput = (value) => {
181
+ if (Array.isArray(value)) {
182
+ return value.map(item => sanitizeProductionOutput(item));
183
+ }
184
+ if (value == null || typeof value !== 'object') {
185
+ return value;
186
+ }
187
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
188
+ key,
189
+ key === 'agentVersion' && typeof entry === 'string'
190
+ ? publicPartExtractorName(entry)
191
+ : sanitizeProductionOutput(entry),
192
+ ]));
193
+ };
115
194
  const parsePositiveIntegerFlag = (flags, name, fallback) => {
116
195
  const raw = getFlag(flags, name);
117
196
  if (raw == null) {
@@ -142,6 +221,10 @@ const parseMediaType = (value) => {
142
221
  }
143
222
  throw new Error('--media-type must be "image" or "mesh"');
144
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');
145
228
  const getProfileName = (flags) => getFlag(flags, 'profile') ?? defaultProfileName;
146
229
  const getConfigPath = (flags) => getFlag(flags, 'config') ??
147
230
  env.ASSETHUB_CLI_CONFIG ??
@@ -183,6 +266,18 @@ const readStdin = async () => await new Promise((resolve, reject) => {
183
266
  stdin.on('end', () => resolve(value));
184
267
  stdin.on('error', reject);
185
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
+ });
186
281
  const resolveAuth = async (flags) => {
187
282
  const profile = getProfileName(flags);
188
283
  const baseUrlFromFlags = getFlag(flags, 'base-url');
@@ -241,8 +336,46 @@ const readBlob = async (filePath) => {
241
336
  const buffer = await readFile(filePath);
242
337
  return new Blob([new Uint8Array(buffer)]);
243
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
+ };
244
377
  const resolveSource = async ({ flags, client, fileMediaType, }) => {
245
- const sourceResourceId = getFlag(flags, 'source-resource-id');
378
+ const sourceResourceId = getSourceResourceIdFlag(flags);
246
379
  if (sourceResourceId != null) {
247
380
  return { resourceId: sourceResourceId };
248
381
  }
@@ -263,12 +396,32 @@ const resolveSource = async ({ flags, client, fileMediaType, }) => {
263
396
  });
264
397
  return { fileRef: fileRef.fileRef };
265
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
+ }
266
419
  return undefined;
267
420
  };
268
421
  const requireSource = async (options) => {
269
422
  const source = await resolveSource(options);
270
423
  if (source == null) {
271
- 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.');
272
425
  }
273
426
  return source;
274
427
  };
@@ -284,6 +437,44 @@ const sourceToV1ImageInput = (source) => {
284
437
  }
285
438
  throw new Error('v1 image generation does not accept source-resource-id. Use --source-url, --file, or --api-version v2.');
286
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
+ };
287
478
  const pollIfRequested = async ({ ctx, jobId, apiVersion = 'v2', }) => {
288
479
  if (!hasFlag(ctx.flags, 'wait')) {
289
480
  return undefined;
@@ -306,6 +497,60 @@ const requirePositional = (positionals, index, label) => {
306
497
  }
307
498
  return value;
308
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
+ };
309
554
  const commandModels = async (subcommand, positionals, ctx) => {
310
555
  if (subcommand === 'list') {
311
556
  const domain = getFlag(ctx.flags, 'domain');
@@ -451,7 +696,7 @@ const commandImage = async (subcommand, ctx) => {
451
696
  source,
452
697
  });
453
698
  const job = await pollIfRequested({ ctx, jobId: queued.jobId, apiVersion });
454
- print({ queued, job, apiVersion });
699
+ print(await withOptionalDownloads(ctx.flags, { queued, job, apiVersion }));
455
700
  };
456
701
  const commandMesh = async (subcommand, ctx) => {
457
702
  if (subcommand === 'generate') {
@@ -469,7 +714,7 @@ const commandMesh = async (subcommand, ctx) => {
469
714
  isLowPoly: parseBooleanFlag(ctx.flags, 'low-poly'),
470
715
  });
471
716
  const job = await pollIfRequested({ ctx, jobId: queued.jobId });
472
- print({ queued, job });
717
+ print(await withOptionalDownloads(ctx.flags, { queued, job }));
473
718
  return;
474
719
  }
475
720
  if (subcommand === 'retopo') {
@@ -485,7 +730,7 @@ const commandMesh = async (subcommand, ctx) => {
485
730
  polygonType: getFlag(ctx.flags, 'polygon-type'),
486
731
  });
487
732
  const job = await pollIfRequested({ ctx, jobId: queued.jobId });
488
- print({ queued, job });
733
+ print(await withOptionalDownloads(ctx.flags, { queued, job }));
489
734
  return;
490
735
  }
491
736
  throw new Error('Unknown mesh command. Use "mesh generate" or "mesh retopo".');
@@ -493,7 +738,8 @@ const commandMesh = async (subcommand, ctx) => {
493
738
  const commandJobs = async (subcommand, positionals, ctx) => {
494
739
  const jobId = requirePositional(positionals, 2, 'job-id');
495
740
  if (subcommand === 'get') {
496
- print({ job: await ctx.client.v2.getJob(jobId) });
741
+ const job = await ctx.client.v2.getJob(jobId);
742
+ print(await withOptionalDownloads(ctx.flags, { job }));
497
743
  return;
498
744
  }
499
745
  if (subcommand === 'watch') {
@@ -504,23 +750,213 @@ const commandJobs = async (subcommand, positionals, ctx) => {
504
750
  stderr.write(`[poll] job=${current.id} status=${current.status}\n`);
505
751
  },
506
752
  });
507
- print({ job });
753
+ print(await withOptionalDownloads(ctx.flags, { job }));
508
754
  return;
509
755
  }
510
756
  throw new Error('Unknown jobs command. Use "jobs get" or "jobs watch".');
511
757
  };
758
+ const resolvePartExtractorFromMission = async (ctx, orderId, missionId) => {
759
+ const status = await ctx.client.v1.getProductionStatus(orderId);
760
+ const mission = status.missions.find(item => item.id === missionId);
761
+ if (mission?.agentVersion == null || mission.agentVersion.trim() === '') {
762
+ throw new Error('Could not infer the part extractor from the mission. Pass --part-extractor with one of: ' +
763
+ partExtractorPublicNames);
764
+ }
765
+ if (isInternalPartExtractorId(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
+ }
805
+ }
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;
929
+ };
512
930
  const commandProduction = async (subcommand, positionals, ctx) => {
513
931
  if (subcommand === 'analyze') {
514
- print(await ctx.client.v1.analyzeProduction({
515
- imageUrl: requireFlag(ctx.flags, 'image-url'),
516
- agentVersion: requireFlag(ctx.flags, 'agent-version'),
932
+ const analyzed = await ctx.client.v1.analyzeProduction({
933
+ ...(await resolveProductionAnalyzeSource(ctx)),
934
+ agentVersion: resolvePartExtractorForAnalyze(ctx.flags),
517
935
  name: getFlag(ctx.flags, 'name'),
518
- }));
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 })));
519
945
  return;
520
946
  }
521
947
  if (subcommand === 'status') {
522
948
  const orderId = requirePositional(positionals, 2, 'order-id');
523
- print(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 })));
524
960
  return;
525
961
  }
526
962
  if (subcommand === 'execute') {
@@ -531,20 +967,113 @@ const commandProduction = async (subcommand, positionals, ctx) => {
531
967
  const partExtractionMode = getFlag(ctx.flags, 'part-extraction-mode');
532
968
  if (partExtractionMode != null &&
533
969
  partExtractionMode !== 'fast' &&
534
- partExtractionMode !== 'quality') {
535
- throw new Error('--part-extraction-mode must be "fast" or "quality"');
970
+ partExtractionMode !== 'high_quality') {
971
+ throw new Error('--part-extraction-mode must be "fast" or "high_quality"');
536
972
  }
537
- print(await ctx.client.v1.executeProduction({
538
- orderId: requireFlag(ctx.flags, 'order-id'),
539
- missionId: requireFlag(ctx.flags, 'mission-id'),
973
+ const orderId = requireFlag(ctx.flags, 'order-id');
974
+ const missionId = requireFlag(ctx.flags, 'mission-id');
975
+ const partExtractorFlag = getPartExtractorFlag(ctx.flags);
976
+ const agentVersion = partExtractorFlag == null
977
+ ? await resolvePartExtractorFromMission(ctx, orderId, missionId)
978
+ : resolvePartExtractorPublicName(partExtractorFlag);
979
+ const executed = await ctx.client.v1.executeProduction({
980
+ orderId,
981
+ missionId,
540
982
  confirmedTaskIds,
541
- agentVersion: requireFlag(ctx.flags, 'agent-version'),
983
+ agentVersion,
542
984
  partExtractionMode,
543
- }));
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 })));
544
994
  return;
545
995
  }
546
996
  throw new Error('Unknown production command. Use "production analyze", "production status", or "production execute".');
547
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
+ };
548
1077
  const run = async () => {
549
1078
  const parsed = parseArgs(argv.slice(2));
550
1079
  if (hasFlag(parsed.flags, 'help') || parsed.positionals.length === 0) {
@@ -564,6 +1093,9 @@ const run = async () => {
564
1093
  case 'files':
565
1094
  await commandFiles(subcommand, parsed.positionals, ctx);
566
1095
  return;
1096
+ case 'source':
1097
+ await commandSource(subcommand, ctx);
1098
+ return;
567
1099
  case 'image':
568
1100
  await commandImage(subcommand, ctx);
569
1101
  return;
@@ -576,6 +1108,9 @@ const run = async () => {
576
1108
  case 'production':
577
1109
  await commandProduction(subcommand, parsed.positionals, ctx);
578
1110
  return;
1111
+ case 'parts':
1112
+ await commandParts(subcommand, ctx);
1113
+ return;
579
1114
  default:
580
1115
  throw new Error(`Unknown command: ${command}`);
581
1116
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@assethub/cli",
3
- "version": "0.1.0",
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
+ }