crumb-alpha-cli 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 (66) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +38 -0
  3. package/dist/commands/balance.d.ts +2 -0
  4. package/dist/commands/balance.d.ts.map +1 -0
  5. package/dist/commands/balance.js +43 -0
  6. package/dist/commands/balance.js.map +1 -0
  7. package/dist/commands/deposit.d.ts +2 -0
  8. package/dist/commands/deposit.d.ts.map +1 -0
  9. package/dist/commands/deposit.js +52 -0
  10. package/dist/commands/deposit.js.map +1 -0
  11. package/dist/commands/history.d.ts +4 -0
  12. package/dist/commands/history.d.ts.map +1 -0
  13. package/dist/commands/history.js +10 -0
  14. package/dist/commands/history.js.map +1 -0
  15. package/dist/commands/init.d.ts +2 -0
  16. package/dist/commands/init.d.ts.map +1 -0
  17. package/dist/commands/init.js +76 -0
  18. package/dist/commands/init.js.map +1 -0
  19. package/dist/commands/pay.d.ts +4 -0
  20. package/dist/commands/pay.d.ts.map +1 -0
  21. package/dist/commands/pay.js +50 -0
  22. package/dist/commands/pay.js.map +1 -0
  23. package/dist/commands/serve.d.ts +5 -0
  24. package/dist/commands/serve.d.ts.map +1 -0
  25. package/dist/commands/serve.js +43 -0
  26. package/dist/commands/serve.js.map +1 -0
  27. package/dist/commands/wallet.d.ts +4 -0
  28. package/dist/commands/wallet.d.ts.map +1 -0
  29. package/dist/commands/wallet.js +69 -0
  30. package/dist/commands/wallet.js.map +1 -0
  31. package/dist/commands/withdraw.d.ts +4 -0
  32. package/dist/commands/withdraw.d.ts.map +1 -0
  33. package/dist/commands/withdraw.js +54 -0
  34. package/dist/commands/withdraw.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +69 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/utils/config.d.ts +11 -0
  40. package/dist/utils/config.d.ts.map +1 -0
  41. package/dist/utils/config.js +30 -0
  42. package/dist/utils/config.js.map +1 -0
  43. package/dist/utils/keystore.d.ts +5 -0
  44. package/dist/utils/keystore.d.ts.map +1 -0
  45. package/dist/utils/keystore.js +51 -0
  46. package/dist/utils/keystore.js.map +1 -0
  47. package/dist/utils/output.d.ts +8 -0
  48. package/dist/utils/output.d.ts.map +1 -0
  49. package/dist/utils/output.js +35 -0
  50. package/dist/utils/output.js.map +1 -0
  51. package/package.json +39 -0
  52. package/src/commands/balance.ts +48 -0
  53. package/src/commands/deposit.ts +57 -0
  54. package/src/commands/history.ts +11 -0
  55. package/src/commands/init.ts +86 -0
  56. package/src/commands/pay.ts +55 -0
  57. package/src/commands/serve.ts +58 -0
  58. package/src/commands/wallet.ts +81 -0
  59. package/src/commands/withdraw.ts +62 -0
  60. package/src/index.ts +83 -0
  61. package/src/utils/config.ts +41 -0
  62. package/src/utils/keystore.ts +65 -0
  63. package/src/utils/output.ts +40 -0
  64. package/test/config.test.ts +109 -0
  65. package/test/keystore.test.ts +118 -0
  66. package/tsconfig.json +8 -0
