@clanker-chain/clanker-cli 2026.9.7-3 → 2026.9.7-4

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
@@ -25,6 +25,13 @@ import {
25
25
  } from "../lib/identity-query.mjs";
26
26
  import { resolveForRead, resolveOperatorKey, resolveReadIdentity } from "../lib/resolve.mjs";
27
27
  import { runSetup } from "../lib/setup.mjs";
28
+ import { runDoctor } from "../lib/doctor.mjs";
29
+ import {
30
+ c,
31
+ confirmPlan,
32
+ exitCliError,
33
+ nextHint,
34
+ } from "../lib/ui.mjs";
28
35
 
29
36
  function resolveFoundryBinary(name) {
30
37
  const home = os.homedir();
@@ -146,16 +153,17 @@ function usage() {
146
153
 
147
154
  Usage:
148
155
  clanker setup [--preset sepolia|local] [--operator <label>] [--address 0x…] [--key-file path] [--force]
156
+ clanker doctor [--json]
149
157
  clanker init --preset sepolia|local [--force]
150
- clanker whoami [--json] [--operator <label>] [--address 0x…]
151
- clanker operator mint <label> [--json]
152
- clanker operator transfer propose <label> <newOwner>
153
- clanker operator transfer accept <label>
154
- clanker bot mint <label> [operator] [--json]
158
+ clanker whoami [--json] [--operator <label>] [--address 0x…] [--with-bots]
159
+ clanker operator mint <label> [--json] [--yes]
160
+ clanker operator transfer propose <label> <newOwner> [--yes]
161
+ clanker operator transfer accept <label> [--yes]
162
+ clanker bot mint <label> [operator] [--json] [--yes]
155
163
  clanker bots [--json] [--operator <label>] [--address 0x…]
156
164
  clanker bot status <label> [--json]
157
- clanker bot revoke <label> [--json]
158
- clanker bot rotate <label> <newKeyAddress> [--json]
165
+ clanker bot revoke <label> [--json] [--yes]
166
+ clanker bot rotate <label> <newKeyAddress> [--json] [--yes]
159
167
  clanker init-openclaw
160
168
  clanker chain up|deploy|mint-operator|mint-bot|rotate-bot-key|revoke-bot ...
161
169
  clanker check mqtt <bot_id> <operator_id>
@@ -166,11 +174,9 @@ Profile:
166
174
  ~/.clanker/operator.json label + owner + optional key pointer (never raw hex)
167
175
  ~/.clanker/keys/ bot keys (also written to ~/.openclaw/keys/)
168
176
 
169
- Humans: prefer \`clanker setup\` (detects Foundry/OpenClaw hints, writes profile).
170
- Key resolution (mutating commands):
171
- --key > --key-file > OPERATOR_PRIVATE_KEY > profile keyFile > profile env
172
- Anvil account #0 is allowed only on localhost RPC.
173
- Reads (whoami/bots) can use profile owner without a signing key.
177
+ Humans: \`clanker setup\` then \`clanker doctor\` / \`whoami\`.
178
+ Mutates print a plan and confirm unless --yes or --json.
179
+ whoami is fast by default; pass --with-bots to enrich child bots (or use \`clanker bots\`).
174
180
 
175
181
  See docs/operator-cli.md.
176
182
  `);
@@ -223,10 +229,14 @@ async function cmdWhoami(argv) {
223
229
  source = resolved.source;
224
230
  network = resolved.network;
225
231
  } catch (err) {
226
- console.error(err.message);
227
- process.exit(1);
232
+ exitCliError({
233
+ error: err.message,
234
+ because: "whoami needs an owner address from --address, a key, or operator.json",
235
+ try: ["clanker setup", "clanker doctor", "clanker whoami --address 0x…"],
236
+ });
228
237
  }
229
238
 
239
+ const withBots = hasFlag(argv, "--with-bots");
230
240
  const pub = await publicClientFromRpc(network.rpc);
231
241
  const preferred =
232
242
  flagValue(argv, "--operator") ?? loadOperator(network.home)?.label ?? null;
@@ -239,9 +249,14 @@ async function cmdWhoami(argv) {
239
249
  owner: getAddress(address),
240
250
  });
241
251
  if (resolved.error) {
242
- const err = new Error(resolved.error);
243
- err.candidates = resolved.candidates;
244
- throw err;
252
+ exitCliError({
253
+ error: resolved.error,
254
+ because: "preferred operator label did not match this owner on-chain",
255
+ try: [
256
+ "clanker whoami --address 0x…",
257
+ "clanker setup --force # fix label/owner",
258
+ ],
259
+ });
245
260
  }
246
261
  selected = [resolved.operator];
247
262
  } else {
@@ -252,12 +267,14 @@ async function cmdWhoami(argv) {
252
267
  });
253
268
  }
254
269
 
255
- for (const op of selected) {
256
- op.bots = await findBotsByOperator(pub, {
257
- registry: network.registry,
258
- operatorId: op.id,
259
- fromBlock: network.fromBlock,
260
- });
270
+ if (withBots) {
271
+ for (const op of selected) {
272
+ op.bots = await findBotsByOperator(pub, {
273
+ registry: network.registry,
274
+ operatorId: op.id,
275
+ fromBlock: network.fromBlock,
276
+ });
277
+ }
261
278
  }
262
279
 
263
280
  const data = {
@@ -273,18 +290,26 @@ async function cmdWhoami(argv) {
273
290
  active: o.active,
274
291
  registeredAt: o.registeredAt.toString(),
275
292
  revokedAt: o.revokedAt.toString(),
276
- bots: (o.bots ?? []).map((b) => ({
277
- label: b.label,
278
- botKey: b.botKey,
279
- active: b.active,
280
- registeredAt: b.registeredAt.toString(),
281
- revokedAt: b.revokedAt.toString(),
282
- })),
293
+ bots: withBots
294
+ ? (o.bots ?? []).map((b) => ({
295
+ label: b.label,
296
+ botKey: b.botKey,
297
+ active: b.active,
298
+ registeredAt: b.registeredAt.toString(),
299
+ revokedAt: b.revokedAt.toString(),
300
+ }))
301
+ : [],
283
302
  })),
284
303
  };
285
304
 
286
305
  if (hasFlag(argv, "--json")) printJson(data);
287
- else printHumanWhoami(data);
306
+ else {
307
+ printHumanWhoami(data);
308
+ if (!withBots && data.operators.length) {
309
+ console.log(c.dim("(bots omitted — pass --with-bots or run clanker bots)"));
310
+ }
311
+ nextHint(["clanker bots", "clanker doctor"]);
312
+ }
288
313
  }
289
314
 
