@bananalink-test/agent-wallet 0.1.0

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 (2) hide show
  1. package/dist/index.mjs +478 -0
  2. package/package.json +36 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { Command } from "commander";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { createConnection, createServer } from "node:net";
6
+ import { BananalinkClient, ConnectionRejectedError, ConnectionTimeoutError, RequestRejectedError, RequestTimeoutError, SessionClosedError } from "@bananalink-test/client";
7
+ import { parseUnits } from "viem";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { z } from "zod";
11
+
12
+ //#region src/chain/resolveChain.ts
13
+ const CHAINS = {
14
+ ethereum: 1,
15
+ eth: 1,
16
+ polygon: 137,
17
+ matic: 137,
18
+ base: 8453,
19
+ optimism: 10,
20
+ op: 10,
21
+ arbitrum: 42161,
22
+ arb: 42161,
23
+ sepolia: 11155111
24
+ };
25
+ function resolveChain(input) {
26
+ const n = Number(input);
27
+ if (!Number.isNaN(n) && n > 0) return n;
28
+ const id = CHAINS[input.toLowerCase()];
29
+ if (!id) throw new Error(`Unknown chain "${input}". Use a name (ethereum, base, polygon…) or a numeric chain ID.`);
30
+ return id;
31
+ }
32
+
33
+ //#endregion
34
+ //#region src/config/CONFIG_DIR.ts
35
+ const CONFIG_DIR = join(homedir(), ".config", "bananalink-agent");
36
+
37
+ //#endregion
38
+ //#region src/config/PID_FILE.ts
39
+ const PID_FILE = join(CONFIG_DIR, "daemon.pid");
40
+
41
+ //#endregion
42
+ //#region src/config/SOCKET_PATH.ts
43
+ const SOCKET_PATH = join(CONFIG_DIR, "daemon.sock");
44
+
45
+ //#endregion
46
+ //#region src/errors/toErrorPayload.ts
47
+ function toErrorPayload(e) {
48
+ if (e instanceof ConnectionRejectedError) return {
49
+ error: "ConnectionRejected",
50
+ message: "Connection was declined."
51
+ };
52
+ if (e instanceof ConnectionTimeoutError) return {
53
+ error: "ConnectionTimeout",
54
+ message: "Timed out waiting for wallet approval."
55
+ };
56
+ if (e instanceof RequestRejectedError) return {
57
+ error: "RequestRejected",
58
+ message: "Transaction was declined."
59
+ };
60
+ if (e instanceof RequestTimeoutError) return {
61
+ error: "RequestTimeout",
62
+ message: "Timed out waiting for transaction approval."
63
+ };
64
+ if (e instanceof SessionClosedError) return {
65
+ error: "SessionClosed",
66
+ message: "Session expired. Run connect again."
67
+ };
68
+ return {
69
+ error: "Error",
70
+ message: e instanceof Error ? e.message : String(e)
71
+ };
72
+ }
73
+
74
+ //#endregion
75
+ //#region src/output/emit.ts
76
+ function emit(data) {
77
+ console.log(JSON.stringify(data));
78
+ }
79
+
80
+ //#endregion
81
+ //#region src/output/info.ts
82
+ function info(msg) {
83
+ console.error(msg);
84
+ }
85
+
86
+ //#endregion
87
+ //#region src/schemas/DisconnectCmdSchema.ts
88
+ const DisconnectCmdSchema = z.object({ cmd: z.literal("disconnect") });
89
+
90
+ //#endregion
91
+ //#region src/schemas/SendCmdSchema.ts
92
+ const SendCmdSchema = z.object({
93
+ cmd: z.literal("send"),
94
+ to: z.string(),
95
+ chainId: z.string().transform(resolveChain),
96
+ value: z.string().optional(),
97
+ data: z.string().optional(),
98
+ notificationTitle: z.string().optional(),
99
+ notificationBody: z.string().optional()
100
+ });
101
+
102
+ //#endregion
103
+ //#region src/schemas/StatusCmdSchema.ts
104
+ const StatusCmdSchema = z.object({ cmd: z.literal("status") });
105
+
106
+ //#endregion
107
+ //#region src/schemas/DaemonCmdSchema.ts
108
+ const DaemonCmdSchema = z.discriminatedUnion("cmd", [
109
+ StatusCmdSchema,
110
+ SendCmdSchema,
111
+ DisconnectCmdSchema
112
+ ]);
113
+
114
+ //#endregion
115
+ //#region src/config/SESSION_FILE.ts
116
+ const SESSION_FILE = join(CONFIG_DIR, "session.json");
117
+
118
+ //#endregion
119
+ //#region src/schemas/StoredSessionSchema.ts
120
+ const StoredSessionSchema = z.object({
121
+ dappId: z.string(),
122
+ accessToken: z.string().optional()
123
+ });
124
+
125
+ //#endregion
126
+ //#region src/session/loadSession.ts
127
+ function loadSession() {
128
+ try {
129
+ if (existsSync(SESSION_FILE)) {
130
+ const data = readFileSync(SESSION_FILE, "utf8");
131
+ const result = StoredSessionSchema.safeParse(JSON.parse(data));
132
+ if (result.success) return result.data;
133
+ }
134
+ return null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ //#endregion
141
+ //#region src/session/saveSession.ts
142
+ function saveSession(s) {
143
+ mkdirSync(CONFIG_DIR, { recursive: true });
144
+ writeFileSync(SESSION_FILE, JSON.stringify(s, null, 2), { mode: 384 });
145
+ }
146
+
147
+ //#endregion
148
+ //#region src/daemon/runDaemon.ts
149
+ function makeDapp(dappId) {
150
+ return {
151
+ dappId,
152
+ dappName: "AI Agent",
153
+ dappStatement: "An AI agent wants to send transactions on your behalf.",
154
+ domain: "ai.agent",
155
+ uri: "ai://agent",
156
+ icons: [],
157
+ chainId: 1
158
+ };
159
+ }
160
+ function serveSession(session) {
161
+ const server = createServer((socket) => {
162
+ let buf = "";
163
+ socket.on("data", async (chunk) => {
164
+ buf += chunk.toString();
165
+ const newline = buf.indexOf("\n");
166
+ if (newline === -1) return;
167
+ const line = buf.slice(0, newline);
168
+ buf = buf.slice(newline + 1);
169
+ let response;
170
+ try {
171
+ const parseResult = DaemonCmdSchema.safeParse(JSON.parse(line));
172
+ if (!parseResult.success) {
173
+ response = {
174
+ ok: false,
175
+ error: "InvalidCommand",
176
+ message: parseResult.error.message
177
+ };
178
+ socket.write(`${JSON.stringify(response)}\n`);
179
+ socket.end();
180
+ return;
181
+ }
182
+ const cmd = parseResult.data;
183
+ switch (cmd.cmd) {
184
+ case "status":
185
+ response = {
186
+ ok: true,
187
+ connected: true,
188
+ address: session.sessionClaims.address
189
+ };
190
+ break;
191
+ case "send": {
192
+ const valueHex = cmd.value ? `0x${parseUnits(cmd.value, 18).toString(16)}` : void 0;
193
+ const notification = cmd.notificationTitle && cmd.notificationBody ? {
194
+ title: cmd.notificationTitle,
195
+ body: cmd.notificationBody
196
+ } : void 0;
197
+ response = {
198
+ ok: true,
199
+ txHash: await session.request({
200
+ method: "eth_sendTransaction",
201
+ params: [{
202
+ to: cmd.to,
203
+ value: valueHex,
204
+ data: cmd.data ?? "0x",
205
+ chainId: cmd.chainId,
206
+ metadata: notification ? { notification } : void 0
207
+ }]
208
+ }),
209
+ to: cmd.to
210
+ };
211
+ break;
212
+ }
213
+ case "disconnect": {
214
+ const stored = loadSession();
215
+ if (stored) saveSession({ dappId: stored.dappId });
216
+ response = { ok: true };
217
+ socket.write(`${JSON.stringify(response)}\n`);
218
+ socket.end();
219
+ server.close();
220
+ if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
221
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
222
+ process.exit(0);
223
+ }
224
+ }
225
+ } catch (e) {
226
+ response = {
227
+ ok: false,
228
+ ...toErrorPayload(e)
229
+ };
230
+ }
231
+ socket.write(`${JSON.stringify(response)}\n`);
232
+ socket.end();
233
+ });
234
+ });
235
+ server.listen(SOCKET_PATH, () => {
236
+ chmodSync(SOCKET_PATH, 384);
237
+ });
238
+ }
239
+ async function runDaemon() {
240
+ process.stdout.on("error", (err) => {
241
+ if (err.code !== "EPIPE") throw err;
242
+ });
243
+ const stored = loadSession();
244
+ const dappId = stored?.dappId ?? crypto.randomUUID();
245
+ const connection = await new BananalinkClient({ dapp: makeDapp(dappId) }).connect(stored?.accessToken ? { accessToken: stored.accessToken } : void 0);
246
+ if (!stored?.accessToken) {
247
+ emit({
248
+ type: "pending",
249
+ url: connection.connectionUrl
250
+ });
251
+ info("Waiting for wallet approval…");
252
+ }
253
+ const session = await connection.getSession();
254
+ saveSession({
255
+ dappId,
256
+ accessToken: session.sessionClaims.accessToken
257
+ });
258
+ mkdirSync(CONFIG_DIR, { recursive: true });
259
+ if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
260
+ serveSession(session);
261
+ writeFileSync(PID_FILE, String(process.pid), { mode: 384 });
262
+ emit({
263
+ type: "connected",
264
+ ok: true,
265
+ address: session.sessionClaims.address
266
+ });
267
+ }
268
+
269
+ //#endregion
270
+ //#region src/schemas/DisconnectResponseSchema.ts
271
+ const DisconnectResponseSchema = z.object({ ok: z.literal(true) });
272
+
273
+ //#endregion
274
+ //#region src/schemas/ErrorResponseSchema.ts
275
+ const ErrorResponseSchema = z.object({
276
+ ok: z.literal(false),
277
+ error: z.string(),
278
+ message: z.string()
279
+ });
280
+
281
+ //#endregion
282
+ //#region src/schemas/NotConnectedResponseSchema.ts
283
+ const NotConnectedResponseSchema = z.object({
284
+ ok: z.literal(true),
285
+ connected: z.literal(false)
286
+ });
287
+
288
+ //#endregion
289
+ //#region src/schemas/SendResponseSchema.ts
290
+ const SendResponseSchema = z.object({
291
+ ok: z.literal(true),
292
+ txHash: z.string(),
293
+ to: z.string()
294
+ });
295
+
296
+ //#endregion
297
+ //#region src/schemas/StatusResponseSchema.ts
298
+ const StatusResponseSchema = z.object({
299
+ ok: z.literal(true),
300
+ connected: z.literal(true),
301
+ address: z.string()
302
+ });
303
+
304
+ //#endregion
305
+ //#region src/schemas/DaemonResponseSchema.ts
306
+ const DaemonResponseSchema = z.union([
307
+ StatusResponseSchema,
308
+ NotConnectedResponseSchema,
309
+ SendResponseSchema,
310
+ DisconnectResponseSchema,
311
+ ErrorResponseSchema
312
+ ]);
313
+
314
+ //#endregion
315
+ //#region src/ipc/callDaemon.ts
316
+ function callDaemon(command) {
317
+ return new Promise((resolve, reject) => {
318
+ const socket = createConnection(SOCKET_PATH);
319
+ let buf = "";
320
+ socket.on("connect", () => socket.write(`${JSON.stringify(command)}\n`));
321
+ socket.on("data", (chunk) => {
322
+ buf += chunk.toString();
323
+ const newline = buf.indexOf("\n");
324
+ if (newline !== -1) {
325
+ socket.destroy();
326
+ const data = JSON.parse(buf.slice(0, newline));
327
+ const result = DaemonResponseSchema.safeParse(data);
328
+ if (result.success) resolve(result.data);
329
+ else reject(/* @__PURE__ */ new Error("Unexpected daemon response"));
330
+ }
331
+ });
332
+ socket.on("error", reject);
333
+ });
334
+ }
335
+
336
+ //#endregion
337
+ //#region src/ipc/isDaemonRunning.ts
338
+ function isDaemonRunning() {
339
+ if (!existsSync(PID_FILE) || !existsSync(SOCKET_PATH)) return false;
340
+ try {
341
+ const pid = parseInt(readFileSync(PID_FILE, "utf8"), 10);
342
+ process.kill(pid, 0);
343
+ return true;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ //#endregion
350
+ //#region src/index.ts
351
+ /**
352
+ * Bananalink Wallet CLI — for use by AI agents
353
+ *
354
+ * Commands:
355
+ * bananalink-wallet status
356
+ * bananalink-wallet connect
357
+ * bananalink-wallet send --to <address> --chain <name|id> --value <eth>
358
+ * bananalink-wallet disconnect
359
+ *
360
+ * Output: newline-delimited JSON on stdout.
361
+ * Informational messages go to stderr.
362
+ */
363
+ async function cmdStatus() {
364
+ if (!isDaemonRunning()) {
365
+ emit({
366
+ ok: true,
367
+ connected: false
368
+ });
369
+ return;
370
+ }
371
+ emit(await callDaemon({ cmd: "status" }));
372
+ }
373
+ async function cmdConnect() {
374
+ if (isDaemonRunning()) {
375
+ emit(await callDaemon({ cmd: "status" }));
376
+ return;
377
+ }
378
+ const [daemonBin, daemonScriptArgs] = process.argv[1].endsWith(".ts") ? ["tsx", [
379
+ process.argv[1],
380
+ "connect",
381
+ "--daemon"
382
+ ]] : [process.execPath, [
383
+ process.argv[1],
384
+ "connect",
385
+ "--daemon"
386
+ ]];
387
+ const daemon = spawn(daemonBin, daemonScriptArgs, {
388
+ detached: true,
389
+ stdio: [
390
+ "ignore",
391
+ "pipe",
392
+ "inherit"
393
+ ]
394
+ });
395
+ daemon.unref();
396
+ let buf = "";
397
+ daemon.stdout?.on("data", (chunk) => {
398
+ buf += chunk.toString();
399
+ const lines = buf.split("\n");
400
+ buf = lines.pop() ?? "";
401
+ for (const line of lines) {
402
+ if (!line.trim()) continue;
403
+ console.log(line);
404
+ try {
405
+ const msg = JSON.parse(line);
406
+ if (msg.type === "pending" || msg.type === "connected" || msg.ok === false) process.exit(msg.ok === false ? 1 : 0);
407
+ } catch {}
408
+ }
409
+ });
410
+ daemon.on("error", (err) => {
411
+ emit({
412
+ ok: false,
413
+ error: "DaemonError",
414
+ message: err.message
415
+ });
416
+ process.exit(1);
417
+ });
418
+ daemon.on("close", (code) => {
419
+ emit({
420
+ ok: false,
421
+ error: "DaemonError",
422
+ message: `Daemon exited with code ${code}`
423
+ });
424
+ process.exit(1);
425
+ });
426
+ }
427
+ async function cmdSend(opts) {
428
+ if (!isDaemonRunning()) throw new Error("No wallet connected. Run: bananalink-wallet connect");
429
+ emit(await callDaemon({
430
+ cmd: "send",
431
+ to: opts.to,
432
+ chainId: opts.chain,
433
+ value: opts.value,
434
+ data: opts.data,
435
+ notificationTitle: opts.notificationTitle,
436
+ notificationBody: opts.notificationBody
437
+ }));
438
+ }
439
+ async function cmdDisconnect() {
440
+ if (isDaemonRunning()) await callDaemon({ cmd: "disconnect" });
441
+ else {
442
+ const stored = loadSession();
443
+ if (stored) saveSession({ dappId: stored.dappId });
444
+ }
445
+ emit({ ok: true });
446
+ }
447
+ const program = new Command("bananalink-wallet").description("Manage wallet sessions and send onchain transactions").addHelpText("after", "\nOutput: newline-delimited JSON on stdout. Errors go to stderr.").exitOverride();
448
+ program.command("status").description("Show the current wallet connection status").action(async () => {
449
+ await cmdStatus();
450
+ });
451
+ program.command("connect").description("Connect a wallet (spawns background daemon, exits after QR is ready)").option("--daemon", "Run as background daemon (internal use)").action(async (opts) => {
452
+ if (opts.daemon) await runDaemon();
453
+ else await cmdConnect();
454
+ });
455
+ program.command("send").description("Send a transaction via the connected wallet").requiredOption("--to <address>", "Recipient address").requiredOption("--chain <name|id>", "Chain to send on (e.g. base, ethereum, 137)").requiredOption("--value <eth>", "Amount in ETH (e.g. 0.01)").option("--data <hex>", "Optional calldata (hex)").option("--notification-title <text>", "Push notification title shown to the wallet user").option("--notification-body <text>", "Push notification body shown to the wallet user").action(async (opts) => {
456
+ resolveChain(opts.chain);
457
+ await cmdSend(opts);
458
+ });
459
+ program.command("disconnect").description("Disconnect the wallet and clear the session token").action(async () => {
460
+ await cmdDisconnect();
461
+ });
462
+ try {
463
+ await program.parseAsync(process.argv);
464
+ } catch (e) {
465
+ if (e && typeof e === "object" && "code" in e && typeof e.code === "string") {
466
+ const code = e.code;
467
+ if (code === "commander.helpDisplayed" || code === "commander.version") process.exit(0);
468
+ if (code?.startsWith("commander.")) process.exit(1);
469
+ }
470
+ emit({
471
+ ok: false,
472
+ ...toErrorPayload(e)
473
+ });
474
+ process.exit(1);
475
+ }
476
+
477
+ //#endregion
478
+ export { };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@bananalink-test/agent-wallet",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool that gives AI agents onchain transaction capabilities via a Bananalink wallet",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "bananalink-wallet": "./dist/index.mjs"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "devDependencies": {
19
+ "@biomejs/biome": "2.3.15",
20
+ "tsdown": "^0.20.3",
21
+ "tsx": "^4.21.0",
22
+ "typescript": "^5.9.3"
23
+ },
24
+ "dependencies": {
25
+ "commander": "^14.0.3",
26
+ "viem": "^2.47.2",
27
+ "zod": "^4.3.6",
28
+ "@bananalink-test/client": "0.9.5"
29
+ },
30
+ "scripts": {
31
+ "start": "tsx src/index.ts",
32
+ "build": "tsdown",
33
+ "type-check": "tsc --noEmit",
34
+ "lint": "biome check ."
35
+ }
36
+ }