@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/lib/pair.mjs ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Operator pairing client (Policy layer — docs/trust-model.md).
3
+ * Signs with the operator owner key; never the bot key.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { privateKeyToAccount } from "viem/accounts";
10
+ import { loadOperator } from "./profile.mjs";
11
+ import { openclawConfigPath } from "./openclaw-wire.mjs";
12
+ import { resolveOperatorKey } from "./resolve.mjs";
13
+
14
+ /**
15
+ * @param {string} baseUrl
16
+ * @param {{ operatorId: string, action: 'add'|'remove'|'list', peerLabel?: string }} opts
17
+ */
18
+ export async function fetchPairNonce(baseUrl, opts) {
19
+ const url = new URL("/pair-nonce", baseUrl.replace(/\/$/, ""));
20
+ url.searchParams.set("operator_id", opts.operatorId);
21
+ url.searchParams.set("action", opts.action);
22
+ if (opts.peerLabel) url.searchParams.set("peer_label", opts.peerLabel);
23
+ const res = await fetch(url.toString());
24
+ const text = await res.text();
25
+ let body;
26
+ try {
27
+ body = JSON.parse(text);
28
+ } catch {
29
+ throw new Error(`pair-nonce: unexpected response: ${text}`);
30
+ }
31
+ if (!res.ok || !body.nonce || !body.message) {
32
+ throw new Error(body.error || `pair-nonce failed: HTTP ${res.status}`);
33
+ }
34
+ return /** @type {{ nonce: string, message: string, expires_at?: string }} */ (body);
35
+ }
36
+
37
+ /**
38
+ * @param {string} baseUrl
39
+ * @param {{
40
+ * operatorId: string,
41
+ * peerLabel: string,
42
+ * action: 'add'|'remove',
43
+ * nonce: string,
44
+ * signature: `0x${string}`,
45
+ * }} opts
46
+ */
47
+ export async function postPair(baseUrl, opts) {
48
+ const res = await fetch(new URL("/pair", baseUrl.replace(/\/$/, "")).toString(), {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body: JSON.stringify({
52
+ operator_id: opts.operatorId,
53
+ peer_label: opts.peerLabel,
54
+ action: opts.action,
55
+ nonce: opts.nonce,
56
+ signature: opts.signature,
57
+ }),
58
+ });
59
+ const text = await res.text();
60
+ let body;
61
+ try {
62
+ body = JSON.parse(text);
63
+ } catch {
64
+ throw new Error(`pair: unexpected response: ${text}`);
65
+ }
66
+ if (!res.ok) {
67
+ throw new Error(body.error || `pair failed: HTTP ${res.status}`);
68
+ }
69
+ return body;
70
+ }
71
+
72
+ /**
73
+ * @param {string} baseUrl
74
+ * @param {{ operatorId: string, nonce: string, signature: `0x${string}` }} opts
75
+ */
76
+ export async function getPairList(baseUrl, opts) {
77
+ const url = new URL("/pair", baseUrl.replace(/\/$/, ""));
78
+ url.searchParams.set("operator_id", opts.operatorId);
79
+ url.searchParams.set("nonce", opts.nonce);
80
+ url.searchParams.set("signature", opts.signature);
81
+ const res = await fetch(url.toString());
82
+ const text = await res.text();
83
+ let body;
84
+ try {
85
+ body = JSON.parse(text);
86
+ } catch {
87
+ throw new Error(`pair list: unexpected response: ${text}`);
88
+ }
89
+ if (!res.ok) {
90
+ throw new Error(body.error || `pair list failed: HTTP ${res.status}`);
91
+ }
92
+ return body;
93
+ }
94
+
95
+ /**
96
+ * Merge allowOperators + dmPolicy into ~/.openclaw/openclaw.json channels.mqtt.
97
+ * @param {{ peerLabel: string, action: 'add'|'remove', openclawHome?: string }} opts
98
+ */
99
+ export function syncAllowOperators(opts) {
100
+ const openclawHome = opts.openclawHome ?? join(homedir(), ".openclaw");
101
+ const cfgPath = openclawConfigPath(openclawHome);
102
+ if (!existsSync(cfgPath)) {
103
+ return { synced: false, path: cfgPath, reason: "missing_openclaw_json" };
104
+ }
105
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
106
+ const channels = { ...(cfg.channels ?? {}) };
107
+ const mqtt = { ...(channels.mqtt && typeof channels.mqtt === "object" ? channels.mqtt : {}) };
108
+ const prev = Array.isArray(mqtt.allowOperators)
109
+ ? mqtt.allowOperators.filter((x) => typeof x === "string")
110
+ : [];
111
+ let next;
112
+ if (opts.action === "add") {
113
+ next = prev.includes(opts.peerLabel) ? prev : [...prev, opts.peerLabel];
114
+ } else {
115
+ next = prev.filter((x) => x !== opts.peerLabel);
116
+ }
117
+ mqtt.allowOperators = next;
118
+ if (!mqtt.dmPolicy) mqtt.dmPolicy = "pairing";
119
+ channels.mqtt = mqtt;
120
+ cfg.channels = channels;
121
+ mkdirSync(openclawHome, { recursive: true });
122
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
123
+ return { synced: true, path: cfgPath, allowOperators: next };
124
+ }
125
+
126
+ /**
127
+ * Resolve mqtt-auth base URL from network / env / flag.
128
+ * @param {string[]} argv
129
+ * @param {{ mqttAuthServiceUrl?: string|null }} network
130
+ */
131
+ export function resolvePairAuthUrl(argv, network) {
132
+ for (let i = 0; i < argv.length; i += 1) {
133
+ if (argv[i] === "--auth-url" && argv[i + 1]) return argv[i + 1];
134
+ }
135
+ if (process.env.MQTT_AUTH_SERVICE_URL) return process.env.MQTT_AUTH_SERVICE_URL;
136
+ if (network.mqttAuthServiceUrl) return network.mqttAuthServiceUrl;
137
+ throw new Error(
138
+ "mqttAuthServiceUrl required for pairing. Set profile via clanker setup, " +
139
+ "pass --auth-url, or set MQTT_AUTH_SERVICE_URL.",
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Optional OpenClaw home for allowOperators sync (directory containing openclaw.json).
145
+ * @param {string[]} argv
146
+ * @returns {string|undefined}
147
+ */
148
+ export function resolveOpenclawHome(argv) {
149
+ for (let i = 0; i < argv.length; i += 1) {
150
+ if (argv[i] === "--openclaw-home" && argv[i + 1]) return argv[i + 1];
151
+ }
152
+ if (process.env.OPENCLAW_HOME) return process.env.OPENCLAW_HOME;
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * @param {string[]} argv
158
+ * @param {'add'|'remove'|'list'|'status'} action
159
+ * @param {string|null} peerLabel
160
+ */
161
+ export async function runPairAction(argv, action, peerLabel) {
162
+ const resolved = resolveOperatorKey(argv, { requireRegistry: true });
163
+ const operator =
164
+ loadOperator(resolved.network.home) ??
165
+ (resolved.network.operatorLabel
166
+ ? { label: resolved.network.operatorLabel }
167
+ : null);
168
+ const operatorId =
169
+ argv.includes("--operator") && argv[argv.indexOf("--operator") + 1]
170
+ ? argv[argv.indexOf("--operator") + 1]
171
+ : operator?.label;
172
+ if (!operatorId) {
173
+ throw new Error(
174
+ "Operator label required. Run clanker setup / write operator.json, or pass --operator org.you.",
175
+ );
176
+ }
177
+
178
+ const authUrl = resolvePairAuthUrl(argv, resolved.network);
179
+ const account = privateKeyToAccount(resolved.key);
180
+
181
+ if (action === "list" || action === "status") {
182
+ const { nonce, message } = await fetchPairNonce(authUrl, {
183
+ operatorId,
184
+ action: "list",
185
+ });
186
+ const signature = await account.signMessage({ message });
187
+ const listed = await getPairList(authUrl, {
188
+ operatorId,
189
+ nonce,
190
+ signature,
191
+ });
192
+ if (action === "status") {
193
+ if (!peerLabel) throw new Error("Usage: clanker pair status <operator-label>");
194
+ // Resolve peer by scanning allows — mutual flag is per peer_operator_id;
195
+ // we match by requesting add-style peer label via a second pair-nonce is heavy.
196
+ // Instead: check if any allow entry is mutual when peer label hashes match… we
197
+ // only have peer_operator_id hex. Re-fetch is unnecessary: call add-path status
198
+ // by computing whether peer is in allow list via a dedicated pair status using
199
+ // the peer label through POST-less approach — GET list doesn't include labels.
200
+ // Best effort: show mutual if we can add then remove? No.
201
+ // Call /pair-nonce + post with action that doesn't mutate? Use list + ask user.
202
+ // Simpler: after list, for status we POST nothing — check if peer's keccak is in list.
203
+ // We need peer id: keccak of peerLabel.
204
+ const { keccak256, toBytes } = await import("viem");
205
+ const peerId = keccak256(toBytes(peerLabel)).toLowerCase();
206
+ const hit = (listed.allows ?? []).find(
207
+ (a) => String(a.peer_operator_id).toLowerCase() === peerId,
208
+ );
209
+ return {
210
+ action: "status",
211
+ operatorId,
212
+ peerLabel,
213
+ allowed: Boolean(hit),
214
+ mutual: hit?.mutual === true,
215
+ authUrl,
216
+ listed,
217
+ };
218
+ }
219
+ return { action: "list", operatorId, authUrl, listed };
220
+ }
221
+
222
+ if (!peerLabel) {
223
+ throw new Error(`Usage: clanker pair ${action} <operator-label>`);
224
+ }
225
+
226
+ const { nonce, message } = await fetchPairNonce(authUrl, {
227
+ operatorId,
228
+ action,
229
+ peerLabel,
230
+ });
231
+ const signature = await account.signMessage({ message });
232
+ const result = await postPair(authUrl, {
233
+ operatorId,
234
+ peerLabel,
235
+ action,
236
+ nonce,
237
+ signature,
238
+ });
239
+ const sync = syncAllowOperators({
240
+ peerLabel,
241
+ action,
242
+ openclawHome: resolveOpenclawHome(argv),
243
+ });
244
+ return {
245
+ action,
246
+ operatorId,
247
+ peerLabel,
248
+ authUrl,
249
+ result,
250
+ openclawSync: sync,
251
+ };
252
+ }
package/lib/setup.mjs CHANGED
@@ -33,6 +33,10 @@ import {
33
33
  exportFoundryKey,
34
34
  resolveFoundryAddress,
35
35
  } from "./foundry.mjs";
36
+ import {
37
+ consumerFundHints,
38
+ generateOperatorKeyFile,
39
+ } from "./operator-key.mjs";
36
40
  import { c, nextHint } from "./ui.mjs";
37
41
  import { join } from "node:path";
38
42
 
@@ -52,6 +56,7 @@ export function parseSetupFlags(argv) {
52
56
  let skipChainCheck = false;
53
57
  let foundryAccount = null;
54
58
  let exportKey = false;
59
+ let generateKey = false;
55
60
 
56
61
  for (let i = 0; i < argv.length; i += 1) {
57
62
  const a = argv[i];
@@ -63,6 +68,7 @@ export function parseSetupFlags(argv) {
63
68
  else if (a === "--from-block" && argv[i + 1]) fromBlock = BigInt(argv[++i]);
64
69
  else if (a === "--foundry-account" && argv[i + 1]) foundryAccount = argv[++i];
65
70
  else if (a === "--export-key") exportKey = true;
71
+ else if (a === "--generate-key") generateKey = true;
66
72
  else if (a === "--force") force = true;
67
73
  else if (a === "--yes" || a === "-y") yes = true;
68
74
  else if (a === "--skip-key") skipKey = true;
@@ -82,6 +88,7 @@ export function parseSetupFlags(argv) {
82
88
  skipChainCheck,
83
89
  foundryAccount,
84
90
  exportKey,
91
+ generateKey,
85
92
  };
86
93
  }
87
94
 
@@ -203,7 +210,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
203
210
  if (!flags.preset || !PRESETS[flags.preset]) {
204
211
  throw new Error(
205
212
  "Non-interactive setup requires --preset sepolia|local (stdin is not a TTY). " +
206
- "Also pass --operator <label> and --address 0x…",
213
+ "Also pass --operator <label> and --generate-key (or --address / --key-file)",
207
214
  );
208
215
  }
209
216
  if (!flags.operator) {
@@ -218,6 +225,23 @@ export async function runSetupNonInteractive(argv, opts = {}) {
218
225
  inheritStdio: false,
219
226
  };
220
227
 
228
+ if (flags.generateKey) {
229
+ if (flags.skipKey) {
230
+ throw new Error("Cannot combine --generate-key with --skip-key");
231
+ }
232
+ const dest = keyFile || defaultOperatorKeyPath(home);
233
+ const created = generateOperatorKeyFile(dest, {
234
+ force: flags.force,
235
+ });
236
+ keyFile = created.path;
237
+ if (address && getAddress(address) !== getAddress(created.address)) {
238
+ throw new Error(
239
+ `Generated key address ${created.address} does not match --address ${getAddress(address)}`,
240
+ );
241
+ }
242
+ address = created.address;
243
+ }
244
+
221
245
  if (flags.foundryAccount) {
222
246
  if (!address) {
223
247
  address = resolveFoundryAddress(flags.foundryAccount, castOpts);
@@ -238,7 +262,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
238
262
  if (!address && env.OPERATOR_PRIVATE_KEY) address = addressFromEnv(env);
239
263
  if (!address) {
240
264
  throw new Error(
241
- "Non-interactive setup requires --address 0x…, --key-file, OPERATOR_PRIVATE_KEY, or --foundry-account",
265
+ "Non-interactive setup requires --generate-key, --address 0x…, --key-file, OPERATOR_PRIVATE_KEY, or --foundry-account",
242
266
  );
243
267
  }
244
268
 
@@ -305,9 +329,13 @@ export async function runSetupInteractive(argv, opts = {}) {
305
329
  });
306
330
 
307
331
  clack.intro(c.bold("clanker setup"));
308
- clack.log.step("Creates ~/.clanker/config.json (network) and operator.json (identity).");
332
+ clack.log.step(
333
+ "Sets up your operator identity (org account) and network for OpenClaw bots.",
334
+ );
309
335
  clack.log.message(
310
- c.dim("whoami works from owner without a key; mint/revoke need a key pointer later."),
336
+ c.dim(
337
+ "New here? Create a key file — no wallet app or Foundry required. Mint needs a little test ETH later.",
338
+ ),
311
339
  );
312
340
  clack.log.message(c.dim(`Profile: ${home}`));
313
341
  console.log("");
@@ -366,6 +394,28 @@ export async function runSetupInteractive(argv, opts = {}) {
366
394
 
367
395
  let address = flags.address ?? null;
368
396
  let foundryAccountUsed = null;
397
+ let generatedKeyFile = null;
398
+ if (!address && flags.generateKey) {
399
+ const dest = flags.keyFile || defaultOperatorKeyPath(home);
400
+ let forceGen = flags.force;
401
+ if (existsSync(dest) && !forceGen) {
402
+ forceGen = cancelIf(
403
+ await clack.confirm({
404
+ message: `Overwrite existing ${dest}?`,
405
+ initialValue: false,
406
+ }),
407
+ );
408
+ if (!forceGen) {
409
+ clack.cancel("Aborted.");
410
+ process.exit(0);
411
+ }
412
+ }
413
+ const created = generateOperatorKeyFile(dest, { force: true });
414
+ generatedKeyFile = created.path;
415
+ address = created.address;
416
+ clack.log.success(`Created operator key at ${created.path}`);
417
+ clack.log.info(`Your operator address: ${created.address}`);
418
+ }
369
419
  if (!address && flags.keyFile) {
370
420
  address = addressFromKeyFile(flags.keyFile);
371
421
  clack.log.info(`Address from --key-file: ${address}`);
@@ -388,23 +438,95 @@ export async function runSetupInteractive(argv, opts = {}) {
388
438
  );
389
439
  if (useOp) address = hints.operator.owner;
390
440
  }
391
- if (!address && hints.foundryAccounts.length) {
441
+ if (!address) {
392
442
  const options = [
393
- ...hints.foundryAccounts.map((n) => ({
394
- value: n,
395
- label: n,
396
- hint: "resolve via cast wallet address",
397
- })),
398
- { value: "__paste__", label: "Paste an address…", hint: "0x…" },
443
+ {
444
+ value: "__generate__",
445
+ label: "Create a new operator key for me",
446
+ hint: "writes ~/.clanker/op.key (recommended)",
447
+ },
448
+ {
449
+ value: "__keyfile__",
450
+ label: "Use an existing key file…",
451
+ hint: "path to a 0x private key file",
452
+ },
399
453
  ];
454
+ if (hints.foundryAccounts.length) {
455
+ for (const n of hints.foundryAccounts) {
456
+ options.push({
457
+ value: n,
458
+ label: `Foundry: ${n}`,
459
+ hint: "advanced — cast wallet",
460
+ });
461
+ }
462
+ } else {
463
+ options.push({
464
+ value: "__foundry_missing__",
465
+ label: "Foundry account…",
466
+ hint: "advanced — install Foundry first",
467
+ });
468
+ }
469
+ options.push({
470
+ value: "__paste__",
471
+ label: "Paste an address…",
472
+ hint: "read-only unless you add a key later",
473
+ });
474
+
400
475
  const pick = cancelIf(
401
476
  await clack.select({
402
- message: "Operator owner source",
477
+ message: "How do you want to set your operator identity?",
403
478
  options,
404
- initialValue: hints.foundryAccounts[0],
479
+ initialValue: "__generate__",
405
480
  }),
406
481
  );
407
- if (pick === "__paste__") {
482
+
483
+ if (pick === "__generate__") {
484
+ const dest = defaultOperatorKeyPath(home);
485
+ let forceGen = flags.force;
486
+ if (existsSync(dest) && !forceGen) {
487
+ forceGen = cancelIf(
488
+ await clack.confirm({
489
+ message: `Overwrite existing ${dest}?`,
490
+ initialValue: false,
491
+ }),
492
+ );
493
+ if (!forceGen) {
494
+ clack.cancel("Aborted.");
495
+ process.exit(0);
496
+ }
497
+ }
498
+ const created = generateOperatorKeyFile(dest, { force: true });
499
+ generatedKeyFile = created.path;
500
+ address = created.address;
501
+ clack.log.success(`Created operator key at ${created.path}`);
502
+ clack.log.info(`Your operator address: ${created.address}`);
503
+ } else if (pick === "__keyfile__") {
504
+ const path = cancelIf(
505
+ await clack.text({
506
+ message: "Path to operator key file",
507
+ placeholder: join(home, "op.key"),
508
+ validate: (v) =>
509
+ v && String(v).trim() && existsSync(String(v).trim())
510
+ ? undefined
511
+ : "File not found",
512
+ }),
513
+ );
514
+ generatedKeyFile = String(path).trim();
515
+ address = addressFromKeyFile(generatedKeyFile);
516
+ clack.log.info(`Address from key file: ${address}`);
517
+ } else if (pick === "__foundry_missing__") {
518
+ clack.log.warn(
519
+ "Foundry (cast) is not available. Install https://book.getfoundry.sh/ or choose Create a new operator key.",
520
+ );
521
+ address = cancelIf(
522
+ await clack.text({
523
+ message: "Operator owner address",
524
+ placeholder: "0x…",
525
+ validate: (v) =>
526
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
527
+ }),
528
+ );
529
+ } else if (pick === "__paste__") {
408
530
  address = cancelIf(
409
531
  await clack.text({
410
532
  message: "Operator owner address",
@@ -436,16 +558,6 @@ export async function runSetupInteractive(argv, opts = {}) {
436
558
  }
437
559
  }
438
560
  }
439
- if (!address) {
440
- address = cancelIf(
441
- await clack.text({
442
- message: "Operator owner address",
443
- placeholder: "0x…",
444
- validate: (v) =>
445
- /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
446
- }),
447
- );
448
- }
449
561
  address = getAddress(address);
450
562
 
451
563
  let label = flags.operator || hints.operator?.label || null;
@@ -483,7 +595,7 @@ export async function runSetupInteractive(argv, opts = {}) {
483
595
  throw err;
484
596
  }
485
597
 
486
- let keyFile = flags.keyFile;
598
+ let keyFile = flags.keyFile || generatedKeyFile;
487
599
  let keyEnv = flags.keyEnv;
488
600
  let skipKey = flags.skipKey;
489
601
  if (!skipKey && !keyFile && !keyEnv) {
@@ -579,7 +691,16 @@ export async function runSetupInteractive(argv, opts = {}) {
579
691
  if (!key) {
580
692
  nextHint([
581
693
  "clanker whoami",
582
- "clanker setup --key-file ~/.clanker/op.key --force # when you need mint",
694
+ "clanker setup choose Create a new operator key when you need mint",
695
+ ]);
696
+ } else if (preset === "sepolia" && generatedKeyFile) {
697
+ nextHint(consumerFundHints({ address, label }));
698
+ } else if (preset === "sepolia") {
699
+ nextHint([
700
+ "clanker fund",
701
+ "clanker doctor",
702
+ "clanker whoami",
703
+ `clanker bot mint <label>`,
583
704
  ]);
584
705
  } else {
585
706
  nextHint(["clanker whoami", `clanker bot mint <label>`]);
@@ -593,23 +714,20 @@ export async function runSetupInteractive(argv, opts = {}) {
593
714
  export async function runSetup(argv, opts = {}) {
594
715
  const flags = parseSetupFlags(argv);
595
716
  const isTTY = opts.isTTY ?? Boolean(input.isTTY);
717
+ const hasIdentitySource = Boolean(
718
+ flags.address ||
719
+ flags.keyFile ||
720
+ flags.generateKey ||
721
+ flags.foundryAccount ||
722
+ opts.env?.OPERATOR_PRIVATE_KEY ||
723
+ process.env.OPERATOR_PRIVATE_KEY,
724
+ );
596
725
 
597
- if (
598
- !isTTY ||
599
- (flags.yes && flags.preset && flags.operator && (flags.address || flags.keyFile))
600
- ) {
726
+ if (!isTTY || (flags.yes && flags.preset && flags.operator && hasIdentitySource)) {
601
727
  if (!isTTY && !(flags.preset && flags.operator)) {
602
728
  return runSetupNonInteractive(argv, opts);
603
729
  }
604
- if (
605
- flags.yes &&
606
- flags.preset &&
607
- flags.operator &&
608
- (flags.address ||
609
- flags.keyFile ||
610
- opts.env?.OPERATOR_PRIVATE_KEY ||
611
- process.env.OPERATOR_PRIVATE_KEY)
612
- ) {
730
+ if (flags.yes && flags.preset && flags.operator && hasIdentitySource) {
613
731
  return runSetupNonInteractive(argv, opts);
614
732
  }
615
733
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clanker-chain/clanker-cli",
3
- "version": "2026.9.8",
3
+ "version": "2026.9.12",
4
4
  "description": "CLI for wiring clanker-chain identity and MQTT into OpenClaw and other agentic stacks.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,9 @@
25
25
  "dependencies": {
26
26
  "@clack/prompts": "^1.8.0",
27
27
  "picocolors": "^1.1.1",
28
- "viem": "^2.21.0"
28
+ "viem": "^2.56.3"
29
+ },
30
+ "overrides": {
31
+ "ws": "^8.21.0"
29
32
  }
30
33
  }