@@ -0,0 +1,62 @@
1
+ import { GatewayClient } from 'crumb-alpha-core'
2
+ import type { SupportedChainName } from 'crumb-alpha-core'
3
+ import { readConfig } from '../utils/config.js'
4
+ import { loadPrivateKey, getPassword } from '../utils/keystore.js'
5
+ import { heading, success, error, info, keyValue } from '../utils/output.js'
6
+ import ora from 'ora'
7
+
8
+ export async function withdrawCommand(
9
+ amount: string,
10
+ options: { chain?: string },
11
+ ): Promise<void> {
12
+ heading('Withdraw')
13
+
14
+ const config = readConfig()
15
+ if (!config.activeWallet) {
16
+ error('No wallet configured. Run `crumb init` first.')
17
+ return
18
+ }
19
+
20
+ let password: string
21
+ try {
22
+ password = getPassword()
23
+ } catch {
24
+ error('No password provided. Set CRUMB_KEYSTORE_PASSWORD env var.')
25
+ return
26
+ }
27
+
28
+ let privateKey: string
29
+ try {
30
+ privateKey = loadPrivateKey(password)
31
+ } catch (err: any) {
32
+ error(`Failed to decrypt keystore: ${err.message}`)
33
+ return
34
+ }
35
+
36
+ const chain = (config.chain ?? 'arcTestnet') as SupportedChainName
37
+ const gateway = new GatewayClient({
38
+ chain,
39
+ privateKey: privateKey as `0x${string}`,
40
+ })
41
+
42
+ const destChain = options.chain as SupportedChainName | undefined
43
+ info(`Withdrawing ${amount} USDC from Gateway${destChain ? ` to ${destChain}` : ''}...`)
44
+ const spinner = ora(' Processing withdrawal...').start()
45
+
46
+ try {
47
+ const result = await gateway.withdraw(amount, {
48
+ chain: destChain,
49
+ })
50
+ spinner.stop()
51
+
52
+ console.log()
53
+ success('Withdrawn')
54
+ keyValue('Amount:', `${result.formattedAmount} USDC`)
55
+ keyValue('Tx:', result.mintTxHash)
56
+ keyValue('From:', result.sourceChain)
57
+ keyValue('To:', result.destinationChain)
58
+ } catch (err: any) {
59
+ spinner.stop()
60
+ error(`Withdrawal failed: ${err.message}`)
61
+ }
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander'
4
+ import { initCommand } from './commands/init.js'
5
+ import { walletCreateCommand, walletShowCommand, walletFundCommand } from './commands/wallet.js'
6
+ import { balanceCommand } from './commands/balance.js'
7
+ import { depositCommand } from './commands/deposit.js'
8
+ import { withdrawCommand } from './commands/withdraw.js'
9
+ import { payCommand } from './commands/pay.js'
10
+ import { historyCommand } from './commands/history.js'
11
+ import { serveCommand } from './commands/serve.js'
12
+
13
+ const program = new Command()
14
+
15
+ program
16
+ .name('crumb')
17
+ .description('CLI for Crumb agentic payments via Circle Gateway')
18
+ .version('0.1.0')
19
+
20
+ program
21
+ .command('init')
22
+ .description('Initialize Crumb — create config and wallet')
23
+ .action(initCommand)
24
+
25
+ const wallet = program
26
+ .command('wallet')
27
+ .description('Wallet management')
28
+
29
+ wallet
30
+ .command('create')
31
+ .description('Create a new wallet')
32
+ .action(walletCreateCommand)
33
+
34
+ wallet
35
+ .command('show')
36
+ .description('Show active wallet info')
37
+ .action(walletShowCommand)
38
+
39
+ wallet
40
+ .command('fund')
41
+ .description('Instructions to fund your wallet')
42
+ .action(walletFundCommand)
43
+
44
+ program
45
+ .command('balance')
46
+ .description('Check USDC balance (wallet + gateway)')
47
+ .action(balanceCommand)
48
+
49
+ program
50
+ .command('deposit')
51
+ .description('Deposit USDC into Gateway for gasless payments')
52
+ .argument('<amount>', 'Amount in USDC (e.g. 10.0)')
53
+ .action(depositCommand)
54
+
55
+ program
56
+ .command('withdraw')
57
+ .description('Withdraw USDC from Gateway')
58
+ .argument('<amount>', 'Amount in USDC (e.g. 10.0)')
59
+ .option('--chain <chain>', 'Destination chain (for cross-chain withdrawal)')
60
+ .action(withdrawCommand)
61
+
62
+ program
63
+ .command('pay')
64
+ .description('Pay for an x402-protected resource')
65
+ .argument('<url>', 'URL of the x402-protected resource')
66
+ .option('--max-payment <amount>', 'Maximum USDC willing to pay')
67
+ .action(payCommand)
68
+
69
+ program
70
+ .command('history')
71
+ .description('View transaction history')
72
+ .option('--limit <n>', 'Number of transactions to show', '20')
73
+ .action((options) => historyCommand({ limit: parseInt(options.limit, 10) }))
74
+
75
+ program
76
+ .command('serve')
77
+ .description('Run a local test provider')
78
+ .argument('<file>', 'Path to handler file')
79
+ .option('--port <port>', 'Port to listen on', '3000')
80
+ .option('--price <price>', 'USDC per call (e.g. $0.01)', '$0.01')
81
+ .action(serveCommand)
82
+
83
+ program.parse()
@@ -0,0 +1,41 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ const CRUMB_DIR = join(homedir(), '.crumb')
6
+ const CONFIG_PATH = join(CRUMB_DIR, 'config.json')
7
+
8
+ export interface CrumbConfig {
9
+ activeWallet?: string
10
+ chain: string // e.g. 'arcTestnet'
11
+ }
12
+
13
+ export function ensureCrumbDir(): void {
14
+ if (!existsSync(CRUMB_DIR)) {
15
+ mkdirSync(CRUMB_DIR, { recursive: true })
16
+ }
17
+ }
18
+
19
+ export function getCrumbDir(): string {
20
+ return CRUMB_DIR
21
+ }
22
+
23
+ export function getConfigPath(): string {
24
+ return CONFIG_PATH
25
+ }
26
+
27
+ export function readConfig(): CrumbConfig {
28
+ if (!existsSync(CONFIG_PATH)) {
29
+ return { chain: 'arcTestnet' }
30
+ }
31
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
32
+ }
33
+
34
+ export function writeConfig(config: CrumbConfig): void {
35
+ ensureCrumbDir()
36
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
37
+ }
38
+
39
+ export function configExists(): boolean {
40
+ return existsSync(CONFIG_PATH)
41
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto'
4
+ import { getCrumbDir, ensureCrumbDir } from './config.js'
5
+
6
+ const KEYSTORE_FILE = 'keystore.enc'
7
+
8
+ function getKeystorePath(): string {
9
+ return join(getCrumbDir(), KEYSTORE_FILE)
10
+ }
11
+
12
+ export function keystoreExists(): boolean {
13
+ return existsSync(getKeystorePath())
14
+ }
15
+
16
+ export function encryptAndSave(privateKey: string, password: string): void {
17
+ ensureCrumbDir()
18
+
19
+ const salt = randomBytes(32)
20
+ const key = scryptSync(password, salt, 32)
21
+ const iv = randomBytes(16)
22
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
23
+
24
+ let encrypted = cipher.update(privateKey, 'utf-8', 'hex')
25
+ encrypted += cipher.final('hex')
26
+ const authTag = cipher.getAuthTag()
27
+
28
+ const payload = {
29
+ salt: salt.toString('hex'),
30
+ iv: iv.toString('hex'),
31
+ authTag: authTag.toString('hex'),
32
+ encrypted,
33
+ }
34
+
35
+ writeFileSync(getKeystorePath(), JSON.stringify(payload, null, 2))
36
+ }
37
+
38
+ export function loadPrivateKey(password: string): string {
39
+ const path = getKeystorePath()
40
+ if (!existsSync(path)) {
41
+ throw new Error('No keystore found. Run `crumb init` first.')
42
+ }
43
+
44
+ const payload = JSON.parse(readFileSync(path, 'utf-8'))
45
+ const salt = Buffer.from(payload.salt, 'hex')
46
+ const iv = Buffer.from(payload.iv, 'hex')
47
+ const authTag = Buffer.from(payload.authTag, 'hex')
48
+ const key = scryptSync(password, salt, 32)
49
+
50
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
51
+ decipher.setAuthTag(authTag)
52
+
53
+ let decrypted = decipher.update(payload.encrypted, 'hex', 'utf-8')
54
+ decrypted += decipher.final('utf-8')
55
+
56
+ return decrypted
57
+ }
58
+
59
+ export function getPassword(): string {
60
+ const envPassword = process.env.CRUMB_KEYSTORE_PASSWORD
61
+ if (envPassword) return envPassword
62
+ throw new Error(
63
+ 'No password provided. Set CRUMB_KEYSTORE_PASSWORD env var or pass --password flag.',
64
+ )
65
+ }
@@ -0,0 +1,40 @@
1
+ import chalk from 'chalk'
2
+
3
+ export function heading(text: string): void {
4
+ console.log()
5
+ console.log(chalk.bold.cyan(` ◆ ${text}`))
6
+ console.log()
7
+ }
8
+
9
+ export function success(text: string): void {
10
+ console.log(chalk.green(` ✔ ${text}`))
11
+ }
12
+
13
+ export function error(text: string): void {
14
+ console.log(chalk.red(` ✖ ${text}`))
15
+ }
16
+
17
+ export function info(text: string): void {
18
+ console.log(chalk.gray(` ${text}`))
19
+ }
20
+
21
+ export function keyValue(key: string, value: string): void {
22
+ console.log(` ${chalk.dim(key.padEnd(12))} ${value}`)
23
+ }
24
+
25
+ export function box(lines: string[]): void {
26
+ const maxLen = Math.max(...lines.map((l) => l.length))
27
+ const border = '─'.repeat(maxLen + 4)
28
+ console.log()
29
+ console.log(` ┌${border}┐`)
30
+ for (const line of lines) {
31
+ console.log(` │ ${line.padEnd(maxLen + 2)}│`)
32
+ }
33
+ console.log(` └${border}┘`)
34
+ console.log()
35
+ }
36
+
37
+ export function truncateAddress(address: string): string {
38
+ if (address.length <= 12) return address
39
+ return `${address.slice(0, 6)}...${address.slice(-4)}`
40
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ // We need to mock the paths to use a temp directory
7
+ let tempDir: string
8
+
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), 'crumb-test-'))
11
+ })
12
+
13
+ afterEach(() => {
14
+ rmSync(tempDir, { recursive: true, force: true })
15
+ })
16
+
17
+ // Mock the config module paths
18
+ vi.mock('../src/utils/config.js', async () => {
19
+ const fs = await import('node:fs')
20
+ const path = await import('node:path')
21
+
22
+ // We use a getter for tempDir so each test gets the right dir
23
+ const getDir = () => tempDir
24
+ const getConfigPath = () => path.join(getDir(), 'config.json')
25
+
26
+ return {
27
+ ensureCrumbDir: () => {
28
+ const dir = getDir()
29
+ if (!fs.existsSync(dir)) {
30
+ fs.mkdirSync(dir, { recursive: true })
31
+ }
32
+ },
33
+ getCrumbDir: getDir,
34
+ getConfigPath,
35
+ readConfig: () => {
36
+ const configPath = getConfigPath()
37
+ if (!fs.existsSync(configPath)) {
38
+ return { chain: 'arcTestnet' }
39
+ }
40
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
41
+ },
42
+ writeConfig: (config: any) => {
43
+ const dir = getDir()
44
+ if (!fs.existsSync(dir)) {
45
+ fs.mkdirSync(dir, { recursive: true })
46
+ }
47
+ fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2))
48
+ },
49
+ configExists: () => {
50
+ return fs.existsSync(getConfigPath())
51
+ },
52
+ }
53
+ })
54
+
55
+ const { readConfig, writeConfig, configExists, getCrumbDir, ensureCrumbDir } = await import(
56
+ '../src/utils/config.js'
57
+ )
58
+
59
+ describe('config', () => {
60
+ describe('readConfig', () => {
61
+ it('returns default config when no file exists', () => {
62
+ const config = readConfig()
63
+ expect(config).toEqual({ chain: 'arcTestnet' })
64
+ })
65
+
66
+ it('reads existing config', () => {
67
+ writeConfig({ chain: 'arcTestnet', activeWallet: '0xabc' })
68
+ const config = readConfig()
69
+ expect(config.chain).toBe('arcTestnet')
70
+ expect(config.activeWallet).toBe('0xabc')
71
+ })
72
+ })
73
+
74
+ describe('writeConfig', () => {
75
+ it('writes config to disk', () => {
76
+ writeConfig({ chain: 'arcTestnet', activeWallet: '0x123' })
77
+ const config = readConfig()
78
+ expect(config.chain).toBe('arcTestnet')
79
+ expect(config.activeWallet).toBe('0x123')
80
+ })
81
+
82
+ it('overwrites existing config', () => {
83
+ writeConfig({ chain: 'arcTestnet' })
84
+ writeConfig({ chain: 'arcTestnet', activeWallet: '0xnew' })
85
+ const config = readConfig()
86
+ expect(config.chain).toBe('arcTestnet')
87
+ })
88
+ })
89
+
90
+ describe('configExists', () => {
91
+ it('returns false when no config file', () => {
92
+ expect(configExists()).toBe(false)
93
+ })
94
+
95
+ it('returns true after writing config', () => {
96
+ writeConfig({ chain: 'arcTestnet' })
97
+ expect(configExists()).toBe(true)
98
+ })
99
+ })
100
+
101
+ describe('ensureCrumbDir', () => {
102
+ it('creates directory if not exists', () => {
103
+ const dir = getCrumbDir()
104
+ // tempDir already exists, but test the function doesn't throw
105
+ ensureCrumbDir()
106
+ expect(existsSync(dir)).toBe(true)
107
+ })
108
+ })
109
+ })
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ let tempDir: string
7
+
8
+ beforeEach(() => {
9
+ tempDir = mkdtempSync(join(tmpdir(), 'crumb-keystore-test-'))
10
+ })
11
+
12
+ afterEach(() => {
13
+ rmSync(tempDir, { recursive: true, force: true })
14
+ })
15
+
16
+ vi.mock('../src/utils/config.js', () => {
17
+ const fs = require('node:fs') // eslint-disable-line
18
+ return {
19
+ getCrumbDir: () => tempDir,
20
+ ensureCrumbDir: () => {
21
+ if (!fs.existsSync(tempDir)) {
22
+ fs.mkdirSync(tempDir, { recursive: true })
23
+ }
24
+ },
25
+ }
26
+ })
27
+
28
+ const { encryptAndSave, loadPrivateKey, keystoreExists } = await import(
29
+ '../src/utils/keystore.js'
30
+ )
31
+
32
+ describe('keystore', () => {
33
+ const testPrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
34
+ const testPassword = 'test-password-123'
35
+
36
+ describe('encryptAndSave', () => {
37
+ it('creates an encrypted keystore file', () => {
38
+ encryptAndSave(testPrivateKey, testPassword)
39
+ expect(existsSync(join(tempDir, 'keystore.enc'))).toBe(true)
40
+ })
41
+
42
+ it('stores encrypted data as JSON', () => {
43
+ encryptAndSave(testPrivateKey, testPassword)
44
+ const raw = readFileSync(join(tempDir, 'keystore.enc'), 'utf-8')
45
+ const data = JSON.parse(raw)
46
+ expect(data).toHaveProperty('salt')
47
+ expect(data).toHaveProperty('iv')
48
+ expect(data).toHaveProperty('authTag')
49
+ expect(data).toHaveProperty('encrypted')
50
+ })
51
+
52
+ it('does not store the private key in plaintext', () => {
53
+ encryptAndSave(testPrivateKey, testPassword)
54
+ const raw = readFileSync(join(tempDir, 'keystore.enc'), 'utf-8')
55
+ expect(raw).not.toContain(testPrivateKey)
56
+ })
57
+ })
58
+
59
+ describe('loadPrivateKey', () => {
60
+ it('decrypts and returns the original private key', () => {
61
+ encryptAndSave(testPrivateKey, testPassword)
62
+ const result = loadPrivateKey(testPassword)
63
+ expect(result).toBe(testPrivateKey)
64
+ })
65
+
66
+ it('throws with wrong password', () => {
67
+ encryptAndSave(testPrivateKey, testPassword)
68
+ expect(() => loadPrivateKey('wrong-password')).toThrow()
69
+ })
70
+
71
+ it('throws when no keystore exists', () => {
72
+ expect(() => loadPrivateKey(testPassword)).toThrow('No keystore found')
73
+ })
74
+ })
75
+
76
+ describe('keystoreExists', () => {
77
+ it('returns false when no keystore', () => {
78
+ expect(keystoreExists()).toBe(false)
79
+ })
80
+
81
+ it('returns true after saving', () => {
82
+ encryptAndSave(testPrivateKey, testPassword)
83
+ expect(keystoreExists()).toBe(true)
84
+ })
85
+ })
86
+
87
+ describe('roundtrip', () => {
88
+ it('handles various private key formats', () => {
89
+ const keys = [
90
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
91
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
92
+ '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
93
+ ]
94
+
95
+ for (const key of keys) {
96
+ const keystorePath = join(tempDir, 'keystore.enc')
97
+ if (existsSync(keystorePath)) {
98
+ rmSync(keystorePath)
99
+ }
100
+
101
+ encryptAndSave(key, testPassword)
102
+ expect(loadPrivateKey(testPassword)).toBe(key)
103
+ }
104
+ })
105
+
106
+ it('uses different salt/iv each time', () => {
107
+ encryptAndSave(testPrivateKey, testPassword)
108
+ const raw1 = JSON.parse(readFileSync(join(tempDir, 'keystore.enc'), 'utf-8'))
109
+
110
+ encryptAndSave(testPrivateKey, testPassword)
111
+ const raw2 = JSON.parse(readFileSync(join(tempDir, 'keystore.enc'), 'utf-8'))
112
+
113
+ expect(raw1.salt).not.toBe(raw2.salt)
114
+ expect(raw1.iv).not.toBe(raw2.iv)
115
+ expect(raw1.encrypted).not.toBe(raw2.encrypted)
116
+ })
117
+ })
118
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }