@assethub/cli 0.1.18 → 0.1.20

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';
@@ -33,6 +34,8 @@ const artifactGraphPartExtractorApiValues = new Set([
33
34
  'ah_agent_graph_v3_6_1',
34
35
  'ah_agent_graph_v3_6_3',
35
36
  'ah_agent_graph_v3_6_4',
37
+ 'ah_agent_graph_v3_6_5',
38
+ 'ah_agent_graph_pluffy_v3_1',
36
39
  ]);
37
40
  const partExtractorOptions = [
38
41
  {
@@ -65,6 +68,16 @@ const partExtractorOptions = [
65
68
  apiValue: 'ah_agent_graph_v3_6_4',
66
69
  aliases: ['v3.6.4', '3.6.4'],
67
70
  },
71
+ {
72
+ publicName: 'V3.6.5 Primary Images',
73
+ apiValue: 'ah_agent_graph_v3_6_5',
74
+ aliases: ['v3.6.5', '3.6.5'],
75
+ },
76
+ {
77
+ publicName: 'Chibi Character (Pluffy) v3.1',
78
+ apiValue: 'ah_agent_graph_pluffy_v3_1',
79
+ aliases: ['pluffy', 'pluffy v3.1', 'pluffy 3.1'],
80
+ },
68
81
  ];
69
82
  const partExtractorPublicNames = partExtractorOptions
70
83
  .map(option => option.publicName)
@@ -91,7 +104,8 @@ Usage:
91
104
  assethub auth login --access-token-stdin [--base-url <url>] [--profile <name>]
92
105
  assethub auth status [--profile <name>]
93
106
  assethub auth logout [--profile <name>]
94
- assethub workspace list|get
107
+ assethub workspace list [--query <text>] [--limit <1-100>] [--cursor <cursor>]
108
+ assethub workspace get
95
109
  assethub workspace create --name <name> [--operation-id <uuid>]
96
110
  assethub workspace use <workspace-id>
97
111
  assethub workspace members [--workspace <id>]
@@ -105,6 +119,7 @@ Usage:
105
119
  assethub project ingest <source-dir> --out-dir <directory> [--exclude-dir <relative-path>...]
106
120
  assethub language text|vision --input-json @<json> --operation-id <uuid> [--out <json>]
107
121
  assethub canvas download --canvas <id> --out-dir <directory> [--mesh <mesh_asset_id>]
122
+ assethub canvas nodes --canvas <id>
108
123
  assethub canvas import --canvas <id> --file <image> [--name <name>]
109
124
  assethub canvas context get --canvas <id> [--out <json>]
110
125
  assethub canvas context put --canvas <id> --file <json> [--if-version <n>]
@@ -127,6 +142,7 @@ Usage:
127
142
  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
143
  assethub composer run --input-json <json|@file> [--canvas <id>] [--operation-id <uuid>] [--wait] [--download --out-dir <dir>]
129
144
  assethub composer run --from-run <run-id> [--transforms-json <json|@file>] [--mode quick|quality] [--wait]
145
+ assethub composer run --node <shape:id> --canvas <id> [--model <id>] [--mode quick|quality] [--operation-id <uuid>] [--wait]
130
146
  assethub canvas create [--name <name>] [--operation-id <uuid>]
131
147
  assethub canvas list [--cursor <cursor>] [--limit <n>]
132
148
  assethub canvas get|use|open [<id>]
@@ -142,6 +158,7 @@ Usage:
142
158
  assethub evaluations get <evaluation-id>
143
159
  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
160
  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]
161
+ assethub mesh generate --node <shape:id> --canvas <id> [--model-id <id>] [--params-json <json|@file>] [--operation-id <uuid>] [--wait]
145
162
  assethub mesh list [--query <text>] [--cursor <cursor>] [--limit <n>]
146
163
  assethub mesh get <mesh-asset-id>
147
164
  assethub mesh download <mesh-asset-id> --out-dir <directory>
@@ -173,6 +190,7 @@ Global options:
173
190
  --api-key <key> Overrides saved auth and ASSETHUB_API_KEY.
174
191
  --base-url <url> Override the selected API origin. Default: ${defaultBaseUrl}.
175
192
  --profile <name> Select this saved profile ahead of environment credentials and origin.
193
+ --workspace <id> Select workspace scope for this command without changing saved selection.
176
194
  --config <path> Auth config path. Defaults to ~/.assethub/config.json.
177
195
  --version Show installed package version.
178
196
  --help Show help.
