@clanker-chain/clanker-cli 2026.9.8 → 2026.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -26,6 +27,8 @@ import { resolveForRead, resolveOperatorKey, resolveReadIdentity } from "../lib/
26
27
  import { runSetup } from "../lib/setup.mjs";
27
28
  import { runDoctor } from "../lib/doctor.mjs";
28
29
  import {
30
+ botIdentityCard,
31
+ botIdentityJson,
29
32
  hubConnectChecklist,
30
33
  wireOpenClawMqtt,
31
34
  } from "../lib/openclaw-wire.mjs";
@@ -95,8 +98,15 @@ function runScript(scriptPath, args = []) {
95
98
  }
96
99
 
97
100
  function findRepoRoot() {
98
- const here = dirname(new URL(import.meta.url).pathname);
99
- return dirname(dirname(here));
101
+ let dir = dirname(fileURLToPath(import.meta.url));
102
+ for (let i = 0; i < 8; i++) {
103
+ if (existsSync(join(dir, "chain", "foundry.toml"))) return dir;
104
+ const parent = dirname(dir);
105
+ if (parent === dir) break;
106
+ dir = parent;
107
+ }
108
+ // npm install: package root (no monorepo checkout)
109
+ return dirname(dirname(fileURLToPath(import.meta.url)));
100
110
  }
101
111
 
102
112
  /** chain up/deploy and check * need the monorepo; npm installs only ship bin/ + lib/. */
@@ -167,6 +177,10 @@ Usage:
167
177
  clanker bot status <label> [--json]
168
178
  clanker bot revoke <label> [--json] [--yes]
169
179
  clanker bot rotate <label> <newKeyAddress> [--json] [--yes]
180
+ clanker pair add <operator-label> [--yes] [--auth-url URL] [--openclaw-home DIR]
181
+ clanker pair remove <operator-label> [--auth-url URL] [--openclaw-home DIR]
182
+ clanker pair list [--auth-url URL]
183
+ clanker pair status <operator-label> [--auth-url URL]
170
184
  clanker init-openclaw
171
185
  clanker chain up|deploy|mint-operator|mint-bot|rotate-bot-key|revoke-bot ...
172
186
  clanker check mqtt <bot_id> <operator_id>
@@ -178,10 +192,11 @@ Profile:
178
192
  ~/.clanker/keys/ bot keys (also written to ~/.openclaw/keys/)
179
193
 
180
194
  Humans: \`clanker setup\` then \`clanker doctor\` / \`whoami\`.
195
+ Pairing (Policy): both operators run \`clanker pair add\` before DMs deliver on the hub.
181
196
  Mutates print a plan and confirm unless --yes or --json.
182
197
  whoami is fast by default; pass --with-bots to enrich child bots (or use \`clanker bots\`).
183
198
 
184
- See docs/operator-cli.md.
199
+ See docs/operator-cli.md and docs/trust-model.md.
185
200
  `);
186
201
  }
187
202
 
@@ -439,6 +454,93 @@ async function main() {
439
454
  return;
440
455
  }
441
456
 
457
+ if (cmd === "pair") {
458
+ const [sub, ...pairRest] = rest;
459
+ if (!sub || !["add", "remove", "list", "status"].includes(sub)) {
460
+ console.error("Usage: clanker pair add|remove|list|status [operator-label]");
461
+ process.exit(1);
462
+ }
463
+
464
+ let peerLabel = null;
465
+ for (let i = 0; i < pairRest.length; i += 1) {
466
+ const a = pairRest[i];
467
+ if (a.startsWith("--")) {
468
+ if (a !== "--json" && a !== "--yes" && pairRest[i + 1] && !pairRest[i + 1].startsWith("--")) {
469
+ i += 1; // skip flag value
470
+ }
471
+ continue;
472
+ }
473
+ peerLabel = a;
474
+ break;
475
+ }
476
+
477
+ const { runPairAction } = await import("../lib/pair.mjs");
478
+ try {
479
+ if (sub === "add") {
480
+ const planRows = [
481
+ ["action", "pair add (Policy)"],
482
+ ["peer", peerLabel ?? "(missing)"],
483
+ ["note", "One-way allow; peer must pair you for bidirectional DMs"],
484
+ ];
485
+ const ok = await confirmPlan(pairRest, planRows, `Allow operator ${peerLabel}?`);
486
+ if (!ok) process.exit(0);
487
+ }
488
+ const out = await runPairAction(pairRest, sub, peerLabel);
489
+ if (hasFlag(pairRest, "--json")) {
490
+ console.log(JSON.stringify(out, null, 2));
491
+ } else if (sub === "list") {
492
+ console.log(`Operator: ${out.operatorId}`);
493
+ console.log(`Auth: ${out.authUrl}`);
494
+ const allows = out.listed?.allows ?? [];
495
+ if (!allows.length) {
496
+ console.log("(no allows)");
497
+ } else {
498
+ for (const a of allows) {
499
+ console.log(
500
+ ` ${a.peer_operator_id} ${a.mutual ? "mutual" : "pending (one-way)"}`,
501
+ );
502
+ }
503
+ }
504
+ } else if (sub === "status") {
505
+ console.log(`peer: ${out.peerLabel}`);
506
+ console.log(`allowed: ${out.allowed}`);
507
+ console.log(`mutual: ${out.mutual}`);
508
+ if (out.allowed && !out.mutual) {
509
+ console.log("Waiting for peer to run: clanker pair add <your-operator>");
510
+ }
511
+ } else {
512
+ console.log(
513
+ `${sub} ${out.peerLabel}: ${out.result?.mutual ? "mutual" : "one-way / pending"}`,
514
+ );
515
+ if (out.openclawSync?.synced) {
516
+ console.log(`Synced allowOperators → ${out.openclawSync.path}`);
517
+ console.log(
518
+ "Restart (or reload) the OpenClaw gateway so channels.mqtt.allowOperators is picked up.",
519
+ );
520
+ } else if (out.openclawSync?.reason === "missing_openclaw_json") {
521
+ console.log(
522
+ `Hub Policy updated, but no openclaw.json at ${out.openclawSync.path} — ` +
523
+ "client allowOperators was not synced. Create OpenClaw config or set allowOperators manually.",
524
+ );
525
+ }
526
+ if (sub === "add" && !out.result?.mutual) {
527
+ nextHint(`Ask ${out.peerLabel} to run: clanker pair add ${out.operatorId}`);
528
+ }
529
+ }
530
+ } catch (err) {
531
+ exitCliError({
532
+ error: err.message,
533
+ because: "pairing needs operator key + mqtt-auth /pair endpoints",
534
+ try: [
535
+ "clanker doctor",
536
+ "clanker pair add org.peer --auth-url http://127.0.0.1:9090 --yes",
537
+ "See docs/trust-model.md",
538
+ ],
539
+ });
540
+ }
541
+ return;
542
+ }
543
+
442
544
  if (cmd === "operator") {
443
545
  const [sub, ...opArgv] = rest;
444
546
  const {
@@ -615,18 +717,30 @@ async function main() {
615
717
  keyPath: result.key_path,
616
718
  });
617
719
  if (hasFlag(flags, "--json")) {
618
- printJson({ ...result, openclaw: wire });
720
+ printJson({
721
+ ...result,
722
+ openclaw: wire,
723
+ bot_identity: botIdentityJson({
724
+ botId: botLabel,
725
+ keyPath: result.key_path,
726
+ openclawConfigPath: wire.path,
727
+ }),
728
+ });
619
729
  } else {
620
730
  console.log(c.green(`Minted bot ${botLabel} under ${operatorLabel}`));
621
731
  console.log(`botKey: ${result.bot_key}`);
622
- console.log(`key file: ${result.key_path}`);
623
- console.log(`also: ${result.clanker_key_path}`);
624
732
  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
- );
733
+ console.log(c.dim(`also: ${result.clanker_key_path}`));
734
+ console.log("");
735
+ for (const line of botIdentityCard({
736
+ botId: botLabel,
737
+ operatorId: operatorLabel,
738
+ keyPath: result.key_path,
739
+ openclawPath: wire.path,
740
+ created: wire.created,
741
+ })) {
742
+ console.log(line);
743
+ }
630
744
  nextHint(
631
745
  hubConnectChecklist({
632
746
  botId: botLabel,
@@ -921,7 +1035,7 @@ async function main() {
921
1035
  nextHint([
922
1036
  "clanker bot mint <label>",
923
1037
  "Then channels.mqtt.botId / operatorId / privateKeyFile update automatically",
924
- "See docs/operator-cli.md and docs/closed-beta-invite.md",
1038
+ "See docs/operator-cli.md and docs/public-testnet-hub.md",
925
1039
  ]);
926
1040
  process.exit(0);
927
1041
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "viem";
14
14
  import { clankerIdentityAbi } from "./clanker-identity-abi.mjs";
15
15
 
16
- /** Match identity-service EVM_LOG_CHUNK_BLOCKSpublic RPCs reject wide ranges. */
16
+ /** Public RPC eth_getLogs chunk size wide ranges are often rejected. */
17
17
  export const LOG_CHUNK_BLOCKS = 2000n;
18
18
 
19
19
  export function labelToId(label) {
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import { harnessSnippet } from "./profile.mjs";
9
9
 
10
10
  /** Current published OpenClaw plugin pins for closed-beta invite. */
11
- export const OPENCLAW_PLUGIN_PIN = "2026.7.29";
11
+ export const OPENCLAW_PLUGIN_PIN = "2026.9.10";
12
12
 
13
13
  /**
14
14
  * @param {string} [openclawHome]
@@ -94,11 +94,52 @@ export function wireOpenClawMqtt(opts) {
94
94
  export function hubConnectChecklist(opts) {
95
95
  const pin = opts.pluginPin ?? OPENCLAW_PLUGIN_PIN;
96
96
  return [
97
+ "Your bot login is already wired — install plugins and restart OpenClaw.",
97
98
  `openclaw plugins install @clanker-chain/mqtt-channel-plugin@${pin}`,
98
99
  `openclaw plugins install @clanker-chain/mqtt-tools@${pin}`,
99
100
  "Enable plugin ids mqtt + mqtt-tools (already set in openclaw.json if we wired it)",
100
101
  `CONNECT broker: ${opts.channelsMqtt?.brokerUrl ?? "mqtts://mqtt.clanker-chain.com:8883"}`,
101
- `Ask hub operator to allow bot_id "${opts.botId}" in france dmPolicy / allowFrom`,
102
+ "Both operators: clanker pair add <peer-operator> (Policy required before DMs deliver)",
102
103
  "DM openclaw.france.prod-1 to smoke the mesh",
103
104
  ];
104
105
  }
106
+
107
+ /**
108
+ * Plain-language card: what the bot uses vs operator mint key.
109
+ * @param {{
110
+ * botId: string,
111
+ * operatorId: string,
112
+ * keyPath: string,
113
+ * openclawPath: string,
114
+ * created?: boolean,
115
+ * }} opts
116
+ * @returns {string[]}
117
+ */
118
+ export function botIdentityCard(opts) {
119
+ const openclawState = opts.created
120
+ ? `${opts.openclawPath} (channels.mqtt created)`
121
+ : `${opts.openclawPath} (channels.mqtt updated)`;
122
+ return [
123
+ "Bot identity (what OpenClaw uses to CONNECT):",
124
+ `bot id: ${opts.botId}`,
125
+ `operator: ${opts.operatorId}`,
126
+ `bot key: ${opts.keyPath}`,
127
+ `openclaw: ${openclawState}`,
128
+ "Do not give the bot ~/.clanker/op.key — that key is only for mint/transfer.",
129
+ ];
130
+ }
131
+
132
+ /**
133
+ * Structured bot_identity for --json mint output.
134
+ * @param {{ botId: string, keyPath: string, openclawConfigPath: string }} opts
135
+ */
136
+ export function botIdentityJson(opts) {
137
+ return {
138
+ botId: opts.botId,
139
+ keyPath: opts.keyPath,
140
+ openclawConfigPath: opts.openclawConfigPath,
141
+ warnOperatorKey:
142
+ "Do not give the bot ~/.clanker/op.key — that key is only for mint/transfer.",
143
+ };
144
+ }
145
+
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Consumer-path operator key helpers (generate local op.key).
3
+ * Never logs private keys.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
9
+ import { defaultOperatorKeyPath } from "./foundry.mjs";
10
+
11
+ export { defaultOperatorKeyPath };
12
+
13
+ /** Coinbase Developer Platform faucet UI (Base Sepolia). */
14
+ export const BASE_SEPOLIA_FAUCET_URL =
15
+ "https://portal.cdp.coinbase.com/products/faucet";
16
+
17
+ /**
18
+ * Generate a new secp256k1 key and write it to destPath (mode 0o600).
19
+ * Does not print the key.
20
+ *
21
+ * @param {string} destPath
22
+ * @param {{ force?: boolean }} [opts]
23
+ * @returns {{ path: string, address: string }}
24
+ */
25
+ export function generateOperatorKeyFile(destPath, opts = {}) {
26
+ if (existsSync(destPath) && !opts.force) {
27
+ throw new Error(
28
+ `Key file already exists: ${destPath} (pass --force to overwrite)`,
29
+ );
30
+ }
31
+ const key = generatePrivateKey();
32
+ const address = privateKeyToAccount(key).address;
33
+ mkdirSync(dirname(destPath), { recursive: true });
34
+ writeFileSync(destPath, `${key}\n`, { mode: 0o600 });
35
+ return { path: destPath, address };
36
+ }
37
+
38
+ /**
39
+ * Plain-language next steps after creating an operator key on Sepolia.
40
+ * @param {{ address: string, label?: string }} opts
41
+ */
42
+ export function consumerFundHints(opts) {
43
+ const mintLabel = opts.label ? ` ${opts.label}` : "";
44
+ return [
45
+ `Your operator address is ${opts.address} — fund it with Base Sepolia ETH: ${BASE_SEPOLIA_FAUCET_URL}`,
46
+ "clanker doctor",
47
+ `clanker operator mint${mintLabel} --yes`,
48
+ "clanker bot mint <bot_label> --yes",
49
+ ];
50
+ }
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,11 @@ import {
33
33
  exportFoundryKey,
34
34
  resolveFoundryAddress,
35
35
  } from "./foundry.mjs";
36
+ import {
37
+ BASE_SEPOLIA_FAUCET_URL,
38
+ consumerFundHints,
39
+ generateOperatorKeyFile,
40
+ } from "./operator-key.mjs";
36
41
  import { c, nextHint } from "./ui.mjs";
37
42
  import { join } from "node:path";
38
43
 
@@ -52,6 +57,7 @@ export function parseSetupFlags(argv) {
52
57
  let skipChainCheck = false;
53
58
  let foundryAccount = null;
54
59
  let exportKey = false;
60
+ let generateKey = false;
55
61
 
56
62
  for (let i = 0; i < argv.length; i += 1) {
57
63
  const a = argv[i];
@@ -63,6 +69,7 @@ export function parseSetupFlags(argv) {
63
69
  else if (a === "--from-block" && argv[i + 1]) fromBlock = BigInt(argv[++i]);
64
70
  else if (a === "--foundry-account" && argv[i + 1]) foundryAccount = argv[++i];
65
71
  else if (a === "--export-key") exportKey = true;
72
+ else if (a === "--generate-key") generateKey = true;
66
73
  else if (a === "--force") force = true;
67
74
  else if (a === "--yes" || a === "-y") yes = true;
68
75
  else if (a === "--skip-key") skipKey = true;
@@ -82,6 +89,7 @@ export function parseSetupFlags(argv) {
82
89
  skipChainCheck,
83
90
  foundryAccount,
84
91
  exportKey,
92
+ generateKey,
85
93
  };
86
94
  }
87
95
 
@@ -203,7 +211,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
203
211
  if (!flags.preset || !PRESETS[flags.preset]) {
204
212
  throw new Error(
205
213
  "Non-interactive setup requires --preset sepolia|local (stdin is not a TTY). " +
206
- "Also pass --operator <label> and --address 0x…",
214
+ "Also pass --operator <label> and --generate-key (or --address / --key-file)",
207
215
  );
208
216
  }
209
217
  if (!flags.operator) {
@@ -218,6 +226,23 @@ export async function runSetupNonInteractive(argv, opts = {}) {
218
226
  inheritStdio: false,
219
227
  };
220
228
 
229
+ if (flags.generateKey) {
230
+ if (flags.skipKey) {
231
+ throw new Error("Cannot combine --generate-key with --skip-key");
232
+ }
233
+ const dest = keyFile || defaultOperatorKeyPath(home);
234
+ const created = generateOperatorKeyFile(dest, {
235
+ force: flags.force,
236
+ });
237
+ keyFile = created.path;
238
+ if (address && getAddress(address) !== getAddress(created.address)) {
239
+ throw new Error(
240
+ `Generated key address ${created.address} does not match --address ${getAddress(address)}`,
241
+ );
242
+ }
243
+ address = created.address;
244
+ }
245
+
221
246
  if (flags.foundryAccount) {
222
247
  if (!address) {
223
248
  address = resolveFoundryAddress(flags.foundryAccount, castOpts);
@@ -238,7 +263,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
238
263
  if (!address && env.OPERATOR_PRIVATE_KEY) address = addressFromEnv(env);
239
264
  if (!address) {
240
265
  throw new Error(
241
- "Non-interactive setup requires --address 0x…, --key-file, OPERATOR_PRIVATE_KEY, or --foundry-account",
266
+ "Non-interactive setup requires --generate-key, --address 0x…, --key-file, OPERATOR_PRIVATE_KEY, or --foundry-account",
242
267
  );
243
268
  }
244
269
 
@@ -305,9 +330,13 @@ export async function runSetupInteractive(argv, opts = {}) {
305
330
  });
306
331
 
307
332
  clack.intro(c.bold("clanker setup"));
308
- clack.log.step("Creates ~/.clanker/config.json (network) and operator.json (identity).");
333
+ clack.log.step(
334
+ "Sets up your operator identity (org account) and network for OpenClaw bots.",
335
+ );
309
336
  clack.log.message(
310
- c.dim("whoami works from owner without a key; mint/revoke need a key pointer later."),
337
+ c.dim(
338
+ "New here? Create a key file — no wallet app or Foundry required. Mint needs a little test ETH later.",
339
+ ),
311
340
  );
312
341
  clack.log.message(c.dim(`Profile: ${home}`));
313
342
  console.log("");
@@ -366,6 +395,28 @@ export async function runSetupInteractive(argv, opts = {}) {
366
395
 
367
396
  let address = flags.address ?? null;
368
397
  let foundryAccountUsed = null;
398
+ let generatedKeyFile = null;
399
+ if (!address && flags.generateKey) {
400
+ const dest = flags.keyFile || defaultOperatorKeyPath(home);
401
+ let forceGen = flags.force;
402
+ if (existsSync(dest) && !forceGen) {
403
+ forceGen = cancelIf(
404
+ await clack.confirm({
405
+ message: `Overwrite existing ${dest}?`,
406
+ initialValue: false,
407
+ }),
408
+ );
409
+ if (!forceGen) {
410
+ clack.cancel("Aborted.");
411
+ process.exit(0);
412
+ }
413
+ }
414
+ const created = generateOperatorKeyFile(dest, { force: true });
415
+ generatedKeyFile = created.path;
416
+ address = created.address;
417
+ clack.log.success(`Created operator key at ${created.path}`);
418
+ clack.log.info(`Your operator address: ${created.address}`);
419
+ }
369
420
  if (!address && flags.keyFile) {
370
421
  address = addressFromKeyFile(flags.keyFile);
371
422
  clack.log.info(`Address from --key-file: ${address}`);
@@ -388,23 +439,95 @@ export async function runSetupInteractive(argv, opts = {}) {
388
439
  );
389
440
  if (useOp) address = hints.operator.owner;
390
441
  }
391
- if (!address && hints.foundryAccounts.length) {
442
+ if (!address) {
392
443
  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…" },
444
+ {
445
+ value: "__generate__",
446
+ label: "Create a new operator key for me",
447
+ hint: "writes ~/.clanker/op.key (recommended)",
448
+ },
449
+ {
450
+ value: "__keyfile__",
451
+ label: "Use an existing key file…",
452
+ hint: "path to a 0x private key file",
453
+ },
399
454
  ];
455
+ if (hints.foundryAccounts.length) {
456
+ for (const n of hints.foundryAccounts) {
457
+ options.push({
458
+ value: n,
459
+ label: `Foundry: ${n}`,
460
+ hint: "advanced — cast wallet",
461
+ });
462
+ }
463
+ } else {
464
+ options.push({
465
+ value: "__foundry_missing__",
466
+ label: "Foundry account…",
467
+ hint: "advanced — install Foundry first",
468
+ });
469
+ }
470
+ options.push({
471
+ value: "__paste__",
472
+ label: "Paste an address…",
473
+ hint: "read-only unless you add a key later",
474
+ });
475
+
400
476
  const pick = cancelIf(
401
477
  await clack.select({
402
- message: "Operator owner source",
478
+ message: "How do you want to set your operator identity?",
403
479
  options,
404
- initialValue: hints.foundryAccounts[0],
480
+ initialValue: "__generate__",
405
481
  }),
406
482
  );
407
- if (pick === "__paste__") {
483
+
484
+ if (pick === "__generate__") {
485
+ const dest = defaultOperatorKeyPath(home);
486
+ let forceGen = flags.force;
487
+ if (existsSync(dest) && !forceGen) {
488
+ forceGen = cancelIf(
489
+ await clack.confirm({
490
+ message: `Overwrite existing ${dest}?`,
491
+ initialValue: false,
492
+ }),
493
+ );
494
+ if (!forceGen) {
495
+ clack.cancel("Aborted.");
496
+ process.exit(0);
497
+ }
498
+ }
499
+ const created = generateOperatorKeyFile(dest, { force: true });
500
+ generatedKeyFile = created.path;
501
+ address = created.address;
502
+ clack.log.success(`Created operator key at ${created.path}`);
503
+ clack.log.info(`Your operator address: ${created.address}`);
504
+ } else if (pick === "__keyfile__") {
505
+ const path = cancelIf(
506
+ await clack.text({
507
+ message: "Path to operator key file",
508
+ placeholder: join(home, "op.key"),
509
+ validate: (v) =>
510
+ v && String(v).trim() && existsSync(String(v).trim())
511
+ ? undefined
512
+ : "File not found",
513
+ }),
514
+ );
515
+ generatedKeyFile = String(path).trim();
516
+ address = addressFromKeyFile(generatedKeyFile);
517
+ clack.log.info(`Address from key file: ${address}`);
518
+ } else if (pick === "__foundry_missing__") {
519
+ clack.log.warn(
520
+ "Foundry (cast) is not available. Install https://book.getfoundry.sh/ or choose Create a new operator key.",
521
+ );
522
+ address = cancelIf(
523
+ await clack.text({
524
+ message: "Operator owner address",
525
+ placeholder: "0x…",
526
+ validate: (v) =>
527
+ /^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
528
+ }),
529
+ );
530
+ } else if (pick === "__paste__") {
408
531
  address = cancelIf(
409
532
  await clack.text({
410
533
  message: "Operator owner address",
@@ -436,16 +559,6 @@ export async function runSetupInteractive(argv, opts = {}) {
436
559
  }
437
560
  }
438
561
  }
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
562
  address = getAddress(address);
450
563
 
451
564
  let label = flags.operator || hints.operator?.label || null;
@@ -483,7 +596,7 @@ export async function runSetupInteractive(argv, opts = {}) {
483
596
  throw err;
484
597
  }
485
598
 
486
- let keyFile = flags.keyFile;
599
+ let keyFile = flags.keyFile || generatedKeyFile;
487
600
  let keyEnv = flags.keyEnv;
488
601
  let skipKey = flags.skipKey;
489
602
  if (!skipKey && !keyFile && !keyEnv) {
@@ -579,7 +692,16 @@ export async function runSetupInteractive(argv, opts = {}) {
579
692
  if (!key) {
580
693
  nextHint([
581
694
  "clanker whoami",
582
- "clanker setup --key-file ~/.clanker/op.key --force # when you need mint",
695
+ "clanker setup choose Create a new operator key when you need mint",
696
+ ]);
697
+ } else if (preset === "sepolia" && generatedKeyFile) {
698
+ nextHint(consumerFundHints({ address, label }));
699
+ } else if (preset === "sepolia") {
700
+ nextHint([
701
+ `If this address needs test ETH: ${BASE_SEPOLIA_FAUCET_URL}`,
702
+ "clanker doctor",
703
+ "clanker whoami",
704
+ `clanker bot mint <label>`,
583
705
  ]);
584
706
  } else {
585
707
  nextHint(["clanker whoami", `clanker bot mint <label>`]);
@@ -593,23 +715,20 @@ export async function runSetupInteractive(argv, opts = {}) {
593
715
  export async function runSetup(argv, opts = {}) {
594
716
  const flags = parseSetupFlags(argv);
595
717
  const isTTY = opts.isTTY ?? Boolean(input.isTTY);
718
+ const hasIdentitySource = Boolean(
719
+ flags.address ||
720
+ flags.keyFile ||
721
+ flags.generateKey ||
722
+ flags.foundryAccount ||
723
+ opts.env?.OPERATOR_PRIVATE_KEY ||
724
+ process.env.OPERATOR_PRIVATE_KEY,
725
+ );
596
726
 
597
- if (
598
- !isTTY ||
599
- (flags.yes && flags.preset && flags.operator && (flags.address || flags.keyFile))
600
- ) {
727
+ if (!isTTY || (flags.yes && flags.preset && flags.operator && hasIdentitySource)) {
601
728
  if (!isTTY && !(flags.preset && flags.operator)) {
602
729
  return runSetupNonInteractive(argv, opts);
603
730
  }
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
- ) {
731
+ if (flags.yes && flags.preset && flags.operator && hasIdentitySource) {
613
732
  return runSetupNonInteractive(argv, opts);
614
733
  }
615
734
  }
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.10",
4
4
  "description": "CLI for wiring clanker-chain identity and MQTT into OpenClaw and other agentic stacks.",
5
5
  "type": "module",
6
6
  "bin": {