@artblocks/abx-cli 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +3663 -0
  2. package/assets/renderer-scaffold/README.md +2 -2
  3. package/assets/renderer-scaffold/src/MyRenderer.sol +2 -2
  4. package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +1 -1
  5. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +17 -0
  6. package/assets/renderer-scaffold/test/MyRenderer.t.sol +51 -2
  7. package/dist/commands/deploy.d.ts +242 -0
  8. package/dist/commands/deploy.d.ts.map +1 -0
  9. package/dist/commands/deploy.js +4764 -0
  10. package/dist/commands/deploy.js.map +1 -0
  11. package/dist/commands/project.d.ts +257 -0
  12. package/dist/commands/project.d.ts.map +1 -0
  13. package/dist/commands/project.js +1299 -0
  14. package/dist/commands/project.js.map +1 -0
  15. package/dist/commands/reads.d.ts +64 -0
  16. package/dist/commands/reads.d.ts.map +1 -0
  17. package/dist/commands/reads.js +701 -0
  18. package/dist/commands/reads.js.map +1 -0
  19. package/dist/commands/scaffold.d.ts +73 -0
  20. package/dist/commands/scaffold.d.ts.map +1 -0
  21. package/dist/commands/scaffold.js +674 -0
  22. package/dist/commands/scaffold.js.map +1 -0
  23. package/dist/commands/service.d.ts +41 -0
  24. package/dist/commands/service.d.ts.map +1 -0
  25. package/dist/commands/service.js +618 -0
  26. package/dist/commands/service.js.map +1 -0
  27. package/dist/commands/storage.d.ts +51 -0
  28. package/dist/commands/storage.d.ts.map +1 -0
  29. package/dist/commands/storage.js +370 -0
  30. package/dist/commands/storage.js.map +1 -0
  31. package/dist/commands/submit-app.d.ts +58 -0
  32. package/dist/commands/submit-app.d.ts.map +1 -0
  33. package/dist/commands/submit-app.js +512 -0
  34. package/dist/commands/submit-app.js.map +1 -0
  35. package/dist/config.d.ts +90 -2
  36. package/dist/config.d.ts.map +1 -1
  37. package/dist/config.js +285 -11
  38. package/dist/config.js.map +1 -1
  39. package/dist/conformance.d.ts +31 -0
  40. package/dist/conformance.d.ts.map +1 -0
  41. package/dist/conformance.js +393 -0
  42. package/dist/conformance.js.map +1 -0
  43. package/dist/deps.d.ts +6 -39
  44. package/dist/deps.d.ts.map +1 -1
  45. package/dist/deps.js +4 -68
  46. package/dist/deps.js.map +1 -1
  47. package/dist/errors.d.ts +20 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +25 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/flag-allowlists.d.ts +53 -0
  52. package/dist/flag-allowlists.d.ts.map +1 -0
  53. package/dist/flag-allowlists.js +157 -0
  54. package/dist/flag-allowlists.js.map +1 -0
  55. package/dist/flags.d.ts +45 -0
  56. package/dist/flags.d.ts.map +1 -1
  57. package/dist/flags.js +117 -1
  58. package/dist/flags.js.map +1 -1
  59. package/dist/jsonout.d.ts +37 -0
  60. package/dist/jsonout.d.ts.map +1 -0
  61. package/dist/jsonout.js +68 -0
  62. package/dist/jsonout.js.map +1 -0
  63. package/dist/kind.d.ts +61 -0
  64. package/dist/kind.d.ts.map +1 -0
  65. package/dist/kind.js +111 -0
  66. package/dist/kind.js.map +1 -0
  67. package/dist/main.js +699 -4736
  68. package/dist/main.js.map +1 -1
  69. package/dist/mintpage.d.ts +17 -2
  70. package/dist/mintpage.d.ts.map +1 -1
  71. package/dist/mintpage.js +241 -54
  72. package/dist/mintpage.js.map +1 -1
  73. package/dist/output.d.ts +179 -0
  74. package/dist/output.d.ts.map +1 -0
  75. package/dist/output.js +756 -0
  76. package/dist/output.js.map +1 -0
  77. package/dist/ownerops.d.ts +302 -51
  78. package/dist/ownerops.d.ts.map +1 -1
  79. package/dist/ownerops.js +1780 -357
  80. package/dist/ownerops.js.map +1 -1
  81. package/dist/preview.d.ts +81 -0
  82. package/dist/preview.d.ts.map +1 -0
  83. package/dist/preview.js +535 -0
  84. package/dist/preview.js.map +1 -0
  85. package/dist/prompt.d.ts +17 -0
  86. package/dist/prompt.d.ts.map +1 -0
  87. package/dist/prompt.js +19 -0
  88. package/dist/prompt.js.map +1 -0
  89. package/dist/provision.d.ts.map +1 -1
  90. package/dist/provision.js +16 -8
  91. package/dist/provision.js.map +1 -1
  92. package/dist/remote.d.ts +153 -52
  93. package/dist/remote.d.ts.map +1 -1
  94. package/dist/remote.js +406 -46
  95. package/dist/remote.js.map +1 -1
  96. package/dist/riskgate.d.ts +58 -0
  97. package/dist/riskgate.d.ts.map +1 -0
  98. package/dist/riskgate.js +212 -0
  99. package/dist/riskgate.js.map +1 -0
  100. package/dist/scaffold.d.ts +10 -0
  101. package/dist/scaffold.d.ts.map +1 -0
  102. package/dist/scaffold.js +52 -0
  103. package/dist/scaffold.js.map +1 -0
  104. package/dist/schema.d.ts +36 -1
  105. package/dist/schema.d.ts.map +1 -1
  106. package/dist/schema.js +121 -26
  107. package/dist/schema.js.map +1 -1
  108. package/dist/script-chunks.d.ts +8 -0
  109. package/dist/script-chunks.d.ts.map +1 -0
  110. package/dist/script-chunks.js +35 -0
  111. package/dist/script-chunks.js.map +1 -0
  112. package/dist/served.d.ts +30 -0
  113. package/dist/served.d.ts.map +1 -0
  114. package/dist/served.js +112 -0
  115. package/dist/served.js.map +1 -0
  116. package/dist/signer.d.ts +13 -0
  117. package/dist/signer.d.ts.map +1 -1
  118. package/dist/signer.js +84 -15
  119. package/dist/signer.js.map +1 -1
  120. package/dist/update-check.d.ts +83 -4
  121. package/dist/update-check.d.ts.map +1 -1
  122. package/dist/update-check.js +140 -19
  123. package/dist/update-check.js.map +1 -1
  124. package/package.json +10 -6
  125. package/skill/SKILL.md +350 -150
  126. package/skill/reference/code-projects.md +183 -26
  127. package/skill/reference/creator-token.md +99 -0
  128. package/skill/reference/decisions.md +174 -0
  129. package/skill/reference/hosting.md +63 -19
  130. package/skill/reference/operating.md +116 -12
  131. package/skill/reference/setup.md +55 -6
  132. package/skill/reference/troubleshooting.md +42 -5
  133. package/dist/inspect.d.ts +0 -48
  134. package/dist/inspect.d.ts.map +0 -1
  135. package/dist/inspect.js +0 -184
  136. package/dist/inspect.js.map +0 -1
  137. package/dist/migrate.d.ts +0 -65
  138. package/dist/migrate.d.ts.map +0 -1
  139. package/dist/migrate.js +0 -180
  140. package/dist/migrate.js.map +0 -1
  141. package/dist/onchain-uri.d.ts +0 -97
  142. package/dist/onchain-uri.d.ts.map +0 -1
  143. package/dist/onchain-uri.js +0 -243
  144. package/dist/onchain-uri.js.map +0 -1
  145. package/dist/upload.d.ts +0 -28
  146. package/dist/upload.d.ts.map +0 -1
  147. package/dist/upload.js +0 -41
  148. package/dist/upload.js.map +0 -1
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Scaffolding + environment commands: `doctor` (check key/RPC/balance/factory/storage — and offer
3
+ * to install/resync the agent skill), `mint-page` (a self-contained Next.js minting app),
4
+ * `scaffold-renderer` (copy the Solidity on-chain-renderer starting point), and `skill` (install
5
+ * the version-locked abx agent skill into a coding agent).
6
+ */
7
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { basename, join as joinPath, resolve as resolvePath, sep } from 'node:path';
10
+ import { createInterface } from 'node:readline';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { isCurrentFactory, isCurrentOneOfOneEditionFactory, makePublicClient, makeWalletClient, oneOfOneImageAbi, probeRpcEndpoints, resolveChain, rpcEnvVar, resolveRpcUrls, redactRpcUrl, } from '@artblocks/abx-sdk';
13
+ import { arweaveAddress, probeStorageBackend, resolveBackend } from '@artblocks/abx-storage';
14
+ import { formatEther } from 'viem';
15
+ import { CHAIN, activeBackendId, arweaveKeyFilePath, factoryAddress, faucetHint, fixedPriceMinterAddress, fixedPriceMinter1155Address, oneOfOneEditionFactoryAddress, loadArweaveJwk, storageOptions, } from '../config.js';
16
+ import { CliError } from '../errors.js';
17
+ import { detectTokenKind } from '../kind.js';
18
+ import { PUBLIC_RPC, mintPageArtifact } from '../mintpage.js';
19
+ import { bold, c, dim, findCliPackageRoot, findRepoRoot, g, info, ok, step, warn } from '../output.js';
20
+ import { declinesSkillInstall } from '../prompt.js';
21
+ import { listConfiguredRemotes, misnamedRemoteVars, probeRemoteCredential, selfTokenMigrationWarning } from '../remote.js';
22
+ import { copyRendererScaffold } from '../scaffold.js';
23
+ import { AGENT_SKILL_PARENTS, SKILL_DIR_NAME, binaryProvenance, checkForCliUpdate, compareVersions, installedSkillCopies, readCliVersion, readSkillVersion, skillRefreshCommands, } from '../update-check.js';
24
+ // ── doctor ────────────────────────────────────────────────────────────────--
25
+ /**
26
+ * Doctor's "want me to fix that?" for a missing or stale skill. Three lanes, deliberately:
27
+ * `--fix` install without asking (CI, scripts, an agent running doctor for someone)
28
+ * interactive name the exact directories, then ask — Enter accepts, since doctor's whole job is
29
+ * getting setup right and this is the one check whose fix is a local file copy
30
+ * non-TTY change NOTHING and print how to do it; a diagnostic must never mutate a
31
+ * scripted environment just because nobody was there to say no
32
+ * Honors `--global` / `--agent` so the fix can target the same place an explicit install would.
33
+ */
34
+ export async function offerSkillInstall(flags, stale, indent) {
35
+ const opts = { global: flags.global !== undefined, agent: flags.agent };
36
+ const src = resolveBundledSkill();
37
+ if (!src)
38
+ return; // no bundled skill to install (dev checkout oddity) — the hint above still stands
39
+ const verb = stale ? 'resync' : 'install';
40
+ const dests = defaultSkillDests(opts);
41
+ if (flags.fix === undefined) {
42
+ if (!process.stdin.isTTY) {
43
+ console.log(`${indent}${dim(`non-interactive — run \`abx doctor --fix\` (or \`abx skill install\`) to ${verb} it.`)}`);
44
+ return;
45
+ }
46
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
47
+ const answer = await new Promise((resolve) => {
48
+ // EOF (Ctrl-D) closes the interface WITHOUT firing the question callback — awaiting only the
49
+ // callback would hang doctor forever. Treat a closed stream as a decline: the safe direction is
50
+ // always "change nothing", never "write files because nobody answered".
51
+ rl.once('close', () => resolve('n'));
52
+ rl.question(`${indent}${verb} the abx skill into ${bold(dests)} now? [Y/n] `, resolve);
53
+ });
54
+ rl.close();
55
+ if (declinesSkillInstall(answer)) {
56
+ console.log(`${indent}${dim(`skipped — \`abx skill install\` when you want it.`)}`);
57
+ return;
58
+ }
59
+ }
60
+ console.log('');
61
+ installSkillToDefaults(src, opts);
62
+ }
63
+ /**
64
+ * The funding half of doctor's signing line. Split out because "is this wallet usable?" is not
65
+ * `balance > 0`.
66
+ *
67
+ * A cold agent in the clean room had `0.000000000848940602 ETH` — 848 gwei, enough for nothing —
68
+ * and doctor called it **funded**, in full wei precision, with no faucet link. The agent then spent
69
+ * its run preparing a deploy it could not send, and listed "insufficient-funds signal is too quiet"
70
+ * as its top friction. `> 0n` was the wrong test: dust is not funding.
71
+ *
72
+ * So there are three states, not two. `DUST_FLOOR` is deliberately a readout threshold and NOT a
73
+ * refusal — we do not estimate anyone's gas, and a cheap L2 deploy under it is the caller's call to
74
+ * make. It only decides whether doctor hands over a faucet link.
75
+ */
76
+ const DUST_FLOOR = 1000000000000000n; // 0.001 ETH
77
+ export async function describeBalance(publicClient, address) {
78
+ const bal = await publicClient.getBalance({ address });
79
+ if (bal === 0n)
80
+ return `empty — fund it (${faucetHint(CHAIN)})`;
81
+ // Trimmed to 6 decimals: a wei-precise figure is unreadable at a glance and this line exists to
82
+ // be glanced at. The exact number is one `cast balance` away for anyone who needs it.
83
+ const eth = formatEther(bal);
84
+ const short = eth.includes('.') ? `${eth.split('.')[0]}.${eth.split('.')[1].slice(0, 6).replace(/0+$/, '') || '0'}` : eth;
85
+ if (bal < DUST_FLOOR)
86
+ return `${short} ETH — too little to deploy; top it up (${faucetHint(CHAIN)})`;
87
+ return `funded ${short} ETH`;
88
+ }
89
+ /** Continuation indent: aligns under a check/opt detail column. */
90
+ const CONT = ' '.repeat(17);
91
+ /**
92
+ * Does every configured endpoint agree with itself about this wallet's nonce?
93
+ *
94
+ * `pending` is by definition >= `latest` on a coherent node. An endpoint that has not caught up with
95
+ * its own head answers LOWER, and the first send of the next `abx` command re-uses a spent nonce —
96
+ * silently, because a failed simulation means nothing is broadcast. Measured on `sepolia.base.org`;
97
+ * never on the publicnode fallback. The write path floors at `max(pending, latest)` so this cannot
98
+ * break a send any more — this names the endpoint instead of quietly routing around it.
99
+ *
100
+ * **Says "unknown" rather than passing on no evidence.** A wallet that has never sent reads 0/0
101
+ * everywhere, which proves nothing about the endpoint — and a check that quietly goes green on no
102
+ * signal is the same false green as an RPC answering `[]` because it pruned its logs.
103
+ */
104
+ async function reportNonceCoherence(address) {
105
+ const urls = resolveRpcUrls(CHAIN);
106
+ if (urls.length === 0)
107
+ return;
108
+ const behind = [];
109
+ let sawAnyActivity = false;
110
+ for (const url of urls) {
111
+ const client = makePublicClient({ chainKey: CHAIN, rpcUrl: url }); // one endpoint, no failover — we are testing THIS one
112
+ try {
113
+ const [pending, latest] = await Promise.all([
114
+ client.getTransactionCount({ address, blockTag: 'pending' }),
115
+ client.getTransactionCount({ address, blockTag: 'latest' }),
116
+ ]);
117
+ if (latest > 0 || pending > 0)
118
+ sawAnyActivity = true;
119
+ if (pending < latest)
120
+ behind.push(`${redactRpcUrl(url)} (pending ${pending} < latest ${latest})`);
121
+ }
122
+ catch {
123
+ // Unreachable endpoints are already the RPC check's business, not this one's.
124
+ }
125
+ }
126
+ if (behind.length > 0) {
127
+ console.log(`${CONT}${c.orange}⚠${c.reset}${dim(` nonce view is BEHIND its own head on: ${behind.join(', ')} — that endpoint can hand the next command a spent nonce. abx floors at max(pending, latest) so sends still work; put a coherent endpoint first in ${rpcEnvVar(CHAIN)} if you script against it directly`)}`);
128
+ }
129
+ else if (!sawAnyActivity) {
130
+ console.log(`${CONT}${dim('nonce coherence: unknown — this wallet has sent nothing yet, so every endpoint reads 0 and the check has no signal')}`);
131
+ }
132
+ }
133
+ export async function cmdDoctor(flags) {
134
+ console.log(bold('\n abx doctor') + dim(` · ${CHAIN}`) + '\n');
135
+ // Two visual tiers: PASS/FAIL checks (✓/✗) for things that are either working or broken, and an
136
+ // "Optional" block (·) for path-dependent setup that is fine to be unset. We deliberately do NOT
137
+ // use ⚠ for "unset but often fine" — that read as noise; ⚠ is reserved for a real gotcha (a
138
+ // range-capped RPC). Labels are padded so both tiers align.
139
+ // 16 = the longest label ('edition factory', 15) + a one-space gutter. It was 13, so that label
140
+ // ran straight into its address (`edition factory0xe8b1…`) — unreadable on the one row whose
141
+ // detail is a value you copy. Any label added here must fit, or this number moves.
142
+ const check = (label, pass, detail = '') => console.log(` ${pass ? g('✓') : `${c.red}✗${c.reset}`} ${label.padEnd(16)}${detail ? dim(detail) : ''}`);
143
+ const opt = (label, detail) => console.log(` ${dim('·')} ${label.padEnd(16)}${dim(detail)}`);
144
+ const hasKey = !!process.env.ABX_DEPLOYER_PK;
145
+ // 1. Agent skill — FIRST and prominent. The primary way to use abx is to let a coding agent drive
146
+ // it, so a missing/stale skill is a ✗: not broken infra, but the main UX isn't set up. Its
147
+ // version lives in SKILL.md frontmatter (version-locked to this CLI). Notify-only — no exit code.
148
+ const cliVersion = readCliVersion();
149
+ const skillCopies = installedSkillCopies();
150
+ const staleCopies = skillCopies.filter((copy) => compareVersions(cliVersion, copy.version) > 0);
151
+ const staleSkills = [...new Set(staleCopies.map((copy) => copy.version))];
152
+ const skillMissing = skillCopies.length === 0;
153
+ const skillStale = !skillMissing && staleCopies.length > 0;
154
+ // Which copy, and the command that refreshes THAT copy — a bare `abx skill install` never touches
155
+ // the global one, so naming it for a stale global copy printed a fix that could not clear the ✗.
156
+ const skillFix = skillRefreshCommands(staleCopies).join(' && ');
157
+ const staleWhere = staleCopies.length === 1 ? ` (${staleCopies[0].scope}: ${staleCopies[0].path})` : '';
158
+ if (skillMissing) {
159
+ check('agent skill', false, `not installed — run ${g('abx skill install')}`);
160
+ console.log(`${CONT}${dim('(recommended: let a coding agent drive abx)')}`);
161
+ }
162
+ else if (skillStale) {
163
+ check('agent skill', false, `v${staleSkills.join(', v')} behind CLI v${cliVersion}${staleWhere} — run ${g(skillFix)}`);
164
+ }
165
+ else {
166
+ check('agent skill', true, `in sync (v${cliVersion})`);
167
+ }
168
+ // Offer to fix it here rather than only naming the command. `npm i -g` + `abx skill install` was a
169
+ // two-step install flow where the second step is easy to skip and invisible when skipped (an agent
170
+ // that never learned abx just... doesn't use it). Doctor is already the documented first run, so
171
+ // this collapses the flow without an npm `postinstall` hook — which could not work anyway: npm runs
172
+ // lifecycle scripts with cwd set to the installed package dir (so the skill would land inside
173
+ // node_modules), pnpm gates install scripts by default, and writing to a user's ~/.claude on
174
+ // install is the kind of side effect that belongs to the user, not to us.
175
+ if (skillMissing || skillStale) {
176
+ await offerSkillInstall(flags, skillStale, CONT);
177
+ }
178
+ console.log('');
179
+ // 2. Core environment (✓/✗). Signing-wallet balances are computed here (they need the RPC) but
180
+ // printed in the Optional block below, so buffer them.
181
+ let signingOpt = null;
182
+ let minter1155Opt = null;
183
+ let forOpt = null;
184
+ try {
185
+ const publicClient = makePublicClient({ chainKey: CHAIN });
186
+ const bn = await publicClient.getBlockNumber();
187
+ // Collapse the RPC report to one line (best endpoint + head), and only add a ⚠ when there is a
188
+ // genuine problem — a range-capped-only set that will grind a resolver under load.
189
+ const probes = await probeRpcEndpoints({ chainKey: CHAIN });
190
+ const usable = probes.filter((pr) => pr.verdict !== 'unusable');
191
+ const best = probes.find((pr) => pr.verdict === 'best') ?? usable[0];
192
+ if (usable.length > 0) {
193
+ check('RPC', true, `${best.label} · head ${bn} · ${best.verdict === 'best' ? 'wide range + archive' : 'range-capped'}`);
194
+ if (!probes.some((pr) => pr.verdict === 'best')) {
195
+ 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')}`);
196
+ }
197
+ // Name a REACHABLE endpoint that can't serve history — but ONLY when it would actually be asked.
198
+ //
199
+ // The hazard is ordering, not existence: the fallback transport tries endpoints IN ORDER and
200
+ // rotates only on an *error*, so a pruning endpoint listed AHEAD of a good one still answers a
201
+ // deep scan, with an empty and successful `[]`. That is worth a ⚠ every time. A pruning endpoint
202
+ // sitting BEHIND a healthy archive endpoint is never consulted for history and is not a problem
203
+ // the reader can act on — and firing anyway made `doctor` look alarming on a clean setup. Three
204
+ // separate eval rooms flagged the noise; one called it "a poor first impression for an already-
205
+ // nervous creator". `probeRpcEndpoints` preserves `resolveRpcUrls` order, so the index IS the
206
+ // consultation order.
207
+ const bestIdx = probes.findIndex((pr) => pr.verdict === 'best');
208
+ for (const [i, pr] of probes.entries()) {
209
+ if (pr.verdict !== 'unusable' || !pr.reachable)
210
+ continue;
211
+ if (bestIdx !== -1 && i > bestIdx)
212
+ continue; // shadowed by a healthy archive endpoint ahead of it
213
+ console.log(`${CONT}${c.orange}⚠${c.reset}${dim(` ${pr.label} is listed ahead of a full-archive endpoint and can't reconstruct an older project: ${pr.reason ?? 'no archive-depth logs'}. Put a full-archive endpoint FIRST in ${rpcEnvVar(CHAIN)} — the fallback transport rotates on error only, so this one answers a deep scan with an empty, successful []`)}`);
214
+ }
215
+ }
216
+ else {
217
+ check('RPC', false, `${CHAIN} — no endpoint usable for reconstruction; add a wide-range archive RPC to ABX_RPC_URLS`);
218
+ }
219
+ const factory = factoryAddress();
220
+ if (factory) {
221
+ const code = await publicClient.getCode({ address: factory });
222
+ if (!code || code === '0x')
223
+ check('factory', false, `${factory} — no code on ${CHAIN}; \`abx deploy\` redeploys`);
224
+ else if (await isCurrentFactory(publicClient, factory))
225
+ check('factory', true, factory);
226
+ else
227
+ check('factory', false, `${factory} — older/incompatible; \`abx deploy\` redeploys`);
228
+ }
229
+ else {
230
+ check('factory', false, 'none yet — `abx demo` deploys one');
231
+ }
232
+ // The edition twin — same shape as the 721 `factory` check above (isCurrent* probe, same
233
+ // pass/fail posture on "no code yet"). The other two edition factories (EditionImage/
234
+ // EditionCode) and the two edition-related singletons that have no analogous check today
235
+ // either (the 1155 minter, and — same as the 721 minter — no code-version probe to run) are
236
+ // left to their own commands' own bootstrap (`abx deploy-series --copies` / `deploy-code
237
+ // --copies` / `minter configure --token-id`) rather than duplicating a fourth/fifth check here.
238
+ const editionFactory = oneOfOneEditionFactoryAddress();
239
+ if (editionFactory) {
240
+ const code = await publicClient.getCode({ address: editionFactory });
241
+ if (!code || code === '0x')
242
+ check('edition factory', false, `${editionFactory} — no code on ${CHAIN}; \`abx deploy --copies <n|open>\` redeploys`);
243
+ else if (await isCurrentOneOfOneEditionFactory(publicClient, editionFactory))
244
+ check('edition factory', true, editionFactory);
245
+ else
246
+ check('edition factory', false, `${editionFactory} — older/incompatible; \`abx deploy --copies <n|open>\` redeploys`);
247
+ }
248
+ else {
249
+ check('edition factory', false, 'none yet — `abx deploy --copies <n|open>` deploys one');
250
+ }
251
+ // The shared edition sale singleton — Optional, not pass/fail: unlike the factory (a platform
252
+ // allowlist anchor), a project that never sells through the shared minter has no reason to have
253
+ // one deployed yet (same reason there is no equivalent 721 `fixedPriceMinter` check above).
254
+ const minter1155 = fixedPriceMinter1155Address();
255
+ if (minter1155) {
256
+ const code = await publicClient.getCode({ address: minter1155 });
257
+ minter1155Opt = code && code !== '0x' ? minter1155 : `${minter1155} configured but no code on ${CHAIN} — \`abx minter configure <addr> --token-id <n> …\` redeploys`;
258
+ }
259
+ if (hasKey) {
260
+ const { account } = makeWalletClient({ chainKey: CHAIN });
261
+ signingOpt = `env key ${account.address} · ${await describeBalance(publicClient, account.address)}`;
262
+ await reportNonceCoherence(account.address);
263
+ }
264
+ if (flags.for) {
265
+ forOpt = `${flags.for} · ${await describeBalance(publicClient, flags.for)}`;
266
+ }
267
+ }
268
+ catch (err) {
269
+ check('RPC', false, err.message);
270
+ }
271
+ // storage backend — resolve it (catches missing config), then probe liveness/creds. B4: the probe
272
+ // is the fuller `probeStorageBackend` (packages/storage/probe.ts), not a bare `.health()` call —
273
+ // for fs/ipfs that's the SAME check as before (reused, not reimplemented); for cloud it's now a
274
+ // real PUT (API) + GET (public base) round trip — the only thing that catches the R2/S3
275
+ // endpoint-vs-public-base trap `.health()` alone couldn't; arweave adds an identity+balance READ
276
+ // (never a paid upload). Kept fast on purpose: only runs once a backend actually resolves, bounded
277
+ // to 1.5s — the same budget `checkForCliUpdate` below uses for its own network check.
278
+ const backendId = activeBackendId();
279
+ try {
280
+ const backend = resolveBackend(storageOptions());
281
+ const h = await probeStorageBackend(storageOptions(), { timeoutMs: 1500 });
282
+ // Whether this backend can name a public URL for what it stores decides one thing operators hit
283
+ // later: a REMOTE effects runner needs it (the resolver serves referenced output by redirect, so
284
+ // it takes a URL and refuses bytes). Co-located rendering doesn't care — hence a note, not a fail.
285
+ const publishable = backend.locator
286
+ ? 'can publish to a remote resolver'
287
+ : 'local-only — a remote effects runner needs cloud/ipfs/arweave (co-located rendering is fine)';
288
+ check('storage', h.ok, `${backend.id} · ${h.detail} · ${publishable}`);
289
+ // On failure, name BOTH URLs the cloud check touched — the R2/S3 endpoint-vs-public-base trap is
290
+ // only diagnosable with both hosts on screen, not a bare "fetch failed".
291
+ if (!h.ok && (h.putUrl || h.publicUrl)) {
292
+ console.log(`${CONT}${dim(`put ${h.putUrl ?? '(none)'} · get ${h.publicUrl ?? '(none)'}`)}`);
293
+ }
294
+ }
295
+ catch (err) {
296
+ check('storage', false, `${backendId} · ${err.message}`);
297
+ }
298
+ // 2b. Version & provenance — the 3-class drift ladder the skill has long taught in prose
299
+ // ("Resolve the CLI before you install anything"), now checked instead of just explained.
300
+ // `checkForCliUpdate` is already bounded to a ~1.5s network timeout + a 6h disk cache (see
301
+ // update-check.ts), so this never adds a meaningful wait — same budget the RPC probe above uses.
302
+ console.log(`\n ${dim('Version & provenance:')}`);
303
+ const provenance = binaryProvenance();
304
+ if (provenance === 'npx') {
305
+ check('binary', false, 'running via npx — can silently keep serving a stale cached copy after an upgrade');
306
+ console.log(`${CONT}${dim('install instead: `npm install --save-dev @artblocks/abx-cli` (project-local) or `-g` (machine-wide)')}`);
307
+ }
308
+ else {
309
+ // Says WHAT is running, not how you typed it. This used to read `source checkout (pnpm abx)`,
310
+ // which a cold agent flagged twice: the sandbox puts a bare `abx` on PATH that runs the checkout
311
+ // through tsx, so `source` was right while `(pnpm abx)` named an invocation the caller never made.
312
+ // A provenance line that misstates the invocation undermines every other readout next to it.
313
+ check('binary', true, provenance === 'source' ? 'running the source checkout (not an npm install)' : 'npm install');
314
+ }
315
+ const latest = await checkForCliUpdate(cliVersion);
316
+ if (latest)
317
+ check('npm', false, `v${cliVersion} → v${latest} available — npm i -g @artblocks/abx-cli@latest`);
318
+ else
319
+ check('npm', true, `v${cliVersion} (or offline — checked at most every 6h)`);
320
+ if (skillMissing)
321
+ check('skill ver', false, 'no installed skill to compare — abx skill install');
322
+ else if (skillStale)
323
+ check('skill ver', false, `v${staleSkills.join(', v')} behind CLI v${cliVersion}${staleWhere} — ${skillFix}`);
324
+ else
325
+ check('skill ver', true, `v${cliVersion} matches the CLI`);
326
+ // 3. Optional — path-dependent setup. Unset is fine; these say WHEN you'll need each, so an unset
327
+ // value never reads as a warning.
328
+ console.log(`\n ${dim('Optional — depends how you deploy:')}`);
329
+ if (signingOpt)
330
+ opt('signing', signingOpt);
331
+ else
332
+ 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.');
333
+ if (forOpt)
334
+ opt('wallet --for', forOpt);
335
+ const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
336
+ if (baseUrl)
337
+ opt('resolver URL', baseUrl);
338
+ else
339
+ opt('resolver URL', 'ABX_PUBLIC_BASE_URL unset → needed for off-chain/resolver-served deploys; skip if fully on-chain (--onchain-image / --onchain-uri).');
340
+ if (minter1155Opt)
341
+ opt('1155 minter', minter1155Opt);
342
+ else
343
+ opt('1155 minter', 'none yet — `abx minter configure <addr> --token-id <n> …` on an edition deploys one');
344
+ if (!process.env.ARWEAVE_JWK && existsSync(arweaveKeyFilePath())) {
345
+ const jwk = loadArweaveJwk();
346
+ opt('arweave key', `${jwk ? arweaveAddress(jwk) + ' ' : ''}holds upload credits — back it up: \`abx storage backup-key --out <path>\``);
347
+ }
348
+ // Named remotes: report what's configured, and — the part doctor was missing — flag a credential
349
+ // stored under a name the CLI does NOT read. That fault presents as "it acts like I never gave it a
350
+ // key", and until now it only surfaced from `abx remote <name>`, which a creator reaches later
351
+ // (doctor is the thing they're told to run FIRST).
352
+ // PROBE each named remote, don't just count it. Reporting "configured" for a credential the
353
+ // service rejects is the same false green as an RPC that answers `[]` because it pruned its logs:
354
+ // presence is not capability, and doctor is the command a creator is told to run FIRST — so the
355
+ // 401 surfaced only later, from `abx remote <name>`, after they had already trusted this line.
356
+ // One cheap authed call per remote, short timeout, and a failure here is never fatal to doctor.
357
+ const remotes = listConfiguredRemotes();
358
+ for (const r of remotes) {
359
+ const name = r.name.toLowerCase();
360
+ if (!r.hasToken) {
361
+ opt('remotes', `${name} ${c.orange}(no token)${c.reset}`);
362
+ continue;
363
+ }
364
+ const verdict = await probeRemoteCredential(r.name);
365
+ if (verdict.ok)
366
+ opt('remotes', `${name} ${dim(verdict.detail)}`);
367
+ else if (verdict.fatal)
368
+ check('remotes', false, `${name} — ${verdict.detail}`);
369
+ else
370
+ opt('remotes', `${name} ${c.orange}${verdict.detail}${c.reset}`);
371
+ }
372
+ for (const bad of misnamedRemoteVars()) {
373
+ check('remote key', false, `${bad.key} is set but is NOT read — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN). Rename it.`);
374
+ }
375
+ // The self-host default's credential moved to the named-remote grammar (ABX_REMOTE_SELF_TOKEN) —
376
+ // the OLD var is never read now, so flag it here rather than let a creator discover it as a 401
377
+ // that reads like a wrong key. Same condition `requireRemoteToken` hard-stops a real command on.
378
+ const selfTokenMigration = selfTokenMigrationWarning();
379
+ if (selfTokenMigration)
380
+ check('self token', false, selfTokenMigration);
381
+ console.log('');
382
+ }
383
+ // ── mint-page — scaffold a Next.js mint site for a collection (fixed-price minter) ─────────────
384
+ /**
385
+ * Refuse a mint-page target with NO minter lane. The generated page reads
386
+ * `maxInvocations`/`paused` (or, for an edition, the per-id `totalSupply`/`maxSupply`) and mints
387
+ * through a shared fixed-price minter — none of which a plain 1/1 (`OneOfOneImage`) has, since it
388
+ * has no minter lane at all. Before this check, pointing `mint-page` at a 1/1 wrote a page that
389
+ * compiled and ran fine, then sat on "Loading…" forever with no diagnostic.
390
+ *
391
+ * `kind` is the caller's `detectTokenKind` result — every kind EXCEPT `1of1` composes a minter lane
392
+ * (Series/SeriesCode via `AbxFixedPriceMinter`; the three edition twins via its 1155 sibling — see
393
+ * the parity plan's "deliberate asymmetry" note on why even `OneOfOneEdition`, unlike its 721
394
+ * namesake, ships the sale stack). `undefined` means the probe itself couldn't run (RPC unreachable,
395
+ * address has no code, etc.) — that must NOT refuse: offline/best-effort scaffolding is allowed,
396
+ * same as the `name()` read a few lines above this call.
397
+ */
398
+ export function assertMintableSeries(token, kind) {
399
+ if (kind?.kind === '1of1') {
400
+ throw new CliError(`${token} looks like a 1/1 (OneOfOneImage) — mint-page sells through a shared fixed-price minter, which needs a Series/EditionImage/EditionCode contract (or an edition of one work); a plain 1/1 has no minter lane.\n` +
401
+ ` Deploy a Series instead: abx deploy-series --dir <folder> --count 1\n` +
402
+ ` …or copies of one work: abx deploy --copies <n|open>`);
403
+ }
404
+ }
405
+ export async function cmdMintPage(address, flags) {
406
+ const usage = 'abx mint-page <token> [--dir mint-page] [--name "…"] [--rpc <public-url>] [--minter-contract 0x..]';
407
+ const token = address;
408
+ if (!token || !token.startsWith('0x')) {
409
+ console.error(`usage: ${usage}\n`);
410
+ process.exitCode = 1;
411
+ return;
412
+ }
413
+ const publicClient = makePublicClient({ chainKey: CHAIN });
414
+ // Kind decides which shared minter (721 `AbxFixedPriceMinter` vs its 1155 `…1155` sibling) and
415
+ // which purchase shape (one project-wide sale vs a per-`(token,id)` sale with a quantity) the
416
+ // generated page needs. A probe failure (RPC unreachable, no code at the address) is left
417
+ // `undefined` and does NOT refuse — matches the best-effort name() read below.
418
+ let kind;
419
+ try {
420
+ kind = await detectTokenKind(publicClient, token);
421
+ }
422
+ catch {
423
+ warn(`could not confirm ${token}'s kind (RPC unreachable?) — continuing without the check.`);
424
+ }
425
+ assertMintableSeries(token, kind);
426
+ const isEdition = !!kind?.isEdition;
427
+ const minter = isEdition ? fixedPriceMinter1155Address(flags['minter-contract']) : fixedPriceMinterAddress(flags['minter-contract']);
428
+ if (!minter) {
429
+ throw new Error(`no shared ${isEdition ? 'edition ' : ''}fixed-price minter known for ${CHAIN} — run ` +
430
+ `\`abx minter configure ${token}${isEdition ? ' --token-id <n>' : ''} …\` first (it deploys + records one), ` +
431
+ `or set ${isEdition ? 'ABX_FIXED_PRICE_MINTER_1155' : 'ABX_FIXED_PRICE_MINTER'}.`);
432
+ }
433
+ const chain = resolveChain(CHAIN);
434
+ const rpcUrl = flags.rpc && flags.rpc !== 'true' ? flags.rpc : PUBLIC_RPC[chain.id];
435
+ if (!rpcUrl) {
436
+ throw new Error(`no public RPC known for ${CHAIN} (chainId ${chain.id}) — pass --rpc <public-keyless-url> (it is embedded in the public site, so it must not contain a secret key).`);
437
+ }
438
+ // Default the display name from the token's on-chain name (read-only); --name overrides.
439
+ let collectionName = flags.name && flags.name !== 'true' ? flags.name : '';
440
+ if (!collectionName) {
441
+ try {
442
+ collectionName = (await publicClient.readContract({
443
+ address: token,
444
+ abi: oneOfOneImageAbi,
445
+ functionName: 'name',
446
+ }));
447
+ }
448
+ catch {
449
+ collectionName = 'ABX Collection';
450
+ }
451
+ }
452
+ const art = mintPageArtifact({
453
+ token,
454
+ minter,
455
+ chainId: chain.id,
456
+ chainName: chain.name,
457
+ rpcUrl,
458
+ collectionName,
459
+ explorer: chain.blockExplorers?.default?.url,
460
+ isEdition,
461
+ });
462
+ const dir = flags.dir && flags.dir !== 'true' ? flags.dir : 'mint-page';
463
+ const outDir = resolvePath(dir);
464
+ console.log(bold('\n ABX Self-Host Toolkit — mint page'));
465
+ info(`collection ${bold(collectionName)} ${dim(token)}${isEdition ? dim(` · ${kind.label} (edition)`) : ''}`);
466
+ info(`minter ${minter} ${dim('· ' + chain.name + ' · reads via ' + rpcUrl)}`);
467
+ if (isEdition)
468
+ info(dim('edition purchase shape: token id + quantity, price × qty — no minted-tokens gallery in this v1 page (see the README).'));
469
+ step('Write the mint-page artifact (self-contained Next.js app — reads chain directly, no backend)');
470
+ for (const f of art.files) {
471
+ const dest = joinPath(outDir, f.path);
472
+ mkdirSync(joinPath(dest, '..'), { recursive: true });
473
+ writeFileSync(dest, f.content);
474
+ info(`wrote ${joinPath(dir, f.path)}`);
475
+ }
476
+ step(`Next steps (run from ${bold(dir)}/)`);
477
+ art.steps.forEach((s, i) => console.log(` ${g(`${i + 1}.`)} ${s}`));
478
+ step('Customize — this is a starting point');
479
+ info('Plain React + one CSS file; ask the agent for a bespoke layout per drop. ETH sales + injected wallet by default;');
480
+ info('the README covers RainbowKit (multi-wallet), ERC-20 (add an approve step), and RPC/gallery notes.');
481
+ warn('The RPC in .env.local is PUBLIC (may rate-limit). Never put a secret-keyed RPC in a NEXT_PUBLIC_ var — it ships to the browser.');
482
+ console.log('');
483
+ }
484
+ // ── flags ─────────────────────────────────────────────────────────────────--
485
+ // parseFlags + the Flags type live in flags.ts (unit-testable; repeatable flags like --dep
486
+ // accumulate comma-joined there).
487
+ // ── skill: install the abx agent skill into the user's coding agent ───────────────────────
488
+ // The skill is the agentic half of the toolkit — the CLI is only useful once an agent knows how
489
+ // to drive it. It ships bundled inside the published package (packages/cli/skill/, created at
490
+ // prepack); in dev the same command reads the canonical copy straight from the repo. This is the
491
+ // CANONICAL install path precisely because the bundle is version-locked to the CLI it ships inside
492
+ // (the version lives in SKILL.md frontmatter — see update-check.ts). By default it writes to BOTH
493
+ // `.claude/skills` (Claude Code) and the neutral `.agents/skills` (Cursor · Codex · Gemini ·
494
+ // Copilot all read it), so one command covers the whole ecosystem; `--agent` narrows it. A separate
495
+ // git-based channel, `npx skills add ArtBlocks/abx --skill abx-self-host`, works once the repo is public but is NOT
496
+ // version-locked to a local CLI — prefer `abx skill install`.
497
+ /** Locate the skill folder: the bundled copy beside the compiled CLI (published), else the
498
+ * canonical repo copy (dev). Returns null if neither is present. */
499
+ export function resolveBundledSkill() {
500
+ const here = fileURLToPath(import.meta.url);
501
+ // `findCliPackageRoot()`, never a counted `..` — see its doc comment for the alpha.21 field report
502
+ // this caused (`abx skill install` broken on every published install).
503
+ const pkgDir = findCliPackageRoot();
504
+ const bundled = pkgDir ? joinPath(pkgDir, 'skill') : null;
505
+ // Running from SOURCE (`pnpm abx`, tsx on packages/cli/src/**) means we're in the repo working
506
+ // tree, where the canonical skill is the source of truth and `<pkg>/skill` is gitignored PREPACK
507
+ // OUTPUT that may be arbitrarily old. Preferring the bundle there is actively destructive: a
508
+ // leftover `skill/` from a months-old `npm pack` silently overwrote the canonical skill with a
509
+ // copy 8 versions behind (and the drift check then reported the damage as if the user had caused
510
+ // it). In the published layout there is no repo and no canonical copy, so the bundle is correct.
511
+ const root = findRepoRoot();
512
+ const canonical = root ? joinPath(root, '.claude', 'skills', 'abx-self-host') : null;
513
+ // "Running from source" = this module IS the repo's own CLI source, checked by containment rather
514
+ // than by pattern. Two earlier attempts at this were wrong in opposite directions:
515
+ // - `/[/\\]src[/\\][^/\\]+$/` required the module to sit DIRECTLY in `src/`, so it read false
516
+ // for everything under `src/commands/` — including this file. Combined with the package-root fix
517
+ // above, that would make a dev run prefer `<pkg>/skill`, the gitignored prepack output, over the
518
+ // canonical skill: exactly the "actively destructive" case the note below describes.
519
+ // - matching a bare `/src/` anywhere would read TRUE for any user whose project happens to live
520
+ // under a `src` directory (`~/src/my-drop/node_modules/@artblocks/abx-cli/...`).
521
+ // Containment under the resolved repo's `packages/cli/src` is neither: it is exact, and it is
522
+ // independent of how deep in `src/` this file sits.
523
+ const cliSrc = root ? joinPath(root, 'packages', 'cli', 'src') + sep : null;
524
+ const fromSource = !!cliSrc && here.startsWith(cliSrc);
525
+ if (fromSource && canonical && existsSync(joinPath(canonical, 'SKILL.md')))
526
+ return canonical;
527
+ // Published layout: <pkg>/skill, written by scripts/bundle-skill.mjs at prepack.
528
+ if (bundled && existsSync(joinPath(bundled, 'SKILL.md')))
529
+ return bundled;
530
+ if (canonical && existsSync(joinPath(canonical, 'SKILL.md')))
531
+ return canonical;
532
+ return null;
533
+ }
534
+ /** Locate the in-chain renderer Foundry scaffold: bundled beside the CLI (published), else the
535
+ * repo copy (dev). Same layout in both — it lives under the CLI package's `assets/`. */
536
+ export function resolveRendererScaffold() {
537
+ // Same fix as {resolveBundledSkill}: this had the identical counted-`..` bug, so
538
+ // `abx scaffold-renderer` was broken on every published install too — unreported, found while
539
+ // fixing the skill path.
540
+ const pkgDir = findCliPackageRoot();
541
+ const bundled = pkgDir ? joinPath(pkgDir, 'assets', 'renderer-scaffold') : null;
542
+ if (bundled && existsSync(joinPath(bundled, 'foundry.toml')))
543
+ return bundled;
544
+ const root = findRepoRoot();
545
+ if (root) {
546
+ const canonical = joinPath(root, 'packages', 'cli', 'assets', 'renderer-scaffold');
547
+ if (existsSync(joinPath(canonical, 'foundry.toml')))
548
+ return canonical;
549
+ }
550
+ return null;
551
+ }
552
+ /** `abx scaffold-renderer [<dir>]` — write a ready-to-build Foundry project for the in-chain
553
+ * Solidity renderer lane (a seed + PostParam → SVG renderer + coherent traits + tests + a deploy
554
+ * script). abx doesn't compile/deploy Solidity — the creator runs forge, then hands the deployed
555
+ * address to `deploy-code --image-renderer`. Mirrors how deploy-resolver scaffolds an artifact. */
556
+ export function cmdScaffoldRenderer(rest, flags) {
557
+ const dir = resolvePath(rest[0] ?? 'my-renderer');
558
+ const src = resolveRendererScaffold();
559
+ if (!src) {
560
+ throw new Error('bundled renderer scaffold not found (expected <pkg>/assets/renderer-scaffold). Reinstall @artblocks/abx-cli.');
561
+ }
562
+ if (existsSync(dir) && readdirSync(dir).length > 0 && flags.force === undefined) {
563
+ throw new Error(`${dir} already exists and is not empty — pass a fresh path, or --force to write into it.`);
564
+ }
565
+ // Copy + assert it landed. Throws rather than printing a success banner over an empty directory —
566
+ // the alpha.9→alpha.14 failure mode. See src/scaffold.ts for why this is not inline.
567
+ copyRendererScaffold(src, dir);
568
+ ok(`in-chain renderer scaffold → ${dir}`);
569
+ step('The in-chain Solidity renderer lane — nothing to run after deploy, tokenURI resolves from chain forever');
570
+ info(`${bold('src/MyRenderer.sol')} draws an SVG from the token ${bold('seed')} + a ${bold('palette')} HexColor PostParam; ${bold('src/MyTraits.sol')} reads the SAME seed for coherent on-chain traits. Fork the work; keep the invariants in ${bold('src/interfaces/IAbxFieldRenderer.sol')} (above all: render() must NEVER revert).`);
571
+ info('build + test + deploy it yourself with Foundry (abx does not compile/deploy Solidity):');
572
+ console.log(` ${g('cd')} ${basename(dir)}`);
573
+ console.log(` ${g('forge soldeer install')} ${dim('# fetch solady + forge-std (pinned)')}`);
574
+ console.log(` ${g('forge test')} ${dim('# MUST pass — the never-revert cases')}`);
575
+ console.log(` ${g('forge script script/Deploy.s.sol --rpc-url <rpc> --private-key <key> --broadcast')}`);
576
+ info('then wire the printed addresses into your drop:');
577
+ console.log(` ${g('abx deploy-code --image-renderer <MyRenderer> --attributes-renderer <MyTraits> --onchain-uri --schema palette:HexColor:TokenOwner --name "…" --symbol …')}`);
578
+ info(`full walkthrough: ${bold(`${basename(dir)}/README.md`)} · interface + invariants: https://abx.docs.artblocks.io/protocol/renderers/`);
579
+ }
580
+ /** Human label for each skills-parent dir — which agents pick the skill up from there. */
581
+ export const SKILL_PARENT_LABELS = {
582
+ '.claude/skills': 'Claude Code',
583
+ '.agents/skills': 'Cursor · Codex · Gemini · Copilot',
584
+ };
585
+ /** Resolve which skills-parent dirs `install` should write to. No `--agent` → the whole-ecosystem
586
+ * default (Claude Code + the neutral `.agents/skills` everyone else reads). `--agent a,b` narrows
587
+ * to those agents' dirs (de-duped, since several share `.agents/skills`). */
588
+ export function resolveInstallParents(agentFlag) {
589
+ if (!agentFlag || agentFlag === 'true')
590
+ return ['.claude/skills', '.agents/skills'];
591
+ const parents = new Set();
592
+ for (const raw of agentFlag.split(',')) {
593
+ const a = raw.trim().toLowerCase();
594
+ const parent = AGENT_SKILL_PARENTS[a];
595
+ if (!parent) {
596
+ throw new Error(`unknown --agent '${a}'. Use one of: ${Object.keys(AGENT_SKILL_PARENTS).join(', ')} (or omit --agent to install for every agent).`);
597
+ }
598
+ parents.add(parent);
599
+ }
600
+ return [...parents];
601
+ }
602
+ /** Copy the bundled skill folder to `dest`, replacing any prior copy so a re-install after an
603
+ * upgrade never leaves stale reference files behind.
604
+ *
605
+ * Returns `'already-canonical'` when destination IS the source, having done nothing. That case is
606
+ * not hypothetical: in a dev checkout of the abx repo `resolveBundledSkill()` resolves to the
607
+ * canonical `.claude/skills/abx-self-host`, which is exactly where a cwd-relative install writes —
608
+ * so the `rmSync` below would delete the canonical skill, and the copy would then have no source.
609
+ * A self-destructing install is a bad way to learn that. */
610
+ export function installSkillTo(src, dest) {
611
+ if (resolvePath(src) === resolvePath(dest))
612
+ return 'already-canonical';
613
+ mkdirSync(joinPath(dest, '..'), { recursive: true });
614
+ rmSync(dest, { recursive: true, force: true });
615
+ cpSync(src, dest, { recursive: true });
616
+ return 'installed';
617
+ }
618
+ /**
619
+ * Install the bundled skill into the default per-agent parents (or `~` with `global`), reporting
620
+ * each destination. Shared by `abx skill install` and `abx doctor`'s offer to fix a missing/stale
621
+ * skill — one implementation, so the two can't drift on where the skill lands or what it prints.
622
+ */
623
+ export function installSkillToDefaults(src, opts = {}) {
624
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
625
+ const base = opts.global ? homedir() : process.cwd();
626
+ const parents = resolveInstallParents(opts.agent);
627
+ ok(`installed the abx skill v${version}${opts.global ? ' (global, ~)' : ''}:`);
628
+ for (const parent of parents) {
629
+ const dest = joinPath(base, parent, SKILL_DIR_NAME);
630
+ const outcome = installSkillTo(src, dest);
631
+ const label = SKILL_PARENT_LABELS[parent];
632
+ const note = outcome === 'already-canonical' ? dim(' (already the canonical copy — left as is)') : label ? dim(' → ' + label) : '';
633
+ console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${note}`);
634
+ }
635
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
636
+ 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.`);
637
+ return version;
638
+ }
639
+ /** The default skill destinations, as a display string — what doctor's prompt has to name up front. */
640
+ export function defaultSkillDests(opts = {}) {
641
+ const prefix = opts.global ? '~/' : './';
642
+ return resolveInstallParents(opts.agent).map((p) => `${prefix}${p}`).join(' and ');
643
+ }
644
+ export async function cmdSkill(rest, flags) {
645
+ const sub = rest[0] ?? 'install';
646
+ const src = resolveBundledSkill();
647
+ if (!src) {
648
+ throw new Error('bundled skill not found (expected <pkg>/skill or .claude/skills/abx-self-host). ' +
649
+ 'Reinstall @artblocks/abx-cli, or install cross-agent with: npx skills add ArtBlocks/abx --skill abx-self-host');
650
+ }
651
+ if (sub === 'path') {
652
+ console.log(src);
653
+ return;
654
+ }
655
+ if (sub === 'install') {
656
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
657
+ // Escape hatch: --target <dir> writes the skill folder straight under <dir> (for an agent
658
+ // whose skills dir we don't special-case, or a bespoke location).
659
+ if (typeof flags.target === 'string' && flags.target !== 'true') {
660
+ const dest = joinPath(resolvePath(flags.target), SKILL_DIR_NAME);
661
+ if (installSkillTo(src, dest) === 'already-canonical') {
662
+ ok(`${dest} is already the canonical skill v${version} — nothing to install.`);
663
+ return;
664
+ }
665
+ ok(`installed the abx skill v${version} → ${dest}`);
666
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
667
+ return;
668
+ }
669
+ installSkillToDefaults(src, { global: flags.global !== undefined, agent: flags.agent });
670
+ return;
671
+ }
672
+ throw new Error('usage: abx skill <install|path> [--agent claude|cursor|codex|gemini|copilot] [--global] [--target <dir>]');
673
+ }
674
+ //# sourceMappingURL=scaffold.js.map