@promus/cli 0.24.17

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 (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { parseTelegramArgs } from './telegram'
3
+
4
+ describe('parseTelegramArgs', () => {
5
+ it('errors on missing subcommand', () => {
6
+ const r = parseTelegramArgs([])
7
+ expect('error' in r).toBe(true)
8
+ if ('error' in r) expect(r.error).toContain('usage')
9
+ })
10
+
11
+ it('errors on unknown subcommand', () => {
12
+ const r = parseTelegramArgs(['nuke'])
13
+ expect('error' in r).toBe(true)
14
+ if ('error' in r) expect(r.error).toContain('nuke')
15
+ })
16
+
17
+ it('parses setup', () => {
18
+ const r = parseTelegramArgs(['setup'])
19
+ expect('error' in r).toBe(false)
20
+ if (!('error' in r)) expect(r.sub).toBe('setup')
21
+ })
22
+
23
+ it('parses status', () => {
24
+ const r = parseTelegramArgs(['status'])
25
+ if (!('error' in r)) expect(r.sub).toBe('status')
26
+ })
27
+
28
+ it('parses remove without --yes', () => {
29
+ const r = parseTelegramArgs(['remove'])
30
+ if (!('error' in r)) {
31
+ expect(r.sub).toBe('remove')
32
+ expect(r.yes).toBeFalsy()
33
+ }
34
+ })
35
+
36
+ it('parses remove --yes', () => {
37
+ const r = parseTelegramArgs(['remove', '--yes'])
38
+ if (!('error' in r)) {
39
+ expect(r.sub).toBe('remove')
40
+ expect(r.yes).toBe(true)
41
+ }
42
+ })
43
+
44
+ it('parses remove -y', () => {
45
+ const r = parseTelegramArgs(['remove', '-y'])
46
+ if (!('error' in r)) {
47
+ expect(r.yes).toBe(true)
48
+ }
49
+ })
50
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `promus telegram <subcommand>` — argv dispatcher.
3
+ *
4
+ * Subcommands:
5
+ * setup interactive wizard: validate token, encrypt + persist locally
6
+ * status confirm token still valid + show stored config
7
+ * remove delete the encrypted local blob (does NOT revoke at @BotFather)
8
+ */
9
+
10
+ export interface TelegramArgs {
11
+ sub: 'setup' | 'status' | 'remove'
12
+ yes?: boolean
13
+ }
14
+
15
+ const VALID_SUBS = ['setup', 'status', 'remove'] as const
16
+
17
+ export function parseTelegramArgs(argv: string[]): TelegramArgs | { error: string } {
18
+ const sub = argv[0]
19
+ if (!sub) return { error: 'usage: promus telegram <setup | status | remove>' }
20
+ const valid = (VALID_SUBS as readonly string[]).includes(sub)
21
+ if (!valid) return { error: `unknown subcommand '${sub}' (expected: ${VALID_SUBS.join(' | ')})` }
22
+ const yes = argv.includes('--yes') || argv.includes('-y')
23
+ return { sub: sub as TelegramArgs['sub'], yes }
24
+ }
25
+
26
+ export async function runTelegram(args: TelegramArgs): Promise<void> {
27
+ switch (args.sub) {
28
+ case 'setup': {
29
+ const { runTelegramSetup } = await import('./telegram-setup')
30
+ await runTelegramSetup()
31
+ return
32
+ }
33
+ case 'status': {
34
+ const { runTelegramStatus } = await import('./telegram-status')
35
+ await runTelegramStatus()
36
+ return
37
+ }
38
+ case 'remove': {
39
+ const { runTelegramRemove } = await import('./telegram-remove')
40
+ await runTelegramRemove({ yes: args.yes })
41
+ return
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,303 @@
1
+ import { cancel, intro, isCancel, outro, password, select, spinner } from '@clack/prompts'
2
+ import {
3
+ SANDBOX_BURN_RATE_OG_PER_HOUR,
4
+ SANDBOX_PROVIDER_GALILEO,
5
+ SandboxSettlementClient,
6
+ VISION_PROVIDER_DEFAULTS,
7
+ agentPaths,
8
+ depositToLedger,
9
+ explorerTxUrl,
10
+ fetchAndDecryptKeystore,
11
+ getGasPriceWithFloor,
12
+ getLedgerBalance,
13
+ iNFTAgentId,
14
+ transferFundToProvider,
15
+ waitForReceiptResilient,
16
+ } from '@promus/core'
17
+ import { type Address, formatEther, getAddress, parseEther } from 'viem'
18
+ import { findAndLoadConfig } from '../config/load'
19
+ import { withSilencedConsole } from '../util/silence-console'
20
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
21
+
22
+ export interface TopupOpts {
23
+ /** Top up the agent EOA from operator wallet, amount in ETH. */
24
+ agent?: number
25
+ /** Top up the compute ledger from agent EOA, amount in ETH. */
26
+ compute?: number
27
+ /**
28
+ * v0.21.5: top up the Galileo SandboxBilling deposit from operator wallet, amount in ETH.
29
+ * Was: `provider` in v0.17.1+; renamed to disambiguate from "compute provider".
30
+ */
31
+ sandbox?: number
32
+ /**
33
+ * @deprecated Use `sandbox` instead. v0.17.1 named this `provider` (matching
34
+ * the SandboxBilling smart-contract field name) which collided with "compute
35
+ * provider". Kept as an alias for backwards compat with existing runbooks;
36
+ * will be removed in a future release.
37
+ */
38
+ provider?: number
39
+ /**
40
+ * Transfer N ETH from the main ledger into the vision provider sub-account.
41
+ * Without this, `vision.analyze` + `browser.vision` fail with "Sub-account
42
+ * not found" on fresh agents (init wizard only seeds the inference
43
+ * provider). Mainnet-only — no vision provider exists on testnet.
44
+ */
45
+ vision?: number
46
+ }
47
+
48
+ export async function runTopup(opts: TopupOpts): Promise<void> {
49
+ intro('promus topup')
50
+
51
+ const loaded = await findAndLoadConfig()
52
+ if (!loaded) {
53
+ cancel('No promus.config.ts found. Run `promus init` first.')
54
+ return
55
+ }
56
+ const { config } = loaded
57
+ if (!config.identity.iNFT || !config.identity.agent) {
58
+ cancel('Config has no iNFT or agent. Run `promus init` first.')
59
+ return
60
+ }
61
+
62
+ const agentAddress = config.identity.agent as Address
63
+ const network = config.network
64
+ const finalAgentId = iNFTAgentId({
65
+ contractAddress: config.identity.iNFT.contract as Address,
66
+ tokenId: BigInt(config.identity.iNFT.tokenId),
67
+ })
68
+ const paths = agentPaths.agent(finalAgentId)
69
+
70
+ // 'sandbox' is the canonical mode discriminant (was 'provider' in v0.17.1+).
71
+ // `opts.provider` is accepted as a backwards-compat alias.
72
+ let mode: 'agent' | 'compute' | 'sandbox' | 'vision' | null = null
73
+ let amount = 0
74
+ if (opts.agent !== undefined) {
75
+ mode = 'agent'
76
+ amount = opts.agent
77
+ } else if (opts.compute !== undefined) {
78
+ mode = 'compute'
79
+ amount = opts.compute
80
+ } else if (opts.sandbox !== undefined) {
81
+ mode = 'sandbox'
82
+ amount = opts.sandbox
83
+ } else if (opts.provider !== undefined) {
84
+ mode = 'sandbox'
85
+ amount = opts.provider
86
+ } else if (opts.vision !== undefined) {
87
+ mode = 'vision'
88
+ amount = opts.vision
89
+ }
90
+
91
+ if (!mode) {
92
+ const choice = (await select({
93
+ message: 'What do you want to top up?',
94
+ options: [
95
+ {
96
+ value: 'agent' as const,
97
+ label: 'Agent wallet (infra gas)',
98
+ hint: 'operator sends ETH to agent EOA',
99
+ },
100
+ {
101
+ value: 'compute' as const,
102
+ label: 'Compute ledger (inference credits)',
103
+ hint: 'agent deposits ETH into compute ledger (mainnet)',
104
+ },
105
+ {
106
+ value: 'vision' as const,
107
+ label: 'Vision provider sub-account (vision.analyze + browser.vision)',
108
+ hint: 'transfer from main ledger into vision provider envelope (mainnet)',
109
+ },
110
+ {
111
+ value: 'sandbox' as const,
112
+ label: 'Sandbox billing deposit (Galileo testnet runtime fees)',
113
+ hint: 'operator deposits ETH into SandboxBilling for harness burn',
114
+ },
115
+ ],
116
+ })) as 'agent' | 'compute' | 'sandbox' | 'vision' | symbol
117
+ if (isCancel(choice)) {
118
+ cancel('Aborted.')
119
+ return
120
+ }
121
+ mode = choice
122
+
123
+ const amtRaw = (await password({
124
+ message: `Amount in ETH to move to ${mode}`,
125
+ validate: v => {
126
+ const n = Number(v)
127
+ if (!Number.isFinite(n) || n <= 0) return 'Positive number required.'
128
+ return undefined
129
+ },
130
+ })) as string | symbol
131
+ if (isCancel(amtRaw)) {
132
+ cancel('Aborted.')
133
+ return
134
+ }
135
+ amount = Number(amtRaw)
136
+ }
137
+
138
+ if (mode === 'sandbox') {
139
+ const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
140
+ if (!operator) return
141
+ const operatorAccount = await operator.account()
142
+ const galileoPub = await operator.publicClient('0g-testnet')
143
+ const galileoWallet = await operator.walletClient('0g-testnet')
144
+ const settle = new SandboxSettlementClient({
145
+ publicClient: galileoPub,
146
+ walletClient: galileoWallet,
147
+ })
148
+ const wei = parseEther(String(amount))
149
+
150
+ const sBefore = spinner()
151
+ sBefore.start('Reading current Galileo deposit')
152
+ let before = 0n
153
+ try {
154
+ before = await settle.getBalance(operatorAccount.address, SANDBOX_PROVIDER_GALILEO)
155
+ sBefore.stop(
156
+ `current deposit ${formatEther(before)} ETH (~${(Number(before) / 1e18 / SANDBOX_BURN_RATE_OG_PER_HOUR).toFixed(1)}h runway)`,
157
+ )
158
+ } catch (e) {
159
+ sBefore.stop(`balance read failed: ${(e as Error).message.slice(0, 120)}`)
160
+ }
161
+
162
+ const sDep = spinner()
163
+ sDep.start(`Depositing ${amount} ETH to Galileo provider`)
164
+ try {
165
+ const tx = await settle.deposit({
166
+ recipient: operatorAccount.address,
167
+ provider: SANDBOX_PROVIDER_GALILEO,
168
+ amountWei: wei,
169
+ })
170
+ await waitForReceiptResilient(galileoPub, tx, { tries: 60, delayMs: 2000 })
171
+ const after = await settle.getBalance(operatorAccount.address, SANDBOX_PROVIDER_GALILEO)
172
+ sDep.stop(
173
+ `deposit confirmed → ${explorerTxUrl('0g-testnet', tx)} (new balance ${formatEther(after)} ETH ≈ ${(Number(after) / 1e18 / SANDBOX_BURN_RATE_OG_PER_HOUR).toFixed(1)}h)`,
174
+ )
175
+ outro(`Galileo deposit topped up by ${amount} ETH`)
176
+ } catch (e) {
177
+ sDep.stop(`deposit failed: ${(e as Error).message.slice(0, 120)}`)
178
+ } finally {
179
+ await operator.close?.()
180
+ }
181
+ return
182
+ }
183
+
184
+ if (mode === 'agent') {
185
+ const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
186
+ if (!operator) return
187
+
188
+ const s = spinner()
189
+ s.start(`Sending ${amount} ETH from operator to agent ${agentAddress}`)
190
+ try {
191
+ const opWc = await operator.walletClient(network)
192
+ const opAccount = opWc.account
193
+ if (!opAccount) throw new Error('walletClient is missing default account')
194
+ const pub = await operator.publicClient(network)
195
+ const fundGasPrice = await getGasPriceWithFloor(pub)
196
+ const tx = await withSilencedConsole(() =>
197
+ opWc.sendTransaction({
198
+ to: agentAddress,
199
+ value: parseEther(String(amount)),
200
+ chain: operator.chain(network),
201
+ account: opAccount,
202
+ maxFeePerGas: fundGasPrice,
203
+ maxPriorityFeePerGas: fundGasPrice,
204
+ }),
205
+ )
206
+ await waitForReceiptResilient(pub, tx)
207
+ s.stop(`funded → ${explorerTxUrl(network, tx)}`)
208
+ outro(`agent ${agentAddress} balance refreshed`)
209
+ } catch (e) {
210
+ s.stop(`fund failed: ${(e as Error).message.slice(0, 120)}`)
211
+ } finally {
212
+ await operator.close?.()
213
+ }
214
+ return
215
+ }
216
+
217
+ // mode === 'compute' or 'vision' — both need the agent's privkey since
218
+ // they're agent-signed broker calls (depositFund vs transferFund).
219
+ if (mode === 'vision' && network !== '0g-mainnet') {
220
+ cancel('Vision provider is mainnet-only; no testnet provider exists yet.')
221
+ return
222
+ }
223
+ const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
224
+ if (!operator) return
225
+
226
+ const inftContract = config.identity.iNFT.contract as Address
227
+ const inftTokenId = BigInt(config.identity.iNFT.tokenId)
228
+
229
+ const sUnlock = spinner()
230
+ sUnlock.start('Fetching encrypted keystore + decrypting via operator wallet')
231
+ let agentPrivkey: `0x${string}`
232
+ try {
233
+ const decrypted = await withSilencedConsole(() =>
234
+ fetchAndDecryptKeystore({
235
+ network,
236
+ contractAddress: inftContract,
237
+ tokenId: inftTokenId,
238
+ signer: operator,
239
+ agentAddress,
240
+ cachePath: paths.keystore,
241
+ }),
242
+ )
243
+ agentPrivkey = decrypted.privkeyHex
244
+ sUnlock.stop(`unlocked (keystore source: ${decrypted.source})`)
245
+ } catch (e) {
246
+ sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
247
+ await operator.close?.()
248
+ return
249
+ }
250
+
251
+ const sBal = spinner()
252
+ sBal.start('Reading current ledger balance')
253
+ try {
254
+ const bal = await withSilencedConsole(() =>
255
+ getLedgerBalance({ network, privkeyHex: agentPrivkey }),
256
+ )
257
+ sBal.stop(
258
+ bal
259
+ ? `current ledger ${formatEther(bal.totalBalance)} ETH total / ${formatEther(bal.availableBalance)} ETH available`
260
+ : 'no ledger yet — depositing will open one',
261
+ )
262
+ } catch (e) {
263
+ sBal.stop(`balance read failed: ${(e as Error).message.slice(0, 120)}`)
264
+ }
265
+
266
+ if (mode === 'vision') {
267
+ const providerRaw = VISION_PROVIDER_DEFAULTS[network as '0g-mainnet']
268
+ if (!providerRaw) {
269
+ console.error(`Vision provider not configured for network ${network}`)
270
+ await operator.close?.()
271
+ return
272
+ }
273
+ const provider = getAddress(providerRaw)
274
+ const sVis = spinner()
275
+ sVis.start(`Transferring ${amount} ETH from main ledger to vision provider sub-account`)
276
+ try {
277
+ await withSilencedConsole(() =>
278
+ transferFundToProvider({ network, privkeyHex: agentPrivkey, provider, amount }),
279
+ )
280
+ sVis.stop(`vision sub-account seeded (${provider.slice(0, 8)}...${provider.slice(-4)})`)
281
+ outro(
282
+ `vision provider has ${amount} ETH allocated. vision.analyze + browser.vision should work now.`,
283
+ )
284
+ } catch (e) {
285
+ sVis.stop(`transfer failed: ${(e as Error).message.slice(0, 160)}`)
286
+ } finally {
287
+ await operator.close?.()
288
+ }
289
+ return
290
+ }
291
+
292
+ const sDep = spinner()
293
+ sDep.start(`Depositing ${amount} ETH into compute ledger`)
294
+ try {
295
+ await withSilencedConsole(() => depositToLedger({ network, privkeyHex: agentPrivkey, amount }))
296
+ sDep.stop('deposit complete')
297
+ outro(`ledger topped up by ${amount} ETH`)
298
+ } catch (e) {
299
+ sDep.stop(`deposit failed: ${(e as Error).message.slice(0, 120)}`)
300
+ } finally {
301
+ await operator.close?.()
302
+ }
303
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { parseTransferArgs } from './transfer'
3
+
4
+ const REF = 'eip155:16661:0x9e71d79f06f956d4d2666b5c93dafab721c84721:7'
5
+ const TO = '0x06B74fe8070C96D92e3a2A8A871849Ac81e4c09e'
6
+ const KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'
7
+
8
+ describe('parseTransferArgs', () => {
9
+ test('minimum: ref + --to', () => {
10
+ const r = parseTransferArgs([REF, '--to', TO])
11
+ expect('error' in r).toBe(false)
12
+ if ('error' in r) return
13
+ expect(r.ref).toBe(REF)
14
+ expect(r.to.toLowerCase()).toBe(TO.toLowerCase())
15
+ expect(r.dryRun).toBeUndefined()
16
+ expect(r.yes).toBeUndefined()
17
+ expect(r.noPurge).toBeUndefined()
18
+ })
19
+
20
+ test('--dry-run flag', () => {
21
+ const r = parseTransferArgs([REF, '--to', TO, '--dry-run'])
22
+ if ('error' in r) throw new Error(r.error)
23
+ expect(r.dryRun).toBe(true)
24
+ })
25
+
26
+ test('--yes shorthand -y', () => {
27
+ const r = parseTransferArgs([REF, '--to', TO, '-y'])
28
+ if ('error' in r) throw new Error(r.error)
29
+ expect(r.yes).toBe(true)
30
+ })
31
+
32
+ test('--no-purge', () => {
33
+ const r = parseTransferArgs([REF, '--to', TO, '--no-purge'])
34
+ if ('error' in r) throw new Error(r.error)
35
+ expect(r.noPurge).toBe(true)
36
+ })
37
+
38
+ test('--recipient-key with 0x prefix', () => {
39
+ const r = parseTransferArgs([REF, '--to', TO, '--recipient-key', KEY])
40
+ if ('error' in r) throw new Error(r.error)
41
+ expect(r.recipientKey).toBe(KEY)
42
+ })
43
+
44
+ test('--recipient-key without 0x prefix gets normalized', () => {
45
+ const stripped = KEY.slice(2)
46
+ const r = parseTransferArgs([REF, '--to', TO, '--recipient-key', stripped])
47
+ if ('error' in r) throw new Error(r.error)
48
+ expect(r.recipientKey).toBe(KEY)
49
+ })
50
+
51
+ test('--oracle-key with 0x prefix', () => {
52
+ const r = parseTransferArgs([REF, '--to', TO, '--oracle-key', KEY])
53
+ if ('error' in r) throw new Error(r.error)
54
+ expect(r.oracleKey).toBe(KEY)
55
+ })
56
+
57
+ test('--oracle-key without 0x prefix gets normalized', () => {
58
+ const stripped = KEY.slice(2)
59
+ const r = parseTransferArgs([REF, '--to', TO, '--oracle-key', stripped])
60
+ if ('error' in r) throw new Error(r.error)
61
+ expect(r.oracleKey).toBe(KEY)
62
+ })
63
+
64
+ test('error: missing ref', () => {
65
+ const r = parseTransferArgs(['--to', TO])
66
+ expect('error' in r).toBe(true)
67
+ })
68
+
69
+ test('error: missing --to', () => {
70
+ const r = parseTransferArgs([REF])
71
+ expect('error' in r).toBe(true)
72
+ })
73
+
74
+ test('error: invalid --to address', () => {
75
+ const r = parseTransferArgs([REF, '--to', '0xnotanaddress'])
76
+ expect('error' in r).toBe(true)
77
+ })
78
+
79
+ test('error: invalid --recipient-key length', () => {
80
+ const r = parseTransferArgs([REF, '--to', TO, '--recipient-key', '0xdeadbeef'])
81
+ expect('error' in r).toBe(true)
82
+ })
83
+
84
+ test('error: invalid --oracle-key length', () => {
85
+ const r = parseTransferArgs([REF, '--to', TO, '--oracle-key', '0xdeadbeef'])
86
+ expect('error' in r).toBe(true)
87
+ })
88
+
89
+ test('error: --oracle-key without value', () => {
90
+ const r = parseTransferArgs([REF, '--to', TO, '--oracle-key'])
91
+ expect('error' in r).toBe(true)
92
+ })
93
+
94
+ test('error: unknown flag', () => {
95
+ const r = parseTransferArgs([REF, '--to', TO, '--bogus'])
96
+ expect('error' in r).toBe(true)
97
+ })
98
+
99
+ test('error: unexpected positional', () => {
100
+ const r = parseTransferArgs([REF, 'bonus-arg', '--to', TO])
101
+ expect('error' in r).toBe(true)
102
+ })
103
+
104
+ test('flag order does not matter', () => {
105
+ const r = parseTransferArgs(['--dry-run', '--to', TO, REF, '--yes'])
106
+ if ('error' in r) throw new Error(r.error)
107
+ expect(r.ref).toBe(REF)
108
+ expect(r.dryRun).toBe(true)
109
+ expect(r.yes).toBe(true)
110
+ })
111
+ })