@assethub/cli 0.1.17 → 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
@@ -2,6 +2,8 @@
2
2
  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
+ import { downloadCanvas } from './canvasDownload.js';
6
+ import { executeNodeMeshBatch, resumeNodeMeshBatch, } from './nodeMeshBatch.js';
5
7
  import { writeCanvasComparison } from './canvasComparison.js';
6
8
  import { importCanvasAssetWithState } from './canvasAssetImport.js';
7
9
  import { execFile, spawn } from 'node:child_process';
@@ -15,7 +17,7 @@ import { fileURLToPath } from 'node:url';
15
17
  import { promisify } from 'node:util';
16
18
  import { AssetHubApiError, WorkspaceClientError, createWorkspaceClient, createAssetHubClient, } from '@assethub/api-client';
17
19
  import { resolveCanvasSelection, saveCanvasSelection } from './canvas.js';
18
- import { activeExecution, CliExecutionError, executeRecorded, hasRecordedOperation, openExecutionSession, resumeRecorded, waitForExecution, } from './execution.js';
20
+ import { activeExecution, childOperationId, CliExecutionError, executeRecorded, hasRecordedOperation, openExecutionSession, resumeRecorded, waitForExecution, } from './execution.js';
19
21
  import { buildRunUploadPlan, formatBytes } from './runsUpload/buildRunUpload.js';
20
22
  import { readGraphFolder } from './runsUpload/graphFolder.js';
21
23
  import { uploadRun } from './runsUpload/uploadRun.js';
@@ -90,7 +92,8 @@ Usage:
90
92
  assethub auth login --access-token-stdin [--base-url <url>] [--profile <name>]
91
93
  assethub auth status [--profile <name>]
92
94
  assethub auth logout [--profile <name>]
93
- assethub workspace list|get
95
+ assethub workspace list [--query <text>] [--limit <1-100>] [--cursor <cursor>]
96
+ assethub workspace get
94
97
  assethub workspace create --name <name> [--operation-id <uuid>]
95
98
  assethub workspace use <workspace-id>
96
99
  assethub workspace members [--workspace <id>]
@@ -103,6 +106,8 @@ Usage:
103
106
  assethub workspace context get --internal --org <uuid> --actor <uuid> --canvas <id> [--out <json>] [--order <uuid>]
104
107
  assethub project ingest <source-dir> --out-dir <directory> [--exclude-dir <relative-path>...]
105
108
  assethub language text|vision --input-json @<json> --operation-id <uuid> [--out <json>]
109
+ assethub canvas download --canvas <id> --out-dir <directory> [--mesh <mesh_asset_id>]
110
+ assethub canvas nodes --canvas <id>
106
111
  assethub canvas import --canvas <id> --file <image> [--name <name>]
107
112
  assethub canvas context get --canvas <id> [--out <json>]
108
113
  assethub canvas context put --canvas <id> --file <json> [--if-version <n>]
@@ -125,6 +130,7 @@ Usage:
125
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]
126
131
  assethub composer run --input-json <json|@file> [--canvas <id>] [--operation-id <uuid>] [--wait] [--download --out-dir <dir>]
127
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]
128
134
  assethub canvas create [--name <name>] [--operation-id <uuid>]
129
135
  assethub canvas list [--cursor <cursor>] [--limit <n>]
130
136
  assethub canvas get|use|open [<id>]
@@ -140,6 +146,7 @@ Usage:
140
146
  assethub evaluations get <evaluation-id>
141
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]
142
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]
143
150
  assethub mesh list [--query <text>] [--cursor <cursor>] [--limit <n>]
144
151
  assethub mesh get <mesh-asset-id>
145
152
  assethub mesh download <mesh-asset-id> --out-dir <directory>
@@ -171,6 +178,7 @@ Global options:
171
178
  --api-key <key> Overrides saved auth and ASSETHUB_API_KEY.
172
179
  --base-url <url> Override the selected API origin. Default: ${defaultBaseUrl}.
173
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.
174
182
  --config <path> Auth config path. Defaults to ~/.assethub/config.json.
175
183
  --version Show installed package version.
176
184
  --help Show help.
@@ -432,7 +440,7 @@ const resolveAuth = async (flags) => {
432
440
  : undefined;
433
441
  const profile = explicitProfile ?? config?.defaultProfile ?? defaultProfileName;
434
442
  const storedProfile = config?.profiles?.[profile];
435
- const selected = Boolean(explicitProfile || storedProfile?.workspaceId);
443
+ const selected = Boolean(explicitProfile || storedProfile?.workspaceId || storedProfile?.apiKey?.startsWith('ah_pat_'));
436
444
  if (selected && (!storedProfile || (!storedProfile.apiKey && !overrideKey)))
437
445
  throw new Error('Selected profile has no workspace API key. Use workspace use <id> or auth login --api-key-stdin --profile <name>.');
438
446
  const baseUrl = getFlag(flags, 'base-url') ??
@@ -440,17 +448,17 @@ const resolveAuth = async (flags) => {
440
448
  ? storedProfile.baseUrl
441
449
  : env.ASSETHUB_API_BASE_URL ?? storedProfile?.baseUrl ?? defaultBaseUrl);
442
450
  if (overrideKey)
443
- return { apiKey: overrideKey, baseUrl, profile, source: 'flag' };
451
+ return { apiKey: overrideKey, baseUrl, profile, source: 'flag', workspaceId: getFlag(flags, 'workspace') };
444
452
  if (selected && storedProfile?.apiKey) {
445
- if (storedProfile.workspaceId &&
453
+ if ((storedProfile.workspaceId || storedProfile.apiKey.startsWith('ah_pat_')) &&
446
454
  new URL(baseUrl).origin !== new URL(storedProfile.baseUrl).origin)
447
455
  throw new Error('Selected workspace belongs to another API origin; select a workspace for this origin or supply an explicit API key');
448
- return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile' };
456
+ return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile', workspaceId: getFlag(flags, 'workspace') ?? storedProfile.workspaceId };
449
457
  }
450
458
  if (env.ASSETHUB_API_KEY?.trim())
451
- 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') };
452
460
  if (storedProfile?.apiKey)
453
- return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile' };
461
+ return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile', workspaceId: getFlag(flags, 'workspace') ?? storedProfile.workspaceId };
454
462
  throw new Error('Missing workspace API key. Use "workspace use <id>" after user login, "auth login --api-key-stdin", or ASSETHUB_API_KEY.');
455
463
  };
456
464
  const createContext = async (flags, args) => {
@@ -462,6 +470,7 @@ const createContext = async (flags, args) => {
462
470
  client: createAssetHubClient({
463
471
  apiKey: auth.apiKey,
464
472
  baseUrl: auth.baseUrl,
473
+ workspaceId: auth.workspaceId,
465
474
  }),
466
475
  };
467
476
  };
@@ -1241,12 +1250,25 @@ const commandModels = async (subcommand, positionals, ctx) => {
1241
1250
  }
1242
1251
  throw new Error('Unknown models command. Use "models list" or "models get".');
1243
1252
  };
1244
- const workspaceAuth = async (flags) => {
1253
+ const workspaceAuth = async (flags, allowPersonal = false) => {
1245
1254
  const configPath = getConfigPath(flags);
1246
1255
  const config = await readAuthConfig(configPath);
1247
1256
  const explicitProfile = hasFlag(flags, 'profile') ? requireFlag(flags, 'profile') : undefined;
1248
1257
  const profile = explicitProfile ?? config.defaultProfile ?? defaultProfileName;
1249
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
+ }
1250
1272
  const environmentToken = explicitProfile ? undefined : env.ASSETHUB_ACCESS_TOKEN?.trim();
1251
1273
  const accessToken = environmentToken || stored?.accessToken;
1252
1274
  if (!accessToken)
@@ -1270,6 +1292,7 @@ const workspaceAuth = async (flags) => {
1270
1292
  workspaceMfaToken,
1271
1293
  baseUrl,
1272
1294
  selectedWorkspaceId: environmentToken ? undefined : stored?.workspaceId,
1295
+ personal: false,
1273
1296
  client: createWorkspaceClient({ accessToken, workspaceMfaToken, baseUrl }),
1274
1297
  };
1275
1298
  };
@@ -1279,7 +1302,7 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1279
1302
  : hasFlag(flags, 'profile')
1280
1303
  ? undefined
1281
1304
  : env.ASSETHUB_API_KEY?.trim();
1282
- const auth = await workspaceAuth(flags);
1305
+ const auth = await workspaceAuth(flags, ['list', 'get', 'use'].includes(subcommand ?? ''));
1283
1306
  if (['members', 'invite', 'set-role', 'remove-member'].includes(subcommand ?? '')) {
1284
1307
  let workspaceId = getFlag(flags, 'workspace') ?? auth.selectedWorkspaceId;
1285
1308
  if (!workspaceId)
@@ -1303,10 +1326,21 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1303
1326
  return;
1304
1327
  }
1305
1328
  if (subcommand === 'list') {
1306
- 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
+ }));
1307
1334
  return;
1308
1335
  }
1309
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
+ }
1310
1344
  const account = await auth.client.list();
1311
1345
  const selected = auth.selectedWorkspaceId;
1312
1346
  const workspace = account.workspaces.find(item => selected ? item.id === selected : item.active);
@@ -1331,18 +1365,34 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1331
1365
  throw new Error('Use workspace list|get|create|use');
1332
1366
  const workspaceId = requirePositional(positionals, 2, 'workspace-id');
1333
1367
  const selected = await auth.client.select(workspaceId);
1368
+ if (selected.workspaceId !== workspaceId)
1369
+ throw new Error('Workspace API returned an invalid selection scope');
1334
1370
  if (selected.mfa.status === 'setup_required' ||
1335
1371
  selected.mfa.status === 'verify_required')
1336
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');
1337
1373
  const account = await auth.client.list();
1338
- const workspace = account.workspaces.find(item => item.id === workspaceId);
1374
+ const workspace = selected.workspace ?? account.workspaces.find(item => item.id === workspaceId);
1339
1375
  if (!workspace)
1340
1376
  throw new Error('Workspace is not available to this authenticated user');
1341
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
+ }
1342
1392
  const cached = Object.entries(profiles).find(([, entry]) => entry.userId === account.userId &&
1343
1393
  entry.workspaceId === workspaceId &&
1344
1394
  new URL(entry.baseUrl).origin === new URL(auth.baseUrl).origin &&
1345
- entry.apiKey);
1395
+ entry.apiKey && !entry.apiKey.startsWith('ah_pat_'));
1346
1396
  let apiKey;
1347
1397
  for (const candidate of new Set([cached?.[1].apiKey, suppliedKey])) {
1348
1398
  if (!candidate)
@@ -1445,7 +1495,13 @@ const commandAuth = async (subcommand, flags) => {
1445
1495
  throw new Error('API key is empty. Pass --api-key, use --api-key-stdin, or set ASSETHUB_API_KEY.');
1446
1496
  }
1447
1497
  const baseUrl = getFlag(flags, 'base-url') ?? env.ASSETHUB_API_BASE_URL ?? defaultBaseUrl;
1448
- 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')) {
1449
1505
  const client = createAssetHubClient({ apiKey, baseUrl });
1450
1506
  await client.v2.listModels();
1451
1507
  }
