@clanker-chain/clanker-cli 2026.9.8 → 2026.9.12

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/bin/clanker.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
6
7
  import os from "node:os";
7
8
  import process from "node:process";
8
9
  import { getAddress } from "viem";
@@ -25,7 +26,14 @@ import {
25
26
  import { resolveForRead, resolveOperatorKey, resolveReadIdentity } from "../lib/resolve.mjs";
26
27
  import { runSetup } from "../lib/setup.mjs";
27
28
  import { runDoctor } from "../lib/doctor.mjs";
29
+ import { runFund } from "../lib/fund.mjs";
28
30
  import {
31
+ assessMintBudget,
32
+ formatEthTrim,
33
+ } from "../lib/mint-budget.mjs";
34
+ import {
35
+ botIdentityCard,
36
+ botIdentityJson,
29
37
  hubConnectChecklist,
30
38
  wireOpenClawMqtt,
31
39
  } from "../lib/openclaw-wire.mjs";
@@ -95,8 +103,15 @@ function runScript(scriptPath, args = []) {
95
103
  }
96
104
 
97
105
  function findRepoRoot() {
98
- const here = dirname(new URL(import.meta.url).pathname);
99
- return dirname(dirname(here));
106
+ let dir = dirname(fileURLToPath(import.meta.url));
107
+ for (let i = 0; i < 8; i++) {
108
+ if (existsSync(join(dir, "chain", "foundry.toml"))) return dir;
109
+ const parent = dirname(dir);
110
+ if (parent === dir) break;
111
+ dir = parent;
112
+ }
113
+ // npm install: package root (no monorepo checkout)
114
+ return dirname(dirname(fileURLToPath(import.meta.url)));
100
115
  }
101
116
 
102
117
  /** chain up/deploy and check * need the monorepo; npm installs only ship bin/ + lib/. */
@@ -156,6 +171,7 @@ function usage() {
156
171
 
157
172
  Usage:
158
173
  clanker setup [--preset sepolia|local] [--operator <label>] [--address 0x…] [--key-file path] [--force]
174
+ clanker fund [--no-open] [--timeout ms] [--json]
159
175
  clanker doctor [--json]
160
176
  clanker init --preset sepolia|local [--force]
161
177
  clanker whoami [--json] [--operator <label>] [--address 0x…] [--with-bots]
@@ -167,6 +183,10 @@ Usage:
167
183
  clanker bot status <label> [--json]
168
184
  clanker bot revoke <label> [--json] [--yes]
169
185
  clanker bot rotate <label> <newKeyAddress> [--json] [--yes]
186
+ clanker pair add <operator-label> [--yes] [--auth-url URL] [--openclaw-home DIR]
187
+ clanker pair remove <operator-label> [--auth-url URL] [--openclaw-home DIR]
188
+ clanker pair list [--auth-url URL]
189
+ clanker pair status <operator-label> [--auth-url URL]
170
190
  clanker init-openclaw
171
191
  clanker chain up|deploy|mint-operator|mint-bot|rotate-bot-key|revoke-bot ...
172
192
  clanker check mqtt <bot_id> <operator_id>
@@ -177,11 +197,12 @@ Profile:
177
197
  ~/.clanker/operator.json label + owner + optional key pointer (never raw hex)
178
198
  ~/.clanker/keys/ bot keys (also written to ~/.openclaw/keys/)
179
199
 
180
- Humans: \`clanker setup\` then \`clanker doctor\` / \`whoami\`.
200
+ Humans: \`clanker setup\` then \`clanker fund\` / \`doctor\` / \`whoami\`.
201
+ Pairing (Policy): both operators run \`clanker pair add\` before DMs deliver on the hub.
181
202
  Mutates print a plan and confirm unless --yes or --json.
182
203
  whoami is fast by default; pass --with-bots to enrich child bots (or use \`clanker bots\`).
183
204
 
184
- See docs/operator-cli.md.
205
+ See docs/operator-cli.md, docs/prerequisites.md, and docs/trust-model.md.
185
206
  `);
186
207
  }
187
208
 
@@ -395,6 +416,7 @@ async function main() {
395
416
  because: "setup could not write a valid local profile",
396
417
  try: [
397
418
  "clanker setup --preset sepolia --operator org.you --address 0x… --yes --force",
419
+ "clanker fund",
398
420
  "clanker doctor",
399
421
  ],
400
422
  });
@@ -402,6 +424,22 @@ async function main() {
402
424
  return;
403
425
  }
404
426
 
427
+ if (cmd === "fund") {
428
+ try {
429
+ const { exitCode } = await runFund(rest);
430
+ process.exit(exitCode);
431
+ } catch (err) {
432
+ exitCliError({
433
+ error: err.message,
434
+ because: "fund needs a Sepolia profile with an owner address",
435
+ try: [
436
+ "clanker setup --preset sepolia --operator org.you --generate-key --yes --force",
437
+ "clanker fund",
438
+ ],
439
+ });
440
+ }
441
+ }
442
+
405
443
  if (cmd === "doctor") {
406
444
  const { exitCode } = await runDoctor(rest);
407
445
  process.exit(exitCode);
@@ -439,6 +477,93 @@ async function main() {
439
477
  return;
440
478
  }
441
479
 
480
+ if (cmd === "pair") {
481
+ const [sub, ...pairRest] = rest;
482
+ if (!sub || !["add", "remove", "list", "status"].includes(sub)) {
483
+ console.error("Usage: clanker pair add|remove|list|status [operator-label]");
484
+ process.exit(1);
485
+ }
486
+
487
+ let peerLabel = null;
488
+ for (let i = 0; i < pairRest.length; i += 1) {
489
+ const a = pairRest[i];
490
+ if (a.startsWith("--")) {
491
+ if (a !== "--json" && a !== "--yes" && pairRest[i + 1] && !pairRest[i + 1].startsWith("--")) {
492
+ i += 1; // skip flag value
493
+ }
494
+ continue;
495
+ }
496
+ peerLabel = a;
497
+ break;
498
+ }
499
+
500
+ const { runPairAction } = await import("../lib/pair.mjs");
501
+ try {
502
+ if (sub === "add") {
503
+ const planRows = [
504
+ ["action", "pair add (Policy)"],
505
+ ["peer", peerLabel ?? "(missing)"],
506
+ ["note", "One-way allow; peer must pair you for bidirectional DMs"],
507
+ ];
508
+ const ok = await confirmPlan(pairRest, planRows, `Allow operator ${peerLabel}?`);
509
+ if (!ok) process.exit(0);
510
+ }
511
+ const out = await runPairAction(pairRest, sub, peerLabel);
512
+ if (hasFlag(pairRest, "--json")) {
513
+ console.log(JSON.stringify(out, null, 2));
514
+ } else if (sub === "list") {
515
+ console.log(`Operator: ${out.operatorId}`);
516
+ console.log(`Auth: ${out.authUrl}`);
517
+ const allows = out.listed?.allows ?? [];
518
+ if (!allows.length) {
519
+ console.log("(no allows)");
520
+ } else {
521
+ for (const a of allows) {
522
+ console.log(
523
+ ` ${a.peer_operator_id} ${a.mutual ? "mutual" : "pending (one-way)"}`,
524
+ );
525
+ }
526
+ }
527
+ } else if (sub === "status") {
528
+ console.log(`peer: ${out.peerLabel}`);
529
+ console.log(`allowed: ${out.allowed}`);
530
+ console.log(`mutual: ${out.mutual}`);
531
+ if (out.allowed && !out.mutual) {
532
+ console.log("Waiting for peer to run: clanker pair add <your-operator>");
533
+ }
534
+ } else {
535
+ console.log(
536
+ `${sub} ${out.peerLabel}: ${out.result?.mutual ? "mutual" : "one-way / pending"}`,
537
+ );
538
+ if (out.openclawSync?.synced) {
539
+ console.log(`Synced allowOperators → ${out.openclawSync.path}`);
540
+ console.log(
541
+ "Restart (or reload) the OpenClaw gateway so channels.mqtt.allowOperators is picked up.",
542
+ );
543
+ } else if (out.openclawSync?.reason === "missing_openclaw_json") {
544
+ console.log(
545
+ `Hub Policy updated, but no openclaw.json at ${out.openclawSync.path} — ` +
546
+ "client allowOperators was not synced. Create OpenClaw config or set allowOperators manually.",
547
+ );
548
+ }
549
+ if (sub === "add" && !out.result?.mutual) {
550
+ nextHint(`Ask ${out.peerLabel} to run: clanker pair add ${out.operatorId}`);
551
+ }
552
+ }
553
+ } catch (err) {
554
+ exitCliError({
555
+ error: err.message,
556
+ because: "pairing needs operator key + mqtt-auth /pair endpoints",
557
+ try: [
558
+ "clanker doctor",
559
+ "clanker pair add org.peer --auth-url http://127.0.0.1:9090 --yes",
560
+ "See docs/trust-model.md",
561
+ ],
562
+ });
563
+ }
564
+ return;
565
+ }
566
+
442
567
  if (cmd === "operator") {
443
568
  const [sub, ...opArgv] = rest;
444
569
  const {
@@ -454,8 +579,9 @@ async function main() {
454
579
  process.exit(1);
455
580
  }
456
581
  let planRows;
582
+ let preview;
457
583
  try {
458
- const preview = resolveOperatorKey(flags);
584
+ preview = resolveOperatorKey(flags);
459
585
  planRows = [
460
586
  ["action", "registerOperator"],
461
587
  ["label", label],
@@ -471,10 +597,42 @@ async function main() {
471
597
  try: [
472
598
  "export OPERATOR_PRIVATE_KEY=0x…",
473
599
  "clanker operator mint " + label + " --key-file ~/.clanker/op.key --yes",
600
+ "clanker fund",
474
601
  "clanker doctor",
475
602
  ],
476
603
  });
477
604
  }
605
+ try {
606
+ if (preview.network.registry) {
607
+ const pub = await publicClientFromRpc(preview.network.rpc);
608
+ const budget = await assessMintBudget({
609
+ pub,
610
+ registry: preview.network.registry,
611
+ owner: preview.address,
612
+ rpc: preview.network.rpc,
613
+ operatorLabel: label,
614
+ mode: "operator",
615
+ });
616
+ planRows.push(
617
+ ["fee", `${formatEthTrim(budget.operatorFeeWei)} ETH`],
618
+ ["balance", `${formatEthTrim(budget.balanceWei)} ETH`],
619
+ [
620
+ "needed",
621
+ `${formatEthTrim(budget.neededWei)} ETH (fee + gas cushion)`,
622
+ ],
623
+ );
624
+ if (!budget.funded) {
625
+ exitCliError({
626
+ error: `insufficient ETH: have ${formatEthTrim(budget.balanceWei)}, need ${formatEthTrim(budget.neededWei)}`,
627
+ because: "mint sends the exact registry fee plus gas",
628
+ try: ["clanker fund", "clanker doctor"],
629
+ });
630
+ }
631
+ }
632
+ } catch (err) {
633
+ // Non-fatal if RPC read fails before confirm; mint will still fail clearly.
634
+ planRows.push(["balance", `(could not check: ${err.message ?? err})`]);
635
+ }
478
636
  const ok = await confirmPlan(flags, planRows, `Mint operator ${label}?`);
479
637
  if (!ok) process.exit(0);
480
638
  const result = await chainMintOperator(label, flags);
@@ -575,10 +733,11 @@ async function main() {
575
733
  process.exit(1);
576
734
  }
577
735
  let operatorLabel = operatorArg ?? flagValue(flags, "--operator") ?? null;
736
+ let preview = null;
578
737
  if (!operatorLabel) {
579
738
  try {
580
- const { address, network } = resolveOperatorKey(flags);
581
- const inferred = await resolveOperatorLabel(flags, address, network);
739
+ preview = resolveOperatorKey(flags);
740
+ const inferred = await resolveOperatorLabel(flags, preview.address, preview.network);
582
741
  operatorLabel = inferred.label;
583
742
  } catch (err) {
584
743
  exitCliError({
@@ -591,13 +750,60 @@ async function main() {
591
750
  });
592
751
  }
593
752
  }
753
+ if (!preview) {
754
+ try {
755
+ preview = resolveOperatorKey(flags);
756
+ } catch (err) {
757
+ exitCliError({
758
+ error: err.message,
759
+ because: "bot mint needs a signing key on this network",
760
+ try: [
761
+ `clanker bot mint ${botLabel} ${operatorLabel} --key-file ~/.clanker/op.key --yes`,
762
+ "clanker fund",
763
+ ],
764
+ });
765
+ }
766
+ }
767
+ const planRows = [
768
+ ["action", "registerBot"],
769
+ ["bot", botLabel],
770
+ ["operator", operatorLabel],
771
+ ["owner", preview.address],
772
+ ["registry", preview.network.registry ?? "(none)"],
773
+ ];
774
+ try {
775
+ if (preview.network.registry) {
776
+ const pub = await publicClientFromRpc(preview.network.rpc);
777
+ const budget = await assessMintBudget({
778
+ pub,
779
+ registry: preview.network.registry,
780
+ owner: preview.address,
781
+ rpc: preview.network.rpc,
782
+ operatorLabel,
783
+ mode: "bot",
784
+ });
785
+ planRows.push(
786
+ ["fee", `${formatEthTrim(budget.botFeeWei)} ETH`],
787
+ ["balance", `${formatEthTrim(budget.balanceWei)} ETH`],
788
+ [
789
+ "needed",
790
+ `${formatEthTrim(budget.neededWei)} ETH (fee + gas cushion)`,
791
+ ],
792
+ );
793
+ if (!budget.funded) {
794
+ exitCliError({
795
+ error: `insufficient ETH: have ${formatEthTrim(budget.balanceWei)}, need ${formatEthTrim(budget.neededWei)}`,
796
+ because: "mint sends the exact registry fee plus gas",
797
+ try: ["clanker fund", "clanker doctor"],
798
+ });
799
+ }
800
+ }
801
+ } catch (err) {
802
+ planRows.push(["balance", `(could not check: ${err.message ?? err})`]);
803
+ }
594
804
  const ok = await confirmPlan(
595
805
  flags,
596
- [
597
- ["action", "registerBot"],
598
- ["bot", botLabel],
599
- ["operator", operatorLabel],
600
- ],
806
+ planRows,
601
807
  `Mint bot ${botLabel} under ${operatorLabel}?`,
602
808
  );
603
809
  if (!ok) process.exit(0);
@@ -615,18 +821,30 @@ async function main() {
615
821
  keyPath: result.key_path,
616
822
  });
617
823
  if (hasFlag(flags, "--json")) {
618
- printJson({ ...result, openclaw: wire });
824
+ printJson({
825
+ ...result,
826
+ openclaw: wire,
827
+ bot_identity: botIdentityJson({
828
+ botId: botLabel,
829
+ keyPath: result.key_path,
830
+ openclawConfigPath: wire.path,
831
+ }),
832
+ });
619
833
  } else {
620
834
  console.log(c.green(`Minted bot ${botLabel} under ${operatorLabel}`));
621
835
  console.log(`botKey: ${result.bot_key}`);
622
- console.log(`key file: ${result.key_path}`);
623
- console.log(`also: ${result.clanker_key_path}`);
624
836
  console.log(`tx: ${result.tx}`);
625
- console.log(
626
- wire.created
627
- ? c.green(`Wrote ${wire.path}`)
628
- : c.green(`Updated channels.mqtt in ${wire.path}`),
629
- );
837
+ console.log(c.dim(`also: ${result.clanker_key_path}`));
838
+ console.log("");
839
+ for (const line of botIdentityCard({
840
+ botId: botLabel,
841
+ operatorId: operatorLabel,
842
+ keyPath: result.key_path,
843
+ openclawPath: wire.path,
844
+ created: wire.created,
845
+ })) {
846
+ console.log(line);
847
+ }
630
848
  nextHint(
631
849
  hubConnectChecklist({
632
850
  botId: botLabel,
@@ -921,7 +1139,7 @@ async function main() {
921
1139
  nextHint([
922
1140
  "clanker bot mint <label>",
923
1141
  "Then channels.mqtt.botId / operatorId / privateKeyFile update automatically",
924
- "See docs/operator-cli.md and docs/closed-beta-invite.md",
1142
+ "See docs/operator-cli.md and docs/public-testnet-hub.md",
925
1143
  ]);
926
1144
  process.exit(0);
927
1145
  }
@@ -19,6 +19,11 @@ export const clankerIdentityAbi = [
19
19
  "name": "_feeRecipient",
20
20
  "type": "address",
21
21
  "internalType": "address"
22
+ },
23
+ {
24
+ "name": "_priorRegistry",
25
+ "type": "address",
26
+ "internalType": "address"
22
27
  }
23
28
  ],
24
29
  "stateMutability": "nonpayable"
@@ -176,6 +181,19 @@ export const clankerIdentityAbi = [
176
181
  ],
177
182
  "stateMutability": "view"
178
183
  },
184
+ {
185
+ "type": "function",
186
+ "name": "priorRegistry",
187
+ "inputs": [],
188
+ "outputs": [
189
+ {
190
+ "name": "",
191
+ "type": "address",
192
+ "internalType": "address"
193
+ }
194
+ ],
195
+ "stateMutability": "view"
196
+ },
179
197
  {
180
198
  "type": "function",
181
199
  "name": "proposeOperatorTransfer",
package/lib/doctor.mjs CHANGED
@@ -11,10 +11,25 @@ import {
11
11
  clankerHome,
12
12
  } from "./profile.mjs";
13
13
  import { detectSetupHints, formatSetupDetectTable } from "./setup-detect.mjs";
14
+ import { publicClientFromRpc } from "./identity-query.mjs";
15
+ import {
16
+ assessMintBudget,
17
+ budgetToJson,
18
+ formatBudgetSummary,
19
+ } from "./mint-budget.mjs";
14
20
  import { c, nextHint } from "./ui.mjs";
15
21
 
16
22
  /**
17
- * @param {{ home?: string, env?: NodeJS.ProcessEnv, openclawDir?: string, castBin?: string, spawn?: Function, fetchImpl?: typeof fetch }} [opts]
23
+ * @param {{
24
+ * home?: string,
25
+ * env?: NodeJS.ProcessEnv,
26
+ * openclawDir?: string,
27
+ * castBin?: string,
28
+ * spawn?: Function,
29
+ * fetchImpl?: typeof fetch,
30
+ * publicClient?: { readContract: Function, getBalance: Function },
31
+ * skipBalance?: boolean,
32
+ * }} [opts]
18
33
  */
19
34
  export async function runDoctorChecks(opts = {}) {
20
35
  const env = opts.env ?? process.env;
@@ -103,6 +118,59 @@ export async function runDoctorChecks(opts = {}) {
103
118
  : "Foundry cast not on PATH (optional)",
104
119
  });
105
120
 
121
+ /** @type {object|null} */
122
+ let budget = null;
123
+
124
+ const canCheckBalance =
125
+ !opts.skipBalance &&
126
+ hasOwner &&
127
+ registry &&
128
+ /^0x[0-9a-fA-F]{40}$/.test(registry) &&
129
+ rpc;
130
+
131
+ if (canCheckBalance) {
132
+ try {
133
+ if (isLocalRpc(rpc)) {
134
+ budget = await assessMintBudget({
135
+ pub: { readContract: async () => 0n, getBalance: async () => 0n },
136
+ registry,
137
+ owner: getAddress(operator.owner),
138
+ rpc,
139
+ operatorLabel: operator.label,
140
+ });
141
+ checks.push({
142
+ id: "balance",
143
+ ok: true,
144
+ level: "pass",
145
+ message: formatBudgetSummary(budget),
146
+ });
147
+ } else {
148
+ const pub =
149
+ opts.publicClient ?? (await publicClientFromRpc(rpc));
150
+ budget = await assessMintBudget({
151
+ pub,
152
+ registry,
153
+ owner: getAddress(operator.owner),
154
+ rpc,
155
+ operatorLabel: operator.label,
156
+ });
157
+ checks.push({
158
+ id: "balance",
159
+ ok: budget.funded,
160
+ level: budget.funded ? "pass" : "fail",
161
+ message: formatBudgetSummary(budget),
162
+ });
163
+ }
164
+ } catch (err) {
165
+ checks.push({
166
+ id: "balance",
167
+ ok: false,
168
+ level: "warn",
169
+ message: `balance check skipped: ${err.message ?? err}`,
170
+ });
171
+ }
172
+ }
173
+
106
174
  const authUrl = config?.mqttAuthServiceUrl;
107
175
  if (authUrl && typeof fetchImpl === "function") {
108
176
  const healthUrl = `${String(authUrl).replace(/\/$/, "")}/health`;
@@ -146,8 +214,12 @@ export async function runDoctorChecks(opts = {}) {
146
214
  const readyWhoami = checks
147
215
  .filter((ch) => ch.id === "config" || ch.id === "registry" || ch.id === "operator")
148
216
  .every((ch) => ch.ok);
149
- const readyMint = readyWhoami && hasKey &&
150
- !checks.some((ch) => ch.id === "anvil_public" && !ch.ok);
217
+ const balanceOk = !checks.some((ch) => ch.id === "balance" && ch.level === "fail");
218
+ const readyMint =
219
+ readyWhoami &&
220
+ hasKey &&
221
+ !checks.some((ch) => ch.id === "anvil_public" && !ch.ok) &&
222
+ balanceOk;
151
223
 
152
224
  return {
153
225
  home,
@@ -156,17 +228,19 @@ export async function runDoctorChecks(opts = {}) {
156
228
  readyWhoami,
157
229
  readyMint,
158
230
  ok: readyWhoami,
231
+ budget,
159
232
  };
160
233
  }
161
234
 
162
235
  /**
163
236
  * @param {string[]} argv
164
- * @param {{ home?: string, env?: NodeJS.ProcessEnv }} [opts]
237
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv, publicClient?: object, skipBalance?: boolean }} [opts]
165
238
  * @returns {Promise<{ exitCode: number, report: object }>}
166
239
  */
167
240
  export async function runDoctor(argv = [], opts = {}) {
168
241
  const json = argv.includes("--json");
169
242
  const report = await runDoctorChecks(opts);
243
+ const budgetJson = report.budget ? budgetToJson(report.budget) : null;
170
244
 
171
245
  if (json) {
172
246
  console.log(
@@ -177,6 +251,15 @@ export async function runDoctor(argv = [], opts = {}) {
177
251
  readyMint: report.readyMint,
178
252
  home: report.home,
179
253
  checks: report.checks,
254
+ ...(budgetJson
255
+ ? {
256
+ balanceWei: budgetJson.balanceWei,
257
+ neededWei: budgetJson.neededWei,
258
+ shortfallWei: budgetJson.shortfallWei,
259
+ claimsNeeded: budgetJson.claimsNeeded,
260
+ budget: budgetJson,
261
+ }
262
+ : {}),
180
263
  },
181
264
  null,
182
265
  2,
@@ -207,12 +290,16 @@ export async function runDoctor(argv = [], opts = {}) {
207
290
  }
208
291
  if (report.readyMint) {
209
292
  console.log(c.green("Ready for: clanker operator mint / bot mint"));
293
+ } else if (report.budget && !report.budget.funded && !report.budget.local) {
294
+ console.log(c.dim("Mint: fund the operator address first (clanker fund)"));
210
295
  } else {
211
296
  console.log(c.dim("Mint/revoke: need signing key (and non-Anvil owner on public RPC)"));
212
297
  }
213
298
 
214
299
  if (!report.readyWhoami) {
215
300
  nextHint(["clanker setup"]);
301
+ } else if (report.budget && !report.budget.funded && !report.budget.local) {
302
+ nextHint(["clanker fund", "clanker doctor"]);
216
303
  } else if (!report.readyMint) {
217
304
  nextHint([
218
305
  "clanker whoami",