@artblocks/abx-cli 0.1.0-alpha.4 → 0.1.0-alpha.6

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/main.js CHANGED
@@ -43,7 +43,7 @@
43
43
  * the tx for a multisig / offline signer). The agent picks the lane; the CLI signs.
44
44
  */
45
45
  import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
46
- import { randomBytes } from 'node:crypto';
46
+ import { createHash, randomBytes } from 'node:crypto';
47
47
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
48
48
  import { fileURLToPath } from 'node:url';
49
49
  import { homedir } from 'node:os';
@@ -60,6 +60,7 @@ import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkill
60
60
  import { analyzeScript, recommendLane } from './inspect.js';
61
61
  import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
62
62
  import { parseSchemaSpecs, describeSchema } from './schema.js';
63
+ import { declinesSkillInstall } from './prompt.js';
63
64
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
64
65
  /** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
65
66
  * to the on-chain enums (PARAM_TYPES / AUTH_OPTIONS) + the Select-options / Range-bounds format —
@@ -77,7 +78,7 @@ import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureP
77
78
  import { openWalletSession, signTx } from './signer.js';
78
79
  import { remoteAddProject, remoteRemoveProject } from './remote.js';
79
80
  import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
80
- import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
81
+ import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, faucetHint, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
81
82
  const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
82
83
  // Block explorer base per chain (keyed by EIP-155 chainId), for the tx/address links the CLI prints.
83
84
  const EXPLORERS = {
@@ -297,17 +298,6 @@ async function detectCanonicalFactory(address, override, stored) {
297
298
  }
298
299
  return factoryAddress() ?? undefined;
299
300
  }
300
- /** A short faucet pointer for the active testnet — a 0-balance signer's #1 next step. A known URL
301
- * plus a search hint (URLs rot; the search always works), so "fund it" isn't a dead end. */
302
- function faucetHint(chainKey) {
303
- const url = {
304
- 'base-sepolia': 'https://portal.cdp.coinbase.com/products/faucet',
305
- sepolia: 'https://www.alchemy.com/faucets/ethereum-sepolia',
306
- };
307
- return url[chainKey]
308
- ? `get free test ETH from a ${chainKey} faucet (${url[chainKey]} — or search "${chainKey} faucet"), usually ≤1 min`
309
- : `get test ETH from a "${chainKey}" faucet`;
310
- }
311
301
  // Funding preflight — a 0-balance signer fails only at the tx, with a confusing error. Surface it
312
302
  // up front. Especially the wallet lane (--sign/--for), which has no env key for `doctor` to check.
313
303
  async function warnUnfunded(publicClient, address) {
@@ -986,7 +976,9 @@ async function cmdDeploy(flags, serveAfter) {
986
976
  // editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
987
977
  // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
988
978
  const traits = parseDeployTraits(flags);
989
- console.log(bold(`\n ABX Self-Host Toolkit — ${serveAfter ? 'demo' : 'deploy'}\n ${dim('a project, served from chain alone')}`));
979
+ console.log(serveAfter
980
+ ? bold(`\n ABX · your first token\n ${dim("we'll put art on a blockchain — then delete our copy and get all of it back")}`)
981
+ : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
990
982
  // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
991
983
  // env key it died confusingly, and WITH one it signed from that key while the operator had asked
992
984
  // for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
@@ -1005,6 +997,17 @@ async function cmdDeploy(flags, serveAfter) {
1005
997
  throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
1006
998
  'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
1007
999
  }
1000
+ // Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
1001
+ // the first thing to discover the port was taken — after the deploy tx had already been signed and
1002
+ // paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
1003
+ // Node EADDRINUSE stack trace. Check first, and name the fix.
1004
+ if (serveAfter) {
1005
+ const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1006
+ if (await portInUse(wanted)) {
1007
+ throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
1008
+ ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1009
+ }
1010
+ }
1008
1011
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1009
1012
  const publicClient = makePublicClient({ chainKey: CHAIN });
1010
1013
  // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
@@ -1035,7 +1038,7 @@ async function cmdDeploy(flags, serveAfter) {
1035
1038
  if (signer)
1036
1039
  await warnUnfunded(publicClient, signer);
1037
1040
  }
1038
- step('Trust anchor');
1041
+ step(serveAfter ? 'Who vouches for this token?' : 'Trust anchor');
1039
1042
  let factory;
1040
1043
  if (dryRun) {
1041
1044
  const existing = factoryAddress(flags.factory);
@@ -1050,24 +1053,47 @@ async function cmdDeploy(flags, serveAfter) {
1050
1053
  else {
1051
1054
  factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
1052
1055
  }
1056
+ // The demo explains what it just did; `deploy` stays terse. The authenticity point is the least
1057
+ // obvious thing about ABX and the easiest to get wrong, so it's worth spelling out — but in plain
1058
+ // language, with the actual term parked in a dim aside for whoever wants to look it up.
1059
+ if (serveAfter) {
1060
+ console.log(` ${g('✦')} anyone can write a contract that ${bold('claims')} to be an ABX token`);
1061
+ console.log(` ${g('✦')} only this one factory can make a token that ${bold('is')} one`);
1062
+ info(dim('so "is this real?" is a yes/no question anyone can ask the chain — not a matter of trusting us'));
1063
+ info(dim(`the factory answers it on-chain: isAbxClone(yourToken) · ${EXPLORER}/address/${factory}`));
1064
+ }
1053
1065
  // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
1054
1066
  // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
1055
1067
  // URLs are still baked as a fallback if the renderer is later cleared.
1056
1068
  // --onchain-image: stage the --image bytes on-chain (chunk store, ownerless) and bake a
1057
1069
  // `reader` field into the deploy — so large on-chain content needs NO post-deploy tx. It
1058
1070
  // implies on-chain URI resolution (the renderer emits the reader-backed image).
1059
- const onchainImage = !!flags['onchain-image'];
1060
- const onChainUri = !!flags['onchain-uri'] || onchainImage;
1061
- // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
1062
- // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
1063
- const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
1064
1071
  // A fully on-chain token self-resolves via the renderer; the stored off-chain pointer is
1065
1072
  // never read while a renderer is set. So bake a real URL only if one was explicitly given —
1066
1073
  // otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
1067
1074
  const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
1075
+ const onchainImage = !!flags['onchain-image'];
1076
+ // `abx demo` defaults to FULLY ON-CHAIN. Its default art is a generative SVG, which the renderer
1077
+ // can inline — so the token self-resolves and, crucially, NO localhost gets baked into the
1078
+ // contract as the tokenURI base. The old default shipped a first-ever token that resolved for
1079
+ // nobody but its author (broken on every marketplace, dead the moment `abx serve` stops) and
1080
+ // taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the art
1081
+ // on-chain, "rebuilt from the chain alone" now covers the IMAGE, not just the metadata.
1082
+ //
1083
+ // Opting back into off-chain custody is anything that says "I have somewhere to host": a real base
1084
+ // URL, an explicit --backend, or a RASTER --image. An SVG --image still goes on-chain — inlining is
1085
+ // exactly what the renderer supports (v1 is SVG-only), so there's no reason to send someone's own
1086
+ // vector art down the localhost path. Raster stays off-chain because forcing it on-chain would
1087
+ // silently inline a placeholder instead of their image.
1088
+ const demoImageInlineable = !flags.image || contentTypeFromPath(flags.image) === 'image/svg+xml';
1089
+ const demoDefaultsOnChain = serveAfter && demoImageInlineable && !flags.backend && !hasPublicUrl;
1090
+ const onChainUri = !!flags['onchain-uri'] || onchainImage || demoDefaultsOnChain;
1091
+ // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
1092
+ // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
1093
+ const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
1068
1094
  let renderer = zeroAddress;
1069
1095
  if (onChainUri) {
1070
- step('On-chain renderer');
1096
+ step(serveAfter ? 'What will answer when someone asks about your token' : 'On-chain renderer');
1071
1097
  if (dryRun) {
1072
1098
  renderer = rendererAddress(flags.renderer) ?? zeroAddress;
1073
1099
  info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `would use renderer ${renderer}`);
@@ -1075,8 +1101,28 @@ async function cmdDeploy(flags, serveAfter) {
1075
1101
  else {
1076
1102
  renderer = await ensureRenderer(flags.renderer);
1077
1103
  }
1104
+ // A first-timer has no idea what a "renderer" is, and the word suggests something that draws
1105
+ // pictures. What it actually does is assemble the JSON a marketplace asks for, on-chain, out of
1106
+ // the fields your contract holds — worth one plain sentence, since it's why no server is needed.
1107
+ if (serveAfter) {
1108
+ info(dim('a marketplace asks your contract a question; this shared contract composes the answer'));
1109
+ info(dim('already deployed, used by every ABX token, owned by no one — you are not paying to set it up'));
1110
+ }
1111
+ }
1112
+ step(serveAfter ? `Mint it · one transaction on ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1113
+ // What the chain ends up holding is the single most useful thing to understand about an ABX token,
1114
+ // and it's invisible unless someone says it out loud. Two honest versions, because the answer is
1115
+ // genuinely different per lane. Demo only — `deploy` prints the same facts per-field.
1116
+ if (serveAfter && onChainUri) {
1117
+ console.log(` Your art goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
1118
+ console.log(` ${g('✦')} the chain will hold ${dim('your art · your name on it · your royalty · you as owner')}`);
1119
+ console.log(` ${g('✦')} you will need ${dim('nothing else. no server, no IPFS pin, no monthly bill to forget')}`);
1120
+ }
1121
+ else if (serveAfter) {
1122
+ console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the art')}`);
1123
+ console.log(` ${g('✦')} this computer holds ${dim('the image bytes themselves')}`);
1124
+ info(dim('the chain proves the bytes are unaltered; it does not store them (that fingerprint is a keccak256 hash)'));
1078
1125
  }
1079
- step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1080
1126
  // Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
1081
1127
  // tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
1082
1128
  // not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
@@ -1103,7 +1149,14 @@ async function cmdDeploy(flags, serveAfter) {
1103
1149
  }
1104
1150
  }
1105
1151
  }
1106
- if (onChainUri) {
1152
+ if (onChainUri && serveAfter) {
1153
+ // The demo's version of the same fact, in words a first-timer can act on. Naming what we are
1154
+ // NOT doing matters here: a localhost URL written into a contract is the single most common way
1155
+ // a first NFT ends up permanently broken, and the demo used to model exactly that.
1156
+ info(dim('nothing points at this computer — no http://localhost anywhere in your contract.'));
1157
+ info(dim('anyone can read your token from the chain, forever, with you offline.'));
1158
+ }
1159
+ else if (onChainUri) {
1107
1160
  info('tokenURI/contractURI resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.');
1108
1161
  if (!hasPublicUrl)
1109
1162
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
@@ -1280,7 +1333,9 @@ async function cmdDeploy(flags, serveAfter) {
1280
1333
  // wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy.
1281
1334
  // The connecting wallet pays for (and is the deployer of) all of it — staging can't precede
1282
1335
  // the connect here, so it happens inside the session, then the deploy bakes in the manifest.
1283
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1336
+ // --onchain-image implies on-chain resolution, so NOTHING points at baseUrl — saying it did was
1337
+ // a flat contradiction of the line above it (and reintroduced the localhost the lane exists to avoid).
1338
+ info('a wallet will become the owner; the token resolves from chain — no URI base is baked in.');
1284
1339
  const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1285
1340
  const stagingTxs = plan.mode === 'single' ? 1 : plan.txCount;
1286
1341
  const session = await openWalletSession({
@@ -1345,8 +1400,12 @@ async function cmdDeploy(flags, serveAfter) {
1345
1400
  info(`tx ${EXPLORER}/tx/${r.txHash} (block ${blockNumber})`);
1346
1401
  }
1347
1402
  else {
1348
- // wallet lane (off-chain custody) or cold lane: a single deploy tx, no staging sequence.
1349
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1403
+ // wallet lane (off-chain custody OR on-chain URI) or cold lane: a single deploy tx, no staging.
1404
+ // Only claim a URI base when one is actually written — on the on-chain lane this line used to
1405
+ // announce `http://localhost:8787` two lines after promising no localhost anywhere.
1406
+ info(onChainUri
1407
+ ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
1408
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1350
1409
  const result = await signTx(async (signer) => {
1351
1410
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1352
1411
  return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
@@ -1359,7 +1418,7 @@ async function cmdDeploy(flags, serveAfter) {
1359
1418
  blockNumber = result.blockNumber;
1360
1419
  ok(`deployed ${clone}`);
1361
1420
  }
1362
- step('Index it — replay the event spine from chain');
1421
+ step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1363
1422
  const indexer = new SelfHostIndexer();
1364
1423
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
1365
1424
  const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
@@ -1374,10 +1433,20 @@ async function cmdDeploy(flags, serveAfter) {
1374
1433
  attributes: offChainTraits,
1375
1434
  };
1376
1435
  indexer.register(baseReg);
1377
- const { state, elapsedMs } = await indexer.reindex(clone);
1378
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1379
- info(`name "${state.name}" · owner ${state.owner} · canonical: ${state.isCanonical ? 'yes (factory-verified)' : 'unverified'}`);
1380
- info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1436
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1437
+ if (serveAfter) {
1438
+ if (state.eventCount > 0)
1439
+ ok(`read ${bold(String(state.eventCount))} events straight off ${CHAIN} in ${elapsedMs}ms — no API key, no company's server`);
1440
+ info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? `${g('verified real')} ${dim('(that factory vouched for it)')}` : `${c.orange}unverified${c.reset}`}`);
1441
+ info(dim(`optional features switched on: ${state.extensions.map((e) => e.name.replace(/^abx\.extension\./, '')).join(' · ') || 'none'}`));
1442
+ walkthroughSpine(state);
1443
+ }
1444
+ else {
1445
+ if (state.eventCount > 0)
1446
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1447
+ info(`name "${state.name}" · owner ${state.owner} · canonical: ${state.isCanonical ? 'yes (factory-verified)' : 'unverified'}`);
1448
+ info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1449
+ }
1381
1450
  // For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
1382
1451
  // from this machine's content index and store it on the registration — so the LOCAL resolver
1383
1452
  // points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
@@ -1426,8 +1495,21 @@ async function cmdDeploy(flags, serveAfter) {
1426
1495
  }
1427
1496
  return;
1428
1497
  }
1429
- step('Serve the token API + dashboard');
1498
+ // The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
1499
+ // shipping real work doesn't need their projection deleted to make a point.
1500
+ if (serveAfter)
1501
+ await walkthroughRebuild(indexer, clone, state);
1502
+ // Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
1503
+ // print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
1504
+ // while the read-back prints below, which reads like the step failed.
1430
1505
  const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
1506
+ if (serveAfter)
1507
+ await walkthroughReadBack(state, url, onChainUri);
1508
+ // On the on-chain lane this server is a convenience, not infrastructure — say so, or standing one
1509
+ // up as the finale re-teaches the dependency the whole run just disproved.
1510
+ step(onChainUri ? 'Go look at it' : 'Serve the token API + dashboard');
1511
+ if (onChainUri)
1512
+ info(dim('a local viewer, purely for your eyes — your token does not need it. Ctrl-C whenever; the token stays up.'));
1431
1513
  printServing(url, clone);
1432
1514
  keepAlive();
1433
1515
  }
@@ -1869,8 +1951,11 @@ async function cmdDeploySeries(flags) {
1869
1951
  }
1870
1952
  else {
1871
1953
  // wallet lane (off-chain / inline) or cold lane: a single deploy tx, no staging sequence —
1872
- // tokenFields is already built.
1873
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1954
+ // tokenFields is already built. Same correction as the 1/1 lane: only claim a URI base when one
1955
+ // is actually written, or an --onchain-uri Series announces a localhost it never bakes.
1956
+ info(onChainUri
1957
+ ? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
1958
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1874
1959
  const result = await signTx(async (signer) => {
1875
1960
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1876
1961
  return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
@@ -1898,8 +1983,9 @@ async function cmdDeploySeries(flags) {
1898
1983
  tokenAttributes: offChainTokenTraits,
1899
1984
  };
1900
1985
  indexer.register(baseReg);
1901
- const { state, elapsedMs } = await indexer.reindex(clone);
1902
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1986
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1987
+ if (state.eventCount > 0)
1988
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1903
1989
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1904
1990
  // Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
1905
1991
  // via `abx add --remote`, a hosted one) points images off this node.
@@ -2163,7 +2249,11 @@ async function cmdAdd(address, flags) {
2163
2249
  info(`scanning blocks ${start}${dim(' → ')}${head} ${dim(`(~${span} blocks)`)} — on a range-capped RPC (see ${g('abx doctor')}) this can take a few minutes with no per-block output; leave it running.`);
2164
2250
  }
2165
2251
  catch { /* advisory only — the real scan still runs */ }
2166
- const { state, elapsedMs } = await indexer.reindex(address);
2252
+ // Don't accept a zero here either: `deploy-code` finishes through this command, so this IS the
2253
+ // post-deploy index for a code project — and a real ABX clone always emits a spine (its extension
2254
+ // registrations at minimum), so 0 events means the RPC hasn't served the logs yet, not that the
2255
+ // project is empty. See reindexAfterDeploy.
2256
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, address);
2167
2257
  // Resolve durable locators for off-chain-by-hash content from this machine's index.
2168
2258
  const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
2169
2259
  if (Object.keys(locators).length) {
@@ -2180,7 +2270,14 @@ async function cmdAdd(address, flags) {
2180
2270
  contentLocators: JSON.stringify(locators),
2181
2271
  });
2182
2272
  }
2183
- ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2273
+ // A on 0 events is the lie that produced an empty dashboard; reindexAfterDeploy has already
2274
+ // explained the failure and named the recovery command, so don't stamp it as success too.
2275
+ if (state.eventCount > 0) {
2276
+ ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2277
+ }
2278
+ else {
2279
+ warn(`registered ${state.name ?? address}, but with NO reconstructed state — it will serve empty until the index succeeds.`);
2280
+ }
2184
2281
  info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
2185
2282
  }
2186
2283
  /** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
@@ -2247,6 +2344,199 @@ async function ensureSeedSource(publicClient) {
2247
2344
  * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
2248
2345
  * Seeds: canonical randomizer by default; --no-seed opts out.
2249
2346
  */
2347
+ // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
2348
+ //
2349
+ // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
2350
+ // learn what the toolkit does on their behalf. Its old form asserted the interesting claims
2351
+ // ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
2352
+ // smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
2353
+ // chain now holds, throw the local projection away and rebuild it, then read the token back the way
2354
+ // a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
2355
+ // run has to behave identically.
2356
+ /**
2357
+ * The deterministic part of a projection: everything that is a pure function of the chain.
2358
+ * Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
2359
+ * happened to answer, and the head at scan time all legitimately differ between two replays, so
2360
+ * folding them in would make the rebuild proof fail for reasons that aren't about correctness.
2361
+ */
2362
+ function projectionFingerprint(s) {
2363
+ const canonical = {
2364
+ address: s.address.toLowerCase(),
2365
+ name: s.name,
2366
+ symbol: s.symbol,
2367
+ owner: s.owner?.toLowerCase() ?? null,
2368
+ isCanonical: s.isCanonical,
2369
+ deployBlock: s.deployBlock,
2370
+ eventCount: s.eventCount,
2371
+ royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
2372
+ extensions: s.extensions.map((e) => e.name).sort(),
2373
+ collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
2374
+ tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
2375
+ events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
2376
+ };
2377
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
2378
+ }
2379
+ /**
2380
+ * Print the reconstructed spine — the point being that this list IS the database. Rendered inside
2381
+ * the index step (no header of its own: "Index it — replay the event spine" immediately followed by
2382
+ * a separate "The event spine" step read as a stutter).
2383
+ */
2384
+ function walkthroughSpine(state) {
2385
+ if (state.events.length === 0) {
2386
+ warn('no events to show (the index came back empty — see the recovery hint above).');
2387
+ return;
2388
+ }
2389
+ const width = Math.max(...state.events.map((e) => e.name.length));
2390
+ state.events.forEach((e, i) => {
2391
+ // ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
2392
+ // own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
2393
+ const reg = e.register === 2 ? p('ABX') : dim('ERC');
2394
+ console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
2395
+ });
2396
+ console.log(` ${dim('→')} those ${bold(String(state.events.length))} lines ${bold('are')} the database. ` +
2397
+ dim('There is no other copy that counts — not ours, not anyone\'s.'));
2398
+ console.log(` ${p('ABX')} ${dim('= ABX\'s own events')} ${dim('ERC')} ${dim('= bog-standard ERC-721/7572, which is why wallets and marketplaces that have never heard of ABX still show your token.')}`);
2399
+ }
2400
+ /**
2401
+ * [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
2402
+ *
2403
+ * This is the one step that can't be faked by good output — it drops the projection for real
2404
+ * (registration kept), confirms it's gone, replays from the deploy block, and compares a
2405
+ * fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
2406
+ */
2407
+ async function walkthroughRebuild(indexer, address, before) {
2408
+ step('The moment of truth · delete it all');
2409
+ const fpBefore = projectionFingerprint(before);
2410
+ indexer.dropProjection(address);
2411
+ const gone = indexer.getProject(address) === null;
2412
+ console.log(` ${dim('wiping this computer\'s copy …')} ${gone ? g('gone. nothing left locally.') : `${c.orange}⚠ still present${c.reset}`}`);
2413
+ const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
2414
+ const fpAfter = projectionFingerprint(after);
2415
+ console.log(` ${dim(`asking ${CHAIN} to tell us everything again …`)} ` +
2416
+ `${g(`${after.eventCount} events, ${elapsedMs}ms`)}`);
2417
+ if (fpBefore === fpAfter) {
2418
+ ok(bold('byte-for-byte identical.'));
2419
+ info('Your token just survived losing every local file. No backup, no API key, no company —');
2420
+ info(`the chain remembered. ${dim('That is the whole point of ABX.')}`);
2421
+ info(dim(`checked by hashing every chain-derived field, not by eyeballing it: sha256 ${fpAfter.slice(0, 12)}…`));
2422
+ }
2423
+ else {
2424
+ warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
2425
+ info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
2426
+ }
2427
+ }
2428
+ /**
2429
+ * [5] Read the token back the way a marketplace would.
2430
+ *
2431
+ * Which is genuinely a different act per lane, so it reads from the real source in each case rather
2432
+ * than always going through the local server:
2433
+ * • fully on-chain → call `tokenURI(0)` on the contract. That IS what a marketplace does, and on
2434
+ * this lane the whole answer (art included) comes back from the chain with nothing else running.
2435
+ * • off-chain custody → fetch the resolver, because that's what the baked URI points at.
2436
+ * Reading the on-chain lane over HTTP would have quietly implied the local server was load-bearing
2437
+ * when it isn't — the opposite of the lesson.
2438
+ */
2439
+ async function walkthroughReadBack(state, baseUrl, onChainUri) {
2440
+ step('Read it back the way a marketplace would');
2441
+ const token = state.tokens[0];
2442
+ if (!token?.minted) {
2443
+ info('no minted token to read yet.');
2444
+ return;
2445
+ }
2446
+ if (onChainUri) {
2447
+ info(dim(`calling tokenURI(0) on your contract — the same call OpenSea makes …`));
2448
+ try {
2449
+ const uri = (await makePublicClient({ chainKey: CHAIN }).readContract({
2450
+ address: state.address,
2451
+ abi: oneOfOneImageAbi,
2452
+ functionName: 'tokenURI',
2453
+ args: [0n],
2454
+ }));
2455
+ const json = decodeOnChainJson(uri);
2456
+ if (json) {
2457
+ const parsed = JSON.parse(json);
2458
+ const img = typeof parsed.image === 'string' ? parsed.image : '';
2459
+ console.log(` ${g('✓')} came back with: ${bold(String(parsed.name ?? '(no name)'))}`);
2460
+ console.log(` ${dim(`image: ${img.slice(0, 48)}${img.length > 48 ? '…' : ''}`)}`);
2461
+ // The punchline of the whole lane: a data: URI means the art travelled IN the answer.
2462
+ if (img.startsWith('data:'))
2463
+ info(`${g('the art itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
2464
+ }
2465
+ else {
2466
+ console.log(` ${dim(uri.slice(0, 160))}${uri.length > 160 ? dim('…') : ''}`);
2467
+ }
2468
+ info(dim('nothing was running to answer that. no server of ours, no server of yours.'));
2469
+ }
2470
+ catch (e) {
2471
+ info(dim(`could not read tokenURI from the chain: ${e.message}`));
2472
+ }
2473
+ console.log(` ${dim('read it yourself any time:')} ${bold(`abx tokenuri ${state.address}`)}`);
2474
+ return;
2475
+ }
2476
+ info(`tokenURI(0) ${dim('on chain →')} ${token.tokenURI ?? dim('(none)')}`);
2477
+ // Off-chain custody: the CHAIN holds a keccak256 commitment to the image and a URI base; this NODE
2478
+ // holds the bytes. That split is the thing worth understanding, so name it rather than implying the
2479
+ // JSON came from the chain.
2480
+ info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
2481
+ try {
2482
+ const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
2483
+ const json = (await res.json());
2484
+ const shown = { name: json.name, image: json.image };
2485
+ console.log(` ${dim(JSON.stringify(shown))}`);
2486
+ if (Array.isArray(json.abx_provenance)) {
2487
+ info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
2488
+ }
2489
+ }
2490
+ catch (e) {
2491
+ info(dim(`could not read the served metadata: ${e.message}`));
2492
+ }
2493
+ console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2494
+ }
2495
+ /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2496
+ async function portInUse(port) {
2497
+ const { createServer } = await import('node:net');
2498
+ return new Promise((resolve) => {
2499
+ const probe = createServer();
2500
+ probe.once('error', (e) => resolve(e.code === 'EADDRINUSE'));
2501
+ probe.once('listening', () => probe.close(() => resolve(false)));
2502
+ probe.listen(port);
2503
+ });
2504
+ }
2505
+ /**
2506
+ * Index a project we *just* deployed — and don't believe a zero.
2507
+ *
2508
+ * `eth_getLogs` is read-after-write inconsistent on load-balanced RPCs: `waitForTransactionReceipt`
2509
+ * resolves against a node that has the block, then the log query lands on one that doesn't yet, and
2510
+ * returns an empty set for a block we KNOW contains our deploy. The old code took that single read at
2511
+ * face value, printed `✓ reconstructed 0 events`, stored the empty projection, and served an empty
2512
+ * dashboard — a first-run that looks like the toolkit simply doesn't work. It reproduced 100% of the
2513
+ * time on `https://sepolia.base.org`, which is the DEFAULT endpoint when there's no `.env`, i.e. the
2514
+ * documented first run was the broken path. The same block returned all 9 logs seconds later.
2515
+ *
2516
+ * We have the one thing that makes this checkable: we just minted, so the spine cannot be empty.
2517
+ * So verify instead of trusting — re-scan with backoff until events appear, and if they never do,
2518
+ * say so as a FAILURE with the recovery command rather than dressing a zero up as a ✓.
2519
+ */
2520
+ async function reindexAfterDeploy(indexer, address, opts = {}) {
2521
+ const attempts = opts.attempts ?? 6;
2522
+ const delayMs = opts.delayMs ?? 1500;
2523
+ let last = await indexer.reindex(address);
2524
+ for (let i = 1; i < attempts && last.state.eventCount === 0; i++) {
2525
+ if (i === 1) {
2526
+ info(dim("no events yet — the RPC hasn't served the logs for that block; re-scanning…"));
2527
+ }
2528
+ await new Promise((r) => setTimeout(r, delayMs));
2529
+ // `full: true` — the stored projection has 0 events, so there is no valid checkpoint to
2530
+ // resume from; a full replay from the deploy block is the only correct re-scan.
2531
+ last = await indexer.reindex(address, { full: true });
2532
+ }
2533
+ if (last.state.eventCount === 0) {
2534
+ warn(`the RPC still reports no logs for this project after ${attempts} tries — the deploy DID succeed ` +
2535
+ `(it's on chain), but this node can't reconstruct it yet.`);
2536
+ console.log(` ${dim('recover with')} ${bold(`abx index ${address} --full`)} ${dim('in a minute, or point ABX_RPC_URLS at a better endpoint (`abx doctor` ranks them).')}`);
2537
+ }
2538
+ return last;
2539
+ }
2250
2540
  /**
2251
2541
  * `abx preview` — serve the program on localhost, live, for as long as the work is being made.
2252
2542
  *
@@ -4258,6 +4548,44 @@ async function cmdMigrate(address, flags) {
4258
4548
  info('keep the source running until DNS / base-URI propagates (source-only images were already re-pinned above, unless a warning said otherwise).');
4259
4549
  }
4260
4550
  // ── doctor ────────────────────────────────────────────────────────────────--
4551
+ /**
4552
+ * Doctor's "want me to fix that?" for a missing or stale skill. Three lanes, deliberately:
4553
+ * `--fix` install without asking (CI, scripts, an agent running doctor for someone)
4554
+ * interactive name the exact directories, then ask — Enter accepts, since doctor's whole job is
4555
+ * getting setup right and this is the one check whose fix is a local file copy
4556
+ * non-TTY change NOTHING and print how to do it; a diagnostic must never mutate a
4557
+ * scripted environment just because nobody was there to say no
4558
+ * Honors `--global` / `--agent` so the fix can target the same place an explicit install would.
4559
+ */
4560
+ async function offerSkillInstall(flags, stale, indent) {
4561
+ const opts = { global: flags.global !== undefined, agent: flags.agent };
4562
+ const src = resolveBundledSkill();
4563
+ if (!src)
4564
+ return; // no bundled skill to install (dev checkout oddity) — the hint above still stands
4565
+ const verb = stale ? 'resync' : 'install';
4566
+ const dests = defaultSkillDests(opts);
4567
+ if (flags.fix === undefined) {
4568
+ if (!process.stdin.isTTY) {
4569
+ console.log(`${indent}${dim(`non-interactive — run \`abx doctor --fix\` (or \`abx skill install\`) to ${verb} it.`)}`);
4570
+ return;
4571
+ }
4572
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4573
+ const answer = await new Promise((resolve) => {
4574
+ // EOF (Ctrl-D) closes the interface WITHOUT firing the question callback — awaiting only the
4575
+ // callback would hang doctor forever. Treat a closed stream as a decline: the safe direction is
4576
+ // always "change nothing", never "write files because nobody answered".
4577
+ rl.once('close', () => resolve('n'));
4578
+ rl.question(`${indent}${verb} the abx skill into ${bold(dests)} now? [Y/n] `, resolve);
4579
+ });
4580
+ rl.close();
4581
+ if (declinesSkillInstall(answer)) {
4582
+ console.log(`${indent}${dim(`skipped — \`abx skill install\` when you want it.`)}`);
4583
+ return;
4584
+ }
4585
+ }
4586
+ console.log('');
4587
+ installSkillToDefaults(src, opts);
4588
+ }
4261
4589
  async function cmdDoctor(flags) {
4262
4590
  console.log(bold('\n abx doctor') + dim(` · ${CHAIN}`) + '\n');
4263
4591
  // Two visual tiers: PASS/FAIL checks (✓/✗) for things that are either working or broken, and an
@@ -4274,16 +4602,28 @@ async function cmdDoctor(flags) {
4274
4602
  const cliVersion = readCliVersion();
4275
4603
  const skillVersions = installedSkillVersions();
4276
4604
  const staleSkills = skillVersions.filter((v) => compareVersions(cliVersion, v) > 0);
4277
- if (skillVersions.length === 0) {
4605
+ const skillMissing = skillVersions.length === 0;
4606
+ const skillStale = !skillMissing && staleSkills.length > 0;
4607
+ if (skillMissing) {
4278
4608
  check('agent skill', false, `not installed — run ${g('abx skill install')}`);
4279
4609
  console.log(`${CONT}${dim('(recommended: let a coding agent drive abx)')}`);
4280
4610
  }
4281
- else if (staleSkills.length > 0) {
4611
+ else if (skillStale) {
4282
4612
  check('agent skill', false, `v${staleSkills.join(', v')} behind CLI v${cliVersion} — run ${g('abx skill install')}`);
4283
4613
  }
4284
4614
  else {
4285
4615
  check('agent skill', true, `in sync (v${cliVersion})`);
4286
4616
  }
4617
+ // Offer to fix it here rather than only naming the command. `npm i -g` + `abx skill install` was a
4618
+ // two-step install flow where the second step is easy to skip and invisible when skipped (an agent
4619
+ // that never learned abx just... doesn't use it). Doctor is already the documented first run, so
4620
+ // this collapses the flow without an npm `postinstall` hook — which could not work anyway: npm runs
4621
+ // lifecycle scripts with cwd set to the installed package dir (so the skill would land inside
4622
+ // node_modules), pnpm gates install scripts by default, and writing to a user's ~/.claude on
4623
+ // install is the kind of side effect that belongs to the user, not to us.
4624
+ if (skillMissing || skillStale) {
4625
+ await offerSkillInstall(flags, skillStale, CONT);
4626
+ }
4287
4627
  console.log('');
4288
4628
  // 2. Core environment (✓/✗). Signing-wallet balances are computed here (they need the RPC) but
4289
4629
  // printed in the Optional block below, so buffer them.
@@ -4376,7 +4716,13 @@ function printServing(url, address) {
4376
4716
  console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
4377
4717
  console.log(` ${dim('state API ')}${url}/api/project/${address}`);
4378
4718
  }
4379
- console.log(`\n ${dim('Open the dashboard, then hit “Re-index from chain” to watch state rebuild live.')}`);
4719
+ // The dashboard is READ-ONLY: re-index/verify are admin actions that 404 unless the node has an
4720
+ // ABX_RESOLVER_ADMIN_TOKEN, so there is no button to press. This line used to say "hit Re-index
4721
+ // from chain", which sent every first-run user hunting for a control that isn't there.
4722
+ console.log(`\n ${dim('The dashboard shows the event spine it replayed — that table IS the reconstruction.')}`);
4723
+ if (address) {
4724
+ console.log(` ${dim('Rebuild it yourself (read-only, safe):')} ${bold(`abx index ${address} --full`)} ${dim('— replays from the deploy block and must land on identical state.')}`);
4725
+ }
4380
4726
  console.log(` ${dim('Ctrl-C to stop.')}\n`);
4381
4727
  }
4382
4728
  // ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────
@@ -4986,8 +5332,12 @@ const COMMAND_HELP = {
4986
5332
  ${bold('abx set-max-invocations')} <address> --max <N> ${dim('— LOWER the supply cap (Series). Sends a tx.')}
4987
5333
  --max <N> the new cap — MONOTONIC: can only DECREASE, and never below what's already minted (else it reverts)`,
4988
5334
  doctor: `
4989
- ${bold('abx doctor')} ${dim('— preflight readiness: signing key/wallet, RPC health, canonical factory, storage. Read-only.')}
5335
+ ${bold('abx doctor')} ${dim('— preflight readiness: agent skill, signing key/wallet, RPC health, canonical factory, storage.')}
4990
5336
  --for 0x.. also report that address's balance (fund before signing)
5337
+ ${g('--fix')} ${dim('install/resync the agent skill without asking (the one thing doctor can repair)')}
5338
+ ${g('--global')} · ${g('--agent')} <a> ${dim('where --fix installs the skill (mirrors `abx skill install`)')}
5339
+ ${dim('Read-only apart from --fix. Interactively it OFFERS to install a missing/stale skill; a non-TTY')}
5340
+ ${dim('run changes nothing and just prints the command, so scripts and CI are never mutated.')}
4991
5341
  ${dim('a missing signing key is NOT fatal — the wallet lane (`--sign`) needs no key in `.env`.')}`,
4992
5342
  status: `
4993
5343
  ${bold('abx status')} ${dim('— list the projects this node has indexed, plus node info. Read-only.')}`,
@@ -5265,6 +5615,31 @@ function installSkillTo(src, dest) {
5265
5615
  rmSync(dest, { recursive: true, force: true });
5266
5616
  cpSync(src, dest, { recursive: true });
5267
5617
  }
5618
+ /**
5619
+ * Install the bundled skill into the default per-agent parents (or `~` with `global`), reporting
5620
+ * each destination. Shared by `abx skill install` and `abx doctor`'s offer to fix a missing/stale
5621
+ * skill — one implementation, so the two can't drift on where the skill lands or what it prints.
5622
+ */
5623
+ function installSkillToDefaults(src, opts = {}) {
5624
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
5625
+ const base = opts.global ? homedir() : process.cwd();
5626
+ const parents = resolveInstallParents(opts.agent);
5627
+ ok(`installed the abx skill v${version}${opts.global ? ' (global, ~)' : ''}:`);
5628
+ for (const parent of parents) {
5629
+ const dest = joinPath(base, parent, SKILL_DIR_NAME);
5630
+ installSkillTo(src, dest);
5631
+ const label = SKILL_PARENT_LABELS[parent];
5632
+ console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${label ? dim(' → ' + label) : ''}`);
5633
+ }
5634
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
5635
+ info(`the skill is version-locked to this CLI (v${version}); re-run ${g('abx skill install')} after upgrading so the two stay in sync.`);
5636
+ return version;
5637
+ }
5638
+ /** The default skill destinations, as a display string — what doctor's prompt has to name up front. */
5639
+ function defaultSkillDests(opts = {}) {
5640
+ const prefix = opts.global ? '~/' : './';
5641
+ return resolveInstallParents(opts.agent).map((p) => `${prefix}${p}`).join(' and ');
5642
+ }
5268
5643
  async function cmdSkill(rest, flags) {
5269
5644
  const sub = rest[0] ?? 'install';
5270
5645
  const src = resolveBundledSkill();
@@ -5287,17 +5662,7 @@ async function cmdSkill(rest, flags) {
5287
5662
  info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
5288
5663
  return;
5289
5664
  }
5290
- const base = flags.global !== undefined ? homedir() : process.cwd();
5291
- const parents = resolveInstallParents(flags.agent);
5292
- ok(`installed the abx skill v${version}${flags.global !== undefined ? ' (global, ~)' : ''}:`);
5293
- for (const parent of parents) {
5294
- const dest = joinPath(base, parent, SKILL_DIR_NAME);
5295
- installSkillTo(src, dest);
5296
- const label = SKILL_PARENT_LABELS[parent];
5297
- console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${label ? dim(' → ' + label) : ''}`);
5298
- }
5299
- info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
5300
- info(`the skill is version-locked to this CLI (v${version}); re-run ${g('abx skill install')} after upgrading so the two stay in sync.`);
5665
+ installSkillToDefaults(src, { global: flags.global !== undefined, agent: flags.agent });
5301
5666
  return;
5302
5667
  }
5303
5668
  throw new Error('usage: abx skill <install|path> [--agent claude|cursor|codex|gemini|copilot] [--global] [--target <dir>]');