@@ -1454,6 +1510,7 @@ const commandAuth = async (subcommand, flags) => {
1454
1510
  profiles[profile] = {
1455
1511
  apiKey,
1456
1512
  baseUrl,
1513
+ ...(account ? { userId: account.userId } : {}),
1457
1514
  updatedAt: new Date().toISOString(),
1458
1515
  };
1459
1516
  await writeAuthConfig(configPath, {
@@ -1466,15 +1523,19 @@ const commandAuth = async (subcommand, flags) => {
1466
1523
  profile,
1467
1524
  baseUrl,
1468
1525
  configPath,
1469
- verified: !hasFlag(flags, 'skip-verify'),
1526
+ verified: personal || !hasFlag(flags, 'skip-verify'),
1527
+ ...(account ? { authentication: 'personal', userId: account.userId } : {}),
1470
1528
  });
1471
1529
  return;
1472
1530
  }
1473
1531
  if (subcommand === 'status') {
1474
1532
  const config = await readAuthConfig(configPath);
1475
1533
  const current = config.profiles?.[getFlag(flags, 'profile') ?? config.defaultProfile ?? profile];
1476
- if ((!hasFlag(flags, 'profile') && env.ASSETHUB_ACCESS_TOKEN?.trim()) ||
1477
- (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))) {
1478
1539
  const auth = await workspaceAuth(flags);
1479
1540
  const account = await auth.client.list();
1480
1541
  print({
@@ -1491,7 +1552,18 @@ const commandAuth = async (subcommand, flags) => {
1491
1552
  const client = createAssetHubClient({
1492
1553
  apiKey: auth.apiKey,
1493
1554
  baseUrl: auth.baseUrl,
1555
+ workspaceId: auth.workspaceId,
1494
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
+ }
1495
1567
  const models = await client.v2.listModels();
1496
1568
  print({
1497
1569
  authenticated: true,
@@ -1637,6 +1709,134 @@ const printRecorded = async (ctx, queued) => {
1637
1709
  });
1638
1710
  process.exitCode = executionExitCode(execution);
1639
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
+ };
1640
1840
  const commandImage = async (subcommand, ctx) => {
1641
1841
  if (subcommand !== 'generate')
1642
1842
  throw new Error('Unknown image command. Use "image generate".');
@@ -1744,6 +1944,54 @@ const refinementModeInfo = (capabilities, mode) => {
1744
1944
  throw new Error(`Refinement mode ${mode} is unavailable${info.reason ? `: ${info.reason}` : ''}`);
1745
1945
  return info;
1746
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
+ };
1747
1995
  const commandComposerRefine = async (ctx) => {
1748
1996
  const capabilities = await ctx.client.v2.getMeshRefinementModes();
1749
1997
  if (hasFlag(ctx.flags, 'list-modes')) {
@@ -1767,20 +2015,7 @@ const commandComposerRefine = async (ctx) => {
1767
2015
  const input = json
1768
2016
  ? await readJsonArgument(json, '--input-json')
1769
2017
  : previous
1770
- ? {
1771
- ...(previous.requestedInput ?? previous.input),
1772
- ...(previous.operation === 'mesh.refine' &&
1773
- (previous.requestedInput?.mode ?? previous.input.mode)
1774
- ? { mode: previous.requestedInput?.mode ?? previous.input.mode }
1775
- : { mode: undefined }),
1776
- parts: previous.composition?.parts ??
1777
- previous.requestedInput?.parts ??
1778
- previous.input.parts,
1779
- transforms: previous.composition?.transforms,
1780
- referenceTransform: previous.composition?.referenceTransform ??
1781
- previous.requestedInput?.referenceTransform ??
1782
- previous.input.referenceTransform,
1783
- }
2018
+ ? composerInputFromRun(previous, 'mesh.refine')
1784
2019
  : {
1785
2020
  parts: parts.map(assetId => ({
1786
2021
  assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
@@ -1848,6 +2083,9 @@ const commandComposerRefine = async (ctx) => {
1848
2083
  mode,
1849
2084
  instruction,
1850
2085
  ...(maxRounds != null ? { maxRounds } : {}),
2086
+ ...(input.agentRuntime
2087
+ ? { agentRuntime: input.agentRuntime }
2088
+ : {}),
1851
2089
  };
1852
2090
  await printRecorded(ctx, await executeRecorded(session, 'mesh.refine', body, {
1853
2091
  ...executionOptions(ctx.flags),
@@ -1865,6 +2103,7 @@ const commandComposer = async (subcommand, ctx) => {
1865
2103
  }
1866
2104
  if (subcommand !== 'run')
1867
2105
  throw new Error('Use composer models|run');
2106
+ const nodeId = canvasNodeFlag(ctx.flags);
1868
2107
  const fromRun = getFlag(ctx.flags, 'from-run');
1869
2108
  const json = getFlag(ctx.flags, 'input-json');
1870
2109
  const parts = getFlagValues(ctx.flags, 'part');
@@ -1874,20 +2113,23 @@ const commandComposer = async (subcommand, ctx) => {
1874
2113
  const previous = fromRun ? await ctx.client.v2.getRun(fromRun) : undefined;
1875
2114
  if (previous && previous.operation !== 'mesh.compose')
1876
2115
  throw new Error('--from-run must identify a mesh.compose execution');
1877
- const input = previous
1878
- ? { ...(previous.requestedInput ?? previous.input) }
1879
- : json
1880
- ? await readJsonArgument(json, '--input-json')
1881
- : {
1882
- parts: parts.map(assetId => ({
1883
- assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
1884
- })),
1885
- fullBodyImageAssetId: requireFlag(ctx.flags, 'reference'),
1886
- };
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
+ };
1887
2128
  delete input.executionContext;
1888
- if (!Array.isArray(input.parts) ||
1889
- !input.parts.length ||
1890
- !input.fullBodyImageAssetId)
2129
+ if (!nodeId &&
2130
+ (!Array.isArray(input.parts) ||
2131
+ !input.parts.length ||
2132
+ !input.fullBodyImageAssetId))
1891
2133
  throw new Error('Composer requires parts[] and fullBodyImageAssetId');
1892
2134
  const mode = getFlag(ctx.flags, 'mode');
1893
2135
  if (mode != null && mode !== 'quick' && mode !== 'quality')
@@ -1903,17 +2145,48 @@ const commandComposer = async (subcommand, ctx) => {
1903
2145
  const transforms = getFlag(ctx.flags, 'transforms-json');
1904
2146
  if (transforms)
1905
2147
  input.transforms = await readJsonArgument(transforms, '--transforms-json');
2148
+ if (previous && input.transforms) {
2149
+ delete input.quickRunId;
2150
+ delete input.quickScene;
2151
+ }
1906
2152
  const canvasId = selectedCanvasId(ctx.flags) ?? previous?.canvas.id;
1907
2153
  if (previous && canvasId !== previous.canvas.id)
1908
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
+ }
1909
2172
  const session = await openExecutionSession({
1910
2173
  ...stateOptions(ctx),
1911
2174
  canvasId,
1912
2175
  operation: 'mesh.compose',
2176
+ ...(nodeId ? { create: false } : {}),
1913
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
+ }
1914
2186
  const queued = await executeRecorded(session, 'mesh.compose', input, {
1915
2187
  ...executionOptions(ctx.flags),
1916
2188
  ...(previous ? { parentRunId: previous.runId } : {}),
2189
+ ...(nodeId ? { canvasNode: { nodeId } } : {}),
1917
2190
  });
1918
2191
  await printRecorded(ctx, queued);
1919
2192
  };
@@ -1937,6 +2210,11 @@ const commandMesh = async (subcommand, positionals, ctx) => {
1937
2210
  return;
1938
2211
  }
1939
2212
  if (subcommand === 'generate') {
2213
+ const nodeId = canvasNodeFlag(ctx.flags);
2214
+ if (nodeId) {
2215
+ await commandNodeMesh(ctx, nodeId);
2216
+ return;
2217
+ }
1940
2218
  const inputJson = getFlag(ctx.flags, 'input-json');
1941
2219
  const input = inputJson
1942
2220
  ? await readJsonArgument(inputJson, '--input-json')
@@ -2567,12 +2845,6 @@ const productionSession = (ctx, operation) => openExecutionSession({
2567
2845
  canvasId: selectedCanvasId(ctx.flags),
2568
2846
  operation,
2569
2847
  });
2570
- const childOperationId = (parent, step) => {
2571
- const hash = createHash('sha256')
2572
- .update(JSON.stringify([parent, step]))
2573
- .digest('hex');
2574
- return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
2575
- };
2576
2848
  const commandProduction = async (subcommand, positionals, ctx) => {
2577
2849
  if (subcommand === 'agents') {
2578
2850
  print(sanitizeProductionOutput(await ctx.client.v1.getProductionAgents()));
@@ -2812,7 +3084,7 @@ const commandParts = async (subcommand, ctx) => {
2812
3084
  ...stateOptions(ctx),
2813
3085
  operationId: executeOperationId,
2814
3086
  expectedCanvasId: session.canvas.id,
2815
- expectedBody: {
3087
+ expectedBodySubset: {
2816
3088
  partExtractionMode: getFlag(ctx.flags, 'part-extraction-mode'),
2817
3089
  ...(getFlagValues(ctx.flags, 'task-id').length
2818
3090
  ? { confirmedTaskIds: getFlagValues(ctx.flags, 'task-id') }
@@ -3023,6 +3295,28 @@ const canvasForRead = (ctx) => openExecutionSession({
3023
3295
  create: false,
3024
3296
  });
3025
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
+ }
3303
+ if (subcommand === 'download') {
3304
+ const outDir = requireFlag(ctx.flags, 'out-dir');
3305
+ const { canvas } = await canvasForRead(ctx);
3306
+ const mesh = getFlag(ctx.flags, 'mesh');
3307
+ if (mesh && !/^mesh_[1-9]\d*$/.test(mesh))
3308
+ throw new Error('--mesh must be a mesh_<id> asset ID');
3309
+ const result = await downloadCanvas({
3310
+ client: ctx.client,
3311
+ canvasId: canvas.id,
3312
+ mesh,
3313
+ outDir,
3314
+ progress: message => process.stderr.write(`${message}\n`),
3315
+ });
3316
+ print(result);
3317
+ process.exitCode = result.exitCode;
3318
+ return;
3319
+ }
3026
3320
  if (subcommand === 'context' ||
3027
3321
  subcommand === 'import' ||
3028
3322
  subcommand === 'compare' ||
@@ -3338,6 +3632,14 @@ const commandRuns = async (subcommand, positionals, ctx) => {
3338
3632
  return;
3339
3633
  }
3340
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
+ }
3341
3643
  const result = await resumeRecorded({
3342
3644
  ...stateOptions(ctx),
3343
3645
  operationId: requirePositional(positionals, 2, 'operation-id'),
@@ -3370,6 +3672,7 @@ const commandRuns = async (subcommand, positionals, ctx) => {
3370
3672
  plan,
3371
3673
  baseUrl: ctx.auth.baseUrl,
3372
3674
  apiKey: ctx.auth.apiKey,
3675
+ workspaceId: ctx.auth.workspaceId,
3373
3676
  skipRegister: args.skipRegister,
3374
3677
  onProgress: event => {
3375
3678
  if (event.kind === 'register') {
@@ -3490,7 +3793,17 @@ const run = async () => {
3490
3793
  if (command === 'mcp') {
3491
3794
  if (subcommand !== 'config')
3492
3795
  throw new Error('Use mcp tools [tool-name] or mcp config --client cursor|codex');
3493
- 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));
3494
3807
  return;
3495
3808
  }
3496
3809
  if (command === 'doctor') {