@@ -434,7 +452,7 @@ const resolveAuth = async (flags) => {
434
452
  : undefined;
435
453
  const profile = explicitProfile ?? config?.defaultProfile ?? defaultProfileName;
436
454
  const storedProfile = config?.profiles?.[profile];
437
- const selected = Boolean(explicitProfile || storedProfile?.workspaceId);
455
+ const selected = Boolean(explicitProfile || storedProfile?.workspaceId || storedProfile?.apiKey?.startsWith('ah_pat_'));
438
456
  if (selected && (!storedProfile || (!storedProfile.apiKey && !overrideKey)))
439
457
  throw new Error('Selected profile has no workspace API key. Use workspace use <id> or auth login --api-key-stdin --profile <name>.');
440
458
  const baseUrl = getFlag(flags, 'base-url') ??
@@ -442,17 +460,17 @@ const resolveAuth = async (flags) => {
442
460
  ? storedProfile.baseUrl
443
461
  : env.ASSETHUB_API_BASE_URL ?? storedProfile?.baseUrl ?? defaultBaseUrl);
444
462
  if (overrideKey)
445
- return { apiKey: overrideKey, baseUrl, profile, source: 'flag' };
463
+ return { apiKey: overrideKey, baseUrl, profile, source: 'flag', workspaceId: getFlag(flags, 'workspace') };
446
464
  if (selected && storedProfile?.apiKey) {
447
- if (storedProfile.workspaceId &&
465
+ if ((storedProfile.workspaceId || storedProfile.apiKey.startsWith('ah_pat_')) &&
448
466
  new URL(baseUrl).origin !== new URL(storedProfile.baseUrl).origin)
449
467
  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' };
468
+ return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile', workspaceId: getFlag(flags, 'workspace') ?? storedProfile.workspaceId };
451
469
  }
452
470
  if (env.ASSETHUB_API_KEY?.trim())
453
- return { apiKey: env.ASSETHUB_API_KEY, baseUrl, profile, source: 'env' };
471
+ return { apiKey: env.ASSETHUB_API_KEY, baseUrl, profile, source: 'env', workspaceId: getFlag(flags, 'workspace') };
454
472
  if (storedProfile?.apiKey)
455
- return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile' };
473
+ return { apiKey: storedProfile.apiKey, baseUrl, profile, source: 'profile', workspaceId: getFlag(flags, 'workspace') ?? storedProfile.workspaceId };
456
474
  throw new Error('Missing workspace API key. Use "workspace use <id>" after user login, "auth login --api-key-stdin", or ASSETHUB_API_KEY.');
457
475
  };
458
476
  const createContext = async (flags, args) => {
@@ -464,6 +482,7 @@ const createContext = async (flags, args) => {
464
482
  client: createAssetHubClient({
465
483
  apiKey: auth.apiKey,
466
484
  baseUrl: auth.baseUrl,
485
+ workspaceId: auth.workspaceId,
467
486
  }),
468
487
  };
469
488
  };
@@ -1243,12 +1262,25 @@ const commandModels = async (subcommand, positionals, ctx) => {
1243
1262
  }
1244
1263
  throw new Error('Unknown models command. Use "models list" or "models get".');
1245
1264
  };
