botanary 0.5.2 → 0.6.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 (100) hide show
  1. package/dist/botanary.js +2 -0
  2. package/package.json +8 -6
  3. package/dist/bin/botanary.js +0 -25
  4. package/dist/bin/botanary.js.map +0 -1
  5. package/dist/src/app-url.js +0 -52
  6. package/dist/src/app-url.js.map +0 -1
  7. package/dist/src/cli.js +0 -195
  8. package/dist/src/cli.js.map +0 -1
  9. package/dist/src/commands/account.js +0 -171
  10. package/dist/src/commands/account.js.map +0 -1
  11. package/dist/src/commands/agent.js +0 -338
  12. package/dist/src/commands/agent.js.map +0 -1
  13. package/dist/src/commands/authority.js +0 -634
  14. package/dist/src/commands/authority.js.map +0 -1
  15. package/dist/src/commands/mcp.js +0 -163
  16. package/dist/src/commands/mcp.js.map +0 -1
  17. package/dist/src/commands/services.js +0 -460
  18. package/dist/src/commands/services.js.map +0 -1
  19. package/dist/src/commands/session-writes.js +0 -196
  20. package/dist/src/commands/session-writes.js.map +0 -1
  21. package/dist/src/commands/sign.js +0 -311
  22. package/dist/src/commands/sign.js.map +0 -1
  23. package/dist/src/commands/system.js +0 -135
  24. package/dist/src/commands/system.js.map +0 -1
  25. package/dist/src/commands/tokens.js +0 -36
  26. package/dist/src/commands/tokens.js.map +0 -1
  27. package/dist/src/commands/utilities.js +0 -119
  28. package/dist/src/commands/utilities.js.map +0 -1
  29. package/dist/src/commands/wallet.js +0 -1013
  30. package/dist/src/commands/wallet.js.map +0 -1
  31. package/dist/src/commands/watch.js +0 -259
  32. package/dist/src/commands/watch.js.map +0 -1
  33. package/dist/src/completion.js +0 -69
  34. package/dist/src/completion.js.map +0 -1
  35. package/dist/src/config-store.js +0 -52
  36. package/dist/src/config-store.js.map +0 -1
  37. package/dist/src/context.js +0 -19
  38. package/dist/src/context.js.map +0 -1
  39. package/dist/src/errors.js +0 -116
  40. package/dist/src/errors.js.map +0 -1
  41. package/dist/src/help/examples.js +0 -904
  42. package/dist/src/help/examples.js.map +0 -1
  43. package/dist/src/help/format.js +0 -144
  44. package/dist/src/help/format.js.map +0 -1
  45. package/dist/src/help/groups.js +0 -39
  46. package/dist/src/help/groups.js.map +0 -1
  47. package/dist/src/install-method.js +0 -50
  48. package/dist/src/install-method.js.map +0 -1
  49. package/dist/src/mcp-snippets.js +0 -27
  50. package/dist/src/mcp-snippets.js.map +0 -1
  51. package/dist/src/outcome.js +0 -63
  52. package/dist/src/outcome.js.map +0 -1
  53. package/dist/src/package-version.js +0 -53
  54. package/dist/src/package-version.js.map +0 -1
  55. package/dist/src/parser.js +0 -48
  56. package/dist/src/parser.js.map +0 -1
  57. package/dist/src/progress.js +0 -56
  58. package/dist/src/progress.js.map +0 -1
  59. package/dist/src/prompt-doc.js +0 -92
  60. package/dist/src/prompt-doc.js.map +0 -1
  61. package/dist/src/render/accounts.js +0 -21
  62. package/dist/src/render/accounts.js.map +0 -1
  63. package/dist/src/render/activity.js +0 -34
  64. package/dist/src/render/activity.js.map +0 -1
  65. package/dist/src/render/agents.js +0 -42
  66. package/dist/src/render/agents.js.map +0 -1
  67. package/dist/src/render/balance.js +0 -85
  68. package/dist/src/render/balance.js.map +0 -1
  69. package/dist/src/render/banner.js +0 -17
  70. package/dist/src/render/banner.js.map +0 -1
  71. package/dist/src/render/chains.js +0 -52
  72. package/dist/src/render/chains.js.map +0 -1
  73. package/dist/src/render/colors.js +0 -50
  74. package/dist/src/render/colors.js.map +0 -1
  75. package/dist/src/render/gas.js +0 -34
  76. package/dist/src/render/gas.js.map +0 -1
  77. package/dist/src/render/grant.js +0 -34
  78. package/dist/src/render/grant.js.map +0 -1
  79. package/dist/src/render/kv.js +0 -60
  80. package/dist/src/render/kv.js.map +0 -1
  81. package/dist/src/render/mandates.js +0 -111
  82. package/dist/src/render/mandates.js.map +0 -1
  83. package/dist/src/render/print.js +0 -18
  84. package/dist/src/render/print.js.map +0 -1
  85. package/dist/src/render/redact.js +0 -23
  86. package/dist/src/render/redact.js.map +0 -1
  87. package/dist/src/render/table.js +0 -16
  88. package/dist/src/render/table.js.map +0 -1
  89. package/dist/src/repl.js +0 -232
  90. package/dist/src/repl.js.map +0 -1
  91. package/dist/src/review.js +0 -52
  92. package/dist/src/review.js.map +0 -1
  93. package/dist/src/run-installer.js +0 -30
  94. package/dist/src/run-installer.js.map +0 -1
  95. package/dist/src/settings.js +0 -88
  96. package/dist/src/settings.js.map +0 -1
  97. package/dist/src/update-check.js +0 -91
  98. package/dist/src/update-check.js.map +0 -1
  99. package/dist/src/validate.js +0 -138
  100. package/dist/src/validate.js.map +0 -1
@@ -1,1013 +0,0 @@
1
- import * as clack from '@clack/prompts';
2
- import { readFile } from 'node:fs/promises';
3
- import { fetchComposite, walletApiGet, runLogin, openBrowser, portfolioFreshness, resolveMandateForSend, signUnderMandateReal, preflightReal, scopedToAccount, resolveAsset, baseUnits, GET_ROUTES, } from 'botanary-mcp';
4
- import { submitIntent, describeSignOutcome } from './sign.js';
5
- import { colors, glyphs } from '../render/colors.js';
6
- import { renderTable } from '../render/table.js';
7
- import { redactForDisplay } from '../render/redact.js';
8
- import { group } from '../help/groups.js';
9
- import { printResult } from '../render/print.js';
10
- import { renderBalance } from '../render/balance.js';
11
- import { renderActivity } from '../render/activity.js';
12
- import { renderMandates } from '../render/mandates.js';
13
- import { renderAgents } from '../render/agents.js';
14
- import { renderChains } from '../render/chains.js';
15
- import { renderGas } from '../render/gas.js';
16
- import { renderAccounts } from '../render/accounts.js';
17
- import { section } from '../render/kv.js';
18
- import { step, notify, cliHitlContext } from '../progress.js';
19
- import { confirmOrThrow, dryRunPayload, renderReview } from '../review.js';
20
- import { classifyWalletSend } from '../outcome.js';
21
- import { assertAddress, assertTierAllows, familyOf, findChain, isTokenRef, parseAmount, symbolRefusalMessage, tokenChoicesFor, } from '../validate.js';
22
- import { CliError, EXIT, invalidArgs, notLoggedIn } from '../errors.js';
23
- import { appUrl } from '../app-url.js';
24
- /** A WalletSession carries `sessionToken` - redact it in any printed output unless --reveal, the same
25
- * redact-by-default rule the design spec commits to everywhere. Anything that prints a session (login,
26
- * status, whoami, accounts use) must route through this rather than printResult - or a hand-rolled
27
- * `console.log(JSON.stringify(...))` - directly.
28
- *
29
- * That is not a style preference. `login --json` hand-rolled its own print and shipped the owner's live
30
- * bearer to stdout in 0.3.0 - and `--json` is the PIPE path, so it landed in CI logs, shell history and
31
- * anything downstream of the pipe. One printer means one place to get this right. */
32
- function printSessionResult(ctx, data) {
33
- const session = data?.session ?? data;
34
- const token = session?.sessionToken;
35
- printResult(ctx, redactForDisplay(data, token, ctx.reveal));
36
- }
37
- async function requireWalletToken(ctx) {
38
- const session = await ctx.runtime.walletSession();
39
- if (!session) {
40
- throw notLoggedIn();
41
- }
42
- return session.sessionToken;
43
- }
44
- /** Same shape as `account.ts`/`authority.ts`/`services.ts`'s own `reviewAndSubmit` - duplicated per
45
- * `sign.ts`'s own comment on why a shared helper never gets pulled back out of that file. */
46
- async function reviewAndSubmit(ctx, title, rows, warnings, kind, params) {
47
- if (ctx.dryRun) {
48
- printResult(ctx, dryRunPayload(title, rows, warnings), () => renderReview('Dry run - nothing will be sent', rows, warnings));
49
- return;
50
- }
51
- await confirmOrThrow(ctx, title, rows, warnings);
52
- const outcome = await submitIntent(ctx, kind, params);
53
- printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
54
- }
55
- /** Read and parse a JSON file for a complex, arbitrarily-sized intent field - the same escape hatch
56
- * `agent request --calls-file` already established, rather than a wall of flags for a deeply nested
57
- * shape (a delegation grant's budgets, a mandate confirm's candidates). */
58
- async function readJsonFile(path, flag) {
59
- let raw;
60
- try {
61
- raw = await readFile(path, 'utf8');
62
- }
63
- catch (e) {
64
- throw invalidArgs(`Could not read ${flag} "${path}": ${e instanceof Error ? e.message : String(e)}`);
65
- }
66
- try {
67
- return JSON.parse(raw);
68
- }
69
- catch (e) {
70
- throw invalidArgs(`${flag} "${path}" is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
71
- }
72
- }
73
- export function registerWalletCommands(program) {
74
- group(program
75
- .command('login')
76
- .description('Log in as the account owner, in your browser')
77
- .action(async () => {
78
- const ctx = program.opts().__ctx;
79
- const result = await runLogin({
80
- api: ctx.runtime.api,
81
- store: ctx.runtime.walletSessionStore,
82
- openBrowser,
83
- // Alongside onPrompt below, never instead of it: onPrompt prints the code and the URL once,
84
- // and this reports each status change while the wait runs. Silent under --json.
85
- ctx: cliHitlContext(ctx),
86
- now: () => Date.now(),
87
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
88
- // The whole point of the 0.6.0 callback: say the code and the URL NOW, not when the flow ends
89
- // up to ten minutes later. Without this, an owner whose browser did not open sat in front of a
90
- // blank terminal holding the one thing they needed hostage until it expired.
91
- onPrompt: ({ userCode, url, browserOpened }) => {
92
- notify(ctx, glyphs.info(`Your code: ${colors.bold(userCode)}`));
93
- notify(ctx, browserOpened
94
- ? colors.dim(` Opened your browser to approve: ${url}`)
95
- : colors.warning(` Could not open a browser - open this URL to approve: ${url}`));
96
- },
97
- });
98
- if (result.status === 'authorized') {
99
- if (ctx.json) {
100
- // Same payload as 0.3.0, routed through the one redaction path instead of a second
101
- // hand-rolled JSON.stringify - see printSessionResult's header. --reveal is still the
102
- // deliberate opt-in for an owner who actually wants the token; the default never prints it.
103
- printSessionResult(ctx, { status: 'authorized', session: result.session ?? null });
104
- }
105
- else {
106
- console.log(glyphs.success(`Logged in${result.session ? ` (account ${result.session.accountId ?? 'default'})` : ''}`));
107
- }
108
- return;
109
- }
110
- throw new CliError(`Login ${result.status}.`, `login_${result.status}`, EXIT.notAuthenticated, result.status === 'expired' ? 'Run `botanary login` again.' : 'Approve the request in your browser next time.');
111
- }), 'Wallet');
112
- group(program
113
- .command('logout')
114
- .description('Revoke and clear the stored owner session')
115
- .action(async () => {
116
- const ctx = program.opts().__ctx;
117
- await step(ctx, 'Logging out', () => ctx.runtime.walletLogout());
118
- if (ctx.json) {
119
- console.log(JSON.stringify({ status: 'logged_out' }));
120
- }
121
- else {
122
- console.log(glyphs.success('Logged out.'));
123
- }
124
- }), 'Wallet');
125
- group(program
126
- .command('status')
127
- .description('Owner session and this machine\'s agent binding, side by side')
128
- .action(async () => {
129
- const ctx = program.opts().__ctx;
130
- const session = await ctx.runtime.walletSession();
131
- const hasAgent = await ctx.runtime.store.hasIdentity();
132
- const rows = [
133
- ['wallet', session ? colors.success(`logged in (account ${session.accountId ?? 'default'})`) : colors.muted('not logged in')],
134
- ['agent', hasAgent ? colors.success('identity present') : colors.muted('not paired')],
135
- ];
136
- if (ctx.json)
137
- return printSessionResult(ctx, { session, hasAgent });
138
- console.log(renderTable(rows));
139
- }), 'Wallet');
140
- group(program
141
- .command('whoami')
142
- .description('Who Botanary says you are, in both lanes')
143
- .action(async () => {
144
- const ctx = program.opts().__ctx;
145
- const session = await ctx.runtime.walletSession();
146
- const hasAgent = await ctx.runtime.store.hasIdentity();
147
- // An unclaimed agent identity makes GET /agents/me fail, and that is an ANSWER, not an error: it
148
- // is exactly what "this machine is not a connected agent" looks like. Never let it fail whoami.
149
- let agent = null;
150
- let agentError = null;
151
- if (hasAgent) {
152
- try {
153
- agent = await step(ctx, 'Asking Botanary about this agent', () => ctx.runtime.me());
154
- }
155
- catch (e) {
156
- agentError = e instanceof Error ? e.message : String(e);
157
- }
158
- }
159
- const payload = { session, hasAgent, agent, agentError };
160
- if (ctx.json)
161
- return printSessionResult(ctx, payload);
162
- const rows = [
163
- ['owner', session ? colors.success(`logged in (account ${session.accountId ?? 'default'})`) : colors.muted('not logged in')],
164
- ['agent key', hasAgent ? colors.success('present on this machine') : colors.muted('none on this machine')],
165
- [
166
- 'agent status',
167
- agent
168
- ? colors.success(`connected as "${agent.name ?? 'unnamed'}" on account ${agent.accountId ?? '-'}`)
169
- : colors.muted(agentError ? `not a connected agent (${agentError})` : 'not paired'),
170
- ],
171
- ];
172
- console.log(renderTable(rows));
173
- }), 'Wallet');
174
- group(program
175
- .command('chains')
176
- .description('Every chain this backend serves, and what each can do')
177
- .action(async () => {
178
- const ctx = program.opts().__ctx;
179
- const token = await requireWalletToken(ctx);
180
- const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', token));
181
- printResult(ctx, chains, renderChains);
182
- }), 'Wallet');
183
- group(program
184
- .command('gas')
185
- .description('Gas methods available for this account on a chain')
186
- .option('--chain-id <chainId>', 'Chain to check - see `botanary chains`')
187
- .action(async (opts) => {
188
- const ctx = program.opts().__ctx;
189
- const token = await requireWalletToken(ctx);
190
- const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', token));
191
- const chain = findChain(chains, opts.chainId ?? ctx.chain);
192
- // EVM chains are chainId-keyed and non-EVM chains are key-keyed, and GET /gas/methods takes
193
- // whichever applies - never both, and never a chainId invented for a chain that has none.
194
- const query = chain.chainId === null ? `key=${encodeURIComponent(chain.key)}` : `chainId=${chain.chainId}`;
195
- const methods = await step(ctx, 'Reading gas methods', () => ctx.runtime.api.get(`/v1/gas/methods?${query}`, token));
196
- printResult(ctx, methods, (data) => renderGas(data, `${chain.name} (${chain.key})`));
197
- }), 'Wallet');
198
- group(program
199
- .command('balance')
200
- .description('What this account holds, across every chain')
201
- .action(async () => {
202
- const ctx = program.opts().__ctx;
203
- const token = await requireWalletToken(ctx);
204
- const result = await step(ctx, 'Reading balances', () => fetchComposite(ctx.runtime.api, token, [
205
- { key: 'balance', path: '/v1/balance' },
206
- { key: 'chains', path: '/v1/chains' },
207
- ]));
208
- const freshness = portfolioFreshness(result.data.balance);
209
- printResult(ctx, { ...result.data, freshness }, (data) => renderBalance(data, { verbose: ctx.verbose }));
210
- }), 'Wallet');
211
- group(program
212
- .command('send')
213
- .description('Send tokens from an account you own')
214
- .option('--recipient <address>', 'Destination address (0x... on EVM)')
215
- .option('--amount <amount>', 'Amount in display units, e.g. 25')
216
- .option('--token <addressOrAssetRef>', 'Token contract address (0x...) or CAIP-19 asset ref - see `botanary tokens`')
217
- .option('--chain-id <chainId>', 'Chain to send on - see `botanary chains`')
218
- .action(async (opts) => {
219
- const ctx = program.opts().__ctx;
220
- const walletToken = await requireWalletToken(ctx);
221
- // Validate required inputs before making network calls. If chain-id is not specified and we're
222
- // not in interactive mode, fail immediately with missing input - do not fetch chains first.
223
- let amountRaw = opts.amount;
224
- let tokenRaw = opts.token;
225
- let recipient = opts.recipient;
226
- const chainId = opts.chainId ?? ctx.chain;
227
- if (!ctx.interactive && (!chainId || !amountRaw || !tokenRaw || !recipient)) {
228
- throw invalidArgs('Missing required input', 'Use --amount, --token, --recipient, and --chain-id, or run in an interactive terminal.');
229
- }
230
- const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
231
- const chain = findChain(chains, chainId);
232
- assertTierAllows(chain, 'send');
233
- const family = familyOf(chain.namespace);
234
- // Hoisted out of the interactive-only branch below: the non-interactive path needs `held` too, to
235
- // build a symbol refusal that names real candidates instead of complaining into the void. A
236
- // refusal that lists nothing because the CLI declined to look is the papercut this task exists to
237
- // remove.
238
- const balance = await step(ctx, 'Reading balances', () => ctx.runtime.api.get('/v1/balance', walletToken));
239
- const held = tokenChoicesFor(balance, chain.chainId ?? Number.NaN);
240
- if (ctx.interactive && (!amountRaw || !tokenRaw || !recipient)) {
241
- const answers = await clack.group({
242
- amount: () => clack.text({
243
- message: 'Amount?',
244
- placeholder: '25',
245
- initialValue: amountRaw,
246
- // Validate at the prompt too, so a typo is caught where it was made rather than three
247
- // steps later inside a build call.
248
- validate: (value) => {
249
- const parsed = Number(String(value).trim());
250
- return Number.isFinite(parsed) && parsed > 0 ? undefined : 'A positive number, e.g. 25';
251
- },
252
- }),
253
- token: () => held.length
254
- ? clack.select({
255
- message: `Token? (held on ${chain.name})`,
256
- // The picker's VALUE is the assetRef, not the symbol - the same identity `resolveAsset`
257
- // takes below, so a picked token never re-enters the ambiguity this task removes.
258
- options: held.map((h) => ({
259
- value: h.assetRef,
260
- label: `${h.symbol} (${h.amount ?? 0})`,
261
- hint: `${h.address} ${h.decimals}dp ${h.source ?? ''}`.trim(),
262
- })),
263
- })
264
- : clack.text({ message: 'Token contract address?', placeholder: '0xAb12...', initialValue: tokenRaw }),
265
- recipient: () => clack.text({
266
- message: 'To?',
267
- placeholder: family === 'evm' ? '0xAb12...9F3' : 'address',
268
- initialValue: recipient,
269
- }),
270
- }, {
271
- onCancel: () => {
272
- clack.cancel('Cancelled.');
273
- process.exit(0);
274
- },
275
- });
276
- amountRaw = answers.amount;
277
- tokenRaw = answers.token;
278
- recipient = answers.recipient;
279
- }
280
- const amount = parseAmount(amountRaw);
281
- if (!tokenRaw) {
282
- throw invalidArgs('--token is required.', 'A contract address or CAIP-19 asset ref - see `botanary tokens`.');
283
- }
284
- if (!isTokenRef(tokenRaw)) {
285
- const matches = held.filter((h) => h.symbol.toUpperCase() === String(tokenRaw).trim().toUpperCase());
286
- throw invalidArgs(symbolRefusalMessage(String(tokenRaw).trim(), chain.name, matches), 'Run `botanary tokens` to list every token this account holds.');
287
- }
288
- const to = assertAddress(recipient, family);
289
- const asset = await step(ctx, 'Resolving token', () => resolveAsset(ctx.runtime, chain.chainId ?? Number.NaN, tokenRaw, walletToken));
290
- // From the ORIGINAL typed string, not the round-tripped `amount` number: a float round-trip
291
- // (`String(0.1 + 0.2)`) can lose or invent trailing digits on a token amount, and base units are
292
- // exactly the value that must never drift from what the user actually typed.
293
- const sendAmountRaw = baseUnits(String(amountRaw), asset.decimals);
294
- const input = {
295
- recipient: to,
296
- amount,
297
- asset,
298
- amountRaw: sendAmountRaw,
299
- chainId: chain.chainId ?? Number.NaN,
300
- chainKey: chain.key,
301
- };
302
- const session = await ctx.runtime.walletSession();
303
- const mandate = await step(ctx, 'Checking mandates', () => resolveMandateForSend(ctx.runtime, input));
304
- const lane = mandate.accountId !== null && mandate.accountId === (session?.accountId ?? '') && mandate.inBounds
305
- ? 'mandate'
306
- : 'browser';
307
- const rows = [
308
- { label: 'amount', value: `${amount} ${asset.symbol}` },
309
- { label: 'token', value: `${asset.symbol} ${asset.address} ${asset.decimals}dp` },
310
- { label: 'source', value: asset.source },
311
- { label: 'to', value: to },
312
- { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
313
- {
314
- label: 'signed by',
315
- value: lane === 'mandate'
316
- ? "this machine's mandate, locally, with no browser"
317
- : 'you, in your browser',
318
- },
319
- ];
320
- const warnings = [];
321
- // `source: 'chain'` means resolution fell all the way through to reading the contract itself -
322
- // nothing in Botanary's registry, discovered set or imported list recognises this token, so the
323
- // name shown is exactly what the contract claims for itself and nothing more.
324
- if (asset.source === 'chain') {
325
- warnings.push(`Botanary does not recognise this token - nothing but the contract itself vouches for the name "${asset.symbol}". Check the address.`);
326
- }
327
- // The backend's own reason, not re-authored here: it is the only sentence that can honestly say
328
- // WHY the mandate lane was not taken.
329
- if (lane === 'browser' && mandate.accountId !== null && mandate.accountId !== (session?.accountId ?? '')) {
330
- warnings.push(`Mandate is bound to account ${mandate.accountId}, but you are logged into account ${session?.accountId ?? 'default'}.`);
331
- }
332
- else if (lane === 'browser' && mandate.accountId !== null && mandate.reason) {
333
- warnings.push(mandate.reason);
334
- }
335
- if (chain.testnet)
336
- warnings.push('This is a testnet chain.');
337
- if (ctx.dryRun) {
338
- const payload = dryRunPayload('Review this payment', rows, warnings);
339
- printResult(ctx, payload, () => `${renderReview('Dry run - nothing will be sent', rows, warnings)}`);
340
- return;
341
- }
342
- await confirmOrThrow(ctx, 'Review this payment', rows, warnings);
343
- // LANE ARBITRATION STAYS HERE - it is the decision `submitIntent` deliberately does not make
344
- // (see its own doc comment in sign.ts). Only the browser lane parks a sign request; the mandate
345
- // lane signs locally and never reaches submitIntent at all.
346
- if (lane === 'mandate') {
347
- const relay = await step(ctx, 'Signing under your mandate', () => signUnderMandateReal(ctx.runtime, mandate.grant, input));
348
- const result = {
349
- lane: 'agent',
350
- text: `Signed locally under this machine's own mandate on account ${session?.accountId ?? ''} - no ` +
351
- 'browser was opened. The on-chain mandate is what authorized this, not a click.',
352
- relay,
353
- };
354
- const outcome = classifyWalletSend(result);
355
- ctx.outcome.exit = outcome.exit;
356
- printResult(ctx, result, () => {
357
- const lines = [result.text];
358
- if (outcome.note)
359
- lines.push(colors.dim(outcome.note));
360
- return lines.join('\n');
361
- });
362
- return;
363
- }
364
- // Advisory only - never blocks the handoff. Computed before parking so its warning still reads
365
- // as "before you approve" rather than a footnote after the fact.
366
- const preflightWarning = await preflightReal(ctx.runtime, walletToken)(input);
367
- const outcome = await submitIntent(ctx, 'send', {
368
- chainKey: input.chainKey,
369
- token: input.asset.assetRef,
370
- amount: String(input.amount),
371
- amountRaw: input.amountRaw,
372
- to: input.recipient,
373
- });
374
- printResult(ctx, outcome.data, () => {
375
- const lines = [describeSignOutcome(outcome)];
376
- if (preflightWarning)
377
- lines.push(colors.dim(`Note: ${preflightWarning}`));
378
- if (outcome.note)
379
- lines.push(colors.dim(outcome.note));
380
- return lines.join('\n');
381
- });
382
- }), 'Wallet');
383
- group(program
384
- .command('swap')
385
- .description('Swap tokens in an account you own')
386
- .option('--token-in <addressOrAssetRef>', 'Token to sell - contract address or CAIP-19 asset ref, never a symbol')
387
- .option('--token-out <addressOrAssetRef>', 'Token to buy - contract address or CAIP-19 asset ref, never a symbol')
388
- .option('--amount-in <amount>', 'Amount of --token-in to sell, in display units, e.g. 25')
389
- .option('--chain-id <chainId>', 'Chain to swap on - see `botanary chains`')
390
- .option('--max-slippage-bps <bps>', 'Maximum acceptable slippage, in basis points')
391
- .action(async (opts) => {
392
- const ctx = program.opts().__ctx;
393
- const walletToken = await requireWalletToken(ctx);
394
- const tokenInRaw = opts.tokenIn;
395
- const tokenOutRaw = opts.tokenOut;
396
- const amountInRaw = opts.amountIn;
397
- const chainId = opts.chainId ?? ctx.chain;
398
- if (!chainId || !tokenInRaw || !tokenOutRaw || !amountInRaw) {
399
- throw invalidArgs('Missing required input', 'Use --token-in, --token-out, --amount-in, and --chain-id.');
400
- }
401
- const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
402
- const chain = findChain(chains, chainId);
403
- assertTierAllows(chain, 'send');
404
- if (chain.chainId === null) {
405
- throw invalidArgs(`${chain.name} (${chain.key}) has no EVM chain id - swap is EVM-only today.`, 'Pick an EVM chain - see `botanary chains`.');
406
- }
407
- const amountIn = parseAmount(amountInRaw, '--amount-in');
408
- // Fetched unconditionally, same as `send`: a symbol refusal that names real candidates instead
409
- // of complaining into the void needs this even on the success path, because the failure can be
410
- // discovered only after trying to validate the flag.
411
- const balance = await step(ctx, 'Reading balances', () => ctx.runtime.api.get('/v1/balance', walletToken));
412
- const held = tokenChoicesFor(balance, chain.chainId);
413
- for (const [raw, flag] of [
414
- [tokenInRaw, '--token-in'],
415
- [tokenOutRaw, '--token-out'],
416
- ]) {
417
- if (!isTokenRef(raw)) {
418
- const matches = held.filter((h) => h.symbol.toUpperCase() === String(raw).trim().toUpperCase());
419
- throw invalidArgs(symbolRefusalMessage(String(raw).trim(), chain.name, matches), `Run \`botanary tokens\` to list every token this account holds, then pass an address to ${flag}.`);
420
- }
421
- }
422
- const [assetIn, assetOut] = await Promise.all([
423
- step(ctx, 'Resolving token in', () => resolveAsset(ctx.runtime, chain.chainId, tokenInRaw, walletToken)),
424
- step(ctx, 'Resolving token out', () => resolveAsset(ctx.runtime, chain.chainId, tokenOutRaw, walletToken)),
425
- ]);
426
- const rows = [
427
- { label: 'sell', value: `${amountIn} ${assetIn.symbol}` },
428
- { label: 'buy', value: assetOut.symbol },
429
- { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
430
- { label: 'signed by', value: 'you, in your browser' },
431
- ];
432
- const warnings = [];
433
- if (assetIn.source === 'chain' || assetOut.source === 'chain') {
434
- warnings.push('At least one token is unrecognised by Botanary - nothing but the contract itself vouches for its name.');
435
- }
436
- if (chain.testnet)
437
- warnings.push('This is a testnet chain.');
438
- if (ctx.dryRun) {
439
- const payload = dryRunPayload('Review this swap', rows, warnings);
440
- printResult(ctx, payload, () => renderReview('Dry run - nothing will be sent', rows, warnings));
441
- return;
442
- }
443
- await confirmOrThrow(ctx, 'Review this swap', rows, warnings);
444
- const outcome = await submitIntent(ctx, 'swap', {
445
- chainId: chain.chainId,
446
- fromToken: { symbol: assetIn.symbol, address: assetIn.address },
447
- toToken: { symbol: assetOut.symbol, address: assetOut.address },
448
- amountIn: String(amountIn),
449
- ...(opts.maxSlippageBps ? { maxSlippageBps: Number(opts.maxSlippageBps) } : {}),
450
- });
451
- printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
452
- }), 'Wallet');
453
- group(program
454
- .command('activity')
455
- .description('Recent sends, swaps and yield transactions')
456
- .action(async () => {
457
- const ctx = program.opts().__ctx;
458
- const token = await requireWalletToken(ctx);
459
- const session = await ctx.runtime.walletSession();
460
- const result = await step(ctx, 'Reading activity', () => fetchComposite(ctx.runtime.api, token, [
461
- { key: 'history', path: scopedToAccount('/v1/history', session?.accountId ?? null) },
462
- ]));
463
- printResult(ctx, result.data, renderActivity);
464
- }), 'Wallet');
465
- group(program
466
- .command('markets')
467
- .description('Public market data: prices and symbols')
468
- .action(async () => {
469
- const ctx = program.opts().__ctx;
470
- const token = await requireWalletToken(ctx);
471
- const result = await step(ctx, 'Reading markets', () => fetchComposite(ctx.runtime.api, token, [{ key: 'tokens', path: '/v1/markets/tokens' }]));
472
- printResult(ctx, result.data);
473
- }), 'Wallet');
474
- const yieldCmd = group(program
475
- .command('yield')
476
- .description('Yield pools, a shortlist, and this account\'s positions')
477
- .action(async () => {
478
- const ctx = program.opts().__ctx;
479
- const token = await requireWalletToken(ctx);
480
- const session = await ctx.runtime.walletSession();
481
- const result = await step(ctx, 'Reading yield', () => fetchComposite(ctx.runtime.api, token, [
482
- { key: 'pools', path: '/v1/yield/pools' },
483
- { key: 'shortlist', path: '/v1/yield/shortlist' },
484
- { key: 'positions', path: scopedToAccount('/v1/farm/positions', session?.accountId ?? null) },
485
- ]));
486
- printResult(ctx, {
487
- ...result.data,
488
- scopeCaveats: 'shortlist is ranked off your own holdings server-side and is not scoped by accounts use.',
489
- });
490
- }), 'Wallet');
491
- /** Shared by `yield deposit` and `yield withdraw` - identical shape (`--pool-id`, `--amount`,
492
- * `--chain-id`), differing only in the sign-request `kind` and the review title. `yield claim` is NOT
493
- * built off this: `CliFarmClaimIntentDto` carries no `amount` field at all - the backend decides what
494
- * is claimable from a live probe of the account's own accrued rewards, so there is nothing for an
495
- * `--amount` flag on claim to mean. */
496
- function registerFarmAmountCommand(kind, name, verb) {
497
- yieldCmd
498
- .command(name)
499
- .description(`${verb[0].toUpperCase()}${verb.slice(1)} a yield pool, for an account you own`)
500
- .option('--pool-id <poolId>', 'Pool id - see `botanary yield`')
501
- .option('--amount <amount>', `Amount of the pool's underlying asset, in display units, e.g. 25`)
502
- .option('--chain-id <chainId>', 'Chain the pool is on - see `botanary yield`')
503
- .action(async (opts) => {
504
- const ctx = program.opts().__ctx;
505
- const walletToken = await requireWalletToken(ctx);
506
- const poolId = opts.poolId;
507
- const amountRaw = opts.amount;
508
- const chainId = opts.chainId ?? ctx.chain;
509
- if (!poolId || !amountRaw || !chainId) {
510
- throw invalidArgs('Missing required input', 'Use --pool-id, --amount, and --chain-id.');
511
- }
512
- const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
513
- const chain = findChain(chains, chainId);
514
- assertTierAllows(chain, 'send');
515
- if (chain.chainId === null) {
516
- throw invalidArgs(`${chain.name} (${chain.key}) has no EVM chain id - ${verb} is EVM-only today.`, 'Pick an EVM chain - see `botanary chains`.');
517
- }
518
- const amount = parseAmount(amountRaw);
519
- const rows = [
520
- { label: 'pool', value: String(poolId) },
521
- { label: 'amount', value: String(amount) },
522
- { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
523
- { label: 'signed by', value: 'you, in your browser' },
524
- ];
525
- const warnings = [];
526
- if (chain.testnet)
527
- warnings.push('This is a testnet chain.');
528
- if (ctx.dryRun) {
529
- const payload = dryRunPayload(`Review this ${verb}`, rows, warnings);
530
- printResult(ctx, payload, () => renderReview('Dry run - nothing will be sent', rows, warnings));
531
- return;
532
- }
533
- await confirmOrThrow(ctx, `Review this ${verb}`, rows, warnings);
534
- const outcome = await submitIntent(ctx, kind, {
535
- poolId: String(poolId),
536
- chainId: chain.chainId,
537
- amount: String(amount),
538
- });
539
- printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
540
- });
541
- }
542
- registerFarmAmountCommand('farm.deposit', 'deposit', 'deposit');
543
- registerFarmAmountCommand('farm.withdraw', 'withdraw', 'withdraw');
544
- yieldCmd
545
- .command('claim')
546
- .description('Claim accrued rewards from a yield pool, for an account you own')
547
- .option('--pool-id <poolId>', 'Pool id - see `botanary yield`')
548
- .option('--chain-id <chainId>', 'Chain the pool is on - see `botanary yield`')
549
- .action(async (opts) => {
550
- const ctx = program.opts().__ctx;
551
- const walletToken = await requireWalletToken(ctx);
552
- const poolId = opts.poolId;
553
- const chainId = opts.chainId ?? ctx.chain;
554
- if (!poolId || !chainId) {
555
- throw invalidArgs('Missing required input', 'Use --pool-id and --chain-id.');
556
- }
557
- const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
558
- const chain = findChain(chains, chainId);
559
- assertTierAllows(chain, 'send');
560
- if (chain.chainId === null) {
561
- throw invalidArgs(`${chain.name} (${chain.key}) has no EVM chain id - claim is EVM-only today.`, 'Pick an EVM chain - see `botanary chains`.');
562
- }
563
- const rows = [
564
- { label: 'pool', value: String(poolId) },
565
- { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
566
- { label: 'signed by', value: 'you, in your browser' },
567
- ];
568
- const warnings = [];
569
- if (chain.testnet)
570
- warnings.push('This is a testnet chain.');
571
- if (ctx.dryRun) {
572
- const payload = dryRunPayload('Review this claim', rows, warnings);
573
- printResult(ctx, payload, () => renderReview('Dry run - nothing will be sent', rows, warnings));
574
- return;
575
- }
576
- await confirmOrThrow(ctx, 'Review this claim', rows, warnings);
577
- const outcome = await submitIntent(ctx, 'farm.claim', {
578
- poolId: String(poolId),
579
- chainId: chain.chainId,
580
- });
581
- printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
582
- });
583
- // ---- solana: send, swap, and Botanary Mandates Program writes -------------------------------------
584
- // Solana is key-keyed, not chainId-keyed (workspace CLAUDE.md, "Chain families and tiers") - every
585
- // command below takes --key (see `botanary chains`, e.g. "solana" or "solana-devnet") rather than
586
- // --chain-id. Kept as its own group rather than folded into `send`/`swap` above: the DTOs genuinely
587
- // differ (raw base-unit amount strings, a `key` selector, no `resolveAsset`/mandate-lane round trip),
588
- // so branching one command on chain family would replace one simple code path with two.
589
- const solana = group(program.command('solana').description('Send, swap and manage Botanary Mandates on Solana'), 'Wallet');
590
- solana
591
- .command('send')
592
- .description('Send tokens on Solana')
593
- .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`, e.g. solana or solana-devnet')
594
- .requiredOption('--to <address>', 'Destination Solana address')
595
- .option('--mint <address>', 'SPL token mint - omit to send native SOL')
596
- .requiredOption('--amount <amount>', 'Raw base-unit amount (lamports for SOL, raw token units for SPL) - not display units')
597
- .option('--gas-method <method>', 'native, or usdc routed through Kora, gasless')
598
- .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
599
- .action(async (opts) => {
600
- const ctx = program.opts().__ctx;
601
- await requireWalletToken(ctx);
602
- if (opts.gasMethod !== undefined && opts.gasMethod !== 'native' && opts.gasMethod !== 'usdc') {
603
- throw invalidArgs(`--gas-method must be "native" or "usdc", got ${JSON.stringify(opts.gasMethod)}.`);
604
- }
605
- const to = assertAddress(opts.to, 'solana');
606
- const rows = [
607
- { label: 'amount', value: `${opts.amount} (raw base units)` },
608
- { label: 'to', value: to },
609
- { label: 'chain', value: opts.key },
610
- { label: 'signed by', value: 'you, in your browser' },
611
- ];
612
- await reviewAndSubmit(ctx, 'Review this Solana send', rows, [], 'solana.send', {
613
- key: opts.key,
614
- to,
615
- amount: opts.amount,
616
- ...(opts.mint ? { mint: opts.mint } : {}),
617
- ...(opts.gasMethod ? { gasMethod: opts.gasMethod } : {}),
618
- ...(opts.accountId ? { accountId: opts.accountId } : {}),
619
- });
620
- });
621
- solana
622
- .command('swap')
623
- .description('Swap tokens on Solana')
624
- .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
625
- .option('--from-mint <address>', 'Mint to sell - omit for native SOL')
626
- .option('--to-mint <address>', 'Mint to buy - omit for native SOL')
627
- .requiredOption('--amount-in <amount>', 'Amount of the sell side, in HUMAN-SCALE units (e.g. 1.5), not raw base units')
628
- .requiredOption('--max-slippage-bps <bps>', 'Maximum acceptable slippage, in basis points')
629
- .option('--gas-method <method>', 'native, or usdc routed through Kora, gasless')
630
- .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
631
- .action(async (opts) => {
632
- const ctx = program.opts().__ctx;
633
- await requireWalletToken(ctx);
634
- if (opts.gasMethod !== undefined && opts.gasMethod !== 'native' && opts.gasMethod !== 'usdc') {
635
- throw invalidArgs(`--gas-method must be "native" or "usdc", got ${JSON.stringify(opts.gasMethod)}.`);
636
- }
637
- const amountIn = parseAmount(opts.amountIn, '--amount-in');
638
- const maxSlippageBps = Number(opts.maxSlippageBps);
639
- if (!Number.isInteger(maxSlippageBps) || maxSlippageBps < 0) {
640
- throw invalidArgs(`--max-slippage-bps must be a non-negative integer, got ${JSON.stringify(opts.maxSlippageBps)}.`);
641
- }
642
- const rows = [
643
- { label: 'sell', value: `${amountIn}${opts.fromMint ? ` of ${opts.fromMint}` : ' SOL'}` },
644
- { label: 'buy', value: opts.toMint ?? 'SOL' },
645
- { label: 'chain', value: opts.key },
646
- { label: 'signed by', value: 'you, in your browser' },
647
- ];
648
- await reviewAndSubmit(ctx, 'Review this Solana swap', rows, [], 'solana.swap', {
649
- key: opts.key,
650
- amountIn,
651
- maxSlippageBps,
652
- ...(opts.fromMint ? { fromMint: opts.fromMint } : {}),
653
- ...(opts.toMint ? { toMint: opts.toMint } : {}),
654
- ...(opts.gasMethod ? { gasMethod: opts.gasMethod } : {}),
655
- ...(opts.accountId ? { accountId: opts.accountId } : {}),
656
- });
657
- });
658
- const solanaMandate = solana
659
- .command('mandate')
660
- .description('Grant, freeze/unfreeze and revoke Botanary Mandates Program delegations on Solana');
661
- solanaMandate
662
- .command('grant')
663
- .description('Grant a bounded Solana mandate')
664
- .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
665
- .requiredOption('--mint <address>', 'The SPL token mint this mandate bounds')
666
- .requiredOption('--recipient <address...>', 'An allowlisted recipient - repeat for each (at least one, fail-closed)')
667
- .option('--venue <address...>', 'An allowlisted venue program id - repeat for each; omit for none')
668
- .requiredOption('--per-action-max <amount>', 'Raw base-unit max spend per action')
669
- .requiredOption('--cap-limit <amount>', 'Raw base-unit budget per --cap-period-seconds window')
670
- .requiredOption('--cap-period-seconds <seconds>', 'The window --cap-limit applies over, at least 3600 (1 hour)')
671
- .requiredOption('--expiry <iso>', 'When this mandate expires, ISO 8601 - mandatory, unlike the EVM delegation lane')
672
- .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
673
- .action(async (opts) => {
674
- const ctx = program.opts().__ctx;
675
- await requireWalletToken(ctx);
676
- const capPeriodSeconds = Number(opts.capPeriodSeconds);
677
- if (!Number.isInteger(capPeriodSeconds) || capPeriodSeconds < 3600) {
678
- throw invalidArgs(`--cap-period-seconds must be an integer of at least 3600, got ${JSON.stringify(opts.capPeriodSeconds)}.`);
679
- }
680
- const rows = [
681
- { label: 'mint', value: opts.mint },
682
- { label: 'recipients', value: opts.recipient.join(', ') },
683
- { label: 'venues', value: opts.venue?.length ? opts.venue.join(', ') : 'none' },
684
- { label: 'per-action max', value: `${opts.perActionMax} (raw base units)` },
685
- { label: 'cap', value: `${opts.capLimit} (raw base units) every ${capPeriodSeconds}s` },
686
- { label: 'expiry', value: opts.expiry },
687
- { label: 'chain', value: opts.key },
688
- { label: 'signed by', value: 'you, in your browser' },
689
- ];
690
- await reviewAndSubmit(ctx, 'Review this Solana mandate grant', rows, [], 'solana.mandate.grant', {
691
- key: opts.key,
692
- mint: opts.mint,
693
- recipientAllowlist: opts.recipient,
694
- venueAllowlist: opts.venue ?? [],
695
- perActionMax: opts.perActionMax,
696
- capLimit: opts.capLimit,
697
- capPeriodSeconds,
698
- expiry: opts.expiry,
699
- ...(opts.accountId ? { accountId: opts.accountId } : {}),
700
- });
701
- });
702
- solanaMandate
703
- .command('freeze')
704
- .description('Freeze (or unfreeze) every Solana mandate for this owner - one kill switch across every mint')
705
- .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
706
- .option('--unfreeze', 'Unfreeze instead of freeze')
707
- .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
708
- .action(async (opts) => {
709
- const ctx = program.opts().__ctx;
710
- await requireWalletToken(ctx);
711
- const frozen = !opts.unfreeze;
712
- const rows = [
713
- {
714
- label: 'action',
715
- value: frozen ? 'freeze every Solana mandate for this owner' : 'unfreeze every Solana mandate for this owner',
716
- },
717
- { label: 'chain', value: opts.key },
718
- { label: 'signed by', value: 'you, in your browser' },
719
- ];
720
- const warnings = frozen
721
- ? ['This stops the copilot acting under ANY Solana mandate for this owner, across every mint, instantly.']
722
- : [];
723
- await reviewAndSubmit(ctx, `Review this Solana mandate ${frozen ? 'freeze' : 'unfreeze'}`, rows, warnings, 'solana.mandate.freeze', { key: opts.key, frozen, ...(opts.accountId ? { accountId: opts.accountId } : {}) });
724
- });
725
- solanaMandate
726
- .command('revoke <id>')
727
- .description('Revoke a Solana mandate')
728
- .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
729
- .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
730
- .action(async (id, opts) => {
731
- const ctx = program.opts().__ctx;
732
- await requireWalletToken(ctx);
733
- const rows = [
734
- { label: 'mandate', value: id },
735
- { label: 'chain', value: opts.key },
736
- { label: 'signed by', value: 'you, in your browser' },
737
- ];
738
- await reviewAndSubmit(ctx, 'Review this Solana mandate revoke', rows, [], 'solana.mandate.revoke', {
739
- id,
740
- key: opts.key,
741
- ...(opts.accountId ? { accountId: opts.accountId } : {}),
742
- });
743
- });
744
- const mandates = group(program
745
- .command('mandates')
746
- .description('What the agent lane may spend unattended (testnet only)')
747
- .action(async () => {
748
- const ctx = program.opts().__ctx;
749
- const token = await requireWalletToken(ctx);
750
- const session = await ctx.runtime.walletSession();
751
- const result = await step(ctx, 'Reading mandates', () => fetchComposite(ctx.runtime.api, token, [
752
- { key: 'delegations', path: scopedToAccount('/v1/delegations', session?.accountId ?? null) },
753
- { key: 'mandates', path: scopedToAccount('/v1/mandates', session?.accountId ?? null) },
754
- ]));
755
- printResult(ctx, result.data, renderMandates);
756
- }), 'Wallet');
757
- mandates
758
- .command('suggest')
759
- .description('Suggest a bounded mandate for each agent that has none')
760
- .action(async () => {
761
- const ctx = program.opts().__ctx;
762
- const token = await requireWalletToken(ctx);
763
- const session = await ctx.runtime.walletSession();
764
- const result = await step(ctx, 'Reading agents and mandates', () => fetchComposite(ctx.runtime.api, token, [
765
- { key: 'agents', path: scopedToAccount('/v1/agents', session?.accountId ?? null) },
766
- { key: 'delegations', path: scopedToAccount('/v1/delegations', session?.accountId ?? null) },
767
- ]));
768
- const agents = Array.isArray(result.data.agents) ? result.data.agents : [];
769
- const delegations = Array.isArray(result.data.delegations) ? result.data.delegations : [];
770
- const granted = new Set(delegations.map((d) => String(d.delegateeAddress ?? '').toLowerCase()));
771
- const suggestions = agents
772
- .filter((agent) => !granted.has(String(agent.address ?? '').toLowerCase()))
773
- .map((agent) => ({
774
- agentId: String(agent.id ?? ''),
775
- agentName: String(agent.name ?? 'unnamed'),
776
- // NEVER defaulted: naming a chain the backend did not state would invent the single most
777
- // consequential fact in a spending suggestion - WHERE the authority would exist.
778
- chainId: typeof agent.chainId === 'number' ? agent.chainId : null,
779
- rationale: 'This agent is connected but has no mandate, so it can do nothing unattended.',
780
- grantUrl: `${appUrl(ctx.runtime.apiBaseUrl, 'agents')}/${encodeURIComponent(String(agent.id ?? ''))}`,
781
- }));
782
- const payload = {
783
- suggestions,
784
- note: agents.length === 0
785
- ? 'No agents are connected to this account yet. Run `botanary agent pair` on the machine you want to connect.'
786
- : 'Suggestions only. Granting happens in Botanary, and never widens an existing grant.',
787
- };
788
- printResult(ctx, payload, () => suggestions.length
789
- ? [
790
- section('Suggested mandates'),
791
- ...suggestions.map((s) => ` ${s.agentName} (${s.agentId})\n ${s.rationale}\n ${s.grantUrl}`),
792
- colors.dim(` ${payload.note}`),
793
- ].join('\n')
794
- : `${section('Suggested mandates')}\n${colors.muted(` ${payload.note}`)}`);
795
- });
796
- // ---- mandates writes: two parallel systems, two distinct id spaces --------------------------------
797
- // `botanary mandates` above already reads BOTH tables side by side (see `render/mandates.ts`):
798
- // "Delegations (Smart Sessions)" - the classic EVM Smart Session grant lane, `delegation.*` kinds,
799
- // ids from `POST /delegations` - and "Mandates (on-chain)" - the newer rules/mandates system,
800
- // `mandate.*` kinds, ids from `POST /mandates/confirm`. The write verbs below are deliberately named
801
- // to keep the two apart: `grant`/`freeze`/`unfreeze`/`revoke` target a DELEGATION id (the first
802
- // table); `confirm`/`pause`/`resume`/`cancel` target a MANDATE id (the second table). `cancel`, not
803
- // `revoke`, for `mandate.revoke` - `revoke` was already spoken for by `delegation.revoke` above it,
804
- // and reusing one verb across two id spaces is exactly the kind of ambiguity a CLI cannot recover
805
- // from the way a web form with two separate buttons can.
806
- mandates
807
- .command('grant')
808
- .description('Grant bounded access to a delegatee agent (a Smart Session - the classic delegation lane)')
809
- .requiredOption('--grant-file <path>', 'JSON object: { name, delegateeAddress, chainId, budgets: [{ token: { symbol }, amount }], ... } - ' +
810
- 'see the README for the full shape (perActionMax, recipientAllowlist, allowedContract, deniedContracts, ' +
811
- 'conditions, maxActions, rate, expiresAt, alsoOnChainIds, agentId, mandateId, mandateVersion)')
812
- .option('--name <name>', "Override the file's name field")
813
- .option('--delegatee-address <address>', "Override the file's delegateeAddress field")
814
- .option('--chain-id <chainId>', "Override the file's chainId field")
815
- .action(async (opts) => {
816
- const ctx = program.opts().__ctx;
817
- await requireWalletToken(ctx);
818
- const fileParams = await readJsonFile(opts.grantFile, '--grant-file');
819
- if (typeof fileParams !== 'object' || fileParams === null || Array.isArray(fileParams)) {
820
- throw invalidArgs('--grant-file must contain a single JSON object.');
821
- }
822
- const params = { ...fileParams };
823
- if (opts.name !== undefined)
824
- params.name = opts.name;
825
- if (opts.delegateeAddress !== undefined)
826
- params.delegateeAddress = opts.delegateeAddress;
827
- if (opts.chainId !== undefined)
828
- params.chainId = Number(opts.chainId);
829
- if (typeof params.name !== 'string' || !params.name) {
830
- throw invalidArgs('name is required.', 'Set it in --grant-file, or pass --name.');
831
- }
832
- if (typeof params.delegateeAddress !== 'string' || !params.delegateeAddress) {
833
- throw invalidArgs('delegateeAddress is required.', 'Set it in --grant-file, or pass --delegatee-address.');
834
- }
835
- if (typeof params.chainId !== 'number' || !Number.isFinite(params.chainId)) {
836
- throw invalidArgs('chainId is required.', 'Set it in --grant-file, or pass --chain-id.');
837
- }
838
- if (!Array.isArray(params.budgets) || params.budgets.length === 0) {
839
- throw invalidArgs('budgets is required and must be a non-empty array.', 'Set it in --grant-file - see the README for the shape.');
840
- }
841
- const rows = [
842
- { label: 'name', value: String(params.name) },
843
- { label: 'delegatee', value: String(params.delegateeAddress) },
844
- { label: 'chain id', value: String(params.chainId) },
845
- { label: 'budgets', value: String(params.budgets.length) },
846
- { label: 'signed by', value: 'you, in your browser' },
847
- ];
848
- await reviewAndSubmit(ctx, 'Review this mandate grant', rows, [], 'delegation.grant', params);
849
- });
850
- mandates
851
- .command('freeze <delegationId>')
852
- .description('Freeze a delegation (reversible) - id from the Delegations table in `botanary mandates`')
853
- .action(async (delegationId) => {
854
- const ctx = program.opts().__ctx;
855
- await requireWalletToken(ctx);
856
- const rows = [
857
- { label: 'delegation', value: delegationId },
858
- { label: 'signed by', value: 'you, in your browser' },
859
- ];
860
- await reviewAndSubmit(ctx, 'Review this delegation freeze', rows, [], 'delegation.freeze', { delegationId });
861
- });
862
- mandates
863
- .command('unfreeze <delegationId>')
864
- .description('Unfreeze a delegation - id from the Delegations table in `botanary mandates`')
865
- .action(async (delegationId) => {
866
- const ctx = program.opts().__ctx;
867
- await requireWalletToken(ctx);
868
- const rows = [
869
- { label: 'delegation', value: delegationId },
870
- { label: 'signed by', value: 'you, in your browser' },
871
- ];
872
- await reviewAndSubmit(ctx, 'Review this delegation unfreeze', rows, [], 'delegation.unfreeze', { delegationId });
873
- });
874
- mandates
875
- .command('revoke <delegationId>')
876
- .description('Revoke a delegation (permanent) - id from the Delegations table in `botanary mandates`')
877
- .action(async (delegationId) => {
878
- const ctx = program.opts().__ctx;
879
- await requireWalletToken(ctx);
880
- const rows = [
881
- { label: 'delegation', value: delegationId },
882
- { label: 'signed by', value: 'you, in your browser' },
883
- ];
884
- const warnings = ['This is permanent - use `botanary mandates freeze` instead if you may want it back.'];
885
- await reviewAndSubmit(ctx, 'Review this delegation revoke', rows, warnings, 'delegation.revoke', { delegationId });
886
- });
887
- mandates
888
- .command('confirm')
889
- .description('Confirm and enable proposed mandates or rules - candidates usually come from `botanary mandates propose`')
890
- .requiredOption('--candidates-file <path>', 'JSON array of proposed objects, typically saved from `botanary mandates propose --json`')
891
- .action(async (opts) => {
892
- const ctx = program.opts().__ctx;
893
- await requireWalletToken(ctx);
894
- const candidates = await readJsonFile(opts.candidatesFile, '--candidates-file');
895
- if (!Array.isArray(candidates) || candidates.length === 0) {
896
- throw invalidArgs('--candidates-file must contain a non-empty JSON array.');
897
- }
898
- const rows = [
899
- { label: 'candidates', value: String(candidates.length) },
900
- { label: 'signed by', value: 'you, in your browser' },
901
- ];
902
- await reviewAndSubmit(ctx, 'Review this mandate confirm', rows, [], 'mandate.confirm', { candidates });
903
- });
904
- mandates
905
- .command('pause <mandateId>')
906
- .description('Pause a mandate (reversible) - id from the Mandates (on-chain) table in `botanary mandates`')
907
- .action(async (mandateId) => {
908
- const ctx = program.opts().__ctx;
909
- await requireWalletToken(ctx);
910
- const rows = [
911
- { label: 'mandate', value: mandateId },
912
- { label: 'signed by', value: 'you, in your browser' },
913
- ];
914
- await reviewAndSubmit(ctx, 'Review this mandate pause', rows, [], 'mandate.pause', { mandateId });
915
- });
916
- mandates
917
- .command('resume <mandateId>')
918
- .description('Resume a paused mandate - id from the Mandates (on-chain) table in `botanary mandates`')
919
- .action(async (mandateId) => {
920
- const ctx = program.opts().__ctx;
921
- await requireWalletToken(ctx);
922
- const rows = [
923
- { label: 'mandate', value: mandateId },
924
- { label: 'signed by', value: 'you, in your browser' },
925
- ];
926
- await reviewAndSubmit(ctx, 'Review this mandate resume', rows, [], 'mandate.resume', { mandateId });
927
- });
928
- mandates
929
- .command('cancel <mandateId>')
930
- .description('Revoke a mandate (permanent) - named cancel, not revoke, to stay distinct from `mandates revoke`, which ' +
931
- 'targets a delegation, not a mandate. Id from the Mandates (on-chain) table in `botanary mandates`.')
932
- .action(async (mandateId) => {
933
- const ctx = program.opts().__ctx;
934
- await requireWalletToken(ctx);
935
- const rows = [
936
- { label: 'mandate', value: mandateId },
937
- { label: 'signed by', value: 'you, in your browser' },
938
- ];
939
- const warnings = ['This is permanent - use `botanary mandates pause` instead if you may want it back.'];
940
- await reviewAndSubmit(ctx, 'Review this mandate cancel', rows, warnings, 'mandate.revoke', { mandateId });
941
- });
942
- group(program
943
- .command('agents')
944
- .description('Connected agents and any pending approval requests')
945
- .action(async () => {
946
- const ctx = program.opts().__ctx;
947
- const token = await requireWalletToken(ctx);
948
- const session = await ctx.runtime.walletSession();
949
- const result = await step(ctx, 'Reading agents', () => fetchComposite(ctx.runtime.api, token, [
950
- { key: 'agents', path: scopedToAccount('/v1/agents', session?.accountId ?? null) },
951
- { key: 'requests', path: scopedToAccount('/v1/agents/requests', session?.accountId ?? null) },
952
- ]));
953
- printResult(ctx, result.data, renderAgents);
954
- }), 'Wallet');
955
- group(program
956
- .command('open [target]')
957
- .description('Open Botanary in your browser')
958
- .action(async (target) => {
959
- const ctx = program.opts().__ctx;
960
- const url = appUrl(ctx.runtime.apiBaseUrl, target);
961
- const opened = await openBrowser(url);
962
- printResult(ctx, { url, opened }, () => opened ? glyphs.success(`Opened ${url}`) : glyphs.warning(`Could not open a browser - go to ${url}`));
963
- }), 'Wallet');
964
- const accounts = group(program
965
- .command('accounts')
966
- .description('Manage which owner account subsequent commands use'), 'Wallet');
967
- accounts
968
- .command('list')
969
- .description('Every account this login can see')
970
- .action(async () => {
971
- const ctx = program.opts().__ctx;
972
- const token = await requireWalletToken(ctx);
973
- const session = await ctx.runtime.walletSession();
974
- const list = await step(ctx, 'Reading accounts', () => ctx.runtime.api.get('/v1/accounts', token));
975
- printResult(ctx, list, (data) => renderAccounts(data, session?.accountId ?? null));
976
- });
977
- accounts
978
- .command('use <accountId>')
979
- .description('Switch the active account (wallet_use_account)')
980
- .action(async (accountId) => {
981
- const ctx = program.opts().__ctx;
982
- const token = await requireWalletToken(ctx);
983
- const list = await step(ctx, 'Switching account', () => ctx.runtime.api.get('/v1/accounts', token));
984
- if (!Array.isArray(list) || !list.some((a) => a.id === accountId)) {
985
- throw invalidArgs(`No such account id: ${accountId}`, 'Use `botanary accounts list` to see your account ids.');
986
- }
987
- const session = await ctx.runtime.walletSessionStore.switchAccount(accountId);
988
- printSessionResult(ctx, { session });
989
- });
990
- group(program
991
- .command('api [path]')
992
- .description('Read any documented GET endpoint as the owner')
993
- .option('--list [filter]', 'List the documented GET routes instead of calling one')
994
- .action(async (path, opts) => {
995
- const ctx = program.opts().__ctx;
996
- if (opts.list !== undefined) {
997
- const filter = typeof opts.list === 'string' ? opts.list.toLowerCase() : '';
998
- const routes = GET_ROUTES.filter((route) => route.toLowerCase().includes(filter));
999
- printResult(ctx, routes, () => [
1000
- section(`Documented GET routes${filter ? ` matching "${filter}"` : ''}`),
1001
- ...routes.map((route) => ` ${route}`),
1002
- colors.dim(` ${routes.length} of ${GET_ROUTES.length}. Call one with \`botanary api <path>\`.`),
1003
- ].join('\n'));
1004
- return;
1005
- }
1006
- if (!path)
1007
- throw invalidArgs('A path is required.', 'e.g. `botanary api /chains`, or `botanary api --list`.');
1008
- const token = await requireWalletToken(ctx);
1009
- const result = await step(ctx, `GET ${path}`, () => walletApiGet({ api: ctx.runtime.api, token }, path, 'GET'));
1010
- printResult(ctx, result);
1011
- }), 'Wallet');
1012
- }
1013
- //# sourceMappingURL=wallet.js.map