@assethub/cli 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { setTimeout as delay } from 'node:timers/promises';
3
3
  import { cliVersion, diagnose, mcpConfig } from './setup.js';
4
4
  import { ingestProjectSources } from './projectSources.js';
5
5
  import { downloadCanvas } from './canvasDownload.js';
6
+ import { executeNodeMeshBatch, resumeNodeMeshBatch, } from './nodeMeshBatch.js';
6
7
  import { writeCanvasComparison } from './canvasComparison.js';
7
8
  import { importCanvasAssetWithState } from './canvasAssetImport.js';
8
9
  import { execFile, spawn } from 'node:child_process';
@@ -16,7 +17,7 @@ import { fileURLToPath } from 'node:url';
16
17
  import { promisify } from 'node:util';
17
18
  import { AssetHubApiError, WorkspaceClientError, createWorkspaceClient, createAssetHubClient, } from '@assethub/api-client';
18
19
  import { resolveCanvasSelection, saveCanvasSelection } from './canvas.js';
19
- import { activeExecution, CliExecutionError, executeRecorded, hasRecordedOperation, openExecutionSession, resumeRecorded, waitForExecution, } from './execution.js';
20
+ import { activeExecution, childOperationId, CliExecutionError, executeRecorded, hasRecordedOperation, openExecutionSession, resumeRecorded, waitForExecution, } from './execution.js';
20
21
  import { buildRunUploadPlan, formatBytes } from './runsUpload/buildRunUpload.js';
21
22
  import { readGraphFolder } from './runsUpload/graphFolder.js';
22
23
  import { uploadRun } from './runsUpload/uploadRun.js';
@@ -91,7 +92,8 @@ Usage:
91
92
  assethub auth login --access-token-stdin [--base-url <url>] [--profile <name>]
92
93
  assethub auth status [--profile <name>]
93
94
  assethub auth logout [--profile <name>]
94
- assethub workspace list|get
95
+ assethub workspace list [--query <text>] [--limit <1-100>] [--cursor <cursor>]
96
+ assethub workspace get
95
97
  assethub workspace create --name <name> [--operation-id <uuid>]
96
98
  assethub workspace use <workspace-id>
97
99
  assethub workspace members [--workspace <id>]
@@ -105,6 +107,7 @@ Usage:
105
107
  assethub project ingest <source-dir> --out-dir <directory> [--exclude-dir <relative-path>...]
106
108
  assethub language text|vision --input-json @<json> --operation-id <uuid> [--out <json>]
107
109
  assethub canvas download --canvas <id> --out-dir <directory> [--mesh <mesh_asset_id>]
110
+ assethub canvas nodes --canvas <id>
108
111
  assethub canvas import --canvas <id> --file <image> [--name <name>]
109
112
  assethub canvas context get --canvas <id> [--out <json>]
110
113
  assethub canvas context put --canvas <id> --file <json> [--if-version <n>]
@@ -127,6 +130,7 @@ Usage:
127
130
  assethub composer run --part <mesh-asset-id> [--part <mesh-asset-id>...] --reference <image-asset-id> [--model <id>] [--mode quick|quality] [--transforms-json <json|@file>] [--canvas <id>] [--wait]
128
131
  assethub composer run --input-json <json|@file> [--canvas <id>] [--operation-id <uuid>] [--wait] [--download --out-dir <dir>]
129
132
  assethub composer run --from-run <run-id> [--transforms-json <json|@file>] [--mode quick|quality] [--wait]
133
+ assethub composer run --node <shape:id> --canvas <id> [--model <id>] [--mode quick|quality] [--operation-id <uuid>] [--wait]
130
134
  assethub canvas create [--name <name>] [--operation-id <uuid>]
131
135
  assethub canvas list [--cursor <cursor>] [--limit <n>]
132
136
  assethub canvas get|use|open [<id>]
@@ -142,6 +146,7 @@ Usage:
142
146
  assethub evaluations get <evaluation-id>
143
147
  assethub image generate --input-json <json|@file|@-> [--canvas <id>] [--operation-id <uuid>] [--agent <name>] [--derived-from-run <run-id>] [--from-node <graphId/nodeId> --graph-revision <rev>] [--wait]
144
148
  assethub mesh generate --input-json <json|@file|@-> [--canvas <id>] [--operation-id <uuid>] [--agent <name>] [--derived-from-run <run-id>] [--from-node <graphId/nodeId> --graph-revision <rev>] [--wait]
149
+ assethub mesh generate --node <shape:id> --canvas <id> [--model-id <id>] [--params-json <json|@file>] [--operation-id <uuid>] [--wait]
145
150
  assethub mesh list [--query <text>] [--cursor <cursor>] [--limit <n>]
146
151
  assethub mesh get <mesh-asset-id>
147
152
  assethub mesh download <mesh-asset-id> --out-dir <directory>
@@ -173,6 +178,7 @@ Global options:
173
178
  --api-key <key> Overrides saved auth and ASSETHUB_API_KEY.
174
179
  --base-url <url> Override the selected API origin. Default: ${defaultBaseUrl}.
175
180
  --profile <name> Select this saved profile ahead of environment credentials and origin.
181
+ --workspace <id> Select workspace scope for this command without changing saved selection.
176
182
  --config <path> Auth config path. Defaults to ~/.assethub/config.json.
177
183
  --version Show installed package version.
178
184
  --help Show help.
@@ -434,7 +440,7 @@ const resolveAuth = async (flags) => {
434
440
  : undefined;
435
441
  const profile = explicitProfile ?? config?.defaultProfile ?? defaultProfileName;
436
442
  const storedProfile = config?.profiles?.[profile];
437
- const selected = Boolean(explicitProfile || storedProfile?.workspaceId);
443
+ const selected = Boolean(explicitProfile || storedProfile?.workspaceId || storedProfile?.apiKey?.startsWith('ah_pat_'));
438
444
  if (selected && (!storedProfile || (!storedProfile.apiKey && !overrideKey)))
439
445
  throw new Error('Selected profile has no workspace API key. Use workspace use <id> or auth login --api-key-stdin --profile <name>.');
440
446
  const baseUrl = getFlag(flags, 'base-url') ??
@@ -442,17 +448,17 @@ const resolveAuth = async (flags) => {
442
448
  ? storedProfile.baseUrl
443
449
  : env.ASSETHUB_API_BASE_URL ?? storedProfile?.baseUrl ?? defaultBaseUrl);
444
450
  if (overrideKey)
445
- return { apiKey: overrideKey, baseUrl, profile, source: 'flag' };
451
+ return { apiKey: overrideKey, baseUrl, profile, source: 'flag', workspaceId: getFlag(flags, 'workspace') };
446
452
  if (selected && storedProfile?.apiKey) {
447
- if (storedProfile.workspaceId &&
453
+ if ((storedProfile.workspaceId || storedProfile.apiKey.startsWith('ah_pat_')) &&
448
454
  new URL(baseUrl).origin !== new URL(storedProfile.baseUrl).origin)
449
455
  throw new Error('Selected workspace belongs to another API origin; select a workspace for this origin or supply an explicit API key');
450
- return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile' };
456
+ return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile', workspaceId: getFlag(flags, 'workspace') ?? storedProfile.workspaceId };
451
457
  }
452
458
  if (env.ASSETHUB_API_KEY?.trim())
453
- return { apiKey: env.ASSETHUB_API_KEY, baseUrl, profile, source: 'env' };
459
+ return { apiKey: env.ASSETHUB_API_KEY, baseUrl, profile, source: 'env', workspaceId: getFlag(flags, 'workspace') };
454
460
  if (storedProfile?.apiKey)
455
- return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile' };
461
+ return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile', workspaceId: getFlag(flags, 'workspace') ?? storedProfile.workspaceId };
456
462
  throw new Error('Missing workspace API key. Use "workspace use <id>" after user login, "auth login --api-key-stdin", or ASSETHUB_API_KEY.');
457
463
  };
458
464
  const createContext = async (flags, args) => {
@@ -464,6 +470,7 @@ const createContext = async (flags, args) => {
464
470
  client: createAssetHubClient({
465
471
  apiKey: auth.apiKey,
466
472
  baseUrl: auth.baseUrl,
473
+ workspaceId: auth.workspaceId,
467
474
  }),
468
475
  };
469
476
  };
@@ -1243,12 +1250,25 @@ const commandModels = async (subcommand, positionals, ctx) => {
1243
1250
  }
1244
1251
  throw new Error('Unknown models command. Use "models list" or "models get".');
1245
1252
  };
1246
- const workspaceAuth = async (flags) => {
1253
+ const workspaceAuth = async (flags, allowPersonal = false) => {
1247
1254
  const configPath = getConfigPath(flags);
1248
1255
  const config = await readAuthConfig(configPath);
1249
1256
  const explicitProfile = hasFlag(flags, 'profile') ? requireFlag(flags, 'profile') : undefined;
1250
1257
  const profile = explicitProfile ?? config.defaultProfile ?? defaultProfileName;
1251
1258
  const stored = config.profiles?.[profile];
1259
+ const preferredKey = getFlag(flags, 'api-key') ??
1260
+ (explicitProfile || stored?.workspaceId || stored?.apiKey?.startsWith('ah_pat_')
1261
+ ? stored?.apiKey
1262
+ : env.ASSETHUB_API_KEY?.trim() || stored?.apiKey);
1263
+ if (allowPersonal && preferredKey?.startsWith('ah_pat_')) {
1264
+ const auth = await resolveAuth(flags);
1265
+ return {
1266
+ configPath, config, profile, accessToken: auth.apiKey,
1267
+ workspaceMfaToken: undefined, baseUrl: auth.baseUrl,
1268
+ selectedWorkspaceId: auth.workspaceId, personal: true,
1269
+ client: createWorkspaceClient({ accessToken: auth.apiKey, baseUrl: auth.baseUrl, workspaceId: auth.workspaceId }),
1270
+ };
1271
+ }
1252
1272
  const environmentToken = explicitProfile ? undefined : env.ASSETHUB_ACCESS_TOKEN?.trim();
1253
1273
  const accessToken = environmentToken || stored?.accessToken;
1254
1274
  if (!accessToken)
@@ -1272,6 +1292,7 @@ const workspaceAuth = async (flags) => {
1272
1292
  workspaceMfaToken,
1273
1293
  baseUrl,
1274
1294
  selectedWorkspaceId: environmentToken ? undefined : stored?.workspaceId,
1295
+ personal: false,
1275
1296
  client: createWorkspaceClient({ accessToken, workspaceMfaToken, baseUrl }),
1276
1297
  };
1277
1298
  };
@@ -1281,7 +1302,7 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1281
1302
  : hasFlag(flags, 'profile')
1282
1303
  ? undefined
1283
1304
  : env.ASSETHUB_API_KEY?.trim();
1284
- const auth = await workspaceAuth(flags);
1305
+ const auth = await workspaceAuth(flags, ['list', 'get', 'use'].includes(subcommand ?? ''));
1285
1306
  if (['members', 'invite', 'set-role', 'remove-member'].includes(subcommand ?? '')) {
1286
1307
  let workspaceId = getFlag(flags, 'workspace') ?? auth.selectedWorkspaceId;
1287
1308
  if (!workspaceId)
@@ -1305,10 +1326,21 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1305
1326
  return;
1306
1327
  }
1307
1328
  if (subcommand === 'list') {
1308
- print(await auth.client.list());
1329
+ print(await auth.client.list({
1330
+ query: getFlag(flags, 'query'),
1331
+ cursor: getFlag(flags, 'cursor'),
1332
+ limit: hasFlag(flags, 'limit') ? parsePositiveIntegerFlag(flags, 'limit', 100) : undefined,
1333
+ }));
1309
1334
  return;
1310
1335
  }
1311
1336
  if (subcommand === 'get') {
1337
+ if (auth.personal && auth.selectedWorkspaceId) {
1338
+ const selected = await auth.client.select(auth.selectedWorkspaceId);
1339
+ if (!selected.workspace)
1340
+ throw new Error('Workspace API returned no selected workspace');
1341
+ print(selected.workspace);
1342
+ return;
1343
+ }
1312
1344
  const account = await auth.client.list();
1313
1345
  const selected = auth.selectedWorkspaceId;
1314
1346
  const workspace = account.workspaces.find(item => selected ? item.id === selected : item.active);
@@ -1333,18 +1365,34 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1333
1365
  throw new Error('Use workspace list|get|create|use');
1334
1366
  const workspaceId = requirePositional(positionals, 2, 'workspace-id');
1335
1367
  const selected = await auth.client.select(workspaceId);
1368
+ if (selected.workspaceId !== workspaceId)
1369
+ throw new Error('Workspace API returned an invalid selection scope');
1336
1370
  if (selected.mfa.status === 'setup_required' ||
1337
1371
  selected.mfa.status === 'verify_required')
1338
1372
  throw new Error('Workspace requires MFA. Complete verification in AssetHub, then provide the signed ah_workspace_mfa cookie through ASSETHUB_WORKSPACE_MFA together with your user access token');
1339
1373
  const account = await auth.client.list();
1340
- const workspace = account.workspaces.find(item => item.id === workspaceId);
1374
+ const workspace = selected.workspace ?? account.workspaces.find(item => item.id === workspaceId);
1341
1375
  if (!workspace)
1342
1376
  throw new Error('Workspace is not available to this authenticated user');
1343
1377
  const profiles = auth.config.profiles ?? {};
1378
+ if (auth.personal) {
1379
+ if (account.authentication !== 'personal')
1380
+ throw new Error('Workspace API did not verify personal authentication');
1381
+ profiles[auth.profile] = {
1382
+ apiKey: auth.accessToken,
1383
+ userId: account.userId,
1384
+ workspaceId,
1385
+ baseUrl: auth.baseUrl,
1386
+ updatedAt: new Date().toISOString(),
1387
+ };
1388
+ await writeAuthConfig(auth.configPath, { ...auth.config, defaultProfile: auth.profile, profiles });
1389
+ print({ workspaceId, workspace, profile: auth.profile, selected: true, baseUrl: auth.baseUrl });
1390
+ return;
1391
+ }
1344
1392
  const cached = Object.entries(profiles).find(([, entry]) => entry.userId === account.userId &&
1345
1393
  entry.workspaceId === workspaceId &&
1346
1394
  new URL(entry.baseUrl).origin === new URL(auth.baseUrl).origin &&
1347
- entry.apiKey);
1395
+ entry.apiKey && !entry.apiKey.startsWith('ah_pat_'));
1348
1396
  let apiKey;
1349
1397
  for (const candidate of new Set([cached?.[1].apiKey, suppliedKey])) {
1350
1398
  if (!candidate)
@@ -1447,7 +1495,13 @@ const commandAuth = async (subcommand, flags) => {
1447
1495
  throw new Error('API key is empty. Pass --api-key, use --api-key-stdin, or set ASSETHUB_API_KEY.');
1448
1496
  }
1449
1497
  const baseUrl = getFlag(flags, 'base-url') ?? env.ASSETHUB_API_BASE_URL ?? defaultBaseUrl;
1450
- if (!hasFlag(flags, 'skip-verify')) {
1498
+ const personal = apiKey.startsWith('ah_pat_');
1499
+ const account = personal
1500
+ ? await createWorkspaceClient({ accessToken: apiKey, baseUrl }).list()
1501
+ : undefined;
1502
+ if (personal && account?.authentication !== 'personal')
1503
+ throw new Error('Workspace API did not verify personal authentication');
1504
+ if (!personal && !hasFlag(flags, 'skip-verify')) {
1451
1505
  const client = createAssetHubClient({ apiKey, baseUrl });
1452
1506
  await client.v2.listModels();
1453
1507
  }
@@ -1456,6 +1510,7 @@ const commandAuth = async (subcommand, flags) => {
1456
1510
  profiles[profile] = {
1457
1511
  apiKey,
1458
1512
  baseUrl,
1513
+ ...(account ? { userId: account.userId } : {}),
1459
1514
  updatedAt: new Date().toISOString(),
1460
1515
  };
1461
1516
  await writeAuthConfig(configPath, {
@@ -1468,15 +1523,19 @@ const commandAuth = async (subcommand, flags) => {
1468
1523
  profile,
1469
1524
  baseUrl,
1470
1525
  configPath,
1471
- verified: !hasFlag(flags, 'skip-verify'),
1526
+ verified: personal || !hasFlag(flags, 'skip-verify'),
1527
+ ...(account ? { authentication: 'personal', userId: account.userId } : {}),
1472
1528
  });
1473
1529
  return;
1474
1530
  }
1475
1531
  if (subcommand === 'status') {
1476
1532
  const config = await readAuthConfig(configPath);
1477
1533
  const current = config.profiles?.[getFlag(flags, 'profile') ?? config.defaultProfile ?? profile];
1478
- if ((!hasFlag(flags, 'profile') && env.ASSETHUB_ACCESS_TOKEN?.trim()) ||
1479
- (current?.accessToken && !current.apiKey)) {
1534
+ if (!hasFlag(flags, 'api-key') &&
1535
+ !current?.apiKey?.startsWith('ah_pat_') &&
1536
+ (hasFlag(flags, 'profile') || !env.ASSETHUB_API_KEY?.startsWith('ah_pat_')) &&
1537
+ ((!hasFlag(flags, 'profile') && env.ASSETHUB_ACCESS_TOKEN?.trim()) ||
1538
+ (current?.accessToken && !current.apiKey))) {
1480
1539
  const auth = await workspaceAuth(flags);
1481
1540
  const account = await auth.client.list();
1482
1541
  print({
@@ -1493,7 +1552,18 @@ const commandAuth = async (subcommand, flags) => {
1493
1552
  const client = createAssetHubClient({
1494
1553
  apiKey: auth.apiKey,
1495
1554
  baseUrl: auth.baseUrl,
1555
+ workspaceId: auth.workspaceId,
1496
1556
  });
1557
+ if (auth.apiKey.startsWith('ah_pat_')) {
1558
+ const account = await createWorkspaceClient({ accessToken: auth.apiKey, baseUrl: auth.baseUrl, workspaceId: auth.workspaceId }).list();
1559
+ if (account.authentication !== 'personal')
1560
+ throw new Error('Workspace API did not verify personal authentication');
1561
+ if (auth.workspaceId)
1562
+ await client.v2.getCapabilities();
1563
+ print({ authenticated: true, authentication: 'personal', userId: account.userId, workspaceId: auth.workspaceId,
1564
+ profile: auth.profile, source: auth.source, baseUrl: auth.baseUrl });
1565
+ return;
1566
+ }
1497
1567
  const models = await client.v2.listModels();
1498
1568
  print({
1499
1569
  authenticated: true,
@@ -1639,6 +1709,134 @@ const printRecorded = async (ctx, queued) => {
1639
1709
  });
1640
1710
  process.exitCode = executionExitCode(execution);
1641
1711
  };
1712
+ const canvasNodeFlag = (flags) => {
1713
+ if (!hasFlag(flags, 'node'))
1714
+ return undefined;
1715
+ const nodeId = requireFlag(flags, 'node');
1716
+ if (!nodeId.startsWith('shape:') || nodeId.length <= 6)
1717
+ throw new Error('--node must be a native shape:<id> from canvas nodes');
1718
+ if (sourceInputCount(flags) ||
1719
+ [
1720
+ 'input-json',
1721
+ 'upload-id',
1722
+ 'from-node',
1723
+ 'graph-revision',
1724
+ 'from-run',
1725
+ 'part',
1726
+ 'reference',
1727
+ 'transforms-json',
1728
+ ].some(name => hasFlag(flags, name)))
1729
+ throw new Error('--node cannot be combined with explicit sources, parts, reference, input JSON, or another node/run selector');
1730
+ return nodeId;
1731
+ };
1732
+ const printNodeMeshBatch = async (ctx, batch) => {
1733
+ const executions = [...batch.executions];
1734
+ if (hasFlag(ctx.flags, 'wait')) {
1735
+ try {
1736
+ for (let i = 0; i < executions.length; i++)
1737
+ executions[i] = await waitForExecution(ctx.client, executions[i].runId, watchOptions(ctx.flags));
1738
+ }
1739
+ catch (error) {
1740
+ if (!(error instanceof CliExecutionError))
1741
+ throw error;
1742
+ throw new CliExecutionError(error.message, error.exitCode, batch.operationId, error.execution, error.runId);
1743
+ }
1744
+ }
1745
+ const downloads = await maybeDownloadUrls(ctx.flags, executions.flatMap(execution => execution.outputs));
1746
+ print({ ...batch, executions, ...(downloads ? { downloads } : {}) });
1747
+ process.exitCode = Math.max(0, ...executions.map(executionExitCode));
1748
+ };
1749
+ const commandNodeMesh = async (ctx, nodeId) => {
1750
+ const body = {};
1751
+ const modelId = getFlag(ctx.flags, 'model-id');
1752
+ if (modelId)
1753
+ body.modelId = modelId;
1754
+ const name = getFlag(ctx.flags, 'name');
1755
+ if (name)
1756
+ body.name = name;
1757
+ if (hasFlag(ctx.flags, 'face-limit'))
1758
+ body.faceLimit = parsePositiveIntegerFlag(ctx.flags, 'face-limit', 1);
1759
+ if (hasFlag(ctx.flags, 'low-poly'))
1760
+ body.isLowPoly = parseBooleanFlag(ctx.flags, 'low-poly');
1761
+ if (hasFlag(ctx.flags, 'params-json')) {
1762
+ const params = await readJsonArgument(requireFlag(ctx.flags, 'params-json'), '--params-json');
1763
+ if (Object.values(params).some(value => typeof value !== 'string' &&
1764
+ typeof value !== 'boolean' &&
1765
+ !(typeof value === 'number' && Number.isFinite(value))))
1766
+ throw new Error('--params-json values must be strings, booleans, or finite numbers');
1767
+ body.params = params;
1768
+ }
1769
+ const operationId = getFlag(ctx.flags, 'operation-id');
1770
+ const canvasId = selectedCanvasId(ctx.flags);
1771
+ const resumeOptions = {
1772
+ ...stateOptions(ctx),
1773
+ expectedCanvasId: canvasId,
1774
+ expectedCanvasNodeId: nodeId,
1775
+ expectedBody: body,
1776
+ };
1777
+ if (operationId) {
1778
+ const batch = await resumeNodeMeshBatch({ ...resumeOptions, operationId });
1779
+ if (batch) {
1780
+ await printNodeMeshBatch(ctx, batch);
1781
+ return;
1782
+ }
1783
+ if (await hasRecordedOperation(resumeOptions.stateDir, operationId)) {
1784
+ await printRecorded(ctx, await resumeRecorded({
1785
+ ...resumeOptions,
1786
+ operationId,
1787
+ expectedOperation: 'mesh.generate',
1788
+ }));
1789
+ return;
1790
+ }
1791
+ }
1792
+ const session = await openExecutionSession({
1793
+ ...stateOptions(ctx),
1794
+ canvasId,
1795
+ operation: 'mesh.generate',
1796
+ create: false,
1797
+ });
1798
+ const { items } = await ctx.client.v2.listCanvasNodes(session.canvas.id);
1799
+ const target = items.find(item => item.nodeId === nodeId);
1800
+ if (!target)
1801
+ throw new Error('Canvas node is unavailable; read canvas nodes again');
1802
+ const finishedOrBusy = (status) => ['complete', 'completed', 'generating'].includes(status ?? '');
1803
+ if (target.type === 'production-3d') {
1804
+ if (!target.actions.includes('mesh.generate'))
1805
+ throw new Error('Mesh generation is unavailable for this node');
1806
+ const children = items.filter(item => item.sourceNodeId === nodeId &&
1807
+ ['mesh-gen', 'preview-asset', 'part-group'].includes(item.type));
1808
+ if (!children.length)
1809
+ throw new Error('Production node has no saved part nodes to generate');
1810
+ const pending = children.filter(item => item.actions.includes('mesh.generate') &&
1811
+ !finishedOrBusy(item.meshStatus));
1812
+ const batchId = operationId ?? randomUUID();
1813
+ stderr.write(`[nodes] batch ${batchId}\n`);
1814
+ await printNodeMeshBatch(ctx, await executeNodeMeshBatch(session, {
1815
+ ...executionOptions(ctx.flags),
1816
+ operationId: batchId,
1817
+ nodeId,
1818
+ body,
1819
+ children: pending.map(item => item.nodeId),
1820
+ skipped: children
1821
+ .filter(item => !pending.includes(item))
1822
+ .map(({ nodeId, meshStatus }) => ({ nodeId, meshStatus })),
1823
+ }));
1824
+ return;
1825
+ }
1826
+ if (!['mesh-gen', 'preview-asset', 'part-group'].includes(target.type))
1827
+ throw new Error('This canvas node cannot generate a mesh');
1828
+ if (target.meshStatus === 'generating' ||
1829
+ (target.type !== 'mesh-gen' && finishedOrBusy(target.meshStatus))) {
1830
+ print({ nodeId, skipped: true, meshStatus: target.meshStatus });
1831
+ return;
1832
+ }
1833
+ if (!target.actions.includes('mesh.generate'))
1834
+ throw new Error('Mesh generation is unavailable for this node');
1835
+ await printRecorded(ctx, await executeRecorded(session, 'mesh.generate', body, {
1836
+ ...executionOptions(ctx.flags),
1837
+ canvasNode: { nodeId },
1838
+ }));
1839
+ };
1642
1840
  const commandImage = async (subcommand, ctx) => {
1643
1841
  if (subcommand !== 'generate')
1644
1842
  throw new Error('Unknown image command. Use "image generate".');
@@ -1746,6 +1944,54 @@ const refinementModeInfo = (capabilities, mode) => {
1746
1944
  throw new Error(`Refinement mode ${mode} is unavailable${info.reason ? `: ${info.reason}` : ''}`);
1747
1945
  return info;
1748
1946
  };
1947
+ /** Reuse the frozen sources and latest editable scene, never replay server-only receipt fields. */
1948
+ const composerInputFromRun = (previous, operation) => {
1949
+ const source = previous.resolvedInput ?? previous.input ?? previous.requestedInput ?? {};
1950
+ const fields = operation === 'mesh.compose'
1951
+ ? [
1952
+ 'fullBodyImageAssetId',
1953
+ 'agentVersion',
1954
+ 'agentRuntime',
1955
+ 'mode',
1956
+ 'targetCharacterHeightM',
1957
+ 'projectName',
1958
+ 'decimateRatio',
1959
+ 'quickRunId',
1960
+ 'quickScene',
1961
+ ]
1962
+ : ['fullBodyImageAssetId', 'agentRuntime', 'mode', 'instruction', 'maxRounds'];
1963
+ const input = Object.fromEntries(fields
1964
+ .filter(key => source[key] !== undefined)
1965
+ .map(key => [key, source[key]]));
1966
+ const parts = previous.composition?.parts ?? source.parts;
1967
+ const partFields = operation === 'mesh.compose'
1968
+ ? ['assetId', 'name', 'canonicalKey', 'partImageAssetId', 'partTaskId']
1969
+ : [
1970
+ 'assetId',
1971
+ 'name',
1972
+ 'canonicalKey',
1973
+ 'volumeCentroid',
1974
+ 'sourceAssetId',
1975
+ 'transform',
1976
+ ];
1977
+ input.parts = Array.isArray(parts)
1978
+ ? parts.map(part => part && typeof part === 'object'
1979
+ ? Object.fromEntries(Object.entries(part).filter(([key]) => partFields.includes(key)))
1980
+ : part)
1981
+ : parts;
1982
+ const transforms = previous.composition?.transforms ?? source.transforms;
1983
+ if (transforms != null)
1984
+ input.transforms = transforms;
1985
+ if (operation === 'mesh.refine') {
1986
+ const referenceTransform = previous.composition?.referenceTransform ?? source.referenceTransform;
1987
+ if (referenceTransform != null)
1988
+ input.referenceTransform = referenceTransform;
1989
+ // Compose modes are not refinement modes; preserve a prior refinement's mode only.
1990
+ if (previous.operation !== 'mesh.refine')
1991
+ delete input.mode;
1992
+ }
1993
+ return input;
1994
+ };
1749
1995
  const commandComposerRefine = async (ctx) => {
1750
1996
  const capabilities = await ctx.client.v2.getMeshRefinementModes();
1751
1997
  if (hasFlag(ctx.flags, 'list-modes')) {
@@ -1769,20 +2015,7 @@ const commandComposerRefine = async (ctx) => {
1769
2015
  const input = json
1770
2016
  ? await readJsonArgument(json, '--input-json')
1771
2017
  : previous
1772
- ? {
1773
- ...(previous.requestedInput ?? previous.input),
1774
- ...(previous.operation === 'mesh.refine' &&
1775
- (previous.requestedInput?.mode ?? previous.input.mode)
1776
- ? { mode: previous.requestedInput?.mode ?? previous.input.mode }
1777
- : { mode: undefined }),
1778
- parts: previous.composition?.parts ??
1779
- previous.requestedInput?.parts ??
1780
- previous.input.parts,
1781
- transforms: previous.composition?.transforms,
1782
- referenceTransform: previous.composition?.referenceTransform ??
1783
- previous.requestedInput?.referenceTransform ??
1784
- previous.input.referenceTransform,
1785
- }
2018
+ ? composerInputFromRun(previous, 'mesh.refine')
1786
2019
  : {
1787
2020
  parts: parts.map(assetId => ({
1788
2021
  assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
@@ -1850,6 +2083,9 @@ const commandComposerRefine = async (ctx) => {
1850
2083
  mode,
1851
2084
  instruction,
1852
2085
  ...(maxRounds != null ? { maxRounds } : {}),
2086
+ ...(input.agentRuntime
2087
+ ? { agentRuntime: input.agentRuntime }
2088
+ : {}),
1853
2089
  };
1854
2090
  await printRecorded(ctx, await executeRecorded(session, 'mesh.refine', body, {
1855
2091
  ...executionOptions(ctx.flags),
@@ -1867,6 +2103,7 @@ const commandComposer = async (subcommand, ctx) => {
1867
2103
  }
1868
2104
  if (subcommand !== 'run')
1869
2105
  throw new Error('Use composer models|run');
2106
+ const nodeId = canvasNodeFlag(ctx.flags);
1870
2107
  const fromRun = getFlag(ctx.flags, 'from-run');
1871
2108
  const json = getFlag(ctx.flags, 'input-json');
1872
2109
  const parts = getFlagValues(ctx.flags, 'part');
@@ -1876,20 +2113,23 @@ const commandComposer = async (subcommand, ctx) => {
1876
2113
  const previous = fromRun ? await ctx.client.v2.getRun(fromRun) : undefined;
1877
2114
  if (previous && previous.operation !== 'mesh.compose')
1878
2115
  throw new Error('--from-run must identify a mesh.compose execution');
1879
- const input = previous
1880
- ? { ...(previous.requestedInput ?? previous.input) }
1881
- : json
1882
- ? await readJsonArgument(json, '--input-json')
1883
- : {
1884
- parts: parts.map(assetId => ({
1885
- assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
1886
- })),
1887
- fullBodyImageAssetId: requireFlag(ctx.flags, 'reference'),
1888
- };
2116
+ const input = nodeId
2117
+ ? {}
2118
+ : previous
2119
+ ? composerInputFromRun(previous, 'mesh.compose')
2120
+ : json
2121
+ ? await readJsonArgument(json, '--input-json')
2122
+ : {
2123
+ parts: parts.map(assetId => ({
2124
+ assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
2125
+ })),
2126
+ fullBodyImageAssetId: requireFlag(ctx.flags, 'reference'),
2127
+ };
1889
2128
  delete input.executionContext;
1890
- if (!Array.isArray(input.parts) ||
1891
- !input.parts.length ||
1892
- !input.fullBodyImageAssetId)
2129
+ if (!nodeId &&
2130
+ (!Array.isArray(input.parts) ||
2131
+ !input.parts.length ||
2132
+ !input.fullBodyImageAssetId))
1893
2133
  throw new Error('Composer requires parts[] and fullBodyImageAssetId');
1894
2134
  const mode = getFlag(ctx.flags, 'mode');
1895
2135
  if (mode != null && mode !== 'quick' && mode !== 'quality')
@@ -1905,17 +2145,48 @@ const commandComposer = async (subcommand, ctx) => {
1905
2145
  const transforms = getFlag(ctx.flags, 'transforms-json');
1906
2146
  if (transforms)
1907
2147
  input.transforms = await readJsonArgument(transforms, '--transforms-json');
2148
+ if (previous && input.transforms) {
2149
+ delete input.quickRunId;
2150
+ delete input.quickScene;
2151
+ }
1908
2152
  const canvasId = selectedCanvasId(ctx.flags) ?? previous?.canvas.id;
1909
2153
  if (previous && canvasId !== previous.canvas.id)
1910
2154
  throw new Error('Recomposition must use the original canvas to retain lineage');
2155
+ const operationId = getFlag(ctx.flags, 'operation-id');
2156
+ if (nodeId && operationId) {
2157
+ const resumeOptions = {
2158
+ ...stateOptions(ctx),
2159
+ operationId,
2160
+ expectedCanvasId: canvasId,
2161
+ expectedCanvasNodeId: nodeId,
2162
+ expectedOperation: 'mesh.compose',
2163
+ expectedBody: input,
2164
+ };
2165
+ // A mesh batch reserves its root key even though its paid requests have child keys.
2166
+ await resumeNodeMeshBatch(resumeOptions);
2167
+ if (await hasRecordedOperation(resumeOptions.stateDir, operationId)) {
2168
+ await printRecorded(ctx, await resumeRecorded(resumeOptions));
2169
+ return;
2170
+ }
2171
+ }
1911
2172
  const session = await openExecutionSession({
1912
2173
  ...stateOptions(ctx),
1913
2174
  canvasId,
1914
2175
  operation: 'mesh.compose',
2176
+ ...(nodeId ? { create: false } : {}),
1915
2177
  });
2178
+ if (nodeId) {
2179
+ const { items } = await ctx.client.v2.listCanvasNodes(session.canvas.id);
2180
+ const target = items.find(item => item.nodeId === nodeId);
2181
+ if (!target ||
2182
+ !['production-3d', 'part-composer'].includes(target.type) ||
2183
+ !target.actions.includes('mesh.compose'))
2184
+ throw new Error('Composition is unavailable for this canvas node');
2185
+ }
1916
2186
  const queued = await executeRecorded(session, 'mesh.compose', input, {
1917
2187
  ...executionOptions(ctx.flags),
1918
2188
  ...(previous ? { parentRunId: previous.runId } : {}),
2189
+ ...(nodeId ? { canvasNode: { nodeId } } : {}),
1919
2190
  });
1920
2191
  await printRecorded(ctx, queued);
1921
2192
  };
@@ -1939,6 +2210,11 @@ const commandMesh = async (subcommand, positionals, ctx) => {
1939
2210
  return;
1940
2211
  }
1941
2212
  if (subcommand === 'generate') {
2213
+ const nodeId = canvasNodeFlag(ctx.flags);
2214
+ if (nodeId) {
2215
+ await commandNodeMesh(ctx, nodeId);
2216
+ return;
2217
+ }
1942
2218
  const inputJson = getFlag(ctx.flags, 'input-json');
1943
2219
  const input = inputJson
1944
2220
  ? await readJsonArgument(inputJson, '--input-json')
@@ -2569,12 +2845,6 @@ const productionSession = (ctx, operation) => openExecutionSession({
2569
2845
  canvasId: selectedCanvasId(ctx.flags),
2570
2846
  operation,
2571
2847
  });
2572
- const childOperationId = (parent, step) => {
2573
- const hash = createHash('sha256')
2574
- .update(JSON.stringify([parent, step]))
2575
- .digest('hex');
2576
- return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
2577
- };
2578
2848
  const commandProduction = async (subcommand, positionals, ctx) => {
2579
2849
  if (subcommand === 'agents') {
2580
2850
  print(sanitizeProductionOutput(await ctx.client.v1.getProductionAgents()));
@@ -2814,7 +3084,7 @@ const commandParts = async (subcommand, ctx) => {
2814
3084
  ...stateOptions(ctx),
2815
3085
  operationId: executeOperationId,
2816
3086
  expectedCanvasId: session.canvas.id,
2817
- expectedBody: {
3087
+ expectedBodySubset: {
2818
3088
  partExtractionMode: getFlag(ctx.flags, 'part-extraction-mode'),
2819
3089
  ...(getFlagValues(ctx.flags, 'task-id').length
2820
3090
  ? { confirmedTaskIds: getFlagValues(ctx.flags, 'task-id') }
@@ -3025,6 +3295,11 @@ const canvasForRead = (ctx) => openExecutionSession({
3025
3295
  create: false,
3026
3296
  });
3027
3297
  const commandCanvas = async (subcommand, positionals, ctx) => {
3298
+ if (subcommand === 'nodes') {
3299
+ const { canvas } = await canvasForRead(ctx);
3300
+ print(await ctx.client.v2.listCanvasNodes(canvas.id));
3301
+ return;
3302
+ }
3028
3303
  if (subcommand === 'download') {
3029
3304
  const outDir = requireFlag(ctx.flags, 'out-dir');
3030
3305
  const { canvas } = await canvasForRead(ctx);
@@ -3357,6 +3632,14 @@ const commandRuns = async (subcommand, positionals, ctx) => {
3357
3632
  return;
3358
3633
  }
3359
3634
  if (subcommand === 'resume') {
3635
+ const batch = await resumeNodeMeshBatch({
3636
+ ...stateOptions(ctx),
3637
+ operationId: requirePositional(positionals, 2, 'operation-id'),
3638
+ });
3639
+ if (batch) {
3640
+ await printNodeMeshBatch(ctx, batch);
3641
+ return;
3642
+ }
3360
3643
  const result = await resumeRecorded({
3361
3644
  ...stateOptions(ctx),
3362
3645
  operationId: requirePositional(positionals, 2, 'operation-id'),
@@ -3389,6 +3672,7 @@ const commandRuns = async (subcommand, positionals, ctx) => {
3389
3672
  plan,
3390
3673
  baseUrl: ctx.auth.baseUrl,
3391
3674
  apiKey: ctx.auth.apiKey,
3675
+ workspaceId: ctx.auth.workspaceId,
3392
3676
  skipRegister: args.skipRegister,
3393
3677
  onProgress: event => {
3394
3678
  if (event.kind === 'register') {
@@ -3509,7 +3793,17 @@ const run = async () => {
3509
3793
  if (command === 'mcp') {
3510
3794
  if (subcommand !== 'config')
3511
3795
  throw new Error('Use mcp tools [tool-name] or mcp config --client cursor|codex');
3512
- stdout.write(mcpConfig(requireFlag(parsed.flags, 'client'), getFlag(parsed.flags, 'base-url') ?? env.ASSETHUB_API_BASE_URL ?? defaultBaseUrl, hasFlag(parsed.flags, 'account')));
3796
+ const config = await readAuthConfig(getConfigPath(parsed.flags));
3797
+ const explicitProfile = getFlag(parsed.flags, 'profile');
3798
+ const stored = config.profiles?.[explicitProfile ?? config.defaultProfile ?? defaultProfileName];
3799
+ if (explicitProfile && !stored)
3800
+ throw new Error('Selected profile does not exist');
3801
+ const selected = explicitProfile || stored?.workspaceId || stored?.apiKey?.startsWith('ah_pat_');
3802
+ const baseUrl = getFlag(parsed.flags, 'base-url') ??
3803
+ (selected ? stored?.baseUrl : env.ASSETHUB_API_BASE_URL ?? stored?.baseUrl) ?? defaultBaseUrl;
3804
+ const workspaceId = getFlag(parsed.flags, 'workspace') ??
3805
+ (stored && new URL(baseUrl).origin === new URL(stored.baseUrl).origin ? stored.workspaceId : undefined);
3806
+ stdout.write(mcpConfig(requireFlag(parsed.flags, 'client'), baseUrl, hasFlag(parsed.flags, 'account'), workspaceId));
3513
3807
  return;
3514
3808
  }
3515
3809
  if (command === 'doctor') {