1246
- const workspaceAuth = async (flags) => {
1265
+ const workspaceAuth = async (flags, allowPersonal = false) => {
1247
1266
  const configPath = getConfigPath(flags);
1248
1267
  const config = await readAuthConfig(configPath);
1249
1268
  const explicitProfile = hasFlag(flags, 'profile') ? requireFlag(flags, 'profile') : undefined;
1250
1269
  const profile = explicitProfile ?? config.defaultProfile ?? defaultProfileName;
1251
1270
  const stored = config.profiles?.[profile];
1271
+ const preferredKey = getFlag(flags, 'api-key') ??
1272
+ (explicitProfile || stored?.workspaceId || stored?.apiKey?.startsWith('ah_pat_')
1273
+ ? stored?.apiKey
1274
+ : env.ASSETHUB_API_KEY?.trim() || stored?.apiKey);
1275
+ if (allowPersonal && preferredKey?.startsWith('ah_pat_')) {
1276
+ const auth = await resolveAuth(flags);
1277
+ return {
1278
+ configPath, config, profile, accessToken: auth.apiKey,
1279
+ workspaceMfaToken: undefined, baseUrl: auth.baseUrl,
1280
+ selectedWorkspaceId: auth.workspaceId, personal: true,
1281
+ client: createWorkspaceClient({ accessToken: auth.apiKey, baseUrl: auth.baseUrl, workspaceId: auth.workspaceId }),
1282
+ };
1283
+ }
1252
1284
  const environmentToken = explicitProfile ? undefined : env.ASSETHUB_ACCESS_TOKEN?.trim();
1253
1285
  const accessToken = environmentToken || stored?.accessToken;
1254
1286
  if (!accessToken)
@@ -1272,6 +1304,7 @@ const workspaceAuth = async (flags) => {
1272
1304
  workspaceMfaToken,
1273
1305
  baseUrl,
1274
1306
  selectedWorkspaceId: environmentToken ? undefined : stored?.workspaceId,
1307
+ personal: false,
1275
1308
  client: createWorkspaceClient({ accessToken, workspaceMfaToken, baseUrl }),
1276
1309
  };
1277
1310
  };
@@ -1281,7 +1314,7 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1281
1314
  : hasFlag(flags, 'profile')
1282
1315
  ? undefined
1283
1316
  : env.ASSETHUB_API_KEY?.trim();
1284
- const auth = await workspaceAuth(flags);
1317
+ const auth = await workspaceAuth(flags, ['list', 'get', 'use'].includes(subcommand ?? ''));
1285
1318
  if (['members', 'invite', 'set-role', 'remove-member'].includes(subcommand ?? '')) {
1286
1319
  let workspaceId = getFlag(flags, 'workspace') ?? auth.selectedWorkspaceId;
1287
1320
  if (!workspaceId)
@@ -1305,10 +1338,21 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1305
1338
  return;
1306
1339
  }
1307
1340
  if (subcommand === 'list') {
1308
- print(await auth.client.list());
1341
+ print(await auth.client.list({
1342
+ query: getFlag(flags, 'query'),
1343
+ cursor: getFlag(flags, 'cursor'),
1344
+ limit: hasFlag(flags, 'limit') ? parsePositiveIntegerFlag(flags, 'limit', 100) : undefined,
1345
+ }));
1309
1346
  return;
1310
1347
  }
1311
1348
  if (subcommand === 'get') {
1349
+ if (auth.personal && auth.selectedWorkspaceId) {
1350
+ const selected = await auth.client.select(auth.selectedWorkspaceId);
1351
+ if (!selected.workspace)
1352
+ throw new Error('Workspace API returned no selected workspace');
1353
+ print(selected.workspace);
1354
+ return;
1355
+ }
1312
1356
  const account = await auth.client.list();
1313
1357
  const selected = auth.selectedWorkspaceId;
1314
1358
  const workspace = account.workspaces.find(item => selected ? item.id === selected : item.active);
@@ -1333,18 +1377,34 @@ const commandWorkspace = async (subcommand, positionals, flags) => {
1333
1377
  throw new Error('Use workspace list|get|create|use');
1334
1378
  const workspaceId = requirePositional(positionals, 2, 'workspace-id');
1335
1379
  const selected = await auth.client.select(workspaceId);
1380
+ if (selected.workspaceId !== workspaceId)
1381
+ throw new Error('Workspace API returned an invalid selection scope');
1336
1382
  if (selected.mfa.status === 'setup_required' ||
1337
1383
  selected.mfa.status === 'verify_required')
1338
1384
  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
1385
  const account = await auth.client.list();
1340
- const workspace = account.workspaces.find(item => item.id === workspaceId);
1386
+ const workspace = selected.workspace ?? account.workspaces.find(item => item.id === workspaceId);
1341
1387
  if (!workspace)
1342
1388
  throw new Error('Workspace is not available to this authenticated user');
1343
1389
  const profiles = auth.config.profiles ?? {};
1390
+ if (auth.personal) {
1391
+ if (account.authentication !== 'personal')
1392
+ throw new Error('Workspace API did not verify personal authentication');
1393
+ profiles[auth.profile] = {
1394
+ apiKey: auth.accessToken,
1395
+ userId: account.userId,
1396
+ workspaceId,
1397
+ baseUrl: auth.baseUrl,
1398
+ updatedAt: new Date().toISOString(),
1399
+ };
1400
+ await writeAuthConfig(auth.configPath, { ...auth.config, defaultProfile: auth.profile, profiles });
1401
+ print({ workspaceId, workspace, profile: auth.profile, selected: true, baseUrl: auth.baseUrl });
1402
+ return;
1403
+ }
1344
1404
  const cached = Object.entries(profiles).find(([, entry]) => entry.userId === account.userId &&
1345
1405
  entry.workspaceId === workspaceId &&
1346
1406
  new URL(entry.baseUrl).origin === new URL(auth.baseUrl).origin &&
1347
- entry.apiKey);
1407
+ entry.apiKey && !entry.apiKey.startsWith('ah_pat_'));
1348
1408
  let apiKey;
1349
1409
  for (const candidate of new Set([cached?.[1].apiKey, suppliedKey])) {
1350
1410
  if (!candidate)
@@ -1447,7 +1507,13 @@ const commandAuth = async (subcommand, flags) => {
1447
1507
  throw new Error('API key is empty. Pass --api-key, use --api-key-stdin, or set ASSETHUB_API_KEY.');
1448
1508
  }
1449
1509
  const baseUrl = getFlag(flags, 'base-url') ?? env.ASSETHUB_API_BASE_URL ?? defaultBaseUrl;
1450
- if (!hasFlag(flags, 'skip-verify')) {
1510
+ const personal = apiKey.startsWith('ah_pat_');
1511
+ const account = personal
1512
+ ? await createWorkspaceClient({ accessToken: apiKey, baseUrl }).list()
1513
+ : undefined;
1514
+ if (personal && account?.authentication !== 'personal')
1515
+ throw new Error('Workspace API did not verify personal authentication');
1516
+ if (!personal && !hasFlag(flags, 'skip-verify')) {
1451
1517
  const client = createAssetHubClient({ apiKey, baseUrl });
1452
1518
  await client.v2.listModels();
1453
1519
  }
@@ -1456,6 +1522,7 @@ const commandAuth = async (subcommand, flags) => {
1456
1522
  profiles[profile] = {
1457
1523
  apiKey,
1458
1524
  baseUrl,
1525
+ ...(account ? { userId: account.userId } : {}),
1459
1526
  updatedAt: new Date().toISOString(),
1460
1527
  };
1461
1528
  await writeAuthConfig(configPath, {
@@ -1468,15 +1535,19 @@ const commandAuth = async (subcommand, flags) => {
1468
1535
  profile,
1469
1536
  baseUrl,
1470
1537
  configPath,
1471
- verified: !hasFlag(flags, 'skip-verify'),
1538
+ verified: personal || !hasFlag(flags, 'skip-verify'),
1539
+ ...(account ? { authentication: 'personal', userId: account.userId } : {}),
1472
1540
  });
1473
1541
  return;
1474
1542
  }
1475
1543
  if (subcommand === 'status') {
1476
1544
  const config = await readAuthConfig(configPath);
1477
1545
  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)) {
1546
+ if (!hasFlag(flags, 'api-key') &&
1547
+ !current?.apiKey?.startsWith('ah_pat_') &&
1548
+ (hasFlag(flags, 'profile') || !env.ASSETHUB_API_KEY?.startsWith('ah_pat_')) &&
1549
+ ((!hasFlag(flags, 'profile') && env.ASSETHUB_ACCESS_TOKEN?.trim()) ||
1550
+ (current?.accessToken && !current.apiKey))) {
1480
1551
  const auth = await workspaceAuth(flags);
1481
1552
  const account = await auth.client.list();
1482
1553
  print({
@@ -1493,7 +1564,18 @@ const commandAuth = async (subcommand, flags) => {
1493
1564
  const client = createAssetHubClient({
1494
1565
  apiKey: auth.apiKey,
1495
1566
  baseUrl: auth.baseUrl,
1567
+ workspaceId: auth.workspaceId,
1496
1568
  });
1569
+ if (auth.apiKey.startsWith('ah_pat_')) {
1570
+ const account = await createWorkspaceClient({ accessToken: auth.apiKey, baseUrl: auth.baseUrl, workspaceId: auth.workspaceId }).list();
1571
+ if (account.authentication !== 'personal')
1572
+ throw new Error('Workspace API did not verify personal authentication');
1573
+ if (auth.workspaceId)
1574
+ await client.v2.getCapabilities();
1575
+ print({ authenticated: true, authentication: 'personal', userId: account.userId, workspaceId: auth.workspaceId,
1576
+ profile: auth.profile, source: auth.source, baseUrl: auth.baseUrl });
1577
+ return;
1578
+ }
1497
1579
  const models = await client.v2.listModels();
1498
1580
  print({
1499
1581
  authenticated: true,
@@ -1639,6 +1721,134 @@ const printRecorded = async (ctx, queued) => {
1639
1721
  });
1640
1722
  process.exitCode = executionExitCode(execution);
1641
1723
  };
1724
+ const canvasNodeFlag = (flags) => {
1725
+ if (!hasFlag(flags, 'node'))
1726
+ return undefined;
1727
+ const nodeId = requireFlag(flags, 'node');
1728
+ if (!nodeId.startsWith('shape:') || nodeId.length <= 6)
1729
+ throw new Error('--node must be a native shape:<id> from canvas nodes');
1730
+ if (sourceInputCount(flags) ||
1731
+ [
1732
+ 'input-json',
1733
+ 'upload-id',
1734
+ 'from-node',
1735
+ 'graph-revision',
1736
+ 'from-run',
1737
+ 'part',
1738
+ 'reference',
1739
+ 'transforms-json',
1740
+ ].some(name => hasFlag(flags, name)))
1741
+ throw new Error('--node cannot be combined with explicit sources, parts, reference, input JSON, or another node/run selector');
1742
+ return nodeId;
1743
+ };
1744
+ const printNodeMeshBatch = async (ctx, batch) => {
1745
+ const executions = [...batch.executions];
1746
+ if (hasFlag(ctx.flags, 'wait')) {
1747
+ try {
1748
+ for (let i = 0; i < executions.length; i++)
1749
+ executions[i] = await waitForExecution(ctx.client, executions[i].runId, watchOptions(ctx.flags));
1750
+ }
1751
+ catch (error) {
1752
+ if (!(error instanceof CliExecutionError))
1753
+ throw error;
1754
+ throw new CliExecutionError(error.message, error.exitCode, batch.operationId, error.execution, error.runId);
1755
+ }
1756
+ }
1757
+ const downloads = await maybeDownloadUrls(ctx.flags, executions.flatMap(execution => execution.outputs));
1758
+ print({ ...batch, executions, ...(downloads ? { downloads } : {}) });
1759
+ process.exitCode = Math.max(0, ...executions.map(executionExitCode));
1760
+ };
1761
+ const commandNodeMesh = async (ctx, nodeId) => {
1762
+ const body = {};
1763
+ const modelId = getFlag(ctx.flags, 'model-id');
1764
+ if (modelId)
1765
+ body.modelId = modelId;
1766
+ const name = getFlag(ctx.flags, 'name');
1767
+ if (name)
1768
+ body.name = name;
1769
+ if (hasFlag(ctx.flags, 'face-limit'))
1770
+ body.faceLimit = parsePositiveIntegerFlag(ctx.flags, 'face-limit', 1);
1771
+ if (hasFlag(ctx.flags, 'low-poly'))
1772
+ body.isLowPoly = parseBooleanFlag(ctx.flags, 'low-poly');
1773
+ if (hasFlag(ctx.flags, 'params-json')) {
1774
+ const params = await readJsonArgument(requireFlag(ctx.flags, 'params-json'), '--params-json');
1775
+ if (Object.values(params).some(value => typeof value !== 'string' &&
1776
+ typeof value !== 'boolean' &&
1777
+ !(typeof value === 'number' && Number.isFinite(value))))
1778
+ throw new Error('--params-json values must be strings, booleans, or finite numbers');
1779
+ body.params = params;
1780
+ }
1781
+ const operationId = getFlag(ctx.flags, 'operation-id');
1782
+ const canvasId = selectedCanvasId(ctx.flags);
1783
+ const resumeOptions = {
1784
+ ...stateOptions(ctx),
1785
+ expectedCanvasId: canvasId,
1786
+ expectedCanvasNodeId: nodeId,
1787
+ expectedBody: body,
1788
+ };
1789
+ if (operationId) {
1790
+ const batch = await resumeNodeMeshBatch({ ...resumeOptions, operationId });
1791
+ if (batch) {
1792
+ await printNodeMeshBatch(ctx, batch);
1793
+ return;
1794
+ }
1795
+ if (await hasRecordedOperation(resumeOptions.stateDir, operationId)) {
1796
+ await printRecorded(ctx, await resumeRecorded({
1797
+ ...resumeOptions,
1798
+ operationId,
1799
+ expectedOperation: 'mesh.generate',
1800
+ }));
1801
+ return;
1802
+ }
1803
+ }
1804
+ const session = await openExecutionSession({
1805
+ ...stateOptions(ctx),
1806
+ canvasId,
1807
+ operation: 'mesh.generate',
1808
+ create: false,
1809
+ });
1810
+ const { items } = await ctx.client.v2.listCanvasNodes(session.canvas.id);
1811
+ const target = items.find(item => item.nodeId === nodeId);
1812
+ if (!target)
1813
+ throw new Error('Canvas node is unavailable; read canvas nodes again');
1814
+ const finishedOrBusy = (status) => ['complete', 'completed', 'generating'].includes(status ?? '');
1815
+ if (target.type === 'production-3d') {
1816
+ if (!target.actions.includes('mesh.generate'))
1817
+ throw new Error('Mesh generation is unavailable for this node');
1818
+ const children = items.filter(item => item.sourceNodeId === nodeId &&
1819
+ ['mesh-gen', 'preview-asset', 'part-group'].includes(item.type));
1820
+ if (!children.length)
1821
+ throw new Error('Production node has no saved part nodes to generate');
1822
+ const pending = children.filter(item => item.actions.includes('mesh.generate') &&
1823
+ !finishedOrBusy(item.meshStatus));
1824
+ const batchId = operationId ?? randomUUID();
1825
+ stderr.write(`[nodes] batch ${batchId}\n`);
1826
+ await printNodeMeshBatch(ctx, await executeNodeMeshBatch(session, {
1827
+ ...executionOptions(ctx.flags),
1828
+ operationId: batchId,
1829
+ nodeId,
1830
+ body,
1831
+ children: pending.map(item => item.nodeId),
1832
+ skipped: children
1833
+ .filter(item => !pending.includes(item))
1834
+ .map(({ nodeId, meshStatus }) => ({ nodeId, meshStatus })),
1835
+ }));
1836
+ return;
1837
+ }
1838
+ if (!['mesh-gen', 'preview-asset', 'part-group'].includes(target.type))
1839
+ throw new Error('This canvas node cannot generate a mesh');
1840
+ if (target.meshStatus === 'generating' ||
1841
+ (target.type !== 'mesh-gen' && finishedOrBusy(target.meshStatus))) {
1842
+ print({ nodeId, skipped: true, meshStatus: target.meshStatus });
1843
+ return;
1844
+ }
1845
+ if (!target.actions.includes('mesh.generate'))
1846
+ throw new Error('Mesh generation is unavailable for this node');
1847
+ await printRecorded(ctx, await executeRecorded(session, 'mesh.generate', body, {
1848
+ ...executionOptions(ctx.flags),
1849
+ canvasNode: { nodeId },
1850
+ }));
1851
+ };
1642
1852
  const commandImage = async (subcommand, ctx) => {
1643
1853
  if (subcommand !== 'generate')
1644
1854
  throw new Error('Unknown image command. Use "image generate".');
@@ -1746,6 +1956,54 @@ const refinementModeInfo = (capabilities, mode) => {
1746
1956
  throw new Error(`Refinement mode ${mode} is unavailable${info.reason ? `: ${info.reason}` : ''}`);
1747
1957
  return info;
1748
1958
  };
1959
+ /** Reuse the frozen sources and latest editable scene, never replay server-only receipt fields. */
1960
+ const composerInputFromRun = (previous, operation) => {
1961
+ const source = previous.resolvedInput ?? previous.input ?? previous.requestedInput ?? {};
1962
+ const fields = operation === 'mesh.compose'
1963
+ ? [
1964
+ 'fullBodyImageAssetId',
1965
+ 'agentVersion',
1966
+ 'agentRuntime',
1967
+ 'mode',
1968
+ 'targetCharacterHeightM',
1969
+ 'projectName',
1970
+ 'decimateRatio',
1971
+ 'quickRunId',
1972
+ 'quickScene',
1973
+ ]
1974
+ : ['fullBodyImageAssetId', 'agentRuntime', 'mode', 'instruction', 'maxRounds'];
1975
+ const input = Object.fromEntries(fields
1976
+ .filter(key => source[key] !== undefined)
1977
+ .map(key => [key, source[key]]));
1978
+ const parts = previous.composition?.parts ?? source.parts;
1979
+ const partFields = operation === 'mesh.compose'
1980
+ ? ['assetId', 'name', 'canonicalKey', 'partImageAssetId', 'partTaskId']
1981
+ : [
1982
+ 'assetId',
1983
+ 'name',
1984
+ 'canonicalKey',
1985
+ 'volumeCentroid',
1986
+ 'sourceAssetId',
1987
+ 'transform',
1988
+ ];
1989
+ input.parts = Array.isArray(parts)
1990
+ ? parts.map(part => part && typeof part === 'object'
1991
+ ? Object.fromEntries(Object.entries(part).filter(([key]) => partFields.includes(key)))
1992
+ : part)
1993
+ : parts;
1994
+ const transforms = previous.composition?.transforms ?? source.transforms;
1995
+ if (transforms != null)
1996
+ input.transforms = transforms;
1997
+ if (operation === 'mesh.refine') {
1998
+ const referenceTransform = previous.composition?.referenceTransform ?? source.referenceTransform;
1999
+ if (referenceTransform != null)
2000
+ input.referenceTransform = referenceTransform;
2001
+ // Compose modes are not refinement modes; preserve a prior refinement's mode only.
2002
+ if (previous.operation !== 'mesh.refine')
2003
+ delete input.mode;
2004
+ }
2005
+ return input;
2006
+ };
1749
2007
  const commandComposerRefine = async (ctx) => {
1750
2008
  const capabilities = await ctx.client.v2.getMeshRefinementModes();
1751
2009
  if (hasFlag(ctx.flags, 'list-modes')) {
@@ -1769,20 +2027,7 @@ const commandComposerRefine = async (ctx) => {
1769
2027
  const input = json
1770
2028
  ? await readJsonArgument(json, '--input-json')
1771
2029
  : 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
- }
2030
+ ? composerInputFromRun(previous, 'mesh.refine')
1786
2031
  : {
1787
2032
  parts: parts.map(assetId => ({
1788
2033
  assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
@@ -1850,6 +2095,9 @@ const commandComposerRefine = async (ctx) => {
1850
2095
  mode,
1851
2096
  instruction,
1852
2097
  ...(maxRounds != null ? { maxRounds } : {}),
2098
+ ...(input.agentRuntime
2099
+ ? { agentRuntime: input.agentRuntime }
2100
+ : {}),
1853
2101
  };
1854
2102
  await printRecorded(ctx, await executeRecorded(session, 'mesh.refine', body, {
1855
2103
  ...executionOptions(ctx.flags),
@@ -1867,6 +2115,7 @@ const commandComposer = async (subcommand, ctx) => {
1867
2115
  }
1868
2116
  if (subcommand !== 'run')
1869
2117
  throw new Error('Use composer models|run');
2118
+ const nodeId = canvasNodeFlag(ctx.flags);
1870
2119
  const fromRun = getFlag(ctx.flags, 'from-run');
1871
2120
  const json = getFlag(ctx.flags, 'input-json');
1872
2121
  const parts = getFlagValues(ctx.flags, 'part');
@@ -1876,20 +2125,23 @@ const commandComposer = async (subcommand, ctx) => {
1876
2125
  const previous = fromRun ? await ctx.client.v2.getRun(fromRun) : undefined;
1877
2126
  if (previous && previous.operation !== 'mesh.compose')
1878
2127
  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
- };
2128
+ const input = nodeId
2129
+ ? {}
2130
+ : previous
2131
+ ? composerInputFromRun(previous, 'mesh.compose')
2132
+ : json
2133
+ ? await readJsonArgument(json, '--input-json')
2134
+ : {
2135
+ parts: parts.map(assetId => ({
2136
+ assetId: assertFlagHasValue(assetId, '--part <asset-id>'),
2137
+ })),
2138
+ fullBodyImageAssetId: requireFlag(ctx.flags, 'reference'),
2139
+ };
1889
2140
  delete input.executionContext;
1890
- if (!Array.isArray(input.parts) ||
1891
- !input.parts.length ||
1892
- !input.fullBodyImageAssetId)
2141
+ if (!nodeId &&
2142
+ (!Array.isArray(input.parts) ||
2143
+ !input.parts.length ||
2144
+ !input.fullBodyImageAssetId))
1893
2145
  throw new Error('Composer requires parts[] and fullBodyImageAssetId');
1894
2146
  const mode = getFlag(ctx.flags, 'mode');
1895
2147
  if (mode != null && mode !== 'quick' && mode !== 'quality')
@@ -1905,17 +2157,48 @@ const commandComposer = async (subcommand, ctx) => {
1905
2157
  const transforms = getFlag(ctx.flags, 'transforms-json');
1906
2158
  if (transforms)
1907
2159
  input.transforms = await readJsonArgument(transforms, '--transforms-json');
2160
+ if (previous && input.transforms) {
2161
+ delete input.quickRunId;
2162
+ delete input.quickScene;
2163
+ }
1908
2164
  const canvasId = selectedCanvasId(ctx.flags) ?? previous?.canvas.id;
1909
2165
  if (previous && canvasId !== previous.canvas.id)
1910
2166
  throw new Error('Recomposition must use the original canvas to retain lineage');
2167
+ const operationId = getFlag(ctx.flags, 'operation-id');
2168
+ if (nodeId && operationId) {
2169
+ const resumeOptions = {
2170
+ ...stateOptions(ctx),
2171
+ operationId,
2172
+ expectedCanvasId: canvasId,
2173
+ expectedCanvasNodeId: nodeId,
2174
+ expectedOperation: 'mesh.compose',
2175
+ expectedBody: input,
2176
+ };
2177
+ // A mesh batch reserves its root key even though its paid requests have child keys.
2178
+ await resumeNodeMeshBatch(resumeOptions);
2179
+ if (await hasRecordedOperation(resumeOptions.stateDir, operationId)) {
2180
+ await printRecorded(ctx, await resumeRecorded(resumeOptions));
2181
+ return;
2182
+ }
2183
+ }
1911
2184
  const session = await openExecutionSession({
1912
2185
  ...stateOptions(ctx),
1913
2186
  canvasId,
1914
2187
  operation: 'mesh.compose',
2188
+ ...(nodeId ? { create: false } : {}),
1915
2189
  });
2190
+ if (nodeId) {
2191
+ const { items } = await ctx.client.v2.listCanvasNodes(session.canvas.id);
2192
+ const target = items.find(item => item.nodeId === nodeId);
2193
+ if (!target ||
2194
+ !['production-3d', 'part-composer'].includes(target.type) ||
2195
+ !target.actions.includes('mesh.compose'))
2196
+ throw new Error('Composition is unavailable for this canvas node');
2197
+ }
1916
2198
  const queued = await executeRecorded(session, 'mesh.compose', input, {
1917
2199
  ...executionOptions(ctx.flags),
1918
2200
  ...(previous ? { parentRunId: previous.runId } : {}),
2201
+ ...(nodeId ? { canvasNode: { nodeId } } : {}),
1919
2202
  });
1920
2203
  await printRecorded(ctx, queued);
1921
2204
  };
@@ -1939,6 +2222,11 @@ const commandMesh = async (subcommand, positionals, ctx) => {
1939
2222
  return;
1940
2223
  }
1941
2224
  if (subcommand === 'generate') {
2225
+ const nodeId = canvasNodeFlag(ctx.flags);
2226
+ if (nodeId) {
2227
+ await commandNodeMesh(ctx, nodeId);
2228
+ return;
2229
+ }
1942
2230
  const inputJson = getFlag(ctx.flags, 'input-json');
1943
2231
  const input = inputJson
1944
2232
  ? await readJsonArgument(inputJson, '--input-json')
@@ -2569,12 +2857,6 @@ const productionSession = (ctx, operation) => openExecutionSession({
2569
2857
  canvasId: selectedCanvasId(ctx.flags),
2570
2858
  operation,
2571
2859
  });
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
2860
  const commandProduction = async (subcommand, positionals, ctx) => {
2579
2861
  if (subcommand === 'agents') {
2580
2862
  print(sanitizeProductionOutput(await ctx.client.v1.getProductionAgents()));
@@ -2814,7 +3096,7 @@ const commandParts = async (subcommand, ctx) => {
2814
3096
  ...stateOptions(ctx),
2815
3097
  operationId: executeOperationId,
2816
3098
  expectedCanvasId: session.canvas.id,
2817
- expectedBody: {
3099
+ expectedBodySubset: {
2818
3100
  partExtractionMode: getFlag(ctx.flags, 'part-extraction-mode'),
2819
3101
  ...(getFlagValues(ctx.flags, 'task-id').length
2820
3102
  ? { confirmedTaskIds: getFlagValues(ctx.flags, 'task-id') }
@@ -3025,6 +3307,11 @@ const canvasForRead = (ctx) => openExecutionSession({
3025
3307
  create: false,
3026
3308
  });
3027
3309
  const commandCanvas = async (subcommand, positionals, ctx) => {
3310
+ if (subcommand === 'nodes') {
3311
+ const { canvas } = await canvasForRead(ctx);
3312
+ print(await ctx.client.v2.listCanvasNodes(canvas.id));
3313
+ return;
3314
+ }
3028
3315
  if (subcommand === 'download') {
3029
3316
  const outDir = requireFlag(ctx.flags, 'out-dir');
3030
3317
  const { canvas } = await canvasForRead(ctx);
@@ -3357,6 +3644,14 @@ const commandRuns = async (subcommand, positionals, ctx) => {
3357
3644
  return;
3358
3645
  }
3359
3646
  if (subcommand === 'resume') {
3647
+ const batch = await resumeNodeMeshBatch({
3648
+ ...stateOptions(ctx),
3649
+ operationId: requirePositional(positionals, 2, 'operation-id'),
3650
+ });
3651
+ if (batch) {
3652
+ await printNodeMeshBatch(ctx, batch);
3653
+ return;
3654
+ }
3360
3655
  const result = await resumeRecorded({
3361
3656
  ...stateOptions(ctx),
3362
3657
  operationId: requirePositional(positionals, 2, 'operation-id'),
@@ -3389,6 +3684,7 @@ const commandRuns = async (subcommand, positionals, ctx) => {
3389
3684
  plan,
3390
3685
  baseUrl: ctx.auth.baseUrl,
3391
3686
  apiKey: ctx.auth.apiKey,
3687
+ workspaceId: ctx.auth.workspaceId,
3392
3688
  skipRegister: args.skipRegister,
3393
3689
  onProgress: event => {
3394
3690
  if (event.kind === 'register') {
@@ -3509,7 +3805,17 @@ const run = async () => {
3509
3805
  if (command === 'mcp') {
3510
3806
  if (subcommand !== 'config')
3511
3807
  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')));
3808
+ const config = await readAuthConfig(getConfigPath(parsed.flags));
3809
+ const explicitProfile = getFlag(parsed.flags, 'profile');
3810
+ const stored = config.profiles?.[explicitProfile ?? config.defaultProfile ?? defaultProfileName];
3811
+ if (explicitProfile && !stored)
3812
+ throw new Error('Selected profile does not exist');
3813
+ const selected = explicitProfile || stored?.workspaceId || stored?.apiKey?.startsWith('ah_pat_');
3814
+ const baseUrl = getFlag(parsed.flags, 'base-url') ??
3815
+ (selected ? stored?.baseUrl : env.ASSETHUB_API_BASE_URL ?? stored?.baseUrl) ?? defaultBaseUrl;
3816
+ const workspaceId = getFlag(parsed.flags, 'workspace') ??
3817
+ (stored && new URL(baseUrl).origin === new URL(stored.baseUrl).origin ? stored.workspaceId : undefined);
3818
+ stdout.write(mcpConfig(requireFlag(parsed.flags, 'client'), baseUrl, hasFlag(parsed.flags, 'account'), workspaceId));
3513
3819
  return;
3514
3820
  }
3515
3821
  if (command === 'doctor') {