@artblocks/abx-cli 0.1.0-alpha.1 → 0.1.0-alpha.10

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
@@ -14,10 +14,12 @@
14
14
  * abx demo deploy a 1/1 to Sepolia, index it, and serve it
15
15
  * abx deploy [--image ..] deploy + index a 1/1 (--image to custody your own bytes; --no-mint to defer)
16
16
  * abx predict [--salt ..] pre-compute a deploy address (reserve / vanity it before signing)
17
+ * abx preview run a code project on localhost while it's still being made (no chain)
17
18
  * abx deploy-code deploy a code project (SeriesCode): --script <file> (on-chain template)
18
19
  * or --code-dir <dir> (build directory → ipfs/arweave `code` field)
19
- * abx add <address> register + index a project (--remote: on a hosted resolver, not this machine)
20
- * abx index [<address>] re-index a project from chain (replay; --remote to nudge a hosted resolver)
20
+ * abx add <address> register + index a project (--remote <name|url>: on a remote resolver, not this machine)
21
+ * abx remote [<name|url>] inspect a remote service (descriptor · chains · managed rendering · your projects there)
22
+ * abx index [<address>] re-index a project from chain (replay; --remote to nudge a remote resolver)
21
23
  * abx verify <address> re-hash served bytes vs the on-chain commitment (no server)
22
24
  * abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
23
25
  * abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
@@ -34,14 +36,15 @@
34
36
  * abx storage backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path
35
37
  * abx status list indexed projects + node info
36
38
  * abx doctor check environment (key, RPC, balance, factory, storage)
37
- * abx skill install install the abx agent skill into your agent (.claude/skills; --global)
39
+ * abx skill install install the version-locked abx agent skill into your agent(s)
40
+ * (default: Claude Code + the neutral .agents/skills; --agent/--global)
38
41
  *
39
42
  * Every write picks a signing lane: default hot (env key signs), `--sign` (a human
40
43
  * approves in their own wallet via a one-shot localhost page), `--unsigned` (print
41
44
  * the tx for a multisig / offline signer). The agent picks the lane; the CLI signs.
42
45
  */
43
- import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
44
- import { randomBytes } from 'node:crypto';
46
+ import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
47
+ import { createHash, randomBytes } from 'node:crypto';
45
48
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
46
49
  import { fileURLToPath } from 'node:url';
47
50
  import { homedir } from 'node:os';
@@ -54,9 +57,11 @@ import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeS
54
57
  import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
55
58
  import { composeParamsKeys, expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport } from './onchain-uri.js';
56
59
  import { parseFlags, unknownFlags } from './flags.js';
57
- import { checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, SKILL_VERSION_MARKER } from './update-check.js';
60
+ import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
58
61
  import { analyzeScript, recommendLane } from './inspect.js';
62
+ import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
59
63
  import { parseSchemaSpecs, describeSchema } from './schema.js';
64
+ import { declinesSkillInstall } from './prompt.js';
60
65
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
61
66
  /** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
62
67
  * to the on-chain enums (PARAM_TYPES / AUTH_OPTIONS) + the Select-options / Range-bounds format —
@@ -66,22 +71,20 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
66
71
  'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
67
72
  'A palette collectors set = palette:HexColor:TokenOwner';
68
73
  import { uploadAndLocate } from './upload.js';
69
- import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, DEFAULT_CHAIN_KEY, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
74
+ import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
70
75
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
71
76
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
72
77
  import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
73
78
  import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetField, cmdAttach, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
74
79
  import { openWalletSession, signTx } from './signer.js';
75
- import { remoteAddProject, remoteRemoveProject } from './remote.js';
80
+ import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
76
81
  import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
77
- import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
82
+ import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, faucetHint, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
78
83
  const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
79
- // Block explorer base per chain (keyed by EIP-155 chainId), for the tx/address links the CLI prints.
80
- const EXPLORERS = {
81
- 11155111: 'https://sepolia.etherscan.io', // Sepolia
82
- 84532: 'https://sepolia.basescan.org', // Base Sepolia
83
- };
84
- const EXPLORER = EXPLORERS[resolveChain(CHAIN).id] ?? 'https://sepolia.etherscan.io';
84
+ // Block explorer base for the tx/address links the CLI prints. Derived from viem's chain metadata via
85
+ // the SDK (see explorerUrl) rather than a local table — this used to be a hand-maintained map, which is
86
+ // the same shape of bug that had the token-api dashboard sending every Base Sepolia link to Etherscan.
87
+ const EXPLORER = explorerUrl(resolveChain(CHAIN).id);
85
88
  // Product dimensions — what you can launch. One concrete contract exists today
86
89
  // (the 1/1 image); this table is the seam future implementations slot into, so
87
90
  // `abx deploy --type <dimension>` is stable while the contracts grow under it.
@@ -133,7 +136,7 @@ async function maybeNotifyUpdate(flags) {
133
136
  const latest = await checkForCliUpdate(current);
134
137
  if (latest) {
135
138
  console.error(`\n ${c.orange}⚠${c.reset} update available: ${bold('abx')} ${dim(current)} → ${g(latest)}\n` +
136
- ` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx abx@latest <command>')}\n` +
139
+ ` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx @artblocks/abx-cli@latest <command>')}\n` +
137
140
  ` release notes: https://github.com/ArtBlocks/abx/releases ${dim('· silence: ABX_NO_UPDATE_CHECK=1')}\n`);
138
141
  }
139
142
  }
@@ -151,7 +154,7 @@ async function main() {
151
154
  return;
152
155
  }
153
156
  // Notify-only "you're behind" nudge (cached, opt-out, stderr — see update-check.ts). Awaited so
154
- // the notice lands before command output, but it hits the network at most once a day and never
157
+ // the notice lands before command output, but it hits the network at most once every 6h and never
155
158
  // throws, so it can't break or meaningfully slow a command. Skipped for the pure help path below.
156
159
  await maybeNotifyUpdate(flags);
157
160
  // SAFETY: --help / -h on ANY command is read-only — print usage, never execute.
@@ -165,6 +168,7 @@ async function main() {
165
168
  case 'deploy-series': return cmdDeploySeries(flags);
166
169
  case 'deploy-code': return cmdDeployCode(flags);
167
170
  case 'inspect': return cmdInspect(rest[0], flags);
171
+ case 'preview': return cmdPreview(flags);
168
172
  case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
169
173
  case 'predict': return cmdPredict(flags);
170
174
  case 'add': return cmdAdd(rest[0], flags);
@@ -204,8 +208,9 @@ async function main() {
204
208
  case 'set-admin': return cmdSetAdmin(rest[0], flags);
205
209
  case 'forget': return cmdForget(rest[0], flags);
206
210
  case 'migrate': return cmdMigrate(rest[0], flags);
211
+ case 'remote': return cmdRemote(rest[0], flags);
207
212
  case 'storage': return cmdStorage(rest);
208
- case 'status': return cmdStatus();
213
+ case 'status': return cmdStatus(rest[0], flags);
209
214
  case 'state': return cmdState(rest[0], flags);
210
215
  case 'doctor': return cmdDoctor(flags);
211
216
  case 'skill': return cmdSkill(rest, flags);
@@ -220,6 +225,65 @@ async function main() {
220
225
  process.exit(1);
221
226
  }
222
227
  }
228
+ /**
229
+ * A `--dry-run` computes the deterministic deploy address, which is a pure function of
230
+ * (factory, salt, deployer) — so it needs a deployer even though it signs nothing. Resolve it the
231
+ * same way the preview will (`--for`, else an env key) and fail EARLY with the fix if neither
232
+ * exists, rather than after the preview has printed several steps of work.
233
+ */
234
+ /**
235
+ * A dry run only checks whether a factory address is CONFIGURED, not whether it has code on this
236
+ * chain — and the manifest always has an address, so a chain where the trust anchor isn't deployed
237
+ * (a private/local chain, or a wrong-network RPC) sailed past this and died inside
238
+ * `predictDeterministicAddress` with a raw `returned no data ("0x")` and a list of ABI hypotheses.
239
+ * The real deploy and `abx predict` both explain that case; a preview of the same deploy must too.
240
+ * Returns false when the caller should stop (message already printed).
241
+ */
242
+ async function previewFactoryLive(client, factory, label) {
243
+ const code = await client.getCode({ address: factory }).catch(() => undefined);
244
+ if (code && code !== '0x')
245
+ return true;
246
+ warn(`the configured ${label} ${factory} has no code on '${CHAIN}' (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}).`);
247
+ info('so this preview can\'t compute the deterministic address. Either point at a chain where the trust anchor is deployed');
248
+ info(`(${bold('ABX_CHAIN=' + DEFAULT_CHAIN_KEY)} is the default and has one), or deploy your own on this chain with ${bold('--bootstrap-factory')}`);
249
+ info(dim('(a private anchor — platforms won\'t recognize its clones, so it\'s for private/sandbox chains).'));
250
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
251
+ return false;
252
+ }
253
+ /**
254
+ * Placeholder-identity guard, shared by ALL THREE deploy commands. `name`/`symbol` are written
255
+ * on-chain as the public collection identity and are effectively permanent, so a real deploy must
256
+ * never bake a tool default silently: warn in a preview, HARD-STOP a real send unless `--yes`.
257
+ *
258
+ * It was duplicated per command, and `deploy-series` simply never got a copy — its default
259
+ * "ABX Series"/"ABXS" went on-chain with at most a warning, while the skill promises the CLI
260
+ * refuses demo defaults. (deploy-code's copy even said "mirror deploy/deploy-series", which made
261
+ * the gap look closed.) One predicate now, like `loopbackBaseUrl()`, so a fourth command can't drift.
262
+ */
263
+ function assertRealIdentity(flags, o) {
264
+ if (flags.name && flags.symbol)
265
+ return;
266
+ if (!flags.name)
267
+ warn(`no --name → default "${o.name}" would be the on-chain collection name`);
268
+ if (!flags.symbol)
269
+ warn(`no --symbol → default "${o.symbol}" would be the on-chain symbol`);
270
+ if (o.dryRun || flags.yes)
271
+ return; // a preview still runs; --yes is the explicit opt-in
272
+ throw new Error('refusing to write tool placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM ' +
273
+ '(or --yes to accept the defaults). On-chain identity is effectively permanent.');
274
+ }
275
+ function assertPreviewDeployer(flags) {
276
+ if (flags.for)
277
+ return;
278
+ try {
279
+ makeWalletClient({ chainKey: CHAIN });
280
+ }
281
+ catch {
282
+ throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
283
+ '(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
284
+ 'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
285
+ }
286
+ }
223
287
  // When the user/agent opts in (--yes), lift the getLogs chunk cap so a large
224
288
  // reconstruction proceeds despite a range-limited RPC (otherwise it stops early with
225
289
  // guidance — see GetLogsScanTooLargeError + the skill's "Choosing an RPC" decision).
@@ -293,17 +357,6 @@ async function detectCanonicalFactory(address, override, stored) {
293
357
  }
294
358
  return factoryAddress() ?? undefined;
295
359
  }
296
- /** A short faucet pointer for the active testnet — a 0-balance signer's #1 next step. A known URL
297
- * plus a search hint (URLs rot; the search always works), so "fund it" isn't a dead end. */
298
- function faucetHint(chainKey) {
299
- const url = {
300
- 'base-sepolia': 'https://portal.cdp.coinbase.com/products/faucet',
301
- sepolia: 'https://www.alchemy.com/faucets/ethereum-sepolia',
302
- };
303
- return url[chainKey]
304
- ? `get free test ETH from a ${chainKey} faucet (${url[chainKey]} — or search "${chainKey} faucet"), usually ≤1 min`
305
- : `get test ETH from a "${chainKey}" faucet`;
306
- }
307
360
  // Funding preflight — a 0-balance signer fails only at the tx, with a confusing error. Surface it
308
361
  // up front. Especially the wallet lane (--sign/--for), which has no env key for `doctor` to check.
309
362
  async function warnUnfunded(publicClient, address) {
@@ -455,7 +508,7 @@ function refuseMissingFactory(kind, envVar, reason) {
455
508
  ` To deploy your OWN trust anchor instead (private chains, sandboxes — platforms won't recognize its clones), ` +
456
509
  `re-run with --bootstrap-factory.`);
457
510
  }
458
- async function ensureFactory(override, allowBootstrap = false) {
511
+ async function ensureFactory(override, allowBootstrap = false, quiet = false) {
459
512
  // Resolve the canonical factory from the shipped manifest (flag → env → manifest); on a chain
460
513
  // with no entry, deploy a fresh trust anchor and tell the operator how to reuse it.
461
514
  const known = factoryAddress(override);
@@ -465,7 +518,10 @@ async function ensureFactory(override, allowBootstrap = false) {
465
518
  const code = await publicClient.getCode({ address: known });
466
519
  if (code && code !== '0x') {
467
520
  if (await isCurrentFactory(publicClient, known)) {
468
- info(`using canonical factory ${EXPLORER}/address/${known}`);
521
+ // `quiet` suppresses only this happy-path line (the demo resolves the factory without making a
522
+ // teaching moment of it). Bootstrap/mismatch messages below always print — those matter.
523
+ if (!quiet)
524
+ info(`using canonical factory ${EXPLORER}/address/${known}`);
469
525
  return known;
470
526
  }
471
527
  if (!allowBootstrap)
@@ -982,26 +1038,53 @@ async function cmdDeploy(flags, serveAfter) {
982
1038
  // editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
983
1039
  // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
984
1040
  const traits = parseDeployTraits(flags);
985
- console.log(bold(`\n ABX Self-Host Toolkit — ${serveAfter ? 'demo' : 'deploy'}\n ${dim('a project, served from chain alone')}`));
986
- const lane = serveAfter ? 'send' : laneFromFlags(flags); // demo is always hot (one-shot)
1041
+ console.log(serveAfter
1042
+ ? 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")}`)
1043
+ : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
1044
+ // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
1045
+ // env key it died confusingly, and WITH one it signed from that key while the operator had asked
1046
+ // for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
1047
+ // deploy is a single tx, so the wallet lane works here exactly as it does for `deploy`.
1048
+ const lane = laneFromFlags(flags);
1049
+ // The cold lane only PRINTS a tx; demo's whole point is to index + serve what it just deployed,
1050
+ // and there is nothing to index until someone broadcasts. Refuse the combo instead of doing
1051
+ // half the job — `abx deploy --unsigned` is the command for that lane.
1052
+ if (serveAfter && lane === 'unsigned') {
1053
+ throw new Error('`abx demo --unsigned` can\'t work: the cold lane only prints a transaction, and the demo ' +
1054
+ 'indexes + serves the contract it just deployed. Use `abx demo` (hot key) or `abx demo --sign` ' +
1055
+ '(browser wallet) — or `abx deploy --unsigned` if you only want the raw tx.');
1056
+ }
1057
+ // Same shape for --dry-run: previewing sends nothing, so there is nothing to serve.
1058
+ if (serveAfter && flags['dry-run']) {
1059
+ throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
1060
+ 'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
1061
+ }
1062
+ // Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
1063
+ // the first thing to discover the port was taken — after the deploy tx had already been signed and
1064
+ // paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
1065
+ // Node EADDRINUSE stack trace. Check first, and name the fix.
1066
+ if (serveAfter) {
1067
+ const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1068
+ if (await portInUse(wanted)) {
1069
+ throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
1070
+ ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1071
+ }
1072
+ }
987
1073
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1074
+ // A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
1075
+ // it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
1076
+ // reads as "it half-worked", and a first-timer previewing with no key in .env always hits it.
1077
+ if (dryRun)
1078
+ assertPreviewDeployer(flags);
988
1079
  const publicClient = makePublicClient({ chainKey: CHAIN });
989
1080
  // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
990
1081
  // sends nothing, but it DOES read the chain (predict address, resolve the factory/renderer), so
991
1082
  // a wrong-network RPC must still be caught with the clear mismatch message rather than failing
992
1083
  // opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
993
1084
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
994
- // Identity guard: the demo placeholders ("ABX Self-Host Demo"/"ABXSH") are written on-chain as
995
- // the public collection identity. Warn in preview; HARD-STOP a real deploy unless acknowledged.
996
- if (!serveAfter && (!flags.name || !flags.symbol)) {
997
- if (!flags.name)
998
- warn(`no --name → default "${name}" would be the on-chain collection name`);
999
- if (!flags.symbol)
1000
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
1001
- if (!dryRun && !flags.yes) {
1002
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes to accept the defaults).');
1003
- }
1004
- }
1085
+ // `demo` is exempt: its whole job is a zero-argument first token.
1086
+ if (!serveAfter)
1087
+ assertRealIdentity(flags, { name, symbol, dryRun });
1005
1088
  // Funding preflight (real deploys): warn now if the signer is unfunded, not at the tx.
1006
1089
  if (!dryRun) {
1007
1090
  let signer = flags.for;
@@ -1014,7 +1097,13 @@ async function cmdDeploy(flags, serveAfter) {
1014
1097
  if (signer)
1015
1098
  await warnUnfunded(publicClient, signer);
1016
1099
  }
1017
- step('Trust anchor');
1100
+ // `deploy` surfaces the trust anchor as a step; the demo does NOT. It briefly opened on "only this
1101
+ // factory can make a token that IS an ABX token", which is simply false — anything that follows the
1102
+ // protocol's event spine is an ABX token, and the factory is one way to get there, not the
1103
+ // definition of the thing. Rather than restate it more carefully, the demo skips it: a first-timer
1104
+ // does not need a provenance lecture before they have made anything.
1105
+ if (!serveAfter)
1106
+ step('Trust anchor');
1018
1107
  let factory;
1019
1108
  if (dryRun) {
1020
1109
  const existing = factoryAddress(flags.factory);
@@ -1024,10 +1113,12 @@ async function cmdDeploy(flags, serveAfter) {
1024
1113
  return;
1025
1114
  }
1026
1115
  factory = existing;
1116
+ if (!(await previewFactoryLive(publicClient, factory, 'factory')))
1117
+ return;
1027
1118
  info(`would reuse canonical factory ${factory}`);
1028
1119
  }
1029
1120
  else {
1030
- factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
1121
+ factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory'], serveAfter);
1031
1122
  }
1032
1123
  // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
1033
1124
  // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
@@ -1035,18 +1126,32 @@ async function cmdDeploy(flags, serveAfter) {
1035
1126
  // --onchain-image: stage the --image bytes on-chain (chunk store, ownerless) and bake a
1036
1127
  // `reader` field into the deploy — so large on-chain content needs NO post-deploy tx. It
1037
1128
  // implies on-chain URI resolution (the renderer emits the reader-backed image).
1038
- const onchainImage = !!flags['onchain-image'];
1039
- const onChainUri = !!flags['onchain-uri'] || onchainImage;
1040
- // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
1041
- // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
1042
- const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
1043
1129
  // A fully on-chain token self-resolves via the renderer; the stored off-chain pointer is
1044
1130
  // never read while a renderer is set. So bake a real URL only if one was explicitly given —
1045
1131
  // otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
1046
1132
  const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
1133
+ const onchainImage = !!flags['onchain-image'];
1134
+ // `abx demo` defaults to FULLY ON-CHAIN. Its default art is a generative SVG, which the renderer
1135
+ // can inline — so the token self-resolves and, crucially, NO localhost gets baked into the
1136
+ // contract as the tokenURI base. The old default shipped a first-ever token that resolved for
1137
+ // nobody but its author (broken on every marketplace, dead the moment `abx serve` stops) and
1138
+ // taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the art
1139
+ // on-chain, "rebuilt from the chain alone" now covers the IMAGE, not just the metadata.
1140
+ //
1141
+ // Opting back into off-chain custody is anything that says "I have somewhere to host": a real base
1142
+ // URL, an explicit --backend, or a RASTER --image. An SVG --image still goes on-chain — inlining is
1143
+ // exactly what the renderer supports (v1 is SVG-only), so there's no reason to send someone's own
1144
+ // vector art down the localhost path. Raster stays off-chain because forcing it on-chain would
1145
+ // silently inline a placeholder instead of their image.
1146
+ const demoImageInlineable = !flags.image || contentTypeFromPath(flags.image) === 'image/svg+xml';
1147
+ const demoDefaultsOnChain = serveAfter && demoImageInlineable && !flags.backend && !hasPublicUrl;
1148
+ const onChainUri = !!flags['onchain-uri'] || onchainImage || demoDefaultsOnChain;
1149
+ // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
1150
+ // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
1151
+ const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
1047
1152
  let renderer = zeroAddress;
1048
1153
  if (onChainUri) {
1049
- step('On-chain renderer');
1154
+ step(serveAfter ? 'What will answer when someone asks about your token' : 'On-chain renderer');
1050
1155
  if (dryRun) {
1051
1156
  renderer = rendererAddress(flags.renderer) ?? zeroAddress;
1052
1157
  info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `would use renderer ${renderer}`);
@@ -1054,8 +1159,28 @@ async function cmdDeploy(flags, serveAfter) {
1054
1159
  else {
1055
1160
  renderer = await ensureRenderer(flags.renderer);
1056
1161
  }
1162
+ // A first-timer has no idea what a "renderer" is, and the word suggests something that draws
1163
+ // pictures. What it actually does is assemble the JSON a marketplace asks for, on-chain, out of
1164
+ // the fields your contract holds — worth one plain sentence, since it's why no server is needed.
1165
+ if (serveAfter) {
1166
+ info(dim('a marketplace asks your contract a question; this shared contract composes the answer'));
1167
+ info(dim('already deployed, used by every ABX token, owned by no one — you are not paying to set it up'));
1168
+ }
1169
+ }
1170
+ step(serveAfter ? `Mint it · one transaction on ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1171
+ // What the chain ends up holding is the single most useful thing to understand about an ABX token,
1172
+ // and it's invisible unless someone says it out loud. Two honest versions, because the answer is
1173
+ // genuinely different per lane. Demo only — `deploy` prints the same facts per-field.
1174
+ if (serveAfter && onChainUri) {
1175
+ console.log(` Your art goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
1176
+ console.log(` ${g('✦')} the chain will hold ${dim('your art · your name on it · your royalty · you as owner')}`);
1177
+ console.log(` ${g('✦')} you will need ${dim('nothing else. no server, no IPFS pin, no monthly bill to forget')}`);
1178
+ }
1179
+ else if (serveAfter) {
1180
+ console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the art')}`);
1181
+ console.log(` ${g('✦')} this computer holds ${dim('the image bytes themselves')}`);
1182
+ info(dim('the chain proves the bytes are unaltered; it does not store them (that fingerprint is a keccak256 hash)'));
1057
1183
  }
1058
- step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1059
1184
  // Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
1060
1185
  // tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
1061
1186
  // not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
@@ -1082,7 +1207,14 @@ async function cmdDeploy(flags, serveAfter) {
1082
1207
  }
1083
1208
  }
1084
1209
  }
1085
- if (onChainUri) {
1210
+ if (onChainUri && serveAfter) {
1211
+ // The demo's version of the same fact, in words a first-timer can act on. Naming what we are
1212
+ // NOT doing matters here: a localhost URL written into a contract is the single most common way
1213
+ // a first NFT ends up permanently broken, and the demo used to model exactly that.
1214
+ info(dim('nothing points at this computer — no http://localhost anywhere in your contract.'));
1215
+ info(dim('anyone can read your token from the chain, forever, with you offline.'));
1216
+ }
1217
+ else if (onChainUri) {
1086
1218
  info('tokenURI/contractURI resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.');
1087
1219
  if (!hasPublicUrl)
1088
1220
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
@@ -1259,7 +1391,9 @@ async function cmdDeploy(flags, serveAfter) {
1259
1391
  // wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy.
1260
1392
  // The connecting wallet pays for (and is the deployer of) all of it — staging can't precede
1261
1393
  // the connect here, so it happens inside the session, then the deploy bakes in the manifest.
1262
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1394
+ // --onchain-image implies on-chain resolution, so NOTHING points at baseUrl — saying it did was
1395
+ // a flat contradiction of the line above it (and reintroduced the localhost the lane exists to avoid).
1396
+ info('a wallet will become the owner; the token resolves from chain — no URI base is baked in.');
1263
1397
  const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1264
1398
  const stagingTxs = plan.mode === 'single' ? 1 : plan.txCount;
1265
1399
  const session = await openWalletSession({
@@ -1324,8 +1458,12 @@ async function cmdDeploy(flags, serveAfter) {
1324
1458
  info(`tx ${EXPLORER}/tx/${r.txHash} (block ${blockNumber})`);
1325
1459
  }
1326
1460
  else {
1327
- // wallet lane (off-chain custody) or cold lane: a single deploy tx, no staging sequence.
1328
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1461
+ // wallet lane (off-chain custody OR on-chain URI) or cold lane: a single deploy tx, no staging.
1462
+ // Only claim a URI base when one is actually written — on the on-chain lane this line used to
1463
+ // announce `http://localhost:8787` two lines after promising no localhost anywhere.
1464
+ info(onChainUri
1465
+ ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
1466
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1329
1467
  const result = await signTx(async (signer) => {
1330
1468
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1331
1469
  return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
@@ -1338,7 +1476,7 @@ async function cmdDeploy(flags, serveAfter) {
1338
1476
  blockNumber = result.blockNumber;
1339
1477
  ok(`deployed ${clone}`);
1340
1478
  }
1341
- step('Index it — replay the event spine from chain');
1479
+ step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1342
1480
  const indexer = new SelfHostIndexer();
1343
1481
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
1344
1482
  const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
@@ -1353,10 +1491,23 @@ async function cmdDeploy(flags, serveAfter) {
1353
1491
  attributes: offChainTraits,
1354
1492
  };
1355
1493
  indexer.register(baseReg);
1356
- const { state, elapsedMs } = await indexer.reindex(clone);
1357
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1358
- info(`name "${state.name}" · owner ${state.owner} · canonical: ${state.isCanonical ? 'yes (factory-verified)' : 'unverified'}`);
1359
- info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1494
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1495
+ if (serveAfter) {
1496
+ if (state.eventCount > 0)
1497
+ ok(`read ${bold(String(state.eventCount))} events straight off ${CHAIN} in ${elapsedMs}ms — no API key, no company's server`);
1498
+ // "verified real" overstated it in the same way the removed trust-anchor step did — factory
1499
+ // provenance is a fact about how this contract was made, not a verdict on whether a token counts
1500
+ // as ABX. Report the fact.
1501
+ info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? g('made by the canonical factory') : dim('not factory-made')}`);
1502
+ info(dim(`optional features switched on: ${state.extensions.map((e) => e.name.replace(/^abx\.extension\./, '')).join(' · ') || 'none'}`));
1503
+ walkthroughSpine(state);
1504
+ }
1505
+ else {
1506
+ if (state.eventCount > 0)
1507
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1508
+ info(`name "${state.name}" · owner ${state.owner} · canonical: ${canonicalLabel(state.isCanonical)}`);
1509
+ info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1510
+ }
1360
1511
  // For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
1361
1512
  // from this machine's content index and store it on the registration — so the LOCAL resolver
1362
1513
  // points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
@@ -1405,8 +1556,21 @@ async function cmdDeploy(flags, serveAfter) {
1405
1556
  }
1406
1557
  return;
1407
1558
  }
1408
- step('Serve the token API + dashboard');
1559
+ // The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
1560
+ // shipping real work doesn't need their projection deleted to make a point.
1561
+ if (serveAfter)
1562
+ await walkthroughRebuild(indexer, clone, state);
1563
+ // Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
1564
+ // print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
1565
+ // while the read-back prints below, which reads like the step failed.
1409
1566
  const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
1567
+ if (serveAfter)
1568
+ await walkthroughReadBack(state, url, onChainUri);
1569
+ // On the on-chain lane this server is a convenience, not infrastructure — say so, or standing one
1570
+ // up as the finale re-teaches the dependency the whole run just disproved.
1571
+ step(onChainUri ? 'Go look at it' : 'Serve the token API + dashboard');
1572
+ if (onChainUri)
1573
+ info(dim('a local viewer, purely for your eyes — your token does not need it. Ctrl-C whenever; the token stays up.'));
1410
1574
  printServing(url, clone);
1411
1575
  keepAlive();
1412
1576
  }
@@ -1457,21 +1621,15 @@ async function cmdDeploySeries(flags) {
1457
1621
  throw new Error(`--count ${count} exceeds the ${files.length} media file(s) in ${dirPath}`);
1458
1622
  const slots = files.slice(0, count);
1459
1623
  const dryRun = !!flags['dry-run'];
1624
+ if (dryRun)
1625
+ assertPreviewDeployer(flags); // fail fast, before the preview does any work (see cmdDeploy)
1626
+ assertRealIdentity(flags, { name, symbol, dryRun });
1460
1627
  const lane = laneFromFlags(flags);
1461
1628
  const publicClient = makePublicClient({ chainKey: CHAIN });
1462
1629
  // Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
1463
1630
  // the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
1464
1631
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
1465
- // Identity guard (mirror cmdDeploy): never write demo placeholders as the public identity.
1466
- if (!flags.name || !flags.symbol) {
1467
- if (!flags.name)
1468
- warn(`no --name → default "${name}" would be the on-chain collection name`);
1469
- if (!flags.symbol)
1470
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
1471
- if (!dryRun && !flags.yes) {
1472
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
1473
- }
1474
- }
1632
+ // (identity guard already ran above, via the shared assertRealIdentity before any RPC)
1475
1633
  // Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
1476
1634
  const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
1477
1635
  if (mintCount > count)
@@ -1515,6 +1673,8 @@ async function cmdDeploySeries(flags) {
1515
1673
  return;
1516
1674
  }
1517
1675
  factory = existing;
1676
+ if (!(await previewFactoryLive(publicClient, factory, 'Series factory')))
1677
+ return;
1518
1678
  info(`would reuse canonical Series factory ${factory}`);
1519
1679
  }
1520
1680
  else {
@@ -1848,8 +2008,11 @@ async function cmdDeploySeries(flags) {
1848
2008
  }
1849
2009
  else {
1850
2010
  // wallet lane (off-chain / inline) or cold lane: a single deploy tx, no staging sequence —
1851
- // tokenFields is already built.
1852
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
2011
+ // tokenFields is already built. Same correction as the 1/1 lane: only claim a URI base when one
2012
+ // is actually written, or an --onchain-uri Series announces a localhost it never bakes.
2013
+ info(onChainUri
2014
+ ? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
2015
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1853
2016
  const result = await signTx(async (signer) => {
1854
2017
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1855
2018
  return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
@@ -1877,8 +2040,9 @@ async function cmdDeploySeries(flags) {
1877
2040
  tokenAttributes: offChainTokenTraits,
1878
2041
  };
1879
2042
  indexer.register(baseReg);
1880
- const { state, elapsedMs } = await indexer.reindex(clone);
1881
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
2043
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
2044
+ if (state.eventCount > 0)
2045
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1882
2046
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1883
2047
  // Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
1884
2048
  // via `abx add --remote`, a hosted one) points images off this node.
@@ -1972,18 +2136,166 @@ async function cmdPredict(flags) {
1972
2136
  info(dim('(this is the 1/1 lane; a Series/code drop uses a different factory → a different address — pass --dir / --script to predict those, or use that command\'s --dry-run)'));
1973
2137
  console.log('');
1974
2138
  }
1975
- // A `--remote [url]` flag targets a HOSTED resolver's admin control plane instead of
1976
- // this machine's local projection. Bare `--remote` falls back to ABX_PUBLIC_BASE_URL
1977
- // (the URL baked on-chain). Returns null for a local op (the default).
1978
- function remoteTarget(flags) {
1979
- const v = flags.remote;
1980
- if (v === undefined)
1981
- return null;
1982
- const base = v !== 'true' ? v : process.env.ABX_PUBLIC_BASE_URL;
1983
- if (!base) {
1984
- throw new Error('`--remote` needs a resolver URL: pass `--remote https://host` or set ABX_PUBLIC_BASE_URL in .env');
2139
+ // The `--remote <name|url>` target (remote.ts owns the convention): a named remote's
2140
+ // `ABX_REMOTE_<NAME>_URL/_TOKEN`, an ad-hoc URL, or bare `--remote` = the self-host default.
2141
+ // Returns null for a local op (the default).
2142
+ function remoteFlag(flags) {
2143
+ return resolveRemote(flags.remote, flags['remote-token']);
2144
+ }
2145
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2146
+ /**
2147
+ * The factory-verification tri-state, said plainly. `null` means the check never ran (no factory
2148
+ * configured, or the configured one has no code on this chain common on a local/dev chain), which
2149
+ * is NOT the same as the chain telling us this contract isn't a clone of it.
2150
+ */
2151
+ function canonicalLabel(isCanonical) {
2152
+ if (isCanonical === true)
2153
+ return g('yes (factory-verified)');
2154
+ if (isCanonical === false)
2155
+ return `${c.orange}NO — not a clone of the configured factory${c.reset}`;
2156
+ return dim('not checked (no canonical factory for this chain — `abx doctor` shows which)');
2157
+ }
2158
+ /** How a lifecycle state reads at a glance — the same word everywhere it's printed. */
2159
+ function statusLabel(s) {
2160
+ if (s === 'live')
2161
+ return g('live');
2162
+ if (s === 'failed')
2163
+ return `${c.orange}failed${c.reset}`;
2164
+ if (s === 'stale')
2165
+ return `${c.orange}stale${c.reset}`;
2166
+ return dim(s); // queued | backfilling — in progress, not a problem
2167
+ }
2168
+ /** `label value` with only the label dimmed — status values carry their own color, and wrapping
2169
+ * them in `info()` would fight it. */
2170
+ const statusRow = (label, value) => console.log(` ${dim(label.padEnd(10))} ${value}`);
2171
+ /**
2172
+ * What to DO about a failure class — whose problem it is and whether waiting is the answer.
2173
+ * The class alone told an agent enough to reason it out; it does not tell a creator, and "whose
2174
+ * problem is this" is the single question a failed catch-up has to answer.
2175
+ */
2176
+ function indexErrorAction(cls) {
2177
+ if (cls === 'rpc_rate_limited')
2178
+ return "the SERVICE's chain RPC is throttled — not your key, address, or chain. It retries on its own; if it stays this way, that's the operator's to fix.";
2179
+ if (cls === 'rpc_unavailable')
2180
+ return "the SERVICE can't reach its chain RPC — not your key, address, or chain. It retries on its own; if it stays this way, contact the operator.";
2181
+ if (cls === 'not_abx_contract')
2182
+ return 'the service found no ABX events at that address on this chain — check the address and that the service serves the right chain.';
2183
+ return 'the cause is in the service operator’s logs — nothing you can fix from here; contact them if it persists.';
2184
+ }
2185
+ /** The one wording for "registered, but catch-up failed" — shared by add/index so both say the same
2186
+ * thing: what failed, whose problem it is, and that the registration survived. */
2187
+ function failedCatchUpMessage(address, remote, err, check) {
2188
+ const cls = err?.class;
2189
+ return (`${address} is registered on ${remote.url}, but its catch-up FAILED` +
2190
+ `${cls ? ` (${cls}${err?.message ? `: ${err.message}` : ''})` : ''}.\n` +
2191
+ ` ${cls ? `→ ${indexErrorAction(cls)}\n ` : ''}` +
2192
+ `The registration is durable and the service retries with backoff — watch it with ${bold(check + ' --watch')}.`);
2193
+ }
2194
+ /**
2195
+ * One human line for a status read: where it is, how far along, and why if it's unhappy.
2196
+ *
2197
+ * What "where" means depends on the state, so say the right thing rather than one number that reads
2198
+ * differently in each: a percentage is only honest while BACKFILLING (see indexProgress). A `live`
2199
+ * project's `toBlock` only advances when that project has events, so on a busy chain a perfectly
2200
+ * current project sits far below head — printing that as a ratio makes healthy look broken.
2201
+ */
2202
+ function statusLine(s) {
2203
+ const p = indexProgress(s);
2204
+ let where;
2205
+ if (p)
2206
+ where = `${s.toBlock}/${s.headBlock} ${dim(`(${p.percent}%)`)}`;
2207
+ else if (!s.toBlock)
2208
+ where = dim('not indexed yet');
2209
+ else if (s.status === 'live')
2210
+ where = `${g('caught up')} ${dim(`· scanned through block ${s.toBlock}`)}`;
2211
+ else if (s.status === 'stale')
2212
+ where = `${dim('not tracking head right now')} ${dim(`· scanned through block ${s.toBlock}${s.headBlock ? ` of ${s.headBlock}` : ''}`)}`;
2213
+ else
2214
+ where = dim(`scanned through block ${s.toBlock}`);
2215
+ const why = s.error ? ` ${c.orange}${s.error.class}${c.reset}${s.error.message ? dim(` — ${s.error.message}`) : ''}` : '';
2216
+ return `${statusLabel(s.status)} ${where}${why}`;
2217
+ }
2218
+ /**
2219
+ * Report a register/reindex answer, whichever of the two conformant shapes the service used.
2220
+ *
2221
+ * A `200` already carries the counts. A `202` means the registration is durable and catch-up is still
2222
+ * running — so by default we poll to a terminal state and print the SAME summary line, giving the
2223
+ * human the "waits, then tells you what happened" UX without the service holding a socket open for
2224
+ * minutes. `--no-wait` stops at the 202 and names the command that checks later.
2225
+ */
2226
+ async function reportRemoteIndexing(remote, chainId, address, r, flags, verb) {
2227
+ // A real ABX clone ALWAYS emits a spine (its extension registrations at minimum), so a caught-up
2228
+ // projection with zero events means the service scanned the wrong chain/floor or its RPC hasn't
2229
+ // served the logs — not that the project is empty. A ✓ there is the lie that produces an empty
2230
+ // dashboard (the same guard `reindexAfterDeploy` applies locally).
2231
+ const settledLine = (events, tail) => {
2232
+ if (events > 0)
2233
+ ok(`remote resolver ${verb} ${address}: ${tail}`);
2234
+ else
2235
+ warn(`${address} is registered and caught up on ${remote.url}, but with ${bold('0 events')} — it will serve nothing. Check the service covers ${CHAIN} and that its RPC serves logs from the deploy block.`);
2236
+ };
2237
+ if (!isAccepted(r)) {
2238
+ settledLine(r.project.eventCount, `${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
2239
+ return;
2240
+ }
2241
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
2242
+ const check = `abx status ${address} --remote${spec ? ` ${spec}` : ''}`;
2243
+ // The registration is durable in EVERY branch here — but don't claim it "is catching up" when the
2244
+ // service already told us the catch-up failed. Two different sentences for two different facts.
2245
+ if (r.project.status === 'failed') {
2246
+ // Lead with the OUTCOME, not the sub-step that succeeded: a line starting "registered on …" skims
2247
+ // as success even with the failure later in the sentence (a reviewer read it exactly that way).
2248
+ warn(`${bold('catch-up FAILED')} on ${remote.url} — nothing is being served yet ${dim('(the registration itself is durable; the service retries it)')}`);
2249
+ }
2250
+ else {
2251
+ info(`registered — ${r.project.status} ${dim('(the service accepted it and is catching up; the registration is durable)')}`);
2252
+ }
2253
+ // A known failure is not something to "not wait" for — we already have the answer, so report it as
2254
+ // one regardless of --no-wait (never a ✓ over a broken index).
2255
+ if (flags['no-wait'] !== undefined && r.project.status !== 'failed') {
2256
+ info(`not waiting (--no-wait). Check with ${bold(check)}`);
2257
+ return;
2258
+ }
2259
+ const label = `remote ${verb === 'indexed' ? 'add' : 'index'}`;
2260
+ if (r.project.status === 'failed') {
2261
+ // Fetch the class the register response may not have carried, so the "whose problem" line is
2262
+ // never missing on the path that reports the failure soonest.
2263
+ const st = await serviceClient(remote).projectStatus(chainId, address).catch(() => undefined);
2264
+ throw new Error(`${label}: ${failedCatchUpMessage(address, remote, st?.error ?? undefined, check)}`);
2265
+ }
2266
+ let shown = -100;
2267
+ let lastStatus;
2268
+ try {
2269
+ const final = await serviceClient(remote).awaitIndexed(chainId, address, {
2270
+ onProgress: (s) => {
2271
+ const p = indexProgress(s);
2272
+ // Print on a state change or a meaningful step — a poll line every 3s is noise in a log an
2273
+ // agent has to read back.
2274
+ if (s.status !== lastStatus || (p && p.percent - shown >= 10)) {
2275
+ lastStatus = s.status;
2276
+ if (p)
2277
+ shown = p.percent;
2278
+ console.log(` ${statusLine(s)}`);
2279
+ }
2280
+ },
2281
+ });
2282
+ if (final.status === 'failed') {
2283
+ throw new Error(`${label}: ${failedCatchUpMessage(address, remote, final.error, check)}`);
2284
+ }
2285
+ settledLine(final.eventCount, `${final.eventCount} events, ${final.tokenCount} token(s) ${dim('(live)')}`);
2286
+ }
2287
+ catch (err) {
2288
+ if (err instanceof AbxIndexTimeoutError) {
2289
+ warn(`${address} is still ${err.last?.status ?? 'catching up'} on ${remote.url} — nothing is lost, it just isn't done.`);
2290
+ info(`follow it with ${bold(check + ' --watch')}`);
2291
+ return;
2292
+ }
2293
+ // The service went away mid-wait (or rejected the poll). The registration still landed — say
2294
+ // which failure this is, in the same words every other remote command uses.
2295
+ if (err instanceof AbxServiceError)
2296
+ throw describeRemoteError(err, remote, `remote ${verb}: registered, but polling status`);
2297
+ throw err;
1985
2298
  }
1986
- return base;
1987
2299
  }
1988
2300
  /**
1989
2301
  * Ensure a resolver admin token exists locally, generating + persisting one to `.env`
@@ -2016,24 +2328,24 @@ function ensureEffectsToken() {
2016
2328
  process.env.ABX_EFFECTS_TOKEN = token;
2017
2329
  return { token, generated: true };
2018
2330
  }
2019
- /** The shared secret that authorizes remote indexing control (never on-chain signing). */
2020
- function requireAdminToken() {
2021
- const t = process.env.ABX_RESOLVER_ADMIN_TOKEN;
2022
- if (!t) {
2023
- throw new Error('remote ops need ABX_RESOLVER_ADMIN_TOKEN in your .env — it must match the token set on the resolver ' +
2024
- '(`abx deploy-resolver` generates one and wires both sides).');
2025
- }
2026
- return t;
2027
- }
2028
2331
  // ── add ──────────────────────────────────────────────────────────────────────
2029
2332
  // Register + index a project this node didn't deploy. LOCAL by default (this
2030
2333
  // machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
2031
2334
  // the bridge a local deploy can't make on its own (separate projection stores).
2032
2335
  async function cmdAdd(address, flags) {
2033
2336
  if (!address || address.startsWith('--')) {
2034
- console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [url]]\n');
2337
+ console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
2035
2338
  process.exit(1);
2036
2339
  }
2340
+ // REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
2341
+ // with `--remote` a registration on someone else's service), and it has no preview mode — so
2342
+ // silently proceeding to DO the thing when the caller explicitly asked to preview is the one
2343
+ // outcome we must never produce. Name what's read-only instead.
2344
+ if (flags['dry-run'] !== undefined) {
2345
+ throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
2346
+ 'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
2347
+ 'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
2348
+ }
2037
2349
  // `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
2038
2350
  // per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
2039
2351
  // Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
@@ -2045,10 +2357,10 @@ async function cmdAdd(address, flags) {
2045
2357
  if (flags.traits)
2046
2358
  flagTraits.push(...parseTraitPairs(flags.traits));
2047
2359
  const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
2048
- const remote = remoteTarget(flags);
2360
+ const remote = remoteFlag(flags);
2049
2361
  if (remote) {
2050
- const token = requireAdminToken();
2051
- // Bridge what a hosted resolver can't derive itself: the off-chain traits and the durable
2362
+ requireRemoteToken(remote);
2363
+ // Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
2052
2364
  // content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
2053
2365
  // (the local registration), and compute locators from this machine's content index if needed.
2054
2366
  const localReg = new SelfHostIndexer().store.getRegistration(address);
@@ -2057,7 +2369,7 @@ async function cmdAdd(address, flags) {
2057
2369
  : localReg?.attributes
2058
2370
  ? normalizeAttributes(JSON.parse(localReg.attributes))
2059
2371
  : undefined;
2060
- // Bridge a Series' per-token off-chain traits to the hosted resolver (the resolver has no other
2372
+ // Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
2061
2373
  // way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
2062
2374
  // EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
2063
2375
  let tokenAttributes;
@@ -2083,6 +2395,7 @@ async function cmdAdd(address, flags) {
2083
2395
  // (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
2084
2396
  // chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
2085
2397
  const body = {
2398
+ chainId: resolveChain(CHAIN).id,
2086
2399
  address,
2087
2400
  fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
2088
2401
  factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
@@ -2094,12 +2407,22 @@ async function cmdAdd(address, flags) {
2094
2407
  contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
2095
2408
  full: flags.full ? true : undefined,
2096
2409
  };
2097
- info(`${bold('REMOTE')} → ${remote} ${dim('(registering with the hosted resolver — NOT this machine)')}`);
2410
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
2098
2411
  if (body.contentLocators)
2099
2412
  info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
2100
- const r = await remoteAddProject(remote, token, body);
2101
- ok(`remote resolver indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
2102
- info(`it now serves ${remote.replace(/\/+$/, '')}/t/${resolveChain(CHAIN).id}/${address.toLowerCase()}/0`);
2413
+ let r;
2414
+ try {
2415
+ r = await serviceClient(remote).registerProject(body);
2416
+ }
2417
+ catch (err) {
2418
+ throw describeRemoteError(err, remote, 'remote add');
2419
+ }
2420
+ await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed');
2421
+ info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
2422
+ // "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
2423
+ // that URL — it says nothing about whether the bytes it serves match the on-chain commitment, and
2424
+ // two reviewers stopped here and reported a blank page as fixed. Name the step that checks.
2425
+ info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
2103
2426
  return;
2104
2427
  }
2105
2428
  allowLargeScan(flags);
@@ -2142,7 +2465,11 @@ async function cmdAdd(address, flags) {
2142
2465
  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.`);
2143
2466
  }
2144
2467
  catch { /* advisory only — the real scan still runs */ }
2145
- const { state, elapsedMs } = await indexer.reindex(address);
2468
+ // Don't accept a zero here either: `deploy-code` finishes through this command, so this IS the
2469
+ // post-deploy index for a code project — and a real ABX clone always emits a spine (its extension
2470
+ // registrations at minimum), so 0 events means the RPC hasn't served the logs yet, not that the
2471
+ // project is empty. See reindexAfterDeploy.
2472
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, address);
2146
2473
  // Resolve durable locators for off-chain-by-hash content from this machine's index.
2147
2474
  const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
2148
2475
  if (Object.keys(locators).length) {
@@ -2159,7 +2486,14 @@ async function cmdAdd(address, flags) {
2159
2486
  contentLocators: JSON.stringify(locators),
2160
2487
  });
2161
2488
  }
2162
- ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2489
+ // A on 0 events is the lie that produced an empty dashboard; reindexAfterDeploy has already
2490
+ // explained the failure and named the recovery command, so don't stamp it as success too.
2491
+ if (state.eventCount > 0) {
2492
+ ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2493
+ }
2494
+ else {
2495
+ warn(`registered ${state.name ?? address}, but with NO reconstructed state — it will serve empty until the index succeeds.`);
2496
+ }
2163
2497
  info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
2164
2498
  }
2165
2499
  /** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
@@ -2226,6 +2560,271 @@ async function ensureSeedSource(publicClient) {
2226
2560
  * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
2227
2561
  * Seeds: canonical randomizer by default; --no-seed opts out.
2228
2562
  */
2563
+ // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
2564
+ //
2565
+ // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
2566
+ // learn what the toolkit does on their behalf. Its old form asserted the interesting claims
2567
+ // ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
2568
+ // smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
2569
+ // chain now holds, throw the local projection away and rebuild it, then read the token back the way
2570
+ // a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
2571
+ // run has to behave identically.
2572
+ /**
2573
+ * The deterministic part of a projection: everything that is a pure function of the chain.
2574
+ * Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
2575
+ * happened to answer, and the head at scan time all legitimately differ between two replays, so
2576
+ * folding them in would make the rebuild proof fail for reasons that aren't about correctness.
2577
+ */
2578
+ function projectionFingerprint(s) {
2579
+ const canonical = {
2580
+ address: s.address.toLowerCase(),
2581
+ name: s.name,
2582
+ symbol: s.symbol,
2583
+ owner: s.owner?.toLowerCase() ?? null,
2584
+ isCanonical: s.isCanonical,
2585
+ deployBlock: s.deployBlock,
2586
+ eventCount: s.eventCount,
2587
+ royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
2588
+ extensions: s.extensions.map((e) => e.name).sort(),
2589
+ collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
2590
+ tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
2591
+ events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
2592
+ };
2593
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
2594
+ }
2595
+ /**
2596
+ * Print the reconstructed spine — the point being that this list IS the database. Rendered inside
2597
+ * the index step (no header of its own: "Index it — replay the event spine" immediately followed by
2598
+ * a separate "The event spine" step read as a stutter).
2599
+ */
2600
+ function walkthroughSpine(state) {
2601
+ if (state.events.length === 0) {
2602
+ warn('no events to show (the index came back empty — see the recovery hint above).');
2603
+ return;
2604
+ }
2605
+ const width = Math.max(...state.events.map((e) => e.name.length));
2606
+ state.events.forEach((e, i) => {
2607
+ // ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
2608
+ // own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
2609
+ const reg = e.register === 2 ? p('ABX') : dim('ERC');
2610
+ console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
2611
+ });
2612
+ console.log(` ${dim('→')} those ${bold(String(state.events.length))} lines ${bold('are')} the database. ` +
2613
+ dim('There is no other copy that counts — not ours, not anyone\'s.'));
2614
+ 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.')}`);
2615
+ }
2616
+ /**
2617
+ * [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
2618
+ *
2619
+ * This is the one step that can't be faked by good output — it drops the projection for real
2620
+ * (registration kept), confirms it's gone, replays from the deploy block, and compares a
2621
+ * fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
2622
+ */
2623
+ async function walkthroughRebuild(indexer, address, before) {
2624
+ step('The moment of truth · delete it all');
2625
+ const fpBefore = projectionFingerprint(before);
2626
+ indexer.dropProjection(address);
2627
+ const gone = indexer.getProject(address) === null;
2628
+ console.log(` ${dim('wiping this computer\'s copy …')} ${gone ? g('gone. nothing left locally.') : `${c.orange}⚠ still present${c.reset}`}`);
2629
+ const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
2630
+ const fpAfter = projectionFingerprint(after);
2631
+ console.log(` ${dim(`asking ${CHAIN} to tell us everything again …`)} ` +
2632
+ `${g(`${after.eventCount} events, ${elapsedMs}ms`)}`);
2633
+ if (fpBefore === fpAfter) {
2634
+ ok(bold('byte-for-byte identical.'));
2635
+ info('Your token just survived losing every local file. No backup, no API key, no company —');
2636
+ info(`the chain remembered. ${dim('That is the whole point of ABX.')}`);
2637
+ info(dim(`checked by hashing every chain-derived field, not by eyeballing it: sha256 ${fpAfter.slice(0, 12)}…`));
2638
+ }
2639
+ else {
2640
+ warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
2641
+ info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
2642
+ }
2643
+ }
2644
+ /**
2645
+ * [5] Read the token back the way a marketplace would.
2646
+ *
2647
+ * Which is genuinely a different act per lane, so it reads from the real source in each case rather
2648
+ * than always going through the local server:
2649
+ * • fully on-chain → call `tokenURI(0)` on the contract. That IS what a marketplace does, and on
2650
+ * this lane the whole answer (art included) comes back from the chain with nothing else running.
2651
+ * • off-chain custody → fetch the resolver, because that's what the baked URI points at.
2652
+ * Reading the on-chain lane over HTTP would have quietly implied the local server was load-bearing
2653
+ * when it isn't — the opposite of the lesson.
2654
+ */
2655
+ async function walkthroughReadBack(state, baseUrl, onChainUri) {
2656
+ step('Read it back the way a marketplace would');
2657
+ const token = state.tokens[0];
2658
+ if (!token?.minted) {
2659
+ info('no minted token to read yet.');
2660
+ return;
2661
+ }
2662
+ if (onChainUri) {
2663
+ info(dim(`calling tokenURI(0) on your contract — the same call OpenSea makes …`));
2664
+ try {
2665
+ const uri = (await makePublicClient({ chainKey: CHAIN }).readContract({
2666
+ address: state.address,
2667
+ abi: oneOfOneImageAbi,
2668
+ functionName: 'tokenURI',
2669
+ args: [0n],
2670
+ }));
2671
+ const json = decodeOnChainJson(uri);
2672
+ if (json) {
2673
+ const parsed = JSON.parse(json);
2674
+ const img = typeof parsed.image === 'string' ? parsed.image : '';
2675
+ console.log(` ${g('✓')} came back with: ${bold(String(parsed.name ?? '(no name)'))}`);
2676
+ console.log(` ${dim(`image: ${img.slice(0, 48)}${img.length > 48 ? '…' : ''}`)}`);
2677
+ // The punchline of the whole lane: a data: URI means the art travelled IN the answer.
2678
+ if (img.startsWith('data:'))
2679
+ info(`${g('the art itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
2680
+ }
2681
+ else {
2682
+ console.log(` ${dim(uri.slice(0, 160))}${uri.length > 160 ? dim('…') : ''}`);
2683
+ }
2684
+ info(dim('nothing was running to answer that. no server of ours, no server of yours.'));
2685
+ }
2686
+ catch (e) {
2687
+ info(dim(`could not read tokenURI from the chain: ${e.message}`));
2688
+ }
2689
+ console.log(` ${dim('read it yourself any time:')} ${bold(`abx tokenuri ${state.address}`)}`);
2690
+ return;
2691
+ }
2692
+ info(`tokenURI(0) ${dim('on chain →')} ${token.tokenURI ?? dim('(none)')}`);
2693
+ // Off-chain custody: the CHAIN holds a keccak256 commitment to the image and a URI base; this NODE
2694
+ // holds the bytes. That split is the thing worth understanding, so name it rather than implying the
2695
+ // JSON came from the chain.
2696
+ info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
2697
+ try {
2698
+ const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
2699
+ const json = (await res.json());
2700
+ const shown = { name: json.name, image: json.image };
2701
+ console.log(` ${dim(JSON.stringify(shown))}`);
2702
+ if (Array.isArray(json.abx_provenance)) {
2703
+ info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
2704
+ }
2705
+ }
2706
+ catch (e) {
2707
+ info(dim(`could not read the served metadata: ${e.message}`));
2708
+ }
2709
+ console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2710
+ }
2711
+ /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2712
+ async function portInUse(port) {
2713
+ const { createServer } = await import('node:net');
2714
+ return new Promise((resolve) => {
2715
+ const probe = createServer();
2716
+ probe.once('error', (e) => resolve(e.code === 'EADDRINUSE'));
2717
+ probe.once('listening', () => probe.close(() => resolve(false)));
2718
+ probe.listen(port);
2719
+ });
2720
+ }
2721
+ /**
2722
+ * Index a project we *just* deployed — and don't believe a zero.
2723
+ *
2724
+ * `eth_getLogs` is read-after-write inconsistent on load-balanced RPCs: `waitForTransactionReceipt`
2725
+ * resolves against a node that has the block, then the log query lands on one that doesn't yet, and
2726
+ * returns an empty set for a block we KNOW contains our deploy. The old code took that single read at
2727
+ * face value, printed `✓ reconstructed 0 events`, stored the empty projection, and served an empty
2728
+ * dashboard — a first-run that looks like the toolkit simply doesn't work. It reproduced 100% of the
2729
+ * time on `https://sepolia.base.org`, which is the DEFAULT endpoint when there's no `.env`, i.e. the
2730
+ * documented first run was the broken path. The same block returned all 9 logs seconds later.
2731
+ *
2732
+ * We have the one thing that makes this checkable: we just minted, so the spine cannot be empty.
2733
+ * So verify instead of trusting — re-scan with backoff until events appear, and if they never do,
2734
+ * say so as a FAILURE with the recovery command rather than dressing a zero up as a ✓.
2735
+ */
2736
+ async function reindexAfterDeploy(indexer, address, opts = {}) {
2737
+ const attempts = opts.attempts ?? 6;
2738
+ const delayMs = opts.delayMs ?? 1500;
2739
+ let last = await indexer.reindex(address);
2740
+ for (let i = 1; i < attempts && last.state.eventCount === 0; i++) {
2741
+ if (i === 1) {
2742
+ info(dim("no events yet — the RPC hasn't served the logs for that block; re-scanning…"));
2743
+ }
2744
+ await new Promise((r) => setTimeout(r, delayMs));
2745
+ // `full: true` — the stored projection has 0 events, so there is no valid checkpoint to
2746
+ // resume from; a full replay from the deploy block is the only correct re-scan.
2747
+ last = await indexer.reindex(address, { full: true });
2748
+ }
2749
+ if (last.state.eventCount === 0) {
2750
+ warn(`the RPC still reports no logs for this project after ${attempts} tries — the deploy DID succeed ` +
2751
+ `(it's on chain), but this node can't reconstruct it yet.`);
2752
+ 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).')}`);
2753
+ }
2754
+ return last;
2755
+ }
2756
+ /**
2757
+ * `abx preview` — serve the program on localhost, live, for as long as the work is being made.
2758
+ *
2759
+ * The studio lane, and deliberately the FIRST thing to reach for on a code project: it renders the
2760
+ * same document the generator serves (real `abx.js`, real tokenData shape, real dependency tags)
2761
+ * with a synthetic seed, so a creator can refresh for new seeds, drive their PostParams from real
2762
+ * inputs, and watch an animated piece actually move — none of which a still-image sweep can show.
2763
+ * No chain, no key, no deploy. `--shoot` renders the same document headlessly for an agent that
2764
+ * can't open a browser.
2765
+ */
2766
+ async function cmdPreview(flags) {
2767
+ warnStrayFlags(flags, PREVIEW_FLAGS, 'preview');
2768
+ const cfg = previewConfigFromFlags(flags);
2769
+ const shootDir = flags.shoot && flags.shoot !== 'true' ? String(flags.shoot) : flags.shoot === 'true' ? 'abx-preview' : undefined;
2770
+ const count = Math.min(Math.max(Number(flags.count ?? 9) || 9, 1), 64);
2771
+ console.log(bold(`\n ABX Self-Host Toolkit — preview\n ${dim('the program, running locally — no chain, no deploy')}`));
2772
+ const { notes } = previewDepTags(cfg.deps);
2773
+ step('Program');
2774
+ info(cfg.source.kind === 'dir' ? `directory build ${cfg.source.path}/ (its own index.html + abx.js)` : `script ${cfg.source.path} ${dim('(re-read from disk on every render)')}`);
2775
+ if (cfg.schemas.length)
2776
+ info(`params: ${cfg.schemas.map(describeSchema).join(' · ')}`);
2777
+ else
2778
+ info(dim('params: none declared — add --schema key:Type:Auth to drive them from the studio'));
2779
+ for (const n of notes)
2780
+ info(`dep ${n}`);
2781
+ // A raw on-chain dependency can't be fetched without a chain, so the preview would render a
2782
+ // sketch missing its runtime and look broken for the wrong reason. Say so rather than let them
2783
+ // debug their own art.
2784
+ if (cfg.deps.some((d) => d.display.startsWith('0x'))) {
2785
+ warn('an on-chain data-contract dep is NOT loaded in preview — the sketch will run without it here. Use a name@version ref to preview against the CDN copy.');
2786
+ }
2787
+ const server = await startPreviewServer(cfg, shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT));
2788
+ if (shootDir) {
2789
+ step(`Render ${count} seeds headlessly`);
2790
+ try {
2791
+ const shots = await shootPreview(server.url, shootDir, count, {
2792
+ width: Number(flags.width ?? 1000) || 1000,
2793
+ timeoutMs: Number(flags['timeout-ms'] ?? 10_000) || 10_000,
2794
+ });
2795
+ ok(`${shots.length} frames → ${shootDir}/ ${dim('(traits in traits.json)')}`);
2796
+ for (const s of shots) {
2797
+ const t = s.traits && Object.keys(s.traits).length
2798
+ ? Object.entries(s.traits).map(([k, v]) => `${k} ${String(v)}`).join(' · ')
2799
+ : `${c.orange}no traits reported${c.reset}`;
2800
+ console.log(` ${dim(s.seed.slice(0, 10) + '…')} ${t}${s.done ? '' : dim(' (no abx.done())')}`);
2801
+ }
2802
+ const silent = shots.filter((s) => !s.traits || !Object.keys(s.traits).length).length;
2803
+ if (silent === shots.length) {
2804
+ warn('NO frame reported traits — `abx.traits({…})` is the only thing that becomes marketplace `attributes`. Verify with `abx inspect`.');
2805
+ }
2806
+ else if (shots.length > 1 && new Set(shots.map((s) => JSON.stringify(s.traits))).size === 1) {
2807
+ // Only meaningful when traits DID come back: identical values across seeds is the signature
2808
+ // of a sketch that never reads `abx.tokenData.seed` (prototyped on Math.random()), which
2809
+ // deploys as N visually identical tokens. Skipped when nothing reported at all — the
2810
+ // warning above already covers that, and firing both reads as noise.
2811
+ warn('every seed produced identical traits — check the sketch actually reads `abx.tokenData.seed` (the silent "all tokens the same" failure).');
2812
+ }
2813
+ }
2814
+ finally {
2815
+ await server.close();
2816
+ }
2817
+ return;
2818
+ }
2819
+ step('Studio');
2820
+ console.log(`\n ${g('●')} ${bold('preview')} ${server.url}`);
2821
+ console.log(` ${dim('studio ')}${server.url} ${dim('seed + params + live traits')}`);
2822
+ console.log(` ${dim('grid ')}${server.url}/grid ${dim('9 seeds at once, all live')}`);
2823
+ console.log(` ${dim('bare view ')}${server.url}/view ${dim('the generator document itself')}`);
2824
+ console.log(`\n ${dim('Edit the program and refresh — it is re-read from disk. Ctrl-C to stop.')}`);
2825
+ console.log(` ${dim('This is a preview: `abx inspect` is still the wiring check, and a testnet deploy is the faithful end-to-end.')}\n`);
2826
+ await new Promise(() => { }); // block like `serve` — the creator drives it
2827
+ }
2229
2828
  /**
2230
2829
  * `abx inspect <script.js>` — read a generative script and, WITHOUT executing it, report what it
2231
2830
  * needs (traits + their on-chain reproducibility, dependency hints, size → assembled-document size →
@@ -2406,17 +3005,7 @@ async function cmdDeployCode(flags) {
2406
3005
  const dryRun = !!flags['dry-run'];
2407
3006
  const name = flags.name ?? 'ABX Code';
2408
3007
  const symbol = flags.symbol ?? 'ABXC';
2409
- // Identity guard (mirror deploy/deploy-series): never write demo placeholders as the public
2410
- // on-chain identity. dry-run only warns so a preview still runs without --name/--symbol.
2411
- if (!flags.name || !flags.symbol) {
2412
- if (!flags.name)
2413
- warn(`no --name → default "${name}" would be the on-chain collection name`);
2414
- if (!flags.symbol)
2415
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
2416
- if (!dryRun && !flags.yes) {
2417
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
2418
- }
2419
- }
3008
+ assertRealIdentity(flags, { name, symbol, dryRun });
2420
3009
  const maxProvided = flags.max !== undefined;
2421
3010
  const max = Number(flags.max ?? 16);
2422
3011
  if (!Number.isInteger(max) || max <= 0)
@@ -3257,7 +3846,7 @@ async function loadEffects() {
3257
3846
  */
3258
3847
  async function cmdRender(address, tokenIds, flags) {
3259
3848
  if (!address || address.startsWith('--')) {
3260
- console.error('usage: abx render <address> [tokenId…] [--force] [--remote [url]] (missing stills/traits; --force re-renders an existing one — abx render --help)\n');
3849
+ console.error('usage: abx render <address> [tokenId…] [--force] [--remote [name|url]] (missing stills/traits; --force re-renders an existing one — abx render --help)\n');
3261
3850
  process.exit(1);
3262
3851
  }
3263
3852
  // Token ids are decimal (mint order). Keep only those — `parseFlags` leaves `--flag value` pairs
@@ -3294,12 +3883,12 @@ async function cmdRender(address, tokenIds, flags) {
3294
3883
  ok(line);
3295
3884
  return;
3296
3885
  }
3297
- // --remote publishes each render to the hosted resolver's control plane (the locator bridge) so a
3886
+ // --remote publishes each render to the remote resolver's control plane (the locator bridge) so a
3298
3887
  // laptop render lands on a resolver that doesn't share this disk; republish=true makes a re-run
3299
3888
  // restore a resolver that lost its volume without re-rendering. Local (no --remote): shared backend.
3300
- const remote = remoteTarget(flags);
3301
- const resolverUrl = (remote ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3302
- const adminToken = remote ? requireAdminToken() : undefined;
3889
+ const remote = remoteFlag(flags);
3890
+ const resolverUrl = (remote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3891
+ const adminToken = remote ? requireRemoteToken(remote) : undefined;
3303
3892
  // Co-located (no --remote): record each declared output into the shared store's artifact
3304
3893
  // registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
3305
3894
  // lane (adminToken) records rows on the hosted resolver instead.
@@ -3334,8 +3923,11 @@ async function cmdRender(address, tokenIds, flags) {
3334
3923
  * single time and exits (vs `abx render <addr>` which is the per-project repair lane).
3335
3924
  */
3336
3925
  async function cmdEffects(flags) {
3337
- const resolverUrl = (remoteTarget(flags) ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3338
- const adminToken = process.env.ABX_RESOLVER_ADMIN_TOKEN;
3926
+ const effectsRemote = remoteFlag(flags);
3927
+ const resolverUrl = (effectsRemote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3928
+ // Token optional BY DESIGN: no token = the co-located topology (shared store, no publish lane).
3929
+ // A named remote brings its own token; otherwise the self-host env token.
3930
+ const adminToken = effectsRemote?.token ?? process.env.ABX_RESOLVER_ADMIN_TOKEN;
3339
3931
  // Co-located (no admin token — we share the resolver's store): record each declared output into
3340
3932
  // the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
3341
3933
  // admin token, the publish lane records rows on the hosted resolver instead.
@@ -3375,16 +3967,23 @@ async function cmdEffects(flags) {
3375
3967
  await new Promise(() => { }); // block like `serve`
3376
3968
  }
3377
3969
  async function cmdIndex(address, flags) {
3378
- const remote = remoteTarget(flags);
3970
+ const remote = remoteFlag(flags);
3379
3971
  if (remote) {
3380
3972
  if (!address || address.startsWith('--')) {
3381
- console.error('usage: abx index <address> --remote [url] (re-index one project on a hosted resolver)\n');
3973
+ console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
3382
3974
  process.exit(1);
3383
3975
  }
3384
- const token = requireAdminToken();
3385
- info(`${bold('REMOTE')} → ${remote} ${dim('(re-indexing on the hosted resolver — the post-deploy nudge)')}`);
3386
- const r = await remoteAddProject(remote, token, { address, full: flags.full ? true : undefined });
3387
- ok(`remote resolver re-indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
3976
+ requireRemoteToken(remote);
3977
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
3978
+ const chainId = resolveChain(CHAIN).id;
3979
+ let r;
3980
+ try {
3981
+ r = await serviceClient(remote).registerProject({ chainId, address, full: flags.full ? true : undefined });
3982
+ }
3983
+ catch (err) {
3984
+ throw describeRemoteError(err, remote, 'remote index');
3985
+ }
3986
+ await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed');
3388
3987
  return;
3389
3988
  }
3390
3989
  allowLargeScan(flags);
@@ -3414,7 +4013,7 @@ async function cmdVerify(address, flags) {
3414
4013
  process.exit(1);
3415
4014
  }
3416
4015
  allowLargeScan(flags);
3417
- const remote = remoteTarget(flags);
4016
+ const remote = remoteFlag(flags);
3418
4017
  if (remote)
3419
4018
  return cmdVerifyRemote(address, remote);
3420
4019
  const indexer = new SelfHostIndexer();
@@ -3428,7 +4027,11 @@ async function cmdVerify(address, flags) {
3428
4027
  if (!state)
3429
4028
  throw new Error(`could not load state for ${address}`);
3430
4029
  console.log(bold(`\n verify ${state.name ?? address}`));
3431
- info(`canonical: ${state.isCanonical ? g('yes (factory-verified)') : 'unverified'} · owner ${state.owner ?? '—'}`);
4030
+ // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
4031
+ // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
4032
+ // trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
4033
+ // chain) is an environment note. Two reviewers read the collapsed word as a second failure.
4034
+ info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
3432
4035
  const storage = resolveBackend(storageOptions());
3433
4036
  const result = await verifyProject(state, storage);
3434
4037
  const tokens = result.tokens ?? [];
@@ -3558,18 +4161,33 @@ async function cmdVerify(address, flags) {
3558
4161
  console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
3559
4162
  else
3560
4163
  console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
4164
+ // A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
4165
+ // answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
4166
+ // could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
4167
+ // missing render / placeholder is a normal, expected state and still exits 0.
4168
+ if (anyCheck && !allGood)
4169
+ process.exitCode = 1;
3561
4170
  }
3562
4171
  // `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
3563
4172
  // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
3564
4173
  // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
3565
4174
  // sees it — the truthful "did the thumbnail land?" check after a remote render.
3566
- async function cmdVerifyRemote(address, resolver) {
3567
- const base = resolver.replace(/\/$/, '');
4175
+ async function cmdVerifyRemote(address, remote) {
4176
+ const base = remote.url.replace(/\/$/, '');
3568
4177
  const chainId = resolveChain(CHAIN).id;
3569
4178
  console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
3570
- const stateRes = await fetch(`${base}/api/project/${address}`);
4179
+ // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
4180
+ // bare `fetch failed` says neither. `abx status --remote` already gets this right — match it, or the
4181
+ // two commands disagree about the same condition (a reviewer's top misdiagnosis risk).
4182
+ let stateRes;
4183
+ try {
4184
+ stateRes = await fetch(`${base}/api/project/${address}`);
4185
+ }
4186
+ catch {
4187
+ throw new Error(`verify: nothing responded at ${base}${remote.source === 'named' ? ` (from ABX_REMOTE_${remote.name}_URL)` : ''}. Is it running, and is that the right address?`);
4188
+ }
3571
4189
  if (!stateRes.ok) {
3572
- throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${base}`);
4190
+ throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
3573
4191
  }
3574
4192
  const state = (await stateRes.json());
3575
4193
  info(`serving as "${state.name ?? address}"`);
@@ -3615,10 +4233,24 @@ async function cmdVerifyRemote(address, resolver) {
3615
4233
  console.log(` ${c.orange}⚠${c.reset} ${label}: stale — no render at the current state yet (the runner's next notify/sweep picks it up, or \`abx render ${address} --remote ${base}\`)`);
3616
4234
  }
3617
4235
  const { upToDate, stale, rendering, failed } = report.counts;
3618
- const summary = `${upToDate}/${minted.length} up to date${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
3619
- console.log(failed || stale
3620
- ? `\n ${c.orange}⚠${c.reset} ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}\n`
3621
- : `\n ${g(`✓ ${summary}`)}served straight from the resolver.\n`);
4236
+ // RENDERS ONLY say so. This report answers "is there a current render for each token", never
4237
+ // "do the served bytes match the on-chain commitment"; those are different questions and this
4238
+ // command promises the second one too. A project with no renders at all (a 1/1, an image Series)
4239
+ // has nothing to be "up to date" ABOUT, so don't print a 0/N fraction but don't let a green ✓
4240
+ // here read as "the image is verified" either. Byte integrity comes from the check below.
4241
+ if (report.tokens.length === 0) {
4242
+ info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
4243
+ }
4244
+ else {
4245
+ // Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
4246
+ // way "0/M up to date" does.
4247
+ const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
4248
+ if (failed || stale)
4249
+ console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
4250
+ else
4251
+ ok(`renders: ${summary}`);
4252
+ }
4253
+ await reportRemoteByteIntegrity(address, remote, base);
3622
4254
  return;
3623
4255
  }
3624
4256
  let gap = false;
@@ -3636,8 +4268,63 @@ async function cmdVerifyRemote(address, resolver) {
3636
4268
  }
3637
4269
  }
3638
4270
  console.log(gap
3639
- ? `\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`
3640
- : `\n ${g('✓ thumbnails are real renders')} — served straight from the resolver.\n`);
4271
+ ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
4272
+ : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
4273
+ await reportRemoteByteIntegrity(address, remote, base);
4274
+ }
4275
+ /**
4276
+ * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
4277
+ * commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
4278
+ * different question, and a green ✓ there was reading as "the image is correct" when the bytes could
4279
+ * genuinely mismatch.
4280
+ *
4281
+ * The service is the right place to answer it: it holds both the bytes and the chain, and it already
4282
+ * exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
4283
+ * outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
4284
+ * byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
4285
+ */
4286
+ async function reportRemoteByteIntegrity(address, remote, base) {
4287
+ if (!remote.token) {
4288
+ warn(`byte integrity NOT checked — that check is credentialed on the service. Set ${remote.tokenVar} (or pass --remote-token) and re-run, or run ${bold(`abx verify ${address}`)} against a node that holds the bytes.`);
4289
+ return;
4290
+ }
4291
+ let report;
4292
+ try {
4293
+ const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
4294
+ if (res.status === 401 || res.status === 403) {
4295
+ warn(`byte integrity NOT checked — ${base} rejected ${tokenSourceLabel(remote)} for its verify route (the read plane served fine, so this is a credential/scoping issue, not a broken project).`);
4296
+ return;
4297
+ }
4298
+ if (!res.ok) {
4299
+ // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
4300
+ // this is equally "an older self-hosted node" and "a provider that didn't implement it".
4301
+ warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
4302
+ `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
4303
+ `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
4304
+ return;
4305
+ }
4306
+ report = (await res.json());
4307
+ }
4308
+ catch {
4309
+ warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
4310
+ return;
4311
+ }
4312
+ const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
4313
+ if (checked.length === 0) {
4314
+ info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
4315
+ return;
4316
+ }
4317
+ const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
4318
+ if (bad.length === 0) {
4319
+ ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
4320
+ return;
4321
+ }
4322
+ const spec = remote.name?.toLowerCase() ?? base;
4323
+ console.log(` ${c.red}✗${c.reset} ${bold('BYTE MISMATCH')} on token(s) ${bad.map((t) => `#${t.tokenId}`).join(', ')} — ${base} does NOT serve bytes that hash to the on-chain commitment ` +
4324
+ `${dim('(this is what a marketplace shows as a blank or placeholder image)')}. The chain is the truth, so the served copy is the wrong one. Two causes, two fixes:`);
4325
+ info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
4326
+ info(`the bytes only exist on THIS machine (local fs custody) → a hosted resolver can never serve them: ${bold('abx storage upload')} to a durable backend + re-point the field, or serve the project from a node that holds them.`);
4327
+ process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
3641
4328
  }
3642
4329
  // ── tokenuri ─────────────────────────────────────────────────────────────────
3643
4330
  // Read tokenURI(id) STRAIGHT FROM THE CONTRACT on-chain (no server, no node) and decode
@@ -3688,7 +4375,12 @@ async function cmdTokenUri(address, flags) {
3688
4375
  catch {
3689
4376
  const code = await publicClient.getCode({ address }).catch(() => undefined);
3690
4377
  if (!code || code === '0x') {
3691
- console.error(`abx tokenuri: no contract at ${address} on ${CHAIN} double-check the address and that you're on the right chain. If you JUST deployed, give the tx a block or two to mine.\n`);
4378
+ // Name the endpoint we actually asked. "No contract here" is indistinguishable from "you're
4379
+ // pointed at the wrong node", and a chain KEY doesn't disambiguate that — two endpoints can
4380
+ // both claim `sepolia` (a fork, a stale duplicate .env line) and only one has your contract.
4381
+ console.error(`abx tokenuri: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
4382
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
4383
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
3692
4384
  }
3693
4385
  else {
3694
4386
  console.error(`abx tokenuri: ${address} didn't return a tokenURI for token ${tokenId} — it may not be an ABX/ERC-721 token, token ${tokenId} may be unminted (try --token <id>), or — on a large on-chain tokenURI — an unauthenticated RPC read hit its gas cap (try a wallet-connected / high-gas RPC).\n`);
@@ -4007,9 +4699,53 @@ async function cmdState(address, flags) {
4007
4699
  info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
4008
4700
  console.log('');
4009
4701
  }
4010
- async function cmdStatus() {
4702
+ /**
4703
+ * `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
4704
+ * the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
4705
+ *
4706
+ * One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
4707
+ * ask the service, and the same five words either way. Distinct from `abx state <address>`, which
4708
+ * reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
4709
+ */
4710
+ async function cmdStatus(address, flags) {
4711
+ // `abx status --remote <name>` has no address — without this guard the flag itself lands in
4712
+ // rest[0] and gets sent as the address path segment (the same guard every other command applies).
4713
+ if (address?.startsWith('--'))
4714
+ address = undefined;
4715
+ const remote = remoteFlag(flags);
4716
+ if (remote)
4717
+ return cmdStatusRemote(address, remote, flags);
4011
4718
  const indexer = new SelfHostIndexer();
4012
4719
  const regs = indexer.store.listRegistrations();
4720
+ if (address) {
4721
+ const reg = indexer.store.getRegistration(address);
4722
+ if (!reg)
4723
+ throw new Error(`${address} isn't tracked by this node. Add it with \`abx add ${address}\`, or ask a service: \`abx status ${address} --remote <name>\``);
4724
+ const s = indexer.getProject(address);
4725
+ const row = indexer.indexStatus(address);
4726
+ const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
4727
+ // Don't print the address twice when there's no name to lead with.
4728
+ console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
4729
+ statusRow('status', statusLine({
4730
+ chainId: resolveChain(reg.chainKey).id,
4731
+ address,
4732
+ status: row.status,
4733
+ fromBlock: reg.fromBlock,
4734
+ toBlock: s?.toBlock ?? null,
4735
+ headBlock: head,
4736
+ eventCount: s?.eventCount ?? 0,
4737
+ tokenCount: s?.tokens.length ?? 0,
4738
+ mintedCount: 0,
4739
+ reconstructedAt: s?.reconstructedAt ?? null,
4740
+ ...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
4741
+ }));
4742
+ statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
4743
+ statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
4744
+ if (!head)
4745
+ statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
4746
+ console.log('');
4747
+ return;
4748
+ }
4013
4749
  console.log(bold(`\n ABX self-host node`));
4014
4750
  info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
4015
4751
  if (regs.length === 0) {
@@ -4019,27 +4755,114 @@ async function cmdStatus() {
4019
4755
  console.log('');
4020
4756
  for (const reg of regs) {
4021
4757
  const s = indexer.getProject(reg.address);
4022
- const line = s
4023
- ? `${g('●')} ${s.name ?? s.address} ${dim(s.address)} ${s.eventCount} events`
4024
- : `${dim('○')} ${reg.address} ${dim('(registered, not indexed)')}`;
4025
- console.log(' ' + line);
4758
+ const st = indexer.indexStatus(reg.address).status;
4759
+ const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
4760
+ const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
4761
+ console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
4026
4762
  }
4027
- console.log('');
4763
+ console.log(dim(`\n one project in detail: abx status <address>\n`));
4764
+ }
4765
+ /** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
4766
+ * can see. `--watch` tails until everything reaches a terminal state. */
4767
+ async function cmdStatusRemote(address, remote, flags) {
4768
+ requireRemoteToken(remote);
4769
+ const client = serviceClient(remote);
4770
+ const chainId = resolveChain(CHAIN).id;
4771
+ const watch = flags.watch !== undefined;
4772
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
4773
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
4774
+ if (address) {
4775
+ for (;;) {
4776
+ let s;
4777
+ try {
4778
+ s = await client.projectStatus(chainId, address);
4779
+ }
4780
+ catch (err) {
4781
+ throw describeRemoteError(err, remote, 'remote status');
4782
+ }
4783
+ console.log(`\n ${bold(address)}`);
4784
+ statusRow('status', statusLine(s));
4785
+ statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
4786
+ statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
4787
+ // Spell out what `watching` MEANS on the line itself — a bare `no` sent a reviewer to a
4788
+ // different command's output to find out whether it was a problem (it usually isn't).
4789
+ if (s.watcher) {
4790
+ statusRow('watching', s.watcher.watching
4791
+ ? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
4792
+ : `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
4793
+ }
4794
+ // Don't leave a creator staring at a red word with no next move. `failed` especially reads as
4795
+ // terminal when it isn't — name what it means and the one command that follows it.
4796
+ if (!watch && s.status !== 'live') {
4797
+ if (s.status === 'failed' && s.error)
4798
+ statusRow('what now', indexErrorAction(s.error.class));
4799
+ statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
4800
+ }
4801
+ console.log('');
4802
+ if (!watch || s.status === 'live' || s.status === 'failed')
4803
+ return;
4804
+ await sleep(3000);
4805
+ }
4806
+ }
4807
+ for (;;) {
4808
+ let projects;
4809
+ try {
4810
+ projects = await client.listProjects();
4811
+ }
4812
+ catch (err) {
4813
+ throw describeRemoteError(err, remote, 'remote status');
4814
+ }
4815
+ if (projects.length === 0) {
4816
+ console.log(dim('\n no projects visible to this token\n'));
4817
+ return;
4818
+ }
4819
+ console.log('');
4820
+ for (const p of projects) {
4821
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
4822
+ `${p.status ? statusLabel(p.status) : dim('status not reported')}` +
4823
+ `${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
4824
+ }
4825
+ console.log(`\n ${rollUp(projects)}\n`);
4826
+ const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
4827
+ if (!watch || settled)
4828
+ return;
4829
+ await sleep(3000);
4830
+ }
4831
+ }
4832
+ /** "3 live, 1 backfilling, 1 failed (rpc_rate_limited)" — the one-line answer to "is my stuff ok?" */
4833
+ function rollUp(projects) {
4834
+ const counts = new Map();
4835
+ for (const p of projects) {
4836
+ const k = p.status ?? 'unknown';
4837
+ counts.set(k, (counts.get(k) ?? 0) + 1);
4838
+ }
4839
+ const classes = [...new Set(projects.filter((p) => p.error).map((p) => p.error.class))];
4840
+ const parts = [...counts.entries()].map(([k, n]) => `${n} ${k}`);
4841
+ return `${parts.join(', ')}${classes.length ? ` ${dim(`(${classes.join(', ')})`)}` : ''}`;
4028
4842
  }
4029
4843
  // ── forget ────────────────────────────────────────────────────────────────--
4030
4844
  // Drop a project this node tracks (registration + projection) — for cleaning up
4031
4845
  // test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
4032
4846
  async function cmdForget(address, flags) {
4033
4847
  if (!address || address.startsWith('--')) {
4034
- console.error('usage: abx forget <address> [--remote [url]]\n');
4848
+ console.error('usage: abx forget <address> [--remote [name|url]]\n');
4035
4849
  process.exit(1);
4036
4850
  }
4037
- const remote = remoteTarget(flags);
4851
+ const remote = remoteFlag(flags);
4038
4852
  if (remote) {
4039
- const token = requireAdminToken();
4040
- info(`${bold('REMOTE')} → ${remote} ${dim('(deregistering on the hosted resolver — NOT this machine)')}`);
4041
- await remoteRemoveProject(remote, token, address);
4042
- ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
4853
+ requireRemoteToken(remote);
4854
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
4855
+ let removed;
4856
+ try {
4857
+ ({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
4858
+ }
4859
+ catch (err) {
4860
+ throw describeRemoteError(err, remote, 'remote forget');
4861
+ }
4862
+ if (removed)
4863
+ ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
4864
+ else
4865
+ console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
4043
4866
  return;
4044
4867
  }
4045
4868
  const indexer = new SelfHostIndexer();
@@ -4050,6 +4873,97 @@ async function cmdForget(address, flags) {
4050
4873
  indexer.store.deregister(address);
4051
4874
  ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
4052
4875
  }
4876
+ // ── remote ────────────────────────────────────────────────────────────────--
4877
+ // Inspect a remote service. Bare `abx remote` lists the named remotes configured in .env plus the
4878
+ // self-host default pair (URLs + whether a token is set — never the secret itself). With a target,
4879
+ // fetches its PUBLIC service descriptor (what it serves: interfaces, chains, auth, managed
4880
+ // rendering) and — when a token resolves — lists the projects visible to that token, which makes
4881
+ // this the one-command "is my provider key valid?" check. Read-only; registers nothing.
4882
+ async function cmdRemote(spec, flags) {
4883
+ if (!spec || spec.startsWith('--')) {
4884
+ const remotes = listConfiguredRemotes();
4885
+ console.log(`\n ${bold('named remotes')} ${dim('(ABX_REMOTE_<NAME>_URL/_TOKEN in .env — inspect one: abx remote <name>)')}`);
4886
+ if (remotes.length === 0)
4887
+ console.log(dim(' none configured'));
4888
+ for (const r of remotes) {
4889
+ console.log(` ${g('●')} ${r.name.toLowerCase()} ${dim(r.url)} ${r.hasToken ? g('token set') : dim('no token')}`);
4890
+ }
4891
+ // A near-miss var reads as "no token" while the value is sitting in .env under the wrong name.
4892
+ for (const bad of misnamedRemoteVars()) {
4893
+ warn(`${bad.key} isn't a recognized remote var — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN are read).`);
4894
+ }
4895
+ const def = process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL;
4896
+ console.log(`\n ${bold('self-host default')} ${dim('(bare --remote)')}`);
4897
+ console.log(def
4898
+ ? ` ${g('●')} ${def} ${process.env.ABX_RESOLVER_ADMIN_TOKEN ? g('token set') : dim('no ABX_RESOLVER_ADMIN_TOKEN')}`
4899
+ : dim(' none (set ABX_PUBLIC_BASE_URL in .env)'));
4900
+ console.log('');
4901
+ return;
4902
+ }
4903
+ const target = resolveRemote(spec, flags['remote-token']);
4904
+ if (!target)
4905
+ return;
4906
+ const client = serviceClient(target);
4907
+ console.log(`\n ${bold(target.name ? `remote ${target.name.toLowerCase()}` : 'remote')} ${dim(`→ ${target.url}`)}`);
4908
+ let d;
4909
+ try {
4910
+ d = await client.descriptor();
4911
+ }
4912
+ catch (err) {
4913
+ // Two very different situations, and the fix differs — so don't nest the raw client error
4914
+ // (it repeats the URL and leaks `GET`/`fetch failed` at a creator).
4915
+ const status = err instanceof AbxServiceError ? err.status : -1;
4916
+ if (status === 0) {
4917
+ throw new Error(`nothing responded at ${target.url} — check the address. A provider gives you an https:// base ` +
4918
+ `(e.g. https://meta.provider.xyz); if it's your own node, is it running?`);
4919
+ }
4920
+ throw new Error(`${target.url} answered, but serves no ABX service descriptor at /.well-known/abx-service. ` +
4921
+ `That's either an older self-hosted node (fine if it's yours — the remote commands still work against it) ` +
4922
+ `or not an ABX service at all. Verify the URL before registering anything with it.`);
4923
+ }
4924
+ info(`service ${d.service?.name ?? '—'} ${dim(d.service?.version ?? '')}`);
4925
+ info(`serves ${(d.interfaces ?? []).join(' · ') || '—'}`);
4926
+ const chainId = resolveChain(CHAIN).id;
4927
+ const coversChain = (d.chains ?? []).includes(chainId);
4928
+ info(`chains ${(d.chains ?? []).join(', ') || '—'} ${coversChain ? g(`✓ covers ${CHAIN} (${chainId})`) : `${c.orange}⚠${c.reset} does NOT cover ${CHAIN} (${chainId}) — registrations will be refused`}`);
4929
+ if (d.render?.attached) {
4930
+ const outputs = d.render.effects?.flatMap((e) => e.outputs.map((o) => `${e.key}/${o.key}`)).join(', ');
4931
+ info(`rendering managed behind this service${outputs ? ` (${outputs})` : d.render.effects === null ? dim(' (attached — runner unverified right now)') : ''} — code drops need no effects runner here`);
4932
+ }
4933
+ if (d.auth) {
4934
+ info(`auth bearer${d.auth.signupUrl ? ` · get a key: ${d.auth.signupUrl}` : ''}${d.auth.docsUrl ? ` · docs: ${d.auth.docsUrl}` : ''}`);
4935
+ }
4936
+ else {
4937
+ info(`auth none advertised ${dim('(control plane disabled on this node)')}`);
4938
+ }
4939
+ if (!target.token) {
4940
+ // Same near-miss check the register path does — this is where someone lands FIRST when their
4941
+ // credential is set under a name the CLI doesn't read, so the hint has to be here too.
4942
+ const nearMiss = misnamedRemoteVars().find((v) => v.suggestion === target.tokenVar);
4943
+ if (nearMiss)
4944
+ warn(`${nearMiss.key} is set but is NOT read — the convention is ${bold(target.tokenVar)} (only _URL and _TOKEN). Rename it and re-run.`);
4945
+ else
4946
+ info(dim(`no token resolved (set ${target.tokenVar} or pass --remote-token) — descriptor only; can't list your projects.`));
4947
+ console.log('');
4948
+ return;
4949
+ }
4950
+ try {
4951
+ const projects = await client.listProjects();
4952
+ ok(`token accepted — ${projects.length} project(s) visible to it`);
4953
+ for (const p of projects.slice(0, 10)) {
4954
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(`${p.address} · ${p.tokenCount ?? '?'} token(s)`)}` +
4955
+ `${p.status ? ` ${statusLabel(p.status)}` : ''}${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''}`);
4956
+ }
4957
+ if (projects.length > 10)
4958
+ console.log(dim(` … and ${projects.length - 10} more`));
4959
+ if (projects.some((p) => p.status))
4960
+ info(rollUp(projects) + dim(' — one project in detail: abx status <address> --remote ' + (target.name?.toLowerCase() ?? target.url)));
4961
+ }
4962
+ catch (err) {
4963
+ throw describeRemoteError(err, target, 'remote list');
4964
+ }
4965
+ console.log('');
4966
+ }
4053
4967
  // ── migrate ───────────────────────────────────────────────────────────────--
4054
4968
  // Move a contract's OFF-CHAIN operator state from one resolver to another (e.g. fly.io →
4055
4969
  // a droplet). The destination replays all ON-CHAIN state from chain itself; this bridges
@@ -4058,13 +4972,17 @@ async function cmdForget(address, flags) {
4058
4972
  // and write through the admin control plane. It does NOT cut over — after a clean migration the
4059
4973
  // operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
4060
4974
  async function cmdMigrate(address, flags) {
4061
- const from = typeof flags.from === 'string' ? flags.from : undefined;
4062
- const to = typeof flags.to === 'string' ? flags.to : undefined;
4063
- if (!address || address.startsWith('--') || !from || !to) {
4064
- console.error('usage: abx migrate <address> --from <source-resolver-url> --to <dest-resolver-url> [--from-block N]\n');
4975
+ // Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
4976
+ // source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
4977
+ const fromTarget = typeof flags.from === 'string' ? resolveRemote(flags.from) : null;
4978
+ const toTarget = typeof flags.to === 'string' ? resolveRemote(flags.to, flags['remote-token']) : null;
4979
+ if (!address || address.startsWith('--') || !fromTarget || !toTarget) {
4980
+ console.error('usage: abx migrate <address> --from <source-resolver name|url> --to <dest-resolver name|url> [--from-block N]\n');
4065
4981
  process.exit(1);
4066
4982
  }
4067
- const token = requireAdminToken();
4983
+ requireRemoteToken(toTarget);
4984
+ const from = fromTarget.url;
4985
+ const to = toTarget.url;
4068
4986
  const chainId = resolveChain(CHAIN).id;
4069
4987
  allowLargeScan(flags);
4070
4988
  console.log(bold(`\n abx migrate ${dim('— port off-chain state between resolvers (no cutover)')}`));
@@ -4121,6 +5039,7 @@ async function cmdMigrate(address, flags) {
4121
5039
  step('Populate the destination');
4122
5040
  const locCount = Object.keys(plan.contentLocators).length;
4123
5041
  const body = {
5042
+ chainId,
4124
5043
  address,
4125
5044
  // The deploy block (locally known); if somehow absent, omit it so the destination derives it
4126
5045
  // rather than scanning from genesis (never bake a from-0 floor into a fresh resolver).
@@ -4133,9 +5052,18 @@ async function cmdMigrate(address, flags) {
4133
5052
  contentLocators: locCount ? plan.contentLocators : undefined,
4134
5053
  full: true, // first registration on the destination — replay from the deploy block
4135
5054
  };
4136
- info(`${bold('REMOTE')} → ${to} ${dim('(admin control plane — chain replay + off-chain enrichment)')}`);
4137
- const r = await remoteAddProject(to, token, body);
4138
- ok(`destination indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
5055
+ info(`${bold('REMOTE')} → ${to} ${dim('(control plane — chain replay + off-chain enrichment)')}`);
5056
+ let r;
5057
+ try {
5058
+ r = await serviceClient(toTarget).registerProject(body);
5059
+ }
5060
+ catch (err) {
5061
+ throw describeRemoteError(err, toTarget, 'migrate destination');
5062
+ }
5063
+ // Always wait here, even if the caller passed --no-wait: the parity check below reads the
5064
+ // destination's served metadata, and comparing a half-indexed projection would report a false
5065
+ // mismatch — worse than a slow migrate.
5066
+ await reportRemoteIndexing(toTarget, chainId, address, r, {}, 'indexed');
4139
5067
  // 5) Parity check — does the destination now serve the same metadata as the source? Sample a
4140
5068
  // token we did NOT re-pin (a re-pinned image is durable-locator-on-dest vs old-host-on-source
4141
5069
  // BY DESIGN — different there is correct, so comparing it would mislead).
@@ -4165,100 +5093,171 @@ async function cmdMigrate(address, flags) {
4165
5093
  info('keep the source running until DNS / base-URI propagates (source-only images were already re-pinned above, unless a warning said otherwise).');
4166
5094
  }
4167
5095
  // ── doctor ────────────────────────────────────────────────────────────────--
5096
+ /**
5097
+ * Doctor's "want me to fix that?" for a missing or stale skill. Three lanes, deliberately:
5098
+ * `--fix` install without asking (CI, scripts, an agent running doctor for someone)
5099
+ * interactive name the exact directories, then ask — Enter accepts, since doctor's whole job is
5100
+ * getting setup right and this is the one check whose fix is a local file copy
5101
+ * non-TTY change NOTHING and print how to do it; a diagnostic must never mutate a
5102
+ * scripted environment just because nobody was there to say no
5103
+ * Honors `--global` / `--agent` so the fix can target the same place an explicit install would.
5104
+ */
5105
+ async function offerSkillInstall(flags, stale, indent) {
5106
+ const opts = { global: flags.global !== undefined, agent: flags.agent };
5107
+ const src = resolveBundledSkill();
5108
+ if (!src)
5109
+ return; // no bundled skill to install (dev checkout oddity) — the hint above still stands
5110
+ const verb = stale ? 'resync' : 'install';
5111
+ const dests = defaultSkillDests(opts);
5112
+ if (flags.fix === undefined) {
5113
+ if (!process.stdin.isTTY) {
5114
+ console.log(`${indent}${dim(`non-interactive — run \`abx doctor --fix\` (or \`abx skill install\`) to ${verb} it.`)}`);
5115
+ return;
5116
+ }
5117
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5118
+ const answer = await new Promise((resolve) => {
5119
+ // EOF (Ctrl-D) closes the interface WITHOUT firing the question callback — awaiting only the
5120
+ // callback would hang doctor forever. Treat a closed stream as a decline: the safe direction is
5121
+ // always "change nothing", never "write files because nobody answered".
5122
+ rl.once('close', () => resolve('n'));
5123
+ rl.question(`${indent}${verb} the abx skill into ${bold(dests)} now? [Y/n] `, resolve);
5124
+ });
5125
+ rl.close();
5126
+ if (declinesSkillInstall(answer)) {
5127
+ console.log(`${indent}${dim(`skipped — \`abx skill install\` when you want it.`)}`);
5128
+ return;
5129
+ }
5130
+ }
5131
+ console.log('');
5132
+ installSkillToDefaults(src, opts);
5133
+ }
4168
5134
  async function cmdDoctor(flags) {
4169
- console.log(bold('\n abx doctor\n'));
4170
- const check = (label, pass, detail = '') => console.log(` ${pass ? g('✓') : `${c.red}✗${c.reset}`} ${label}${detail ? dim(' ' + detail) : ''}`);
5135
+ console.log(bold('\n abx doctor') + dim(` · ${CHAIN}`) + '\n');
5136
+ // Two visual tiers: PASS/FAIL checks (✓/✗) for things that are either working or broken, and an
5137
+ // "Optional" block (·) for path-dependent setup that is fine to be unset. We deliberately do NOT
5138
+ // use ⚠ for "unset but often fine" — that read as noise; ⚠ is reserved for a real gotcha (a
5139
+ // range-capped RPC). Labels are padded so both tiers align.
5140
+ const CONT = ' '.repeat(17); // continuation indent: aligns under a check/opt detail column
5141
+ const check = (label, pass, detail = '') => console.log(` ${pass ? g('✓') : `${c.red}✗${c.reset}`} ${label.padEnd(13)}${detail ? dim(detail) : ''}`);
5142
+ const opt = (label, detail) => console.log(` ${dim('·')} ${label.padEnd(13)}${dim(detail)}`);
4171
5143
  const hasKey = !!(process.env.ABX_DEPLOYER_PK ?? process.env.SEPOLIA_FUNDED_PK ?? process.env.SEPOLIA_WALLET_PK);
4172
- // Not having an env key is NOT fatal: the wallet lane (`--sign`) signs in the user's own wallet,
4173
- // key-free. Only flag it as a soft note + point at the lanes never imply "paste a private key."
4174
- if (hasKey)
4175
- check('signing key in env', true, 'hot lane ready (env key signs unattended)');
4176
- else
4177
- console.log(` ${c.orange}⚠${c.reset} signing key in env${dim(' none fine if you sign in your own wallet (`--sign`; the key never touches the CLI). Only set ABX_DEPLOYER_PK / SEPOLIA_FUNDED_PK / SEPOLIA_WALLET_PK for hot/unattended signing.')}`);
5144
+ // 1. Agent skill FIRST and prominent. The primary way to use abx is to let a coding agent drive
5145
+ // it, so a missing/stale skill is a ✗: not broken infra, but the main UX isn't set up. Its
5146
+ // version lives in SKILL.md frontmatter (version-locked to this CLI). Notify-only — no exit code.
5147
+ const cliVersion = readCliVersion();
5148
+ const skillVersions = installedSkillVersions();
5149
+ const staleSkills = skillVersions.filter((v) => compareVersions(cliVersion, v) > 0);
5150
+ const skillMissing = skillVersions.length === 0;
5151
+ const skillStale = !skillMissing && staleSkills.length > 0;
5152
+ if (skillMissing) {
5153
+ check('agent skill', false, `not installed — run ${g('abx skill install')}`);
5154
+ console.log(`${CONT}${dim('(recommended: let a coding agent drive abx)')}`);
5155
+ }
5156
+ else if (skillStale) {
5157
+ check('agent skill', false, `v${staleSkills.join(', v')} behind CLI v${cliVersion} — run ${g('abx skill install')}`);
5158
+ }
5159
+ else {
5160
+ check('agent skill', true, `in sync (v${cliVersion})`);
5161
+ }
5162
+ // Offer to fix it here rather than only naming the command. `npm i -g` + `abx skill install` was a
5163
+ // two-step install flow where the second step is easy to skip and invisible when skipped (an agent
5164
+ // that never learned abx just... doesn't use it). Doctor is already the documented first run, so
5165
+ // this collapses the flow without an npm `postinstall` hook — which could not work anyway: npm runs
5166
+ // lifecycle scripts with cwd set to the installed package dir (so the skill would land inside
5167
+ // node_modules), pnpm gates install scripts by default, and writing to a user's ~/.claude on
5168
+ // install is the kind of side effect that belongs to the user, not to us.
5169
+ if (skillMissing || skillStale) {
5170
+ await offerSkillInstall(flags, skillStale, CONT);
5171
+ }
5172
+ console.log('');
5173
+ // 2. Core environment (✓/✗). Signing-wallet balances are computed here (they need the RPC) but
5174
+ // printed in the Optional block below, so buffer them.
5175
+ let signingOpt = null;
5176
+ let forOpt = null;
4178
5177
  try {
4179
5178
  const publicClient = makePublicClient({ chainKey: CHAIN });
4180
5179
  const bn = await publicClient.getBlockNumber();
4181
- check(`RPC reachable (${CHAIN})`, true, `head block ${bn}`);
4182
- // RPC endpointsprobe each for reachability, getLogs range, and archive depth,
4183
- // so the toolkit uses (and recommends) the one fit for the job — and says so if none are.
5180
+ // Collapse the RPC report to one line (best endpoint + head), and only add a ⚠ when there is a
5181
+ // genuine problema range-capped-only set that will grind a resolver under load.
4184
5182
  const probes = await probeRpcEndpoints({ chainKey: CHAIN });
4185
5183
  const usable = probes.filter((pr) => pr.verdict !== 'unusable');
4186
5184
  const best = probes.find((pr) => pr.verdict === 'best') ?? usable[0];
4187
- check(`RPC endpoints for ${CHAIN} (${probes.length})`, usable.length > 0, usable.length > 0
4188
- ? `best for reconstruction: ${best.label} (${best.verdict === 'best' ? 'wide range + archive' : 'usable, range-capped'})`
4189
- : 'none usable for reconstruction');
4190
- for (const pr of probes) {
4191
- const glyph = pr.verdict === 'best' ? g('✓') : pr.verdict === 'capped' ? `${c.orange}⚠${c.reset}` : `${c.red}✗${c.reset}`;
4192
- console.log(` ${glyph} ${pr.label} ${dim('— ' + (pr.verdict === 'best' ? 'wide getLogs range + archive' : pr.reason ?? pr.verdict))}`);
4193
- }
4194
- if (usable.length === 0) {
4195
- console.log(` ${c.orange}↳${c.reset} ${dim('research a current free archive RPC with a wide getLogs range and add it to ABX_RPC_URLS — see the skill’s “Choosing an RPC”')}`);
4196
- }
4197
- else if (!probes.some((pr) => pr.verdict === 'best')) {
4198
- // Every usable endpoint is range-capped. Indexing still works (chunking), but a first
4199
- // reconstruction — and a resolver under marketplace load — is slow + rate-limit-prone. That's
4200
- // a serious infra signal, NOT "a paid plan is required": flag it. A normal deploy→index only
4201
- // scans from the deploy block, so this bites first reconstructions and busy resolvers most.
4202
- console.log(` ${c.orange}⚠ every usable RPC is getLogs-range-capped${c.reset} ${dim('— add a wide-range archive endpoint to ABX_RPC_URLS before running a resolver under load; capped ones grind on wide scans (skill → “Choosing an RPC”).')}`);
4203
- }
4204
- if (hasKey) {
4205
- const { account } = makeWalletClient({ chainKey: CHAIN });
4206
- const bal = await publicClient.getBalance({ address: account.address });
4207
- check('deployer funded', bal > 0n, `${account.address} · ${formatEther(bal)} ETH`);
4208
- }
4209
- // Wallet lane has no env key — let a creator preflight THEIR OWN signing wallet's balance,
4210
- // the one gap where an unfunded wallet otherwise only surfaces at the signing step.
4211
- if (flags.for) {
4212
- const bal = await publicClient.getBalance({ address: flags.for });
4213
- check('wallet funded (--for)', bal > 0n, `${flags.for} · ${formatEther(bal)} ETH${bal > 0n ? '' : ` — ${faucetHint(CHAIN)}`}`);
5185
+ if (usable.length > 0) {
5186
+ check('RPC', true, `${best.label} · head ${bn} · ${best.verdict === 'best' ? 'wide range + archive' : 'range-capped'}`);
5187
+ if (!probes.some((pr) => pr.verdict === 'best')) {
5188
+ console.log(`${CONT}${c.orange}⚠${c.reset}${dim(' every endpoint is getLogs-range-capped — add a wide-range archive RPC to ABX_RPC_URLS before running a resolver under load')}`);
5189
+ }
4214
5190
  }
4215
- else if (!hasKey) {
4216
- console.log(` ${dim(' wallet lane: run `abx doctor --for 0x<your wallet>` to check your signing wallet is funded')}`);
5191
+ else {
5192
+ check('RPC', false, `${CHAIN} no endpoint usable for reconstruction; add a wide-range archive RPC to ABX_RPC_URLS`);
4217
5193
  }
4218
5194
  const factory = factoryAddress();
4219
5195
  if (factory) {
4220
5196
  const code = await publicClient.getCode({ address: factory });
4221
- if (!code || code === '0x') {
4222
- check('canonical factory deployed', false, `${factory} — no code on ${CHAIN}; \`abx deploy\` redeploys`);
4223
- }
4224
- else if (await isCurrentFactory(publicClient, factory)) {
4225
- check('canonical factory deployed', true, factory);
4226
- }
4227
- else {
4228
- check('canonical factory deployed', false, `${factory} — older/incompatible version; \`abx deploy\` redeploys`);
4229
- }
5197
+ if (!code || code === '0x')
5198
+ check('factory', false, `${factory} — no code on ${CHAIN}; \`abx deploy\` redeploys`);
5199
+ else if (await isCurrentFactory(publicClient, factory))
5200
+ check('factory', true, factory);
5201
+ else
5202
+ check('factory', false, `${factory} — older/incompatible; \`abx deploy\` redeploys`);
4230
5203
  }
4231
5204
  else {
4232
- check('canonical factory deployed', false, 'none yet — `abx demo` deploys one');
5205
+ check('factory', false, 'none yet — `abx demo` deploys one');
5206
+ }
5207
+ if (hasKey) {
5208
+ const { account } = makeWalletClient({ chainKey: CHAIN });
5209
+ const bal = await publicClient.getBalance({ address: account.address });
5210
+ signingOpt = `env key ${account.address} · ${bal > 0n ? `funded ${formatEther(bal)} ETH` : `empty — fund it (${faucetHint(CHAIN)})`}`;
5211
+ }
5212
+ if (flags.for) {
5213
+ const bal = await publicClient.getBalance({ address: flags.for });
5214
+ forOpt = `${flags.for} · ${bal > 0n ? `funded ${formatEther(bal)} ETH` : `empty — ${faucetHint(CHAIN)}`}`;
4233
5215
  }
4234
5216
  }
4235
5217
  catch (err) {
4236
- check('RPC reachable', false, err.message);
5218
+ check('RPC', false, err.message);
4237
5219
  }
4238
- // public base URL — baked into on-chain URIs at deploy; unset is fine for a demo
4239
- // but a silent footgun for a real launch, so surface it explicitly.
4240
- const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
4241
- if (baseUrl)
4242
- check('public base URL set (baked into on-chain URIs)', true, baseUrl);
4243
- else
4244
- console.log(` ${c.orange}⚠${c.reset} public base URL${dim(' ABX_PUBLIC_BASE_URL unset → resolver-served deploys REFUSE (localhost on-chain resolves for no one). Set it (or --public-base-url) for a hosted resolver. IRRELEVANT if you go fully on-chain: --onchain-image (1/1) or --onchain-uri (code project) bake no resolver base.')}`);
4245
5220
  // storage backend — resolve it (catches missing config), then probe liveness/creds
4246
5221
  const backendId = activeBackendId();
4247
5222
  try {
4248
5223
  const backend = resolveBackend(storageOptions());
4249
5224
  const h = await backend.health?.();
4250
5225
  if (h)
4251
- check(`storage backend '${backend.id}' reachable`, h.ok, h.detail ?? '');
5226
+ check('storage', h.ok, `${backend.id} · ${h.detail ?? ''}`);
4252
5227
  else
4253
- check(`storage backend '${backend.id}' configured`, true);
5228
+ check('storage', true, `${backend.id} · configured`);
4254
5229
  }
4255
5230
  catch (err) {
4256
- check(`storage backend '${backendId}' configured`, false, err.message);
5231
+ check('storage', false, `${backendId} · ${err.message}`);
4257
5232
  }
4258
- // Managed Turbo/Arweave key if it exists, it holds prepaid credits, so remind them to back it up.
5233
+ // 3. Optionalpath-dependent setup. Unset is fine; these say WHEN you'll need each, so an unset
5234
+ // value never reads as a warning.
5235
+ console.log(`\n ${dim('Optional — depends how you deploy:')}`);
5236
+ if (signingOpt)
5237
+ opt('signing', signingOpt);
5238
+ else
5239
+ opt('signing', 'no env key → sign in your browser wallet (--sign). Preflight yours: `abx doctor --for 0x<addr>`. Set ABX_DEPLOYER_PK for the unattended hot lane.');
5240
+ if (forOpt)
5241
+ opt('wallet --for', forOpt);
5242
+ const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
5243
+ if (baseUrl)
5244
+ opt('resolver URL', baseUrl);
5245
+ else
5246
+ opt('resolver URL', 'ABX_PUBLIC_BASE_URL unset → needed for off-chain/resolver-served deploys; skip if fully on-chain (--onchain-image / --onchain-uri).');
4259
5247
  if (!process.env.ARWEAVE_JWK && existsSync(arweaveKeyFilePath())) {
4260
5248
  const jwk = loadArweaveJwk();
4261
- console.log(` ${dim(' Turbo/Arweave key ' + (jwk ? arweaveAddress(jwk) + ' ' : '') + 'holds your upload credits — back it up: `abx storage backup-key --out <path>`')}`);
5249
+ opt('arweave key', `${jwk ? arweaveAddress(jwk) + ' ' : ''}holds upload credits — back it up: \`abx storage backup-key --out <path>\``);
5250
+ }
5251
+ // Named remotes: report what's configured, and — the part doctor was missing — flag a credential
5252
+ // stored under a name the CLI does NOT read. That fault presents as "it acts like I never gave it a
5253
+ // key", and until now it only surfaced from `abx remote <name>`, which a creator reaches later
5254
+ // (doctor is the thing they're told to run FIRST).
5255
+ const remotes = listConfiguredRemotes();
5256
+ if (remotes.length) {
5257
+ opt('remotes', remotes.map((r) => `${r.name.toLowerCase()}${r.hasToken ? '' : ` ${c.orange}(no token)${c.reset}`}`).join(' · '));
5258
+ }
5259
+ for (const bad of misnamedRemoteVars()) {
5260
+ check('remote key', false, `${bad.key} is set but is NOT read — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN). Rename it.`);
4262
5261
  }
4263
5262
  console.log('');
4264
5263
  }
@@ -4273,7 +5272,13 @@ function printServing(url, address) {
4273
5272
  console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
4274
5273
  console.log(` ${dim('state API ')}${url}/api/project/${address}`);
4275
5274
  }
4276
- console.log(`\n ${dim('Open the dashboard, then hit “Re-index from chain” to watch state rebuild live.')}`);
5275
+ // The dashboard is READ-ONLY: re-index/verify are admin actions that 404 unless the node has an
5276
+ // ABX_RESOLVER_ADMIN_TOKEN, so there is no button to press. This line used to say "hit Re-index
5277
+ // from chain", which sent every first-run user hunting for a control that isn't there.
5278
+ console.log(`\n ${dim('The dashboard shows the event spine it replayed — that table IS the reconstruction.')}`);
5279
+ if (address) {
5280
+ 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.')}`);
5281
+ }
4277
5282
  console.log(` ${dim('Ctrl-C to stop.')}\n`);
4278
5283
  }
4279
5284
  // ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────
@@ -4404,7 +5409,8 @@ async function cmdDeployResolver(flags) {
4404
5409
  const url = art.baseUrl;
4405
5410
  info(`abx deploy --image <art> --name … --public-base-url ${url} (or export ABX_PUBLIC_BASE_URL=${url})`);
4406
5411
  info(`the contract derives ${url}/t/${resolveChain(CHAIN).id}/{address}/{tokenId} from that base.`);
4407
- info(`${bold('then')} abx add <clone> --remote ${dim('# tell the hosted resolver to index it — a LOCAL deploy does NOT')}`);
5412
+ info(`${bold('then')} abx add <clone> --remote ${dim('# tell the remote resolver to index it — a LOCAL deploy does NOT')}`);
5413
+ info(dim(`prefer addressing it by name? add ABX_REMOTE_<NAME>_URL=${url} (+ ABX_REMOTE_<NAME>_TOKEN=<the same token>) to .env → abx add <clone> --remote <name>`));
4408
5414
  console.log('');
4409
5415
  }
4410
5416
  // ── deploy-effects: scaffold the render runner (the resolver's browser-bearing companion) ─────
@@ -4558,12 +5564,15 @@ function keepAlive() {
4558
5564
  // ── per-command usage (printed by `<cmd> --help` / `abx help <cmd>`; read-only) ──
4559
5565
  const COMMAND_HELP = {
4560
5566
  skill: `
4561
- ${bold('abx skill')} ${dim('— install the abx agent skill so your coding agent can drive abx')}
4562
- ${g('abx skill install')} copy the skill into ${bold('.claude/skills/abx-self-host')} in the current project
4563
- ${g('--global')} install into your home dir (~/.claude/skills) instead of the project
4564
- ${g('--target <dir>')} install under a different base dir (writes <dir>/.claude/skills/abx-self-host)
5567
+ ${bold('abx skill')} ${dim('— install the version-locked abx agent skill so your coding agent can drive abx')}
5568
+ ${g('abx skill install')} copy the bundled skill (version-locked to this CLI) into your agent(s)
5569
+ ${dim('default: both')} ${bold('.claude/skills')} ${dim('(Claude Code) and')} ${bold('.agents/skills')} ${dim('(Cursor · Codex · Gemini · Copilot)')}
5570
+ ${g('--agent <name>')} only one agent: ${dim('claude | cursor | codex | gemini | copilot')}
5571
+ ${g('--global')} install into your home dir (~) instead of the current project
5572
+ ${g('--target <dir>')} install the skill folder straight under <dir> (writes <dir>/abx-self-host)
4565
5573
  ${g('abx skill path')} print the absolute path to the bundled skill (for ${g('npx skills add <path>')} or manual copy)
4566
- ${dim('Restart your agent after installing so it picks up the skill. Cross-agent install:')} ${g('npx skills add ArtBlocks/abx')}`,
5574
+ ${dim('Restart your agent after installing so it picks up the skill. Once the repo is public, the git-based')}
5575
+ ${dim('cross-agent installer also works (not version-locked):')} ${g('npx skills add ArtBlocks/abx --skill abx-self-host')}`,
4567
5576
  deploy: `
4568
5577
  ${bold('abx deploy')} ${dim('— deploy + index a 1/1 (no server). Sends a tx in the chosen lane.')}
4569
5578
  --image <path> custody your own image (png · jpg · gif · svg · webp); else generative demo art
@@ -4620,6 +5629,20 @@ const COMMAND_HELP = {
4620
5629
  ${g('abx deploy-series')} --dir ./photos --name "My Series" --symbol MS --onchain-uri --backend ipfs --mint-all --sign
4621
5630
  ${dim('# tiny SVGs → fully on-chain (no storage at all):')}
4622
5631
  ${g('abx deploy-series')} --dir ./svgs --name "My Series" --symbol MS --onchain-image --compress fastlz --mint-all --sign`,
5632
+ preview: `
5633
+ ${bold('abx preview')} (--script <file.js> | --code-dir <dir>) ${dim('— run the program on localhost, live. No chain, no key, no deploy.')}
5634
+ ${g('--schema key:Type:Auth')}[,…] declare PostParams so the studio gives you real inputs for them (e.g. palette:HexColor:TokenOwner)
5635
+ ${g('--dep <name@version>')}[,…] load a library the way the resolver would (built-in CDN map; on-chain refs can't be fetched offline)
5636
+ ${g('--port')} <n> studio port (default ${DEFAULT_PREVIEW_PORT}; the resolver's ${DEFAULT_PORT} stays free)
5637
+ ${g('--shoot')} <dir> render headlessly to PNGs + traits.json and EXIT ${dim('(for an agent that has no browser)')}
5638
+ ${g('--count')} <n> seeds to shoot / show in the grid (default 9) ${g('--width')} <px> ${g('--timeout-ms')} <n>
5639
+ Serves the ${bold('same document the generator serves')} — real ${g('abx.js')}, real tokenData shape, real dep tags — with a synthetic
5640
+ seed, so what you iterate on is what deploys. Routes: ${g('/')} studio (seed + params + live traits) · ${g('/grid')} N seeds at once,
5641
+ all live · ${g('/view')} the bare document. The program is re-read from disk per render, so ${bold('edit and refresh')} — no watcher.
5642
+ Unlike a still-image sweep this shows ${bold('animation')}, which is most of what a screenshot throws away.
5643
+ ${dim('Still do both after the art settles:')} ${g('abx inspect')} ${dim('(is it wired right?) and a testnet deploy (the faithful end-to-end).')}
5644
+ ${g('abx preview')} --script art.js --schema palette:HexColor:TokenOwner
5645
+ ${g('abx preview')} --script art.js --shoot ./frames --count 12`,
4623
5646
  inspect: `
4624
5647
  ${bold('abx inspect')} <script.js> ${dim('— static analysis of a generative script + a lane recommendation. Read-only; the script is never executed.')}
4625
5648
  ${g('--dep <name@version>[,…]')} the on-chain deps you plan to declare, so the assembled-document size estimate is realistic (e.g. --dep p5@1.0.0)
@@ -4686,8 +5709,9 @@ const COMMAND_HELP = {
4686
5709
  No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
4687
5710
  ${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
4688
5711
  (the art is otherwise deterministic, so a plain render idempotent-skips an existing still). Overwrites it (+ republishes on --remote).
4689
- ${g('--remote [url]')} publish each render to a HOSTED resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /admin/effect-artifacts).
4690
- Idempotent; a re-run restores a resolver that lost its volume. Needs ${g('ABX_RESOLVER_ADMIN_TOKEN')}.
5712
+ ${g('--remote [name|url]')} publish each render to a REMOTE resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /v1/effect-artifacts).
5713
+ Idempotent; a re-run restores a resolver that lost its volume. Needs its token (a named remote's
5714
+ ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
4691
5715
  --effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
4692
5716
  ${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
4693
5717
  ${dim(`view of ${g('ABX_RESOLVER_URL')} (else your configured base / local ${g('abx serve')}), and stores to the resolved backend.`)}
@@ -4703,8 +5727,8 @@ const COMMAND_HELP = {
4703
5727
  --port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
4704
5728
  --interval-ms <n> the SAFETY-FLOOR sweep (default ${g('ABX_EFFECTS_INTERVAL_MS')} / 300000) — catches a missed notify / cold start; the watcher is the trigger
4705
5729
  --concurrency <n> parallel renders while draining (default ${g('ABX_EFFECTS_CONCURRENCY')} / 1 — Chromium is heavy; raise deliberately)
4706
- ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a HOSTED resolver: ABX_RESOLVER_URL +')}
4707
- ${dim('ABX_RESOLVER_ADMIN_TOKEN (publish renders + report status). PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5730
+ ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a REMOTE resolver: --remote <name|url> (its')}
5731
+ ${dim('token publishes renders + reports status), or ABX_RESOLVER_URL + ABX_RESOLVER_ADMIN_TOKEN. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
4708
5732
  ${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
4709
5733
  'configure-param': `
4710
5734
  ${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
@@ -4715,7 +5739,7 @@ const COMMAND_HELP = {
4715
5739
  like ${g('params.keys')} / ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
4716
5740
  After a token write, a project whose ${g('params.keys')} doesn't list the key gets a one-line fix suggestion.
4717
5741
  --file <path> read the value from a file (String / Bytes payloads)
4718
- ${g('--remote [url]')} nudge a HOSTED resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
5742
+ ${g('--remote [name|url]')} nudge a REMOTE resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
4719
5743
  the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
4720
5744
  resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
4721
5745
  and auto-re-renders on its own. Keep --remote for a watcher-disabled resolver or when seconds matter.
@@ -4752,7 +5776,8 @@ const COMMAND_HELP = {
4752
5776
  ${bold('abx state')} <address> ${dim('— read-only, on-chain operational snapshot (no tx, no local index).')}
4753
5777
  Shows owner · supply (minted / max, nextTokenId) · paused · minter · primary payee · royalty · renderer.
4754
5778
  Series-only fields are shown for a Series; a 1/1 shows just supply + royalty + renderer.
4755
- Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).`,
5779
+ Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).
5780
+ ${dim('state = what the CHAIN says. For who is SERVING it and how fresh that is, see `abx status`.')}`,
4756
5781
  predict: `
4757
5782
  ${bold('abx predict')} ${dim('— pre-compute a deploy address (read-only).')}
4758
5783
  --salt 0x..<64hex> a fixed / vanity salt --for 0x.. reserve to a deployer --factory 0x..`,
@@ -4866,18 +5891,31 @@ const COMMAND_HELP = {
4866
5891
  ${bold('abx set-max-invocations')} <address> --max <N> ${dim('— LOWER the supply cap (Series). Sends a tx.')}
4867
5892
  --max <N> the new cap — MONOTONIC: can only DECREASE, and never below what's already minted (else it reverts)`,
4868
5893
  doctor: `
4869
- ${bold('abx doctor')} ${dim('— preflight readiness: signing key/wallet, RPC health, canonical factory, storage. Read-only.')}
5894
+ ${bold('abx doctor')} ${dim('— preflight readiness: agent skill, signing key/wallet, RPC health, canonical factory, storage.')}
4870
5895
  --for 0x.. also report that address's balance (fund before signing)
5896
+ ${g('--fix')} ${dim('install/resync the agent skill without asking (the one thing doctor can repair)')}
5897
+ ${g('--global')} · ${g('--agent')} <a> ${dim('where --fix installs the skill (mirrors `abx skill install`)')}
5898
+ ${dim('Read-only apart from --fix. Interactively it OFFERS to install a missing/stale skill; a non-TTY')}
5899
+ ${dim('run changes nothing and just prints the command, so scripts and CI are never mutated.')}
4871
5900
  ${dim('a missing signing key is NOT fatal — the wallet lane (`--sign`) needs no key in `.env`.')}`,
4872
5901
  status: `
4873
- ${bold('abx status')} ${dim('— list the projects this node has indexed, plus node info. Read-only.')}`,
5902
+ ${bold('abx status')} [address] ${dim('— INDEXING status: where a project is in the lifecycle, and how fresh. Read-only.')}
5903
+ ${dim('bare')} this node: chain · factory · storage · data dir, then one line per project
5904
+ ${dim('<address>')} one project in detail: lifecycle · scan floor · blocks indexed vs head · why, if unhappy
5905
+ ${g('--remote')} [name|url] ask a SERVICE instead (your hosted node, or a managed provider) — same five words
5906
+ ${g('--watch')} poll until everything reaches a terminal state (${g('live')} or ${c.orange}failed${c.reset})
5907
+ lifecycle: ${dim('queued')} → ${dim('backfilling')} → ${g('live')} · ${c.orange}stale${c.reset} ${dim('(was live, now lagging — still serving)')} · ${c.orange}failed${c.reset} ${dim('(carries a cause; retried with backoff)')}
5908
+ ${dim('status = who is SERVING it and how fresh. For what the CHAIN says (owner, royalty, locks), see `abx state`.')}`,
4874
5909
  storage: `
4875
5910
  ${bold('abx storage')} <show|upload|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
4876
5911
  ${g('show')} the resolved backend (fs | cloud | ipfs | arweave) + where each value came from
4877
5912
  ${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run]
4878
5913
  ${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
4879
5914
  demo: `
4880
- ${bold('abx demo')} <${dim('no args')}> ${dim('— deploy a throwaway 1/1 to the testnet, index it, and serve it — a guided first run. Sends a tx.')}`,
5915
+ ${bold('abx demo')} <${dim('no args')}> ${dim('— deploy a throwaway 1/1 to the testnet, index it, and serve it — a guided first run. Sends a tx.')}
5916
+ ${g('--sign')} ${dim('approve in your browser wallet instead of a hot env key (no key needed)')}
5917
+ ${g('--for')} <0x…> ${dim('pin who must connect on --sign (owner + royalty receiver + mint recipient)')}
5918
+ ${dim('--unsigned / --dry-run are refused here: both skip the broadcast, and the demo indexes + serves what it deployed.')}`,
4881
5919
  minter: `
4882
5920
  ${bold('abx minter')} <configure|show|buy> <token> ${dim('— sell a Series via the shared fixed-price minter (Minter spine).')}
4883
5921
  ${g('configure')} <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x..] ${dim('(token-owner only)')}
@@ -4892,13 +5930,20 @@ const COMMAND_HELP = {
4892
5930
  ${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
4893
5931
  --from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
4894
5932
  --traits "K=V; K2=V2" / --attributes <file.json> set the off-chain operator traits (on-chain attributes always win)
4895
- ${g('--remote [url]')} target a HOSTED resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it`,
5933
+ ${g('--remote [name|url]')} target a REMOTE resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it.
5934
+ A ${bold('name')} reads ${g('ABX_REMOTE_<NAME>_URL')} + ${g('ABX_REMOTE_<NAME>_TOKEN')} from .env (a managed provider's API key);
5935
+ a URL (or bare --remote = ${g('ABX_PUBLIC_BASE_URL')}) uses ${g('ABX_RESOLVER_ADMIN_TOKEN')}. Inspect first: ${g('abx remote <name>')}
5936
+ --remote-token <t> override the token for this invocation (--token means a token ID elsewhere, hence the name)
5937
+ ${g('--no-wait')} ${dim('with --remote: return as soon as the service accepts it, instead of waiting out its catch-up.')}
5938
+ ${dim('A service may answer "accepted, still indexing" (202) for a long backfill; by default abx polls')}
5939
+ ${dim('to')} ${g('live')} ${dim('and prints the same summary. The registration is durable either way —')} ${g('abx status <addr> --remote')} ${dim('checks later.')}`,
4896
5940
  index: `
4897
5941
  ${bold('abx index')} [<address>] ${dim('— re-index from chain (read-only). Incremental by default.')}
4898
- ${g('--full')} force a full replay from the deploy block (the durability proof) --yes allow a very large scan`,
5942
+ ${g('--full')} force a full replay from the deploy block (the durability proof) --yes allow a very large scan
5943
+ ${g('--remote [name|url]')} re-index on a REMOTE resolver (the post-deploy nudge) ${g('--no-wait')} don't wait out a deferred catch-up`,
4899
5944
  verify: `
4900
5945
  ${bold('abx verify')} <address> ${dim('— re-hash the served bytes against the on-chain commitment (read-only; no server).')}
4901
- ${g('--remote [url]')} verify what a HOSTED resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
5946
+ ${g('--remote [name|url]')} verify what a REMOTE resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
4902
5947
  or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
4903
5948
  ${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
4904
5949
  report a false placeholder for a render that lives on the resolver.
@@ -4908,11 +5953,21 @@ const COMMAND_HELP = {
4908
5953
  ${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
4909
5954
  ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}`,
4910
5955
  forget: `
4911
- ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}`,
5956
+ ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}
5957
+ ${g('--remote [name|url]')} deregister on a REMOTE resolver instead (it stops serving the project; re-add any time)`,
5958
+ remote: `
5959
+ ${bold('abx remote')} [<name|url>] ${dim('— inspect a remote service (read-only; registers nothing).')}
5960
+ Bare: list the named remotes in .env (${g('ABX_REMOTE_<NAME>_URL')} / ${g('_TOKEN')} — token shown as set/unset, never printed)
5961
+ plus the self-host default (bare --remote = ${g('ABX_PUBLIC_BASE_URL')} + ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
5962
+ With a target: fetch its PUBLIC ${g('/.well-known/abx-service')} descriptor — what it serves (interfaces), which chains
5963
+ (flags a mismatch with your ${g('ABX_CHAIN')}), whether ${bold('rendering is managed')} behind it (code drops then need no effects
5964
+ runner), and where a human gets an API key (${g('auth.signupUrl')}). With a token: lists the projects visible to it —
5965
+ ${bold('the one-command "is my provider key valid?" check')} (401 = fix the key · 403 = provider-side scoping, not a typo).`,
4912
5966
  migrate: `
4913
5967
  ${bold('abx migrate')} <address> ${dim('— move a contract\'s OFF-CHAIN state to another resolver (read-only on both; no cutover).')}
4914
- --from <url> the SOURCE resolver (currently serving the contract)
4915
- --to <url> the DESTINATION resolver (its admin control plane) — needs ${g('ABX_RESOLVER_ADMIN_TOKEN')} in .env (the dest's token, from ${g('deploy-resolver')})
5968
+ --from <name|url> the SOURCE resolver (currently serving the contract) — read via its PUBLIC api; no source credential needed
5969
+ --to <name|url> the DESTINATION resolver (its control plane) — the ONE credential migrate needs: a named remote's
5970
+ ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')} (your own node's, from ${g('deploy-resolver')}), else --remote-token
4916
5971
  --from-block <n> chain scan floor for the local read (default: the deploy block, discovered on-chain — never genesis) --yes allow a very large scan
4917
5972
  --backend <id> durable custody for re-pinning source-only images (ipfs · arweave); else config/env
4918
5973
  ${dim('replays on-chain state on the dest from chain, then bridges description / external_url / off-chain')}
@@ -4962,7 +6017,8 @@ function help() {
4962
6017
  --minter 0x.. · --primary-payee 0x.. · --unpaused · ${g('--dry-run')} · see ${g('abx help deploy-series')}
4963
6018
  ${g('abx predict')} pre-compute a deploy address flags: [--salt 0x..] [--for 0x..] [--factory 0x..]
4964
6019
  ${g('abx add')} <address> register + index a project this node didn't deploy
4965
- flags: --from-block --factory --label
6020
+ flags: --from-block --factory --label · ${g('--remote <name|url>')} registers on a REMOTE resolver instead
6021
+ ${g('abx remote')} [<name|url>] inspect a remote service: its descriptor (chains · managed rendering · where to get a key) + your projects there
4966
6022
  ${g('abx index')} [<address>] re-index from chain (incremental by default; ${g('--full')} forces a replay from deploy)
4967
6023
  ${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
4968
6024
  ${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
@@ -4971,7 +6027,9 @@ function help() {
4971
6027
  project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
4972
6028
 
4973
6029
  ${bold('code / generative projects')} ${dim('— a program is the content; output is a function of live on-chain state')}
4974
- ${g('abx inspect')} <script.js> ${bold('start here')} — static analysis (traits + on-chain reproducibility, deps, doc size RPC viability) + a lane recommendation
6030
+ ${g('abx preview')} (--script <f> | --code-dir <d>) ${bold('while you are still making it')} — run the program on localhost, live: refresh for new seeds,
6031
+ drive your PostParams, watch it animate. Same document the generator serves. ${g('--shoot <dir>')} for headless frames. No chain.
6032
+ ${g('abx inspect')} <script.js> ${bold('before you pick a lane')} — static analysis (traits + on-chain reproducibility, deps, doc size → RPC viability) + a lane recommendation
4975
6033
  ${g('abx scaffold-renderer')} [<dir>] write a buildable Foundry project for the ${bold('in-chain Solidity art lane')} (seed + PostParam → on-chain SVG + traits; you forge build/test/deploy)
4976
6034
  ${g('abx deploy-code')} (--script <file> | --code-dir <dir> | ${g('--image-renderer 0x..')}) deploy a ${bold('generative / code project')} (on-chain script, a build directory, or a Solidity SVG renderer — in-chain art)
4977
6035
  ${bold('--public-base-url <url>')} OR ${bold('--onchain-uri')} · --schema key:Type:Auth · ${g('--dep')} name@version|0x.. (ordered; index 0 = the runtime) ·
@@ -5017,13 +6075,13 @@ function help() {
5017
6075
  ${g('abx storage')} topup buy Turbo credits by card --usd <n> (one-time; <100 KB is always free)
5018
6076
  ${g('abx storage')} backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path --out <path>
5019
6077
  ${g('abx forget')} <address> drop a project's local registration + projection (on-chain untouched)
5020
- ${g('abx status')} list indexed projects + node info
6078
+ ${g('abx status')} [address] indexing status: lifecycle + freshness (bare = this node; ${g('--remote')} [name] = a service; ${g('--watch')} to tail)
5021
6079
  ${g('abx doctor')} check environment (key, RPC, balance, factory, storage)
5022
- ${g('abx skill install')} install the abx agent skill into your agent [--global] [--target <dir>] · ${g('abx skill path')} prints the bundled skill
6080
+ ${g('abx skill install')} install the version-locked abx skill into your agent(s) [--agent <name>] [--global] [--target <dir>] · ${g('abx skill path')} prints the bundled skill
5023
6081
  ${g('abx version')} print the installed CLI version
5024
6082
 
5025
6083
  ${dim('Run')} ${g('abx <command> --help')} ${dim('for per-command usage. --help / -h never executes — it only prints usage.')}
5026
- ${dim('abx checks npm for a newer release (once a day, notify-only). Silence with')} ${g('ABX_NO_UPDATE_CHECK=1')} ${dim('or')} ${g('--no-update-check')}${dim('.')}
6084
+ ${dim('abx checks npm for a newer release (every 6h, notify-only). Silence with')} ${g('ABX_NO_UPDATE_CHECK=1')} ${dim('or')} ${g('--no-update-check')}${dim('.')}
5027
6085
  `);
5028
6086
  }
5029
6087
  /** Validate an explicit --salt (a 32-byte hex). Undefined when absent. */
@@ -5041,8 +6099,13 @@ function parseSaltFlag(raw) {
5041
6099
  // ── skill: install the abx agent skill into the user's coding agent ───────────────────────
5042
6100
  // The skill is the agentic half of the toolkit — the CLI is only useful once an agent knows how
5043
6101
  // to drive it. It ships bundled inside the published package (packages/cli/skill/, created at
5044
- // prepack); in dev the same command reads the canonical copy straight from the repo. A separate
5045
- // cross-agent channel is `npx skills add ArtBlocks/abx` (the Agent Skills standard, git-based).
6102
+ // prepack); in dev the same command reads the canonical copy straight from the repo. This is the
6103
+ // CANONICAL install path precisely because the bundle is version-locked to the CLI it ships inside
6104
+ // (the version lives in SKILL.md frontmatter — see update-check.ts). By default it writes to BOTH
6105
+ // `.claude/skills` (Claude Code) and the neutral `.agents/skills` (Cursor · Codex · Gemini ·
6106
+ // Copilot all read it), so one command covers the whole ecosystem; `--agent` narrows it. A separate
6107
+ // git-based channel, `npx skills add ArtBlocks/abx --skill abx-self-host`, works once the repo is public but is NOT
6108
+ // version-locked to a local CLI — prefer `abx skill install`.
5046
6109
  /** Locate the skill folder: the bundled copy beside the compiled CLI (published), else the
5047
6110
  * canonical repo copy (dev). Returns null if neither is present. */
5048
6111
  function resolveBundledSkill() {
@@ -5106,37 +6169,86 @@ function cmdScaffoldRenderer(rest, flags) {
5106
6169
  console.log(` ${g('abx deploy-code --image-renderer <MyRenderer> --attributes-renderer <MyTraits> --onchain-uri --schema palette:HexColor:TokenOwner --name "…" --symbol …')}`);
5107
6170
  info(`full walkthrough: ${bold(`${basename(dir)}/README.md`)} · interface + invariants: https://abx.docs.artblocks.io/protocol/renderers/`);
5108
6171
  }
6172
+ /** Human label for each skills-parent dir — which agents pick the skill up from there. */
6173
+ const SKILL_PARENT_LABELS = {
6174
+ '.claude/skills': 'Claude Code',
6175
+ '.agents/skills': 'Cursor · Codex · Gemini · Copilot',
6176
+ };
6177
+ /** Resolve which skills-parent dirs `install` should write to. No `--agent` → the whole-ecosystem
6178
+ * default (Claude Code + the neutral `.agents/skills` everyone else reads). `--agent a,b` narrows
6179
+ * to those agents' dirs (de-duped, since several share `.agents/skills`). */
6180
+ function resolveInstallParents(agentFlag) {
6181
+ if (!agentFlag || agentFlag === 'true')
6182
+ return ['.claude/skills', '.agents/skills'];
6183
+ const parents = new Set();
6184
+ for (const raw of agentFlag.split(',')) {
6185
+ const a = raw.trim().toLowerCase();
6186
+ const parent = AGENT_SKILL_PARENTS[a];
6187
+ if (!parent) {
6188
+ throw new Error(`unknown --agent '${a}'. Use one of: ${Object.keys(AGENT_SKILL_PARENTS).join(', ')} (or omit --agent to install for every agent).`);
6189
+ }
6190
+ parents.add(parent);
6191
+ }
6192
+ return [...parents];
6193
+ }
6194
+ /** Copy the bundled skill folder to `dest`, replacing any prior copy so a re-install after an
6195
+ * upgrade never leaves stale reference files behind. */
6196
+ function installSkillTo(src, dest) {
6197
+ mkdirSync(joinPath(dest, '..'), { recursive: true });
6198
+ rmSync(dest, { recursive: true, force: true });
6199
+ cpSync(src, dest, { recursive: true });
6200
+ }
6201
+ /**
6202
+ * Install the bundled skill into the default per-agent parents (or `~` with `global`), reporting
6203
+ * each destination. Shared by `abx skill install` and `abx doctor`'s offer to fix a missing/stale
6204
+ * skill — one implementation, so the two can't drift on where the skill lands or what it prints.
6205
+ */
6206
+ function installSkillToDefaults(src, opts = {}) {
6207
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
6208
+ const base = opts.global ? homedir() : process.cwd();
6209
+ const parents = resolveInstallParents(opts.agent);
6210
+ ok(`installed the abx skill v${version}${opts.global ? ' (global, ~)' : ''}:`);
6211
+ for (const parent of parents) {
6212
+ const dest = joinPath(base, parent, SKILL_DIR_NAME);
6213
+ installSkillTo(src, dest);
6214
+ const label = SKILL_PARENT_LABELS[parent];
6215
+ console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${label ? dim(' → ' + label) : ''}`);
6216
+ }
6217
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
6218
+ 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.`);
6219
+ return version;
6220
+ }
6221
+ /** The default skill destinations, as a display string — what doctor's prompt has to name up front. */
6222
+ function defaultSkillDests(opts = {}) {
6223
+ const prefix = opts.global ? '~/' : './';
6224
+ return resolveInstallParents(opts.agent).map((p) => `${prefix}${p}`).join(' and ');
6225
+ }
5109
6226
  async function cmdSkill(rest, flags) {
5110
6227
  const sub = rest[0] ?? 'install';
5111
6228
  const src = resolveBundledSkill();
5112
6229
  if (!src) {
5113
6230
  throw new Error('bundled skill not found (expected <pkg>/skill or .claude/skills/abx-self-host). ' +
5114
- 'Reinstall @artblocks/abx-cli, or install cross-agent with: npx skills add ArtBlocks/abx');
6231
+ 'Reinstall @artblocks/abx-cli, or install cross-agent with: npx skills add ArtBlocks/abx --skill abx-self-host');
5115
6232
  }
5116
6233
  if (sub === 'path') {
5117
6234
  console.log(src);
5118
6235
  return;
5119
6236
  }
5120
6237
  if (sub === 'install') {
5121
- const base = flags.global !== undefined ? homedir() : flags.target ?? process.cwd();
5122
- const destParent = joinPath(base, '.claude', 'skills');
5123
- const dest = joinPath(destParent, 'abx-self-host');
5124
- mkdirSync(destParent, { recursive: true });
5125
- cpSync(src, dest, { recursive: true });
5126
- // Stamp the CLI version so a later run can notice the skill has drifted behind an upgraded CLI
5127
- // (the two are co-versioned). Best-effort — a missing marker just means "no drift nudge".
5128
- try {
5129
- writeFileSync(joinPath(dest, SKILL_VERSION_MARKER), readCliVersion());
5130
- }
5131
- catch {
5132
- /* non-fatal */
6238
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
6239
+ // Escape hatch: --target <dir> writes the skill folder straight under <dir> (for an agent
6240
+ // whose skills dir we don't special-case, or a bespoke location).
6241
+ if (typeof flags.target === 'string' && flags.target !== 'true') {
6242
+ const dest = joinPath(resolvePath(flags.target), SKILL_DIR_NAME);
6243
+ installSkillTo(src, dest);
6244
+ ok(`installed the abx skill v${version} ${dest}`);
6245
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
6246
+ return;
5133
6247
  }
5134
- ok(`installed the abx skill ${dest}`);
5135
- info('restart your agent (Claude Code / Cursor) so it loads the skill, then ask it to launch an NFT with abx.');
5136
- info('cross-agent alternative (git-based): npx skills add ArtBlocks/abx');
6248
+ installSkillToDefaults(src, { global: flags.global !== undefined, agent: flags.agent });
5137
6249
  return;
5138
6250
  }
5139
- throw new Error('usage: abx skill <install|path> [--global] [--target <dir>]');
6251
+ throw new Error('usage: abx skill <install|path> [--agent claude|cursor|codex|gemini|copilot] [--global] [--target <dir>]');
5140
6252
  }
5141
6253
  main().catch((err) => {
5142
6254
  // Keep the actionable message; strip viem's verbose boilerplate trailer (Docs:/Version: lines) so