@tokenbuddy/tokenbuddy 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/src/buyer-store.d.ts +28 -1
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +71 -16
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +17 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +201 -32
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/daemon.d.ts +5 -0
  10. package/dist/src/daemon.d.ts.map +1 -1
  11. package/dist/src/daemon.js +279 -72
  12. package/dist/src/daemon.js.map +1 -1
  13. package/dist/src/doctor-clawtip-wallet.d.ts +14 -0
  14. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -0
  15. package/dist/src/doctor-clawtip-wallet.js +54 -0
  16. package/dist/src/doctor-clawtip-wallet.js.map +1 -0
  17. package/dist/src/doctor-diagnostics.d.ts +2 -0
  18. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  19. package/dist/src/doctor-diagnostics.js +5 -0
  20. package/dist/src/doctor-diagnostics.js.map +1 -1
  21. package/dist/src/init-clawtip-activation.d.ts +48 -0
  22. package/dist/src/init-clawtip-activation.d.ts.map +1 -0
  23. package/dist/src/init-clawtip-activation.js +395 -0
  24. package/dist/src/init-clawtip-activation.js.map +1 -0
  25. package/dist/src/init-payment-options.d.ts +23 -1
  26. package/dist/src/init-payment-options.d.ts.map +1 -1
  27. package/dist/src/init-payment-options.js +97 -22
  28. package/dist/src/init-payment-options.js.map +1 -1
  29. package/dist/src/terminal-image.d.ts +22 -0
  30. package/dist/src/terminal-image.d.ts.map +1 -0
  31. package/dist/src/terminal-image.js +135 -0
  32. package/dist/src/terminal-image.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/buyer-store.ts +140 -17
  35. package/src/cli.ts +251 -33
  36. package/src/daemon.ts +308 -53
  37. package/src/doctor-clawtip-wallet.ts +70 -0
  38. package/src/doctor-diagnostics.ts +11 -0
  39. package/src/init-clawtip-activation.ts +487 -0
  40. package/src/init-payment-options.ts +140 -22
  41. package/src/terminal-image.ts +187 -0
  42. package/tests/e2e.test.ts +79 -5
  43. package/tests/tokenbuddy.test.ts +745 -19
@@ -0,0 +1,487 @@
1
+ import * as p from "@clack/prompts";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { spawn } from "child_process";
6
+ import {
7
+ inspectOpenClawWalletConfig,
8
+ type OpenClawWalletConfigState,
9
+ } from "./init-payment-options.js";
10
+
11
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
12
+ const SLEEP_SLICE_MS = 200;
13
+ const CLAWTIP_MIN_CLI_VERSION = "1.0.4";
14
+ const CLAWTIP_MIN_SKILL_VERSION = "1.0.12";
15
+
16
+ export interface ClawtipBootstrapPayment {
17
+ orderNo: string;
18
+ amountFen: number;
19
+ payTo?: string;
20
+ encryptedData?: string;
21
+ indicator: string;
22
+ slug?: string;
23
+ skillId?: string;
24
+ description?: string;
25
+ resourceUrl?: string;
26
+ }
27
+
28
+ export interface ParsedClawtipOutput {
29
+ authUrl?: string;
30
+ clawtipId?: string;
31
+ mediaPath?: string;
32
+ failureMessage?: string;
33
+ requiresWalletAuth: boolean;
34
+ walletReady: boolean;
35
+ }
36
+
37
+ export interface WaitForClawtipActivationOptions {
38
+ inspectWalletConfig?: () => OpenClawWalletConfigState;
39
+ isCancelled?: () => boolean;
40
+ clawtipId?: string;
41
+ checkRegister?: (clawtipId: string) => Promise<void>;
42
+ cancel?: (message?: string) => void;
43
+ pollIntervalMs?: number;
44
+ sleep?: (ms: number) => Promise<void>;
45
+ }
46
+
47
+ export interface StartClawtipWalletBootstrapOptions {
48
+ home?: string;
49
+ runClawtipCommand?: (args: string[]) => Promise<string>;
50
+ }
51
+
52
+ export interface CheckOpenClawRuntimeOptions {
53
+ runOpenClawCommand?: (args: string[]) => Promise<string>;
54
+ }
55
+
56
+ function defaultSleep(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ function defaultHomeDir(): string {
61
+ return process.env.HOME || os.homedir();
62
+ }
63
+
64
+ function clawtipOrderFilePath(home: string, indicator: string, orderNo: string): string {
65
+ return path.join(home, ".openclaw", "skills", "orders", indicator, `${orderNo}.json`);
66
+ }
67
+
68
+ function sanitizeClawtipOutput(output: string): string {
69
+ return output
70
+ .split("\n")
71
+ .map((line) => {
72
+ if (line.includes("\"u\"") || line.includes("payCredential") || line.includes("access_token")) {
73
+ return "<redacted sensitive ClawTip output>";
74
+ }
75
+ return line;
76
+ })
77
+ .join("\n");
78
+ }
79
+
80
+ function isClawtipPayWalletAuthOutput(args: string[], output: string): boolean {
81
+ if (!args.includes("pay")) {
82
+ return false;
83
+ }
84
+ const lower = output.toLowerCase();
85
+ return lower.includes("authurl")
86
+ || output.includes("MEDIA:")
87
+ || lower.includes("clawtipid")
88
+ || lower.includes("qrcode")
89
+ || lower.includes("扫码");
90
+ }
91
+
92
+ async function runClawtipCli(args: string[]): Promise<string> {
93
+ return await new Promise((resolve, reject) => {
94
+ const child = spawn("npx", args, {
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ });
97
+ let stdout = "";
98
+ let stderr = "";
99
+ child.stdout.on("data", (chunk) => {
100
+ stdout += String(chunk);
101
+ });
102
+ child.stderr.on("data", (chunk) => {
103
+ stderr += String(chunk);
104
+ });
105
+ child.on("error", (error) => {
106
+ reject(error);
107
+ });
108
+ child.on("close", (code) => {
109
+ const combined = `${stdout}${stderr}`;
110
+ if (code === 0) {
111
+ resolve(combined);
112
+ return;
113
+ }
114
+ if (isClawtipPayWalletAuthOutput(args, combined)) {
115
+ resolve(combined);
116
+ return;
117
+ }
118
+ reject(new Error(`ClawTip command failed with exit ${code}: ${sanitizeClawtipOutput(combined)}`));
119
+ });
120
+ });
121
+ }
122
+
123
+ async function runOpenClawCli(args: string[]): Promise<string> {
124
+ return await new Promise((resolve, reject) => {
125
+ const child = spawn("openclaw", args, {
126
+ stdio: ["ignore", "pipe", "pipe"],
127
+ });
128
+ let stdout = "";
129
+ let stderr = "";
130
+ child.stdout.on("data", (chunk) => {
131
+ stdout += String(chunk);
132
+ });
133
+ child.stderr.on("data", (chunk) => {
134
+ stderr += String(chunk);
135
+ });
136
+ child.on("error", (error) => {
137
+ reject(error);
138
+ });
139
+ child.on("close", (code) => {
140
+ const combined = `${stdout}${stderr}`;
141
+ if (code === 0) {
142
+ resolve(combined);
143
+ return;
144
+ }
145
+ reject(new Error(`OpenClaw command failed with exit ${code}: ${combined.trim()}`));
146
+ });
147
+ });
148
+ }
149
+
150
+ function findValueAfterKeys(output: string, keys: string[]): string | undefined {
151
+ for (const line of output.split("\n")) {
152
+ for (const key of keys) {
153
+ const index = line.indexOf(key);
154
+ if (index < 0) {
155
+ continue;
156
+ }
157
+ const raw = line.slice(index + key.length);
158
+ const value = raw
159
+ .replace(/^[:=\s]+/, "")
160
+ .replace(/^["']+|["',\s]+$/g, "")
161
+ .trim();
162
+ if (value) {
163
+ return value;
164
+ }
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ function lineContainsWalletAuthCue(line: string): boolean {
171
+ const lower = line.toLowerCase();
172
+ return lower.includes("authurl")
173
+ || lower.includes("scan")
174
+ || lower.includes("扫码")
175
+ || lower.includes("授权")
176
+ || lower.includes("qrcode")
177
+ || lower.includes("clawtipid")
178
+ || lower.includes("safeMonitor".toLowerCase())
179
+ || lower.includes("unifiedauthm");
180
+ }
181
+
182
+ function findUrlInOutput(output: string): string | undefined {
183
+ for (const line of output.split("\n")) {
184
+ if (line.includes("process.env.CLAWTIP_PAY")
185
+ || line.includes("process.env.GET_PUBLIC_KEY")
186
+ || line.includes("process.env.QUERY_TOKEN")) {
187
+ continue;
188
+ }
189
+ const matches = line.match(/https?:\/\/\S+/g) || [];
190
+ for (const match of matches) {
191
+ const value = match.trim().replace(/^["'`([{\s]+|["'`)\]},.\s]+$/g, "");
192
+ if (!(value.startsWith("https://") || value.startsWith("http://"))) {
193
+ continue;
194
+ }
195
+ const lower = value.toLowerCase();
196
+ if (value.includes("clawtipId")
197
+ || lower.includes("/qrcode")
198
+ || lower.includes("safemonitor")
199
+ || lower.includes("unifiedauthm")
200
+ || lineContainsWalletAuthCue(line)) {
201
+ return value;
202
+ }
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ function findMediaPathInOutput(output: string): string | undefined {
209
+ const keyedPath = findValueAfterKeys(output, ["MEDIA", "media", "mediaPath", "media_path"]);
210
+ if (keyedPath) {
211
+ return keyedPath;
212
+ }
213
+
214
+ for (const line of output.split("\n").reverse()) {
215
+ const value = line.trim().replace(/^["'`]+|["'`,\s]+$/g, "");
216
+ if (path.isAbsolute(value) && /\.(png|jpe?g|webp)$/i.test(value)) {
217
+ return value;
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ function findClawtipFailureMessage(output: string): string | undefined {
224
+ for (const line of output.split("\n")) {
225
+ const trimmed = line.trim();
226
+ if (!trimmed) {
227
+ continue;
228
+ }
229
+ if (trimmed.includes("商家信息有误")
230
+ || trimmed.includes("商户信息有误")
231
+ || trimmed.includes("支付失败")
232
+ || trimmed.includes("下单失败")) {
233
+ return sanitizeClawtipOutput(trimmed);
234
+ }
235
+ }
236
+ return undefined;
237
+ }
238
+
239
+ function decodeClawtipValue(value: string): string {
240
+ try {
241
+ return decodeURIComponent(value);
242
+ } catch {
243
+ return value;
244
+ }
245
+ }
246
+
247
+ function clawtipQrWorkspaceDir(home: string): string {
248
+ return path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
249
+ }
250
+
251
+ function findLatestGeneratedQrMediaPath(home: string, createdAfterMs: number): string | undefined {
252
+ const qrDir = clawtipQrWorkspaceDir(home);
253
+ if (!fs.existsSync(qrDir)) {
254
+ return undefined;
255
+ }
256
+
257
+ const candidates = fs.readdirSync(qrDir)
258
+ .filter((fileName) => /^qrcode-.*\.(png|jpe?g|webp)$/i.test(fileName))
259
+ .map((fileName) => {
260
+ const filePath = path.join(qrDir, fileName);
261
+ return {
262
+ filePath,
263
+ mtimeMs: fs.statSync(filePath).mtimeMs,
264
+ };
265
+ })
266
+ .filter((entry) => entry.mtimeMs >= createdAfterMs - 1_000)
267
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
268
+
269
+ return candidates[0]?.filePath;
270
+ }
271
+
272
+ function extractClawtipIdFromUrl(value: string): string | undefined {
273
+ const decoded = decodeClawtipValue(value);
274
+ for (const segment of decoded.split(/[?&]/)) {
275
+ const [key, rawValue] = segment.split("=", 2);
276
+ if (!key || !rawValue) {
277
+ continue;
278
+ }
279
+ if (["clawtipId", "clawtip_id", "deviceId", "device_id"].includes(key)) {
280
+ const trimmed = rawValue.trim();
281
+ if (trimmed) {
282
+ return trimmed;
283
+ }
284
+ }
285
+ }
286
+ return undefined;
287
+ }
288
+
289
+ export function parseClawtipCliOutput(output: string): ParsedClawtipOutput {
290
+ const authUrl = findValueAfterKeys(output, ["authUrl", "auth_url", "auth url"]) || findUrlInOutput(output);
291
+ const mediaPath = findMediaPathInOutput(output);
292
+ const clawtipId = authUrl
293
+ ? extractClawtipIdFromUrl(authUrl)
294
+ : findValueAfterKeys(output, ["clawtipId", "clawtip_id", "deviceId", "device_id"]);
295
+ const lower = output.toLowerCase();
296
+ const failureMessage = findClawtipFailureMessage(output);
297
+
298
+ return {
299
+ authUrl,
300
+ clawtipId: clawtipId ? decodeClawtipValue(clawtipId) : undefined,
301
+ mediaPath,
302
+ failureMessage,
303
+ requiresWalletAuth: Boolean(authUrl || clawtipId || mediaPath
304
+ || lower.includes("authurl")
305
+ || lower.includes("qrcode")
306
+ || lower.includes("scan")
307
+ || lower.includes("扫码")),
308
+ walletReady: lower.includes("register success")
309
+ || lower.includes("注册成功")
310
+ || lower.includes("tokeninfo")
311
+ || lower.includes("config.json")
312
+ || lower.includes("status: successful")
313
+ || lower.includes("saved u"),
314
+ };
315
+ }
316
+
317
+ export function resolveClawtipQrMediaPath(parsedOutput: ParsedClawtipOutput, orderFile: string): string {
318
+ if (parsedOutput.mediaPath) {
319
+ return parsedOutput.mediaPath;
320
+ }
321
+ throw new Error(
322
+ [
323
+ "ClawTip pay did not return a QR media file.",
324
+ `Order file: ${orderFile}`,
325
+ "For first-time wallet binding, run the ClawTip wallet QR flow before paying.",
326
+ ].join(" ")
327
+ );
328
+ }
329
+
330
+ export function readClawtipPayCredential(orderFile: string): string | undefined {
331
+ if (!fs.existsSync(orderFile)) {
332
+ return undefined;
333
+ }
334
+ const order = JSON.parse(fs.readFileSync(orderFile, "utf8")) as Record<string, unknown>;
335
+ const credential = order.payCredential || order.pay_credential;
336
+ return typeof credential === "string" && credential.trim() ? credential : undefined;
337
+ }
338
+
339
+ export function writeClawtipOrderFile(
340
+ payment: ClawtipBootstrapPayment,
341
+ home: string = defaultHomeDir(),
342
+ ): string {
343
+ const orderFile = clawtipOrderFilePath(home, payment.indicator, payment.orderNo);
344
+ fs.mkdirSync(path.dirname(orderFile), { recursive: true });
345
+ const orderJson = {
346
+ "skill-id": payment.skillId,
347
+ order_no: payment.orderNo,
348
+ amount: payment.amountFen,
349
+ question: "LLM inference purchase",
350
+ encrypted_data: payment.encryptedData,
351
+ pay_to: payment.payTo,
352
+ description: payment.description,
353
+ slug: payment.slug,
354
+ resource_url: payment.resourceUrl,
355
+ };
356
+ fs.writeFileSync(orderFile, JSON.stringify(orderJson, null, 2), "utf8");
357
+ return orderFile;
358
+ }
359
+
360
+ export async function startClawtipWalletBootstrap(
361
+ payment: ClawtipBootstrapPayment,
362
+ options: StartClawtipWalletBootstrapOptions = {},
363
+ ): Promise<{ orderFile: string; parsedOutput: ParsedClawtipOutput; payCredential?: string }> {
364
+ const orderFile = writeClawtipOrderFile(payment, options.home);
365
+ const home = options.home || defaultHomeDir();
366
+ const payStartedAt = Date.now();
367
+ const runCommand = options.runClawtipCommand || runClawtipCli;
368
+ const output = await runCommand([
369
+ "--yes",
370
+ `@clawtip/clawtip-cli@${CLAWTIP_MIN_CLI_VERSION}`,
371
+ "pay",
372
+ "-o",
373
+ payment.orderNo,
374
+ "-i",
375
+ payment.indicator,
376
+ "-v",
377
+ CLAWTIP_MIN_SKILL_VERSION,
378
+ ]);
379
+ const parsedOutput = parseClawtipCliOutput(output);
380
+ if (parsedOutput.failureMessage) {
381
+ throw new Error(`ClawTip pay failed: ${parsedOutput.failureMessage}`);
382
+ }
383
+ if (!parsedOutput.mediaPath) {
384
+ parsedOutput.mediaPath = findLatestGeneratedQrMediaPath(home, payStartedAt);
385
+ if (parsedOutput.mediaPath) {
386
+ parsedOutput.requiresWalletAuth = true;
387
+ }
388
+ }
389
+
390
+ return {
391
+ orderFile,
392
+ parsedOutput,
393
+ payCredential: readClawtipPayCredential(orderFile),
394
+ };
395
+ }
396
+
397
+ export async function checkOpenClawRuntime(
398
+ options: CheckOpenClawRuntimeOptions = {},
399
+ ): Promise<string> {
400
+ const runOpenClawCommand = options.runOpenClawCommand || runOpenClawCli;
401
+ try {
402
+ return (await runOpenClawCommand(["--version"])).trim();
403
+ } catch (error) {
404
+ const message = error instanceof Error ? error.message : String(error);
405
+ throw new Error(
406
+ `OpenClaw CLI is required before ClawTip wallet registration. Ensure \`openclaw --version\` works. ${message}`
407
+ );
408
+ }
409
+ }
410
+
411
+ async function defaultCheckRegister(clawtipId: string): Promise<void> {
412
+ try {
413
+ await runClawtipCli([
414
+ "--yes",
415
+ `@clawtip/clawtip-cli@${CLAWTIP_MIN_CLI_VERSION}`,
416
+ "check-register",
417
+ "-d",
418
+ clawtipId,
419
+ ]);
420
+ } catch {
421
+ // The Rust version treats failed check-register polls as normal pending state.
422
+ }
423
+ }
424
+
425
+ async function sleepUntilNextPoll(
426
+ ms: number,
427
+ sleep: (ms: number) => Promise<void>,
428
+ isCancelled: () => boolean,
429
+ ): Promise<boolean> {
430
+ let remaining = ms;
431
+ while (remaining > 0) {
432
+ if (isCancelled()) {
433
+ return false;
434
+ }
435
+ const slice = Math.min(remaining, SLEEP_SLICE_MS);
436
+ await sleep(slice);
437
+ remaining -= slice;
438
+ }
439
+ return !isCancelled();
440
+ }
441
+
442
+ export async function waitForClawtipActivationConfirmation(
443
+ options: WaitForClawtipActivationOptions = {},
444
+ ): Promise<boolean> {
445
+ let cancelled = false;
446
+ const signalHandler = () => {
447
+ cancelled = true;
448
+ };
449
+ process.once("SIGINT", signalHandler);
450
+
451
+ const inspectWalletConfig = options.inspectWalletConfig || inspectOpenClawWalletConfig;
452
+ const externalCancelled = options.isCancelled || (() => false);
453
+ const isCancelled = () => cancelled || externalCancelled();
454
+ const checkRegister = options.checkRegister || defaultCheckRegister;
455
+ const cancel = options.cancel || p.cancel;
456
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
457
+ const sleep = options.sleep || defaultSleep;
458
+
459
+ try {
460
+ for (;;) {
461
+ if (isCancelled()) {
462
+ cancel("ClawTip activation cancelled.");
463
+ return false;
464
+ }
465
+
466
+ const walletConfig = inspectWalletConfig();
467
+ if (walletConfig.exists) {
468
+ return true;
469
+ }
470
+
471
+ if (options.clawtipId) {
472
+ await checkRegister(options.clawtipId);
473
+ if (inspectWalletConfig().exists) {
474
+ return true;
475
+ }
476
+ }
477
+
478
+ const shouldContinue = await sleepUntilNextPoll(pollIntervalMs, sleep, isCancelled);
479
+ if (!shouldContinue) {
480
+ cancel("ClawTip activation cancelled.");
481
+ return false;
482
+ }
483
+ }
484
+ } finally {
485
+ process.removeListener("SIGINT", signalHandler);
486
+ }
487
+ }
@@ -1,4 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
2
5
  import type { PaymentConfig } from "./buyer-store.js";
3
6
  import type { ProviderCandidate } from "./provider-install.js";
4
7
 
@@ -27,6 +30,32 @@ export interface InitTerminalOption {
27
30
  hint?: string;
28
31
  }
29
32
 
33
+ export interface InitTerminalSelectionState {
34
+ installed: InitTerminalOption[];
35
+ options: InitTerminalOption[];
36
+ }
37
+
38
+ export interface OpenClawWalletConfigState {
39
+ expectedPath: string;
40
+ configsDirExists: boolean;
41
+ exists: boolean;
42
+ alternatePaths: string[];
43
+ }
44
+
45
+ export type ClawtipWalletStatus =
46
+ | "ready"
47
+ | "metadata_missing_wallet"
48
+ | "wallet_missing_metadata"
49
+ | "missing";
50
+
51
+ export interface ClawtipWalletReadiness {
52
+ status: ClawtipWalletStatus;
53
+ walletConfig: OpenClawWalletConfigState;
54
+ savedBinding?: ExistingClawtipBinding;
55
+ reusableBinding?: ExistingClawtipBinding;
56
+ message: string;
57
+ }
58
+
30
59
  export const OTHER_TERMINAL_OPTION: InitTerminalOption = {
31
60
  value: "other",
32
61
  label: "Other",
@@ -65,38 +94,76 @@ export function noteInitComingSoonPayments(
65
94
  );
66
95
  }
67
96
 
68
- export function buildInitTerminalOptions(providers: ProviderCandidate[]): InitTerminalOption[] {
69
- const detected = providers
70
- .filter((provider) => provider.detected)
71
- .map((provider) => {
72
- if (provider.status === "configured") {
73
- return {
74
- value: `${provider.id}:installed`,
75
- label: `${provider.name}(已安装)`,
76
- hint: provider.reason
77
- };
78
- }
79
- return {
80
- value: provider.id,
81
- label: provider.name,
82
- hint: provider.reason
83
- };
84
- });
97
+ export function buildInitTerminalSelectionState(providers: ProviderCandidate[]): InitTerminalSelectionState {
98
+ const installed = providers
99
+ .filter((provider) => provider.detected && provider.status === "configured")
100
+ .map((provider) => ({
101
+ value: provider.id,
102
+ label: `${provider.name}(已安装)`,
103
+ hint: provider.reason
104
+ }));
105
+
106
+ const options = providers
107
+ .filter((provider) => provider.detected && provider.status !== "configured")
108
+ .map((provider) => ({
109
+ value: provider.id,
110
+ label: provider.name,
111
+ hint: provider.reason
112
+ }));
85
113
 
86
- return [...detected, OTHER_TERMINAL_OPTION];
114
+ return {
115
+ installed,
116
+ options: [...options, OTHER_TERMINAL_OPTION]
117
+ };
87
118
  }
88
119
 
89
120
  export function validateInitTerminalSelection(selected: string[] | undefined): string | undefined {
90
121
  if (!selected || selected.length === 0) {
91
122
  return "Select at least one terminal or choose Other.";
92
123
  }
93
- const actionable = selected.filter((value) => !value.endsWith(":installed"));
94
- if (actionable.length === 0) {
95
- return "Installed terminals are already configured. Select another terminal or choose Other.";
96
- }
97
124
  return undefined;
98
125
  }
99
126
 
127
+ export function buildInstalledTerminalMessage(installed: InitTerminalOption[]): string | undefined {
128
+ if (installed.length === 0) {
129
+ return undefined;
130
+ }
131
+ return installed
132
+ .map((entry) => `- ${entry.label}${entry.hint ? `\n ${entry.hint}` : ""}`)
133
+ .join("\n");
134
+ }
135
+
136
+ function defaultHomeDir(): string {
137
+ return process.env.HOME || os.homedir();
138
+ }
139
+
140
+ export function inspectOpenClawWalletConfig(home: string = defaultHomeDir()): OpenClawWalletConfigState {
141
+ const configsDir = path.join(home, ".openclaw", "configs");
142
+ const expectedPath = path.join(configsDir, "config.json");
143
+ const configsDirExists = fs.existsSync(configsDir);
144
+ const alternatePaths: string[] = [];
145
+
146
+ if (configsDirExists) {
147
+ try {
148
+ const entries = fs.readdirSync(configsDir)
149
+ .filter((entry) => entry !== "config.json" && !entry.startsWith("."))
150
+ .sort();
151
+ for (const entry of entries) {
152
+ alternatePaths.push(path.join(configsDir, entry));
153
+ }
154
+ } catch {
155
+ // Ignore directory read failures and treat as no alternates.
156
+ }
157
+ }
158
+
159
+ return {
160
+ expectedPath,
161
+ configsDirExists,
162
+ exists: fs.existsSync(expectedPath),
163
+ alternatePaths
164
+ };
165
+ }
166
+
100
167
  function stringField(value: unknown): string | undefined {
101
168
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
102
169
  }
@@ -118,6 +185,57 @@ export function detectExistingClawtipBinding(payment: PaymentConfig | undefined)
118
185
  };
119
186
  }
120
187
 
188
+ export function detectReusableClawtipBinding(
189
+ payment: PaymentConfig | undefined,
190
+ walletConfig: OpenClawWalletConfigState
191
+ ): ExistingClawtipBinding | undefined {
192
+ if (!walletConfig.exists) {
193
+ return undefined;
194
+ }
195
+ return detectExistingClawtipBinding(payment);
196
+ }
197
+
198
+ export function inspectClawtipWalletReadiness(
199
+ payment: PaymentConfig | undefined,
200
+ walletConfig: OpenClawWalletConfigState = inspectOpenClawWalletConfig(),
201
+ ): ClawtipWalletReadiness {
202
+ const savedBinding = detectExistingClawtipBinding(payment);
203
+ const reusableBinding = detectReusableClawtipBinding(payment, walletConfig);
204
+
205
+ if (reusableBinding) {
206
+ return {
207
+ status: "ready",
208
+ walletConfig,
209
+ savedBinding,
210
+ reusableBinding,
211
+ message: "ClawTip payment metadata and local OpenClaw wallet config are both present.",
212
+ };
213
+ }
214
+
215
+ if (savedBinding) {
216
+ return {
217
+ status: "metadata_missing_wallet",
218
+ walletConfig,
219
+ savedBinding,
220
+ message: "Saved ClawTip payment metadata exists, but the local OpenClaw wallet config is missing.",
221
+ };
222
+ }
223
+
224
+ if (walletConfig.exists) {
225
+ return {
226
+ status: "wallet_missing_metadata",
227
+ walletConfig,
228
+ message: "Local OpenClaw wallet config exists, but TokenBuddy ClawTip payment metadata is not saved.",
229
+ };
230
+ }
231
+
232
+ return {
233
+ status: "missing",
234
+ walletConfig,
235
+ message: "ClawTip payment metadata and local OpenClaw wallet config are not configured.",
236
+ };
237
+ }
238
+
121
239
  export function buildInitSuccessMessage(summaryLines: string[] = []): string {
122
240
  const lines = ["✅ TokenBuddy setup completed successfully."];
123
241
  for (const line of summaryLines) {