290
315
  async function cmdBots(argv) {
@@ -362,12 +387,23 @@ async function main() {
362
387
  try {
363
388
  await runSetup(rest);
364
389
  } catch (err) {
365
- console.error(err.message);
366
- process.exit(1);
390
+ exitCliError({
391
+ error: err.message,
392
+ because: "setup could not write a valid local profile",
393
+ try: [
394
+ "clanker setup --preset sepolia --operator org.you --address 0x… --yes --force",
395
+ "clanker doctor",
396
+ ],
397
+ });
367
398
  }
368
399
  return;
369
400
  }
370
401
 
402
+ if (cmd === "doctor") {
403
+ const { exitCode } = await runDoctor(rest);
404
+ process.exit(exitCode);
405
+ }
406
+
371
407
  if (cmd === "init") {
372
408
  const preset = flagValue(rest, "--preset") ?? rest.find((a) => !a.startsWith("--"));
373
409
  if (!preset || preset === "init") {
@@ -414,13 +450,38 @@ async function main() {
414
450
  console.error("Usage: clanker operator mint <label>");
415
451
  process.exit(1);
416
452
  }
453
+ let planRows;
454
+ try {
455
+ const preview = resolveOperatorKey(flags);
456
+ planRows = [
457
+ ["action", "registerOperator"],
458
+ ["label", label],
459
+ ["owner", preview.address],
460
+ ["rpc", preview.network.rpc],
461
+ ["registry", preview.network.registry ?? "(none)"],
462
+ ["key", preview.source],
463
+ ];
464
+ } catch (err) {
465
+ exitCliError({
466
+ error: err.message,
467
+ because: "operator mint needs a signing key on this network",
468
+ try: [
469
+ "export OPERATOR_PRIVATE_KEY=0x…",
470
+ "clanker operator mint " + label + " --key-file ~/.clanker/op.key --yes",
471
+ "clanker doctor",
472
+ ],
473
+ });
474
+ }
475
+ const ok = await confirmPlan(flags, planRows, `Mint operator ${label}?`);
476
+ if (!ok) process.exit(0);
417
477
  const result = await chainMintOperator(label, flags);
418
478
  if (hasFlag(flags, "--json") || hasFlag(opArgv, "--json")) printJson(result);
419
479
  else {
420
- console.log(`Minted operator ${label}`);
480
+ console.log(c.green(`Minted operator ${label}`));
421
481
  console.log(`owner: ${result.owner}`);
422
482
  console.log(`tx: ${result.tx}`);
423
483
  console.log(`Wrote ~/.clanker/operator.json`);
484
+ nextHint([`clanker bot mint <bot_label>`, "clanker whoami"]);
424
485
  }
425
486
  return;
426
487
  }
@@ -432,9 +493,23 @@ async function main() {
432
493
  console.error("Usage: clanker operator transfer propose <label> <newOwner>");
433
494
  process.exit(1);
434
495
  }
496
+ const ok = await confirmPlan(
497
+ flags,
498
+ [
499
+ ["action", "proposeOperatorTransfer"],
500
+ ["label", label],
501
+ ["newOwner", getAddress(newOwner)],
502
+ ],
503
+ `Propose transfer of ${label}?`,
504
+ );
505
+ if (!ok) process.exit(0);
435
506
  const result = await chainProposeOperatorTransfer(label, getAddress(newOwner), flags);
436
507
  if (hasFlag(flags, "--json")) printJson(result);
437
- else console.log(`Proposed transfer of ${label} → ${newOwner}\ntx: ${result.tx}`);
508
+ else {
509
+ console.log(c.green(`Proposed transfer of ${label} → ${newOwner}`));
510
+ console.log(`tx: ${result.tx}`);
511
+ nextHint([`clanker operator transfer accept ${label} # as new owner`]);
512
+ }
438
513
  return;
439
514
  }
440
515
  if (action === "accept") {
@@ -442,9 +517,23 @@ async function main() {
442
517
  console.error("Usage: clanker operator transfer accept <label>");
443
518
  process.exit(1);
444
519
  }
520
+ const ok = await confirmPlan(
521
+ flags,
522
+ [
523
+ ["action", "acceptOperatorTransfer"],
524
+ ["label", label],
525
+ ],
526
+ `Accept transfer of ${label}?`,
527
+ );
528
+ if (!ok) process.exit(0);
445
529
  const result = await chainAcceptOperatorTransfer(label, flags);
446
530
  if (hasFlag(flags, "--json")) printJson(result);
447
- else console.log(`Accepted transfer of ${label}\nowner: ${result.owner}\ntx: ${result.tx}`);
531
+ else {
532
+ console.log(c.green(`Accepted transfer of ${label}`));
533
+ console.log(`owner: ${result.owner}`);
534
+ console.log(`tx: ${result.tx}`);
535
+ nextHint(["clanker whoami", "clanker bots"]);
536
+ }
448
537
  return;
449
538
  }
450
539
  console.error("Usage: clanker operator transfer propose|accept ...");
@@ -484,20 +573,45 @@ async function main() {
484
573
  }
485
574
  let operatorLabel = operatorArg ?? flagValue(flags, "--operator") ?? null;
486
575
  if (!operatorLabel) {
487
- const { address, network } = resolveOperatorKey(flags);
488
- const inferred = await resolveOperatorLabel(flags, address, network);
489
- operatorLabel = inferred.label;
576
+ try {
577
+ const { address, network } = resolveOperatorKey(flags);
578
+ const inferred = await resolveOperatorLabel(flags, address, network);
579
+ operatorLabel = inferred.label;
580
+ } catch (err) {
581
+ exitCliError({
582
+ error: err.message,
583
+ because: "bot mint needs an operator label or a resolvable signing key",
584
+ try: [
585
+ `clanker bot mint ${botLabel} <operator_label> --yes`,
586
+ "clanker setup",
587
+ ],
588
+ });
589
+ }
490
590
  }
591
+ const ok = await confirmPlan(
592
+ flags,
593
+ [
594
+ ["action", "registerBot"],
595
+ ["bot", botLabel],
596
+ ["operator", operatorLabel],
597
+ ],
598
+ `Mint bot ${botLabel} under ${operatorLabel}?`,
599
+ );
600
+ if (!ok) process.exit(0);
491
601
  const result = await chainMintBot(botLabel, operatorLabel, flags);
492
602
  if (hasFlag(flags, "--json")) printJson(result);
493
603
  else {
494
- console.log(`Minted bot ${botLabel} under ${operatorLabel}`);
604
+ console.log(c.green(`Minted bot ${botLabel} under ${operatorLabel}`));
495
605
  console.log(`botKey: ${result.bot_key}`);
496
606
  console.log(`key file: ${result.key_path}`);
497
607
  console.log(`also: ${result.clanker_key_path}`);
498
608
  console.log(`tx: ${result.tx}`);
499
609
  console.log("\nchannels.mqtt stub:");
500
610
  console.log(JSON.stringify(result.channels_mqtt, null, 2));
611
+ nextHint([
612
+ "Paste channels.mqtt into openclaw.json",
613
+ "openclaw plugins install @clanker-chain/mqtt-channel-plugin@…",
614
+ ]);
501
615
  }
502
616
  return;
503
617
  }
@@ -508,9 +622,22 @@ async function main() {
508
622
  console.error("Usage: clanker bot revoke <label>");
509
623
  process.exit(1);
510
624
  }
625
+ const ok = await confirmPlan(
626
+ flags,
627
+ [
628
+ ["action", "revokeBot"],
629
+ ["bot", botLabel],
630
+ ],
631
+ `Revoke bot ${botLabel}?`,
632
+ );
633
+ if (!ok) process.exit(0);
511
634
  const result = await chainRevokeBot(botLabel, flags);
512
635
  if (hasFlag(flags, "--json")) printJson(result);
513
- else console.log(`Revoked ${botLabel}\ntx: ${result.tx}`);
636
+ else {
637
+ console.log(c.yellow(`Revoked ${botLabel}`));
638
+ console.log(`tx: ${result.tx}`);
639
+ nextHint(["clanker bots", "clanker bot status " + botLabel]);
640
+ }
514
641
  return;
515
642
  }
516
643
 
@@ -520,9 +647,23 @@ async function main() {
520
647
  console.error("Usage: clanker bot rotate <label> <newKeyAddress>");
521
648
  process.exit(1);
522
649
  }
650
+ const ok = await confirmPlan(
651
+ flags,
652
+ [
653
+ ["action", "rotateBotKey"],
654
+ ["bot", botLabel],
655
+ ["newKey", getAddress(newKey)],
656
+ ],
657
+ `Rotate key for ${botLabel}?`,
658
+ );
659
+ if (!ok) process.exit(0);
523
660
  const result = await chainRotateBotKey(botLabel, getAddress(newKey), flags);
524
661
  if (hasFlag(flags, "--json")) printJson(result);
525
- else console.log(`Rotated ${botLabel} → ${newKey}\ntx: ${result.tx}`);
662
+ else {
663
+ console.log(c.green(`Rotated ${botLabel} → ${newKey}`));
664
+ console.log(`tx: ${result.tx}`);
665
+ nextHint(["Update ~/.openclaw/keys/" + botLabel + ".key", "clanker bot status " + botLabel]);
666
+ }
526
667
  return;
527
668
  }
528
669
 
package/lib/doctor.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `clanker doctor` — local readiness checks (mise-doctor habit).
3
+ */
4
+
5
+ import { getAddress } from "viem";
6
+ import {
7
+ ANVIL_DEFAULT_ADDRESS,
8
+ isLocalRpc,
9
+ loadConfig,
10
+ loadOperator,
11
+ clankerHome,
12
+ } from "./profile.mjs";
13
+ import { detectSetupHints, formatSetupDetectTable } from "./setup-detect.mjs";
14
+ import { c, nextHint } from "./ui.mjs";
15
+
16
+ /**
17
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv, openclawDir?: string, castBin?: string, spawn?: Function }} [opts]
18
+ */
19
+ export function runDoctorChecks(opts = {}) {
20
+ const env = opts.env ?? process.env;
21
+ const home = opts.home ?? clankerHome(env);
22
+ const hints = detectSetupHints({
23
+ home,
24
+ env,
25
+ openclawDir: opts.openclawDir,
26
+ castBin: opts.castBin,
27
+ spawn: opts.spawn,
28
+ });
29
+ const config = hints.config ?? loadConfig(home);
30
+ const operator = hints.operator ?? loadOperator(home);
31
+
32
+ /** @type {{ id: string, ok: boolean, level: 'pass'|'warn'|'fail', message: string }[]} */
33
+ const checks = [];
34
+
35
+ checks.push({
36
+ id: "config",
37
+ ok: Boolean(config),
38
+ level: config ? "pass" : "fail",
39
+ message: config
40
+ ? `config.json present (preset=${config.preset})`
41
+ : "config.json missing — run clanker setup",
42
+ });
43
+
44
+ const registry = config?.registryAddress;
45
+ const rpc = config?.chainRpcUrl ?? "";
46
+ checks.push({
47
+ id: "registry",
48
+ ok: Boolean(registry && /^0x[0-9a-fA-F]{40}$/.test(registry)),
49
+ level: registry && /^0x[0-9a-fA-F]{40}$/.test(registry) ? "pass" : "fail",
50
+ message:
51
+ registry && /^0x[0-9a-fA-F]{40}$/.test(registry)
52
+ ? `registry ${registry}`
53
+ : "registry missing — run clanker setup --preset sepolia (or set after local deploy)",
54
+ });
55
+
56
+ const hasOwner =
57
+ Boolean(operator?.owner) && /^0x[0-9a-fA-F]{40}$/.test(operator.owner);
58
+ const hasLabel = Boolean(operator?.label);
59
+ checks.push({
60
+ id: "operator",
61
+ ok: hasOwner && hasLabel,
62
+ level: hasOwner && hasLabel ? "pass" : "fail",
63
+ message:
64
+ hasOwner && hasLabel
65
+ ? `operator.json ${operator.label} · ${operator.owner}`
66
+ : "operator.json incomplete — run clanker setup",
67
+ });
68
+
69
+ if (hasOwner && rpc && !isLocalRpc(rpc)) {
70
+ const anvil =
71
+ getAddress(operator.owner).toLowerCase() ===
72
+ ANVIL_DEFAULT_ADDRESS.toLowerCase();
73
+ checks.push({
74
+ id: "anvil_public",
75
+ ok: !anvil,
76
+ level: anvil ? "fail" : "pass",
77
+ message: anvil
78
+ ? "owner is Anvil #0 on a public RPC — refuse for mutate; fix owner address"
79
+ : "owner is not Anvil #0",
80
+ });
81
+ }
82
+
83
+ const hasKey =
84
+ Boolean(operator?.key?.type === "keyFile" && operator.key.value) ||
85
+ Boolean(operator?.key?.type === "env" && operator.key.value) ||
86
+ Boolean(env.OPERATOR_PRIVATE_KEY);
87
+ checks.push({
88
+ id: "signing",
89
+ ok: true,
90
+ level: hasKey ? "pass" : "warn",
91
+ message: hasKey
92
+ ? "signing key pointer available (mint/revoke OK)"
93
+ : "read-only profile — whoami/bots OK; mint needs --key-file or OPERATOR_PRIVATE_KEY",
94
+ });
95
+
96
+ checks.push({
97
+ id: "foundry",
98
+ ok: true,
99
+ level: hints.foundryAvailable ? "pass" : "warn",
100
+ message: hints.foundryAvailable
101
+ ? `Foundry cast OK (${hints.foundryAccounts.length} account(s))`
102
+ : "Foundry cast not on PATH (optional)",
103
+ });
104
+
105
+ const readyWhoami = checks
106
+ .filter((ch) => ch.id === "config" || ch.id === "registry" || ch.id === "operator")
107
+ .every((ch) => ch.ok);
108
+ const readyMint = readyWhoami && hasKey &&
109
+ !checks.some((ch) => ch.id === "anvil_public" && !ch.ok);
110
+
111
+ return {
112
+ home,
113
+ hints,
114
+ checks,
115
+ readyWhoami,
116
+ readyMint,
117
+ ok: readyWhoami,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * @param {string[]} argv
123
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv }} [opts]
124
+ * @returns {Promise<{ exitCode: number, report: object }>}
125
+ */
126
+ export async function runDoctor(argv = [], opts = {}) {
127
+ const json = argv.includes("--json");
128
+ const report = runDoctorChecks(opts);
129
+
130
+ if (json) {
131
+ console.log(
132
+ JSON.stringify(
133
+ {
134
+ ok: report.ok,
135
+ readyWhoami: report.readyWhoami,
136
+ readyMint: report.readyMint,
137
+ home: report.home,
138
+ checks: report.checks,
139
+ },
140
+ null,
141
+ 2,
142
+ ),
143
+ );
144
+ return { exitCode: report.ok ? 0 : 1, report };
145
+ }
146
+
147
+ console.log(c.bold("clanker doctor"));
148
+ console.log("");
149
+ console.log(formatSetupDetectTable(report.hints));
150
+ console.log("");
151
+ console.log(c.bold("Checks"));
152
+ for (const ch of report.checks) {
153
+ const mark =
154
+ ch.level === "pass"
155
+ ? c.green("pass")
156
+ : ch.level === "warn"
157
+ ? c.yellow("warn")
158
+ : c.red("fail");
159
+ console.log(` [${mark}] ${ch.message}`);
160
+ }
161
+ console.log("");
162
+ if (report.readyWhoami) {
163
+ console.log(c.green("Ready for: clanker whoami"));
164
+ } else {
165
+ console.log(c.red("Not ready for whoami"));
166
+ }
167
+ if (report.readyMint) {
168
+ console.log(c.green("Ready for: clanker operator mint / bot mint"));
169
+ } else {
170
+ console.log(c.dim("Mint/revoke: need signing key (and non-Anvil owner on public RPC)"));
171
+ }
172
+
173
+ if (!report.readyWhoami) {
174
+ nextHint(["clanker setup"]);
175
+ } else if (!report.readyMint) {
176
+ nextHint([
177
+ "clanker whoami",
178
+ "clanker setup --key-file ~/.clanker/op.key --force # to enable mint",
179
+ ]);
180
+ } else {
181
+ nextHint(["clanker whoami", "clanker bots"]);
182
+ }
183
+
184
+ return { exitCode: report.ok ? 0 : 1, report };
185
+ }
package/lib/profile.mjs CHANGED
@@ -17,11 +17,14 @@ export const ANVIL_DEFAULT_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
17
17
  export const SEPOLIA_REGISTRY = "0xD650467f9D7A20f37E55ec23Ca1c711598f97958";
18
18
 
19
19
  /**
20
- * Safe floor for Sepolia getLogs (before registry deploy). Documented so scans
21
- * stay cheap; bump only if you redeploy the registry earlier.
20
+ * Safe floor for Sepolia getLogs (before registry deploy). Use for full historical
21
+ * scans via --from-block; default preset uses the faster public-RPC floor below.
22
22
  */
23
23
  export const SEPOLIA_FROM_BLOCK = 35_000_000n;
24
24
 
25
+ /** Default Sepolia fromBlock for public RPC (faster whoami/bots scans). */
26
+ export const SEPOLIA_FAST_FROM_BLOCK = 46_000_000n;
27
+
25
28
  export const PRESETS = {
26
29
  local: {
27
30
  preset: "local",
@@ -37,7 +40,7 @@ export const PRESETS = {
37
40
  chainRpcUrl: "https://sepolia.base.org",
38
41
  brokerUrl: "mqtts://mqtt.clanker-chain.com:8883",
39
42
  mqttAuthServiceUrl: "https://mqtt-auth.clanker-chain.com",
40
- fromBlock: SEPOLIA_FROM_BLOCK,
43
+ fromBlock: SEPOLIA_FAST_FROM_BLOCK,
41
44
  },
42
45
  };
43
46
 
@@ -187,7 +190,7 @@ export function resolveNetwork(argv = [], opts = {}) {
187
190
  }
188
191
 
189
192
  if (fromBlock == null) {
190
- fromBlock = isLocalRpc(rpc) ? 0n : SEPOLIA_FROM_BLOCK;
193
+ fromBlock = isLocalRpc(rpc) ? 0n : SEPOLIA_FAST_FROM_BLOCK;
191
194
  }
192
195
 
193
196
  return {
@@ -12,11 +12,11 @@ import {
12
12
  loadConfig,
13
13
  loadOperator,
14
14
  openclawKeysDir,
15
+ SEPOLIA_FAST_FROM_BLOCK,
15
16
  } from "./profile.mjs";
16
17
  import { normalizePrivateKey } from "./resolve.mjs";
17
18
 
18
- /** Faster Sepolia log floor for public RPC (registry floor remains SEPOLIA_FROM_BLOCK). */
19
- export const SEPOLIA_FAST_FROM_BLOCK = 46_000_000n;
19
+ export { SEPOLIA_FAST_FROM_BLOCK };
20
20
 
21
21
  /**
22
22
  * Parse `cast wallet list` stdout into account names.
package/lib/setup.mjs CHANGED
@@ -2,13 +2,14 @@
2
2
  * Interactive / flag-driven `clanker setup`.
3
3
  */
4
4
 
5
- import { createInterface } from "node:readline/promises";
6
- import { stdin as input, stdout as output } from "node:process";
5
+ import { stdin as input } from "node:process";
7
6
  import { existsSync } from "node:fs";
8
7
  import { getAddress } from "viem";
8
+ import * as clack from "@clack/prompts";
9
9
  import {
10
10
  ANVIL_DEFAULT_ADDRESS,
11
11
  PRESETS,
12
+ SEPOLIA_FAST_FROM_BLOCK,
12
13
  clankerHome,
13
14
  configPath,
14
15
  initProfile,
@@ -22,12 +23,12 @@ import {
22
23
  resolvePreferredOperator,
23
24
  } from "./identity-query.mjs";
24
25
  import {
25
- SEPOLIA_FAST_FROM_BLOCK,
26
26
  addressFromEnv,
27
27
  addressFromKeyFile,
28
28
  detectSetupHints,
29
29
  formatSetupDetectTable,
30
30
  } from "./setup-detect.mjs";
31
+ import { c, nextHint } from "./ui.mjs";
31
32
 
32
33
  /**
33
34
  * @param {string[]} argv
@@ -72,6 +73,14 @@ export function parseSetupFlags(argv) {
72
73
  };
73
74
  }
74
75
 
76
+ function cancelIf(value) {
77
+ if (clack.isCancel(value)) {
78
+ clack.cancel("Setup aborted.");
79
+ process.exit(0);
80
+ }
81
+ return value;
82
+ }
83
+
75
84
  /**
76
85
  * Validate address + label against registry (and Anvil-on-public).
77
86
  * @param {{ rpc: string, registry: string, label: string, address: string, skipChainCheck?: boolean }} opts
@@ -101,7 +110,6 @@ export async function assertSetupIdentity(opts) {
101
110
  const op = await readOperator(pub, opts.registry, opts.label);
102
111
 
103
112
  if (op.registeredAt === 0n) {
104
- // New operator — fine; mint later
105
113
  return { address, operator: op, skipped: false, unregistered: true };
106
114
  }
107
115
 
@@ -117,14 +125,12 @@ export async function assertSetupIdentity(opts) {
117
125
  }
118
126
 
119
127
  /**
120
- * Build key pointer from flags / choices.
121
128
  * @returns {{ type: 'env'|'keyFile', value: string }|null}
122
129
  */
123
130
  export function buildKeyPointer({ keyFile, keyEnv, skipKey, env = process.env }) {
124
131
  if (skipKey) return null;
125
132
  if (keyFile) {
126
133
  if (!existsSync(keyFile)) throw new Error(`Key file not found: ${keyFile}`);
127
- // validate readable
128
134
  addressFromKeyFile(keyFile);
129
135
  return { type: "keyFile", value: keyFile };
130
136
  }
@@ -176,8 +182,6 @@ export function applySetup(choices, opts = {}) {
176
182
 
177
183
  /**
178
184
  * Non-interactive setup (agents / CI).
179
- * @param {string[]} argv
180
- * @param {{ home?: string, env?: NodeJS.ProcessEnv, skipChainCheck?: boolean }} [opts]
181
185
  */
182
186
  export async function runSetupNonInteractive(argv, opts = {}) {
183
187
  const flags = parseSetupFlags(argv);
@@ -227,7 +231,6 @@ export async function runSetupNonInteractive(argv, opts = {}) {
227
231
  env,
228
232
  });
229
233
 
230
- // Address from key must match --address when both set
231
234
  if (flags.keyFile && flags.address) {
232
235
  const fromKey = getAddress(addressFromKeyFile(flags.keyFile));
233
236
  if (fromKey !== getAddress(flags.address)) {
@@ -252,7 +255,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
252
255
  }
253
256
 
254
257
  /**
255
- * Interactive wizard when stdin is a TTY.
258
+ * Interactive wizard (Clack) when stdin is a TTY.
256
259
  */
257
260
  export async function runSetupInteractive(argv, opts = {}) {
258
261
  const flags = parseSetupFlags(argv);
@@ -266,237 +269,249 @@ export async function runSetupInteractive(argv, opts = {}) {
266
269
  openclawDir: opts.openclawDir,
267
270
  });
268
271
 
269
- const rl =
270
- opts.rl ??
271
- createInterface({ input, output });
272
- const ask = async (q, def) => {
273
- const suffix = def != null && def !== "" ? ` [${def}]` : "";
274
- const ans = (await rl.question(`${q}${suffix}: `)).trim();
275
- return ans || def || "";
276
- };
277
- const askYesNo = async (q, defaultYes = true) => {
278
- const def = defaultYes ? "Y/n" : "y/N";
279
- const ans = (await rl.question(`${q} (${def}): `)).trim().toLowerCase();
280
- if (!ans) return defaultYes;
281
- return ans === "y" || ans === "yes";
282
- };
283
-
284
- try {
285
- console.log("");
286
- console.log("clanker setup — create your local operator profile");
287
- console.log("");
288
- console.log(
289
- "This writes ~/.clanker/config.json (network) and operator.json (who you are).",
290
- );
291
- console.log(
292
- "Reads like `clanker whoami` can use the owner address without a private key;",
293
- );
294
- console.log("mint/revoke still need a key file or OPERATOR_PRIVATE_KEY later.");
295
- console.log("");
296
- console.log(`Profile directory: ${home}`);
297
- console.log("");
298
- console.log(formatSetupDetectTable(hints));
299
- console.log("");
300
-
301
- if ((hints.hasConfig || hints.hasOperator) && !flags.force) {
302
- if (hints.hasConfig && !hints.hasOperator) {
303
- console.log(
304
- "You already have network settings from `clanker init`, but no operator identity.",
305
- );
306
- console.log(
307
- "Continuing will refresh config.json if needed and create operator.json.",
308
- );
309
- const cont = await askYesNo("Continue setup?", true);
310
- if (!cont) {
311
- throw new Error("Aborted. Re-run `clanker setup` when ready.");
312
- }
313
- flags.force = true;
314
- } else {
315
- console.log(
316
- "A full profile already exists. Continuing will REPLACE config.json and/or operator.json",
317
- );
318
- console.log("with the answers you give next (same files, new contents).");
319
- const overwrite = await askYesNo("Replace existing profile files?", false);
320
- if (!overwrite) {
321
- throw new Error(
322
- "Aborted. Pass --force to replace, or edit ~/.clanker/*.json by hand.",
323
- );
324
- }
325
- flags.force = true;
272
+ clack.intro(c.bold("clanker setup"));
273
+ clack.log.step("Creates ~/.clanker/config.json (network) and operator.json (identity).");
274
+ clack.log.message(
275
+ c.dim("whoami works from owner without a key; mint/revoke need a key pointer later."),
276
+ );
277
+ clack.log.message(c.dim(`Profile: ${home}`));
278
+ console.log("");
279
+ console.log(c.dim(formatSetupDetectTable(hints)));
280
+ console.log("");
281
+
282
+ if ((hints.hasConfig || hints.hasOperator) && !flags.force) {
283
+ if (hints.hasConfig && !hints.hasOperator) {
284
+ const cont = cancelIf(
285
+ await clack.confirm({
286
+ message: "Network config exists; continue to create operator.json?",
287
+ initialValue: true,
288
+ }),
289
+ );
290
+ if (!cont) {
291
+ clack.cancel("Aborted.");
292
+ process.exit(0);
326
293
  }
327
- console.log("");
328
- }
329
-
330
- console.log("— Network —");
331
- let preset =
332
- flags.preset ||
333
- hints.config?.preset ||
334
- (await ask("Which network? sepolia (public hub) or local (Anvil)", "sepolia"));
335
- preset = String(preset).toLowerCase();
336
- if (!PRESETS[preset]) throw new Error(`Unknown preset "${preset}"`);
337
-
338
- let fromBlock = flags.fromBlock;
339
- if (preset === "sepolia") {
340
- const useFast =
341
- fromBlock != null
342
- ? false
343
- : await askYesNo(
344
- `Speed up chain scans? Use fromBlock ${SEPOLIA_FAST_FROM_BLOCK} (recommended on public RPC)`,
345
- true,
346
- );
347
- if (fromBlock == null) {
348
- fromBlock = useFast ? SEPOLIA_FAST_FROM_BLOCK : PRESETS.sepolia.fromBlock;
294
+ flags.force = true;
295
+ } else {
296
+ const overwrite = cancelIf(
297
+ await clack.confirm({
298
+ message: "Replace existing config.json / operator.json?",
299
+ initialValue: false,
300
+ }),
301
+ );
302
+ if (!overwrite) {
303
+ clack.cancel("Aborted. Pass --force to replace.");
304
+ process.exit(0);
349
305
  }
306
+ flags.force = true;
350
307
  }
308
+ }
351
309
 
352
- console.log("");
353
- console.log("— Operator identity —");
354
- console.log(
355
- "We need the wallet address that owns (or will own) your on-chain operator label.",
310
+ let preset = flags.preset || hints.config?.preset || null;
311
+ if (!preset) {
312
+ preset = cancelIf(
313
+ await clack.select({
314
+ message: "Network",
315
+ options: [
316
+ { value: "sepolia", label: "sepolia", hint: "public closed-beta hub" },
317
+ { value: "local", label: "local", hint: "Anvil" },
318
+ ],
319
+ initialValue: "sepolia",
320
+ }),
356
321
  );
357
- let address = flags.address ?? null;
358
- if (!address && flags.keyFile) {
359
- address = addressFromKeyFile(flags.keyFile);
360
- console.log(`Using address from --key-file: ${address}`);
361
- }
362
- if (!address && hints.envAddress) {
363
- const useEnv = await askYesNo(
364
- `Use address from OPERATOR_PRIVATE_KEY (${hints.envAddress})?`,
365
- true,
366
- );
367
- if (useEnv) address = hints.envAddress;
368
- }
369
- if (!address && hints.operator?.owner) {
370
- const useOp = await askYesNo(
371
- `Keep existing operator.json owner (${hints.operator.owner})?`,
372
- true,
322
+ }
323
+ preset = String(preset).toLowerCase();
324
+ if (!PRESETS[preset]) throw new Error(`Unknown preset "${preset}"`);
325
+
326
+ // Fast Sepolia default — no interactive fromBlock question
327
+ let fromBlock = flags.fromBlock;
328
+ if (fromBlock == null && preset === "sepolia") {
329
+ fromBlock = SEPOLIA_FAST_FROM_BLOCK;
330
+ }
331
+
332
+ let address = flags.address ?? null;
333
+ if (!address && flags.keyFile) {
334
+ address = addressFromKeyFile(flags.keyFile);
335
+ clack.log.info(`Address from --key-file: ${address}`);
336
+ }
337
+ if (!address && hints.envAddress) {
338
+ const useEnv = cancelIf(
339
+ await clack.confirm({
340
+ message: `Use OPERATOR_PRIVATE_KEY address (${hints.envAddress})?`,
341
+ initialValue: true,
342
+ }),
343
+ );
344
+ if (useEnv) address = hints.envAddress;
345
+ }
346
+ if (!address && hints.operator?.owner) {
347
+ const useOp = cancelIf(
348
+ await clack.confirm({
349
+ message: `Keep existing owner (${hints.operator.owner})?`,
350
+ initialValue: true,
351
+ }),
352
+ );
353
+ if (useOp) address = hints.operator.owner;
354
+ }
355
+ if (!address && hints.foundryAccounts.length) {
356
+ const options = [
357
+ ...hints.foundryAccounts.map((n) => ({
358
+ value: n,
359
+ label: n,
360
+ hint: "Foundry keystore — paste 0x next",
361
+ })),
362
+ { value: "__paste__", label: "Paste an address…", hint: "0x…" },
363
+ ];
364
+ const pick = cancelIf(
365
+ await clack.select({
366
+ message: "Operator owner source",
367
+ options,
368
+ initialValue: hints.foundryAccounts[0],
369
+ }),
370
+ );
371
+ if (pick === "__paste__") {
372
+ address = cancelIf(
373
+ await clack.text({
374
+ message: "Operator owner address",
375
+ placeholder: "0x…",
376
+ validate: (v) =>
377
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
378
+ }),
373
379
  );
374
- if (useOp) address = hints.operator.owner;
375
- }
376
- if (!address && hints.foundryAccounts.length) {
377
- console.log("");
378
- console.log("Foundry accounts on this machine (passwords are never stored here):");
379
- hints.foundryAccounts.forEach((n, i) => console.log(` ${i + 1}. ${n}`));
380
- const pick = await ask(
381
- "Type a Foundry account name to use, or leave blank to paste an address",
382
- hints.foundryAccounts[0] ?? "",
380
+ } else {
381
+ address = cancelIf(
382
+ await clack.text({
383
+ message: `Paste 0x address for Foundry account "${pick}"`,
384
+ placeholder: "0x…",
385
+ validate: (v) =>
386
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
387
+ }),
383
388
  );
384
- if (pick) {
385
- address = await ask(
386
- `Paste the 0x address for "${pick}" (cast wallet address ${pick} after unlock)`,
387
- "",
388
- );
389
- }
390
- }
391
- if (!address) {
392
- address = await ask("Operator owner address (0x…)", "");
393
- }
394
- if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) {
395
- throw new Error("A valid 0x operator address is required");
396
389
  }
397
- address = getAddress(address);
398
-
399
- let label =
400
- flags.operator ||
401
- hints.operator?.label ||
402
- (await ask("Operator label on-chain (e.g. org.openclaw.pat or org.you)", ""));
403
- if (!label) throw new Error("Operator label is required");
404
-
405
- const presetCfg = PRESETS[preset];
406
- console.log("");
407
- console.log("— Chain check —");
408
- console.log(`Looking up "${label}" on ${preset}…`);
409
- const check = await assertSetupIdentity({
390
+ }
391
+ if (!address) {
392
+ address = cancelIf(
393
+ await clack.text({
394
+ message: "Operator owner address",
395
+ placeholder: "0x…",
396
+ validate: (v) =>
397
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
398
+ }),
399
+ );
400
+ }
401
+ address = getAddress(address);
402
+
403
+ let label = flags.operator || hints.operator?.label || null;
404
+ if (!label) {
405
+ label = cancelIf(
406
+ await clack.text({
407
+ message: "Operator label",
408
+ placeholder: "org.openclaw.pat",
409
+ validate: (v) => (v && v.trim() ? undefined : "Label required"),
410
+ }),
411
+ );
412
+ }
413
+
414
+ const presetCfg = PRESETS[preset];
415
+ const spin = clack.spinner();
416
+ spin.start(`Looking up "${label}" on ${preset}`);
417
+ let check;
418
+ try {
419
+ check = await assertSetupIdentity({
410
420
  rpc: presetCfg.chainRpcUrl,
411
421
  registry: presetCfg.registryAddress,
412
422
  label,
413
423
  address,
414
424
  skipChainCheck: flags.skipChainCheck || !presetCfg.registryAddress,
415
425
  });
416
- if (check.unregistered) {
417
- console.log(
418
- `OK: "${label}" is not registered yet — after setup, run: clanker operator mint ${label}`,
426
+ spin.stop(
427
+ check.unregistered
428
+ ? c.yellow(`"${label}" not registered yet — mint after setup`)
429
+ : check.skipped
430
+ ? "Chain check skipped"
431
+ : c.green(`"${label}" active and owned by this address`),
432
+ );
433
+ } catch (err) {
434
+ spin.stop(c.red("Chain check failed"));
435
+ throw err;
436
+ }
437
+
438
+ let keyFile = flags.keyFile;
439
+ let keyEnv = flags.keyEnv;
440
+ let skipKey = flags.skipKey;
441
+ if (!skipKey && !keyFile && !keyEnv) {
442
+ if (hints.hasOperatorPrivateKeyEnv) {
443
+ const use = cancelIf(
444
+ await clack.confirm({
445
+ message: "Store OPERATOR_PRIVATE_KEY pointer for signing?",
446
+ initialValue: true,
447
+ }),
419
448
  );
420
- } else if (check.operator) {
421
- console.log(`OK: "${label}" is active on-chain and owned by this address.`);
449
+ if (use) keyEnv = "OPERATOR_PRIVATE_KEY";
450
+ else skipKey = true;
451
+ } else {
452
+ const path = cancelIf(
453
+ await clack.text({
454
+ message: "Operator key file path (Enter = read-only)",
455
+ placeholder: "~/.clanker/op.key",
456
+ }),
457
+ );
458
+ if (path && String(path).trim()) keyFile = String(path).trim();
459
+ else skipKey = true;
422
460
  }
461
+ }
423
462
 
424
- console.log("");
425
- console.log("— Signing key (optional) ");
426
- console.log(
427
- "Only needed for mint/revoke/transfer. Skip for read-only whoami/bots.",
428
- );
429
- let keyFile = flags.keyFile;
430
- let keyEnv = flags.keyEnv;
431
- let skipKey = flags.skipKey;
432
- if (!skipKey && !keyFile && !keyEnv) {
433
- if (hints.hasOperatorPrivateKeyEnv) {
434
- const use = await askYesNo(
435
- "Remember OPERATOR_PRIVATE_KEY as the signing pointer in operator.json?",
436
- true,
437
- );
438
- if (use) keyEnv = "OPERATOR_PRIVATE_KEY";
439
- else skipKey = true;
440
- } else {
441
- const path = await ask(
442
- "Path to operator private-key file (blank = read-only profile)",
443
- "",
444
- );
445
- if (path) keyFile = path;
446
- else skipKey = true;
447
- }
463
+ const key = buildKeyPointer({ keyFile, keyEnv, skipKey, env });
464
+ if (key?.type === "keyFile") {
465
+ const fromKey = getAddress(addressFromKeyFile(key.value));
466
+ if (fromKey !== address) {
467
+ throw new Error(`Key file address ${fromKey} does not match chosen owner ${address}`);
448
468
  }
469
+ }
449
470
 
450
- const key = buildKeyPointer({ keyFile, keyEnv, skipKey, env });
451
- if (key?.type === "keyFile") {
452
- const fromKey = getAddress(addressFromKeyFile(key.value));
453
- if (fromKey !== address) {
454
- throw new Error(`Key file address ${fromKey} does not match chosen owner ${address}`);
455
- }
456
- }
471
+ clack.note(
472
+ [
473
+ `network: ${preset}`,
474
+ `fromBlock: ${fromBlock ?? presetCfg.fromBlock}`,
475
+ `label: ${label}`,
476
+ `owner: ${address}`,
477
+ `signing: ${key ? `${key.type}=${key.value}` : "none (read-only)"}`,
478
+ ].join("\n"),
479
+ "Summary",
480
+ );
457
481
 
458
- console.log("");
459
- console.log("— Summary (about to write) —");
460
- console.log(` network: ${preset}`);
461
- console.log(` fromBlock:${fromBlock ?? presetCfg.fromBlock}`);
462
- console.log(` label: ${label}`);
463
- console.log(` owner: ${address}`);
464
- console.log(
465
- ` signing: ${key ? `${key.type}=${key.value}` : "none (read-only whoami/bots)"}`,
466
- );
467
- const ok = flags.yes || (await askYesNo("Write these files now?", true));
468
- if (!ok) throw new Error("Aborted");
469
-
470
- const result = applySetup(
471
- {
472
- preset,
473
- force: true,
474
- fromBlock: fromBlock ?? undefined,
475
- registryAddress: presetCfg.registryAddress,
476
- label,
477
- address,
478
- key,
479
- },
480
- { home, env },
482
+ if (!flags.yes) {
483
+ const ok = cancelIf(
484
+ await clack.confirm({ message: "Write these files?", initialValue: true }),
481
485
  );
482
-
483
- console.log("");
484
- console.log(`Wrote ${result.configPath}`);
485
- console.log(`Wrote ${result.operatorPath}`);
486
- if (!key) {
487
- console.log("");
488
- console.log("Next: clanker whoami");
489
- console.log(
490
- "For mint/revoke later: re-run setup with a key file, or pass --key-file / OPERATOR_PRIVATE_KEY.",
491
- );
492
- } else {
493
- console.log("");
494
- console.log("Next: clanker whoami");
486
+ if (!ok) {
487
+ clack.cancel("Aborted.");
488
+ process.exit(0);
495
489
  }
496
- return result;
497
- } finally {
498
- if (!opts.rl) rl.close();
499
490
  }
491
+
492
+ const result = applySetup(
493
+ {
494
+ preset,
495
+ force: true,
496
+ fromBlock: fromBlock ?? undefined,
497
+ registryAddress: presetCfg.registryAddress,
498
+ label,
499
+ address,
500
+ key,
501
+ },
502
+ { home, env },
503
+ );
504
+
505
+ clack.outro(c.green(`Wrote ${result.configPath}\nWrote ${result.operatorPath}`));
506
+ if (!key) {
507
+ nextHint([
508
+ "clanker whoami",
509
+ "clanker setup --key-file ~/.clanker/op.key --force # when you need mint",
510
+ ]);
511
+ } else {
512
+ nextHint(["clanker whoami", `clanker bot mint <label>`]);
513
+ }
514
+ return result;
500
515
  }
501
516
 
502
517
  /**
@@ -506,7 +521,6 @@ export async function runSetup(argv, opts = {}) {
506
521
  const flags = parseSetupFlags(argv);
507
522
  const isTTY = opts.isTTY ?? Boolean(input.isTTY);
508
523
 
509
- // Fully flagged non-interactive path
510
524
  if (
511
525
  !isTTY ||
512
526
  (flags.yes && flags.preset && flags.operator && (flags.address || flags.keyFile))
@@ -514,7 +528,15 @@ export async function runSetup(argv, opts = {}) {
514
528
  if (!isTTY && !(flags.preset && flags.operator)) {
515
529
  return runSetupNonInteractive(argv, opts);
516
530
  }
517
- if (flags.yes && flags.preset && flags.operator && (flags.address || flags.keyFile || opts.env?.OPERATOR_PRIVATE_KEY || process.env.OPERATOR_PRIVATE_KEY)) {
531
+ if (
532
+ flags.yes &&
533
+ flags.preset &&
534
+ flags.operator &&
535
+ (flags.address ||
536
+ flags.keyFile ||
537
+ opts.env?.OPERATOR_PRIVATE_KEY ||
538
+ process.env.OPERATOR_PRIVATE_KEY)
539
+ ) {
518
540
  return runSetupNonInteractive(argv, opts);
519
541
  }
520
542
  }
package/lib/ui.mjs ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Shared human-path CLI UX (color, plans, next hints, structured errors).
3
+ * Respects NO_COLOR / non-TTY via picocolors.
4
+ */
5
+
6
+ import pc from "picocolors";
7
+ import * as clack from "@clack/prompts";
8
+
9
+ export const c = {
10
+ dim: (s) => pc.dim(String(s)),
11
+ green: (s) => pc.green(String(s)),
12
+ yellow: (s) => pc.yellow(String(s)),
13
+ red: (s) => pc.red(String(s)),
14
+ bold: (s) => pc.bold(String(s)),
15
+ cyan: (s) => pc.cyan(String(s)),
16
+ };
17
+
18
+ /**
19
+ * @param {string[]} argv
20
+ * @param {{ stdinTTY?: boolean }} [opts]
21
+ */
22
+ export function isInteractive(argv = [], opts = {}) {
23
+ const tty = opts.stdinTTY ?? Boolean(process.stdin.isTTY);
24
+ if (!tty) return false;
25
+ if (argv.includes("--yes") || argv.includes("-y")) return false;
26
+ if (argv.includes("--json")) return false;
27
+ return true;
28
+ }
29
+
30
+ /**
31
+ * @param {string|string[]} lines
32
+ */
33
+ export function nextHint(lines) {
34
+ const list = Array.isArray(lines) ? lines : [lines];
35
+ console.log("");
36
+ console.log(c.bold("Next:"));
37
+ for (const line of list) {
38
+ console.log(` ${c.cyan(line)}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Cargo-style human error.
44
+ * @param {{ error: string, because?: string, try?: string[] }} opts
45
+ * @returns {string}
46
+ */
47
+ export function formatCliError(opts) {
48
+ const lines = [c.red(`error: ${opts.error}`)];
49
+ if (opts.because) {
50
+ lines.push(c.dim(`because: ${opts.because}`));
51
+ }
52
+ if (opts.try?.length) {
53
+ lines.push(c.yellow("try:"));
54
+ for (const t of opts.try) {
55
+ lines.push(` ${t}`);
56
+ }
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+
61
+ /**
62
+ * Print a plan table (key/value rows).
63
+ * @param {[string, string][]} rows
64
+ * @param {string} [title]
65
+ */
66
+ export function printPlan(rows, title = "Plan") {
67
+ console.log("");
68
+ console.log(c.bold(title));
69
+ const w = Math.min(18, Math.max(4, ...rows.map(([k]) => k.length)));
70
+ for (const [k, v] of rows) {
71
+ const pad = k + " ".repeat(Math.max(0, w - k.length));
72
+ console.log(` ${c.dim(pad)} ${v}`);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Confirm a mutating plan. Skips when not interactive.
78
+ * @param {string[]} argv
79
+ * @param {[string, string][]} rows
80
+ * @param {string} [message]
81
+ * @returns {Promise<boolean>}
82
+ */
83
+ export async function confirmPlan(argv, rows, message = "Proceed?") {
84
+ printPlan(rows);
85
+ if (!isInteractive(argv)) return true;
86
+ const ok = await clack.confirm({
87
+ message,
88
+ initialValue: true,
89
+ });
90
+ if (clack.isCancel(ok) || ok === false) {
91
+ clack.cancel("Aborted.");
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Print structured error to stderr and exit.
99
+ * @param {{ error: string, because?: string, try?: string[], status?: number }} opts
100
+ */
101
+ export function exitCliError(opts) {
102
+ console.error(formatCliError(opts));
103
+ process.exit(opts.status ?? 1);
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clanker-chain/clanker-cli",
3
- "version": "2026.9.7-3",
3
+ "version": "2026.9.7-4",
4
4
  "description": "CLI for wiring clanker-chain identity and MQTT into OpenClaw and other agentic stacks.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,8 @@
23
23
  "mint-bot:sepolia": "bash ./scripts/with-sepolia-env.sh mint-bot"
24
24
  },
25
25
  "dependencies": {
26
+ "@clack/prompts": "^1.8.0",
27
+ "picocolors": "^1.1.1",
26
28
  "viem": "^2.21.0"
27
29
  }
28
30
  }