diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..95c40c9
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: '1.2'
+
+ - run: bun install --frozen-lockfile
+
+ - run: bun run typecheck
+
+ - run: bun run lint
+
+ - run: bun test
+
+ - run: bun run build
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..7b2093f
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,47 @@
+name: Publish to npm
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ registry-url: https://registry.npmjs.org
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: '1.2'
+
+ - run: bun install --frozen-lockfile
+
+ - name: Verify tag matches package.json version
+ run: |
+ PKG_VERSION="v$(node -p "require('./package.json').version")"
+ GIT_TAG="${GITHUB_REF#refs/tags/}"
+ if [ "$PKG_VERSION" != "$GIT_TAG" ]; then
+ echo "Tag $GIT_TAG does not match package.json version $PKG_VERSION"
+ exit 1
+ fi
+
+ - run: bun run typecheck
+
+ - run: bun run lint
+
+ - run: bun test
+
+ - run: bun run build
+
+ - run: npm publish
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f730b50
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+dist/
+.turbo/
+*.tsbuildinfo
+.DS_Store
+.env
+.env.*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7763f92
--- /dev/null
+++ b/README.md
@@ -0,0 +1,199 @@
+
BlindPay CLI 
+
+[](https://discord.gg/x7ap6Gkbe9)
+[](https://twitter.com/intent/follow?screen_name=blindpaylabs)
+[](https://www.npmjs.com/package/@blindpay/cli)
+
+The official CLI for [BlindPay](https://blindpay.com) - Stablecoin API for global payments.
+
+## Installation
+
+```bash
+npm install -g @blindpay/cli
+```
+
+```bash
+bun add -g @blindpay/cli
+```
+
+Or run without installing:
+
+```bash
+npx @blindpay/cli
+```
+
+## Setup
+
+Grab your API key and instance ID from the [BlindPay dashboard](https://app.blindpay.com) and run:
+
+```bash
+blindpay config set --api-key sk_live_... --instance-id inst_...
+```
+
+Alternatively, set environment variables:
+
+```bash
+export BLINDPAY_API_KEY=sk_live_...
+export BLINDPAY_INSTANCE_ID=inst_...
+```
+
+## Commands
+
+Every command supports `--help` for detailed usage and `--json` for machine-readable output.
+
+### Config
+
+| Command | Description |
+|---|---|
+| `blindpay config set` | Set API key, instance ID, or base URL |
+| `blindpay config get` | Show current config (API key masked) |
+| `blindpay config clear` | Remove saved config |
+| `blindpay config path` | Print config file path |
+
+### Instances
+
+| Command | Description |
+|---|---|
+| `blindpay instances update` | Update instance name or redirect URL |
+| `blindpay instances members list` | List instance members |
+
+### Receivers
+
+| Command | Description |
+|---|---|
+| `blindpay receivers list` | List all receivers |
+| `blindpay receivers get ` | Get a receiver by ID |
+| `blindpay receivers create` | Create a new receiver |
+| `blindpay receivers update ` | Update a receiver |
+| `blindpay receivers delete ` | Delete a receiver |
+| `blindpay receivers limits ` | Get receiver limits |
+| `blindpay receivers limits_increase_requests ` | Get limits increase requests |
+
+### Bank Accounts
+
+Requires `--receiver-id` on every command.
+
+| Command | Description |
+|---|---|
+| `blindpay bank_accounts list` | List bank accounts for a receiver |
+| `blindpay bank_accounts get ` | Get a bank account by ID |
+| `blindpay bank_accounts create` | Create a new bank account |
+| `blindpay bank_accounts delete ` | Delete a bank account |
+
+### Blockchain Wallets
+
+Requires `--receiver-id` on every command.
+
+| Command | Description |
+|---|---|
+| `blindpay blockchain_wallets list` | List blockchain wallets for a receiver |
+| `blindpay blockchain_wallets get ` | Get a blockchain wallet by ID |
+| `blindpay blockchain_wallets create` | Create a new blockchain wallet |
+| `blindpay blockchain_wallets delete ` | Delete a blockchain wallet |
+
+### Payouts
+
+| Command | Description |
+|---|---|
+| `blindpay quotes create` | Create a payout quote |
+| `blindpay quotes fx` | Get FX rates |
+| `blindpay payouts list` | List all payouts |
+| `blindpay payouts get ` | Get a payout by ID |
+| `blindpay payouts create` | Execute a payout from a quote |
+
+### Payins
+
+| Command | Description |
+|---|---|
+| `blindpay payin_quotes create` | Create a payin quote |
+| `blindpay payin_quotes fx` | Get FX rates |
+| `blindpay payins list` | List all payins |
+| `blindpay payins get ` | Get a payin by ID |
+| `blindpay payins create` | Execute a payin from a quote |
+
+### Virtual Accounts
+
+Requires `--receiver-id` on every command.
+
+| Command | Description |
+|---|---|
+| `blindpay virtual_accounts list` | List virtual accounts for a receiver |
+| `blindpay virtual_accounts create` | Create a virtual account |
+
+### Offramp Wallets
+
+| Command | Description |
+|---|---|
+| `blindpay offramp_wallets list` | List offramp wallets (`--receiver-id` + `--bank-account-id`) |
+
+### Webhook Endpoints
+
+| Command | Description |
+|---|---|
+| `blindpay webhook_endpoints list` | List webhook endpoints |
+| `blindpay webhook_endpoints create` | Create a webhook endpoint |
+| `blindpay webhook_endpoints delete ` | Delete a webhook endpoint |
+
+### Partner Fees
+
+| Command | Description |
+|---|---|
+| `blindpay partner_fees list` | List partner fees |
+| `blindpay partner_fees create` | Create a partner fee |
+| `blindpay partner_fees delete ` | Delete a partner fee |
+
+### API Keys
+
+| Command | Description |
+|---|---|
+| `blindpay api_keys list` | List API keys |
+| `blindpay api_keys create` | Create an API key |
+| `blindpay api_keys delete ` | Delete an API key |
+
+### Reference Data
+
+| Command | Description |
+|---|---|
+| `blindpay available rails` | List supported payment rails |
+| `blindpay available bank_details --rail ` | Required fields per rail |
+
+### Tooling
+
+| Command | Description |
+|---|---|
+| `blindpay schema` | List all resources and their commands |
+| `blindpay schema ` | Field definitions for a resource (JSON) |
+| `blindpay update` | Print update instructions |
+
+## LLM / automation support
+
+The CLI is designed to work well with LLMs and scripts:
+
+- **`--json`** on any command for structured output
+- **`blindpay schema`** returns field definitions, types, defaults, and enums as JSON
+- **Exit codes**: `0` success, `1` user error, `2` API error
+- **Structured errors** when `--json` is active:
+ ```json
+ {"error": true, "message": "...", "exitCode": 2, "statusCode": 401}
+ ```
+
+For full MCP tool server integration, see [blindpay-mcp](https://github.com/blindpaylabs/blindpay-mcp).
+
+## Updating
+
+```bash
+blindpay update
+# or:
+npm install -g @blindpay/cli@latest
+```
+
+## Support
+
+- Email: [alves@blindpay.com](mailto:alves@blindpay.com)
+- Issues: [GitHub Issues](https://github.com/blindpaylabs/blindpay-cli/issues)
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+Made with ❤️ by the [BlindPay](https://blindpay.com) team
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..3aead91
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,79 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@blindpay/cli",
+ "dependencies": {
+ "@clack/prompts": "^1.0.1",
+ "commander": "^14.0.0",
+ "picocolors": "^1.1.0",
+ },
+ "devDependencies": {
+ "@types/bun": "^1.2.0",
+ "oxlint": "^1.48.0",
+ "typescript": "^5.5.0",
+ },
+ },
+ },
+ "packages": {
+ "@clack/core": ["@clack/core@1.0.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g=="],
+
+ "@clack/prompts": ["@clack/prompts@1.0.1", "", { "dependencies": { "@clack/core": "1.0.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q=="],
+
+ "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.48.0", "", { "os": "android", "cpu": "arm" }, "sha512-1Pz/stJvveO9ZO7ll4ZoEY3f6j2FiUgBLBcCRCiW6ylId9L9UKs+gn3X28m3eTnoiFCkhKwmJJ+VO6vwsu7Qtg=="],
+
+ "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.48.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Zc42RWGE8huo6Ht0lXKjd0NH2lWNmimQHUmD0JFcvShLOuwN+RSEE/kRakc2/0LIgOUuU/R7PaDMCOdQlPgNUQ=="],
+
+ "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.48.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jgZs563/4vaG5jH2RSt2TSh8A2jwsFdmhLXrElMdm3Mmto0HPf85FgInLSNi9HcwzQFvkYV8JofcoUg2GH1HTA=="],
+
+ "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.48.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kvo87BujEUjCJREuWDC4aPh1WoXCRFFWE4C7uF6wuoMw2f6N2hypA/cHHcYn9DdL8R2RrgUZPefC8JExyeIMKA=="],
+
+ "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.48.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-eyzzPaHQKn0RIM+ueDfgfJF2RU//Wp4oaKs2JVoVYcM5HjbCL36+O0S3wO5Xe1NWpcZIG3cEHc/SuOCDRqZDSg=="],
+
+ "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.48.0", "", { "os": "linux", "cpu": "arm" }, "sha512-p3kSloztK7GRO7FyO3u38UCjZxQTl92VaLDsMQAq0eGoiNmeeEF1KPeE4+Fr+LSkQhF8WvJKSuls6TwOlurdPA=="],
+
+ "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.48.0", "", { "os": "linux", "cpu": "arm" }, "sha512-uWM+wiTqLW/V0ZmY/eyTWs8ykhIkzU+K2tz/8m35YepYEzohiUGRbnkpAFXj2ioXpQL+GUe5vmM3SLH6ozlfFw=="],
+
+ "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.48.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-OhQNPjs/OICaYqxYJjKKMaIY7p3nJ9IirXcFoHKD+CQE1BZFCeUUAknMzUeLclDCfudH9Vb/UgjFm8+ZM5puAg=="],
+
+ "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.48.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-adu5txuwGvQ4C4fjYHJD+vnY+OCwCixBzn7J3KF3iWlVHBBImcosSv+Ye+fbMMJui4HGjifNXzonjKm9pXmOiw=="],
+
+ "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.48.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-inlQQRUnHCny/7b7wA6NjEoJSSZPNea4qnDhWyeqBYWx8ukf2kzNDSiamfhOw6bfAYPm/PVlkVRYaNXQbkLeTQ=="],
+
+ "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.48.0", "", { "os": "linux", "cpu": "none" }, "sha512-YiJx6sW6bYebQDZRVWLKm/Drswx/hcjIgbLIhULSn0rRcBKc7d9V6mkqPjKDbhcxJgQD5Zi0yVccJiOdF40AWA=="],
+
+ "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.48.0", "", { "os": "linux", "cpu": "none" }, "sha512-zwSqxMgmb2ITamNfDv9Q9EKBc/4ZhCBP9gkg2hhcgR6sEVGPUDl1AKPC89CBKMxkmPUi3685C38EvqtZn5OtHw=="],
+
+ "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.48.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-c/+2oUWAOsQB5JTem0rW8ODlZllF6pAtGSGXoLSvPTonKI1vAwaKhD9Qw1X36jRbcI3Etkpu/9z/RRjMba8vFQ=="],
+
+ "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.48.0", "", { "os": "linux", "cpu": "x64" }, "sha512-PhauDqeFW5DGed6QxCY5lXZYKSlcBdCXJnH03ZNU6QmDZ0BFM/zSy1oPT2MNb1Afx1G6yOOVk8ErjWsQ7c59ng=="],
+
+ "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.48.0", "", { "os": "linux", "cpu": "x64" }, "sha512-6d7LIFFZGiavbHndhf1cK9kG9qmy2Dmr37sV9Ep7j3H+ciFdKSuOzdLh85mEUYMih+b+esMDlF5DU0WQRZPQjw=="],
+
+ "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.48.0", "", { "os": "none", "cpu": "arm64" }, "sha512-r+0KK9lK6vFp3tXAgDMOW32o12dxvKS3B9La1uYMGdWAMoSeu2RzG34KmzSpXu6MyLDl4aSVyZLFM8KGdEjwaw=="],
+
+ "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.48.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Nkw/MocyT3HSp0OJsKPXrcbxZqSPMTYnLLfsqsoiFKoL1ppVNL65MFa7vuTxJehPlBkjy+95gUgacZtuNMECrg=="],
+
+ "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.48.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-reO1SpefvRmeZSP+WeyWkQd1ArxxDD1MyKgMUKuB8lNuUoxk9QEohYtKnsfsxJuFwMT0JTr7p9wZjouA85GzGQ=="],
+
+ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.48.0", "", { "os": "win32", "cpu": "x64" }, "sha512-T6zwhfcsrorqAybkOglZdPkTLlEwipbtdO1qjE+flbawvwOMsISoyiuaa7vM7zEyfq1hmDvMq1ndvkYFioranA=="],
+
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
+
+ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
+
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
+
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+
+ "oxlint": ["oxlint@1.48.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.48.0", "@oxlint/binding-android-arm64": "1.48.0", "@oxlint/binding-darwin-arm64": "1.48.0", "@oxlint/binding-darwin-x64": "1.48.0", "@oxlint/binding-freebsd-x64": "1.48.0", "@oxlint/binding-linux-arm-gnueabihf": "1.48.0", "@oxlint/binding-linux-arm-musleabihf": "1.48.0", "@oxlint/binding-linux-arm64-gnu": "1.48.0", "@oxlint/binding-linux-arm64-musl": "1.48.0", "@oxlint/binding-linux-ppc64-gnu": "1.48.0", "@oxlint/binding-linux-riscv64-gnu": "1.48.0", "@oxlint/binding-linux-riscv64-musl": "1.48.0", "@oxlint/binding-linux-s390x-gnu": "1.48.0", "@oxlint/binding-linux-x64-gnu": "1.48.0", "@oxlint/binding-linux-x64-musl": "1.48.0", "@oxlint/binding-openharmony-arm64": "1.48.0", "@oxlint/binding-win32-arm64-msvc": "1.48.0", "@oxlint/binding-win32-ia32-msvc": "1.48.0", "@oxlint/binding-win32-x64-msvc": "1.48.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.12.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-m5vyVBgPtPhVCJc3xI//8je9lRc8bYuYB4R/1PH3VPGOjA4vjVhkHtyJukdEjYEjwrw4Qf1eIf+pP9xvfhfMow=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ }
+}
diff --git a/oxlint.json b/oxlint.json
new file mode 100644
index 0000000..a047bcb
--- /dev/null
+++ b/oxlint.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-json-schema/refs/heads/main/packages/schema/oxlint-config-schema.json",
+ "rules": {
+ "no-unused-vars": "warn",
+ "no-console": "off",
+ "eqeqeq": "error",
+ "no-var": "error",
+ "prefer-const": "warn",
+ "no-debugger": "error",
+ "no-empty": "warn",
+ "no-extra-boolean-cast": "warn",
+ "no-unsafe-negation": "error",
+ "no-constant-condition": "warn",
+ "no-dupe-keys": "error",
+ "no-duplicate-case": "error",
+ "no-empty-pattern": "warn",
+ "no-self-assign": "error",
+ "no-self-compare": "error",
+ "no-template-curly-in-string": "warn",
+ "no-unreachable": "error",
+ "no-loss-of-precision": "error"
+ },
+ "ignorePatterns": ["dist/**", "node_modules/**", "*.test.ts"]
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ad90661
--- /dev/null
+++ b/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@blindpay/cli",
+ "type": "module",
+ "version": "0.1.0",
+ "description": "Blindpay CLI - manage receivers, bank accounts, payouts, payins, and more from the terminal",
+ "license": "MIT",
+ "author": "Blindpay (https://blindpay.com/)",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/blindpaylabs/blindpay-cli.git"
+ },
+ "homepage": "https://github.com/blindpaylabs/blindpay-cli#readme",
+ "bugs": {
+ "url": "https://github.com/blindpaylabs/blindpay-cli/issues"
+ },
+ "keywords": [
+ "blindpay",
+ "cli",
+ "api",
+ "payments",
+ "crypto",
+ "stablecoin",
+ "payout",
+ "payin"
+ ],
+ "bin": {
+ "blindpay": "./dist/index.js"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "engines": {
+ "node": ">=18"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "dev": "bun run src/index.ts",
+ "build": "bun build src/index.ts --outdir dist --target node --minify && node -e \"const fs=require('fs');const f='dist/index.js';const c=fs.readFileSync(f,'utf8');fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
+ "typecheck": "tsc --noEmit",
+ "lint": "oxlint -c oxlint.json src/",
+ "lint:fix": "oxlint -c oxlint.json --fix src/",
+ "test": "bun test",
+ "prepublishOnly": "bun run build"
+ },
+ "dependencies": {
+ "@clack/prompts": "^1.0.1",
+ "commander": "^14.0.0",
+ "picocolors": "^1.1.0"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.2.0",
+ "oxlint": "^1.48.0",
+ "typescript": "^5.5.0"
+ }
+}
diff --git a/src/__tests__/api-client.test.ts b/src/__tests__/api-client.test.ts
new file mode 100644
index 0000000..d795dd7
--- /dev/null
+++ b/src/__tests__/api-client.test.ts
@@ -0,0 +1,79 @@
+import { describe, test, expect } from 'bun:test'
+import { resolveContext } from '../utils/api-client'
+
+function saveEnv(...keys: string[]) {
+ const saved = new Map()
+ for (const key of keys)
+ saved.set(key, process.env[key])
+ return {
+ restore() {
+ for (const [key, val] of saved) {
+ if (val !== undefined) process.env[key] = val
+ else delete process.env[key]
+ }
+ },
+ }
+}
+
+describe('api-client', () => {
+ test('resolveContext throws when no config is set', () => {
+ const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID', 'XDG_CONFIG_HOME')
+ delete process.env.BLINDPAY_API_KEY
+ delete process.env.BLINDPAY_INSTANCE_ID
+ process.env.XDG_CONFIG_HOME = '/tmp/blindpay-cli-test-no-config-' + Date.now()
+
+ try {
+ expect(() => resolveContext()).toThrow('No API key configured')
+ }
+ finally {
+ env.restore()
+ }
+ })
+
+ test('resolveContext uses env vars', () => {
+ const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID', 'BLINDPAY_API_URL')
+ process.env.BLINDPAY_API_KEY = 'sk_test_key'
+ process.env.BLINDPAY_INSTANCE_ID = 'inst_test'
+ delete process.env.BLINDPAY_API_URL
+
+ try {
+ const ctx = resolveContext()
+ expect(ctx.instanceId).toBe('inst_test')
+ expect(ctx.headers.Authorization).toBe('Bearer sk_test_key')
+ expect(ctx.baseUrl).toBe('https://api.blindpay.com')
+ }
+ finally {
+ env.restore()
+ }
+ })
+
+ test('resolveContext uses custom base URL from env', () => {
+ const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID', 'BLINDPAY_API_URL')
+ process.env.BLINDPAY_API_KEY = 'sk_test_key'
+ process.env.BLINDPAY_INSTANCE_ID = 'inst_test'
+ process.env.BLINDPAY_API_URL = 'https://custom.example.com/'
+
+ try {
+ const ctx = resolveContext()
+ expect(ctx.baseUrl).toBe('https://custom.example.com')
+ }
+ finally {
+ env.restore()
+ }
+ })
+
+ test('resolveContext includes User-Agent and X-Blindpay-Client headers', () => {
+ const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID')
+ process.env.BLINDPAY_API_KEY = 'sk_test_key'
+ process.env.BLINDPAY_INSTANCE_ID = 'inst_test'
+
+ try {
+ const ctx = resolveContext()
+ expect(ctx.headers['User-Agent']).toMatch(/^blindpay-cli\//)
+ expect(ctx.headers['X-Blindpay-Client']).toBe('cli')
+ }
+ finally {
+ env.restore()
+ }
+ })
+})
diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts
new file mode 100644
index 0000000..8a5eca9
--- /dev/null
+++ b/src/__tests__/config.test.ts
@@ -0,0 +1,103 @@
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { getConfig, setConfig, clearConfig, getConfigPath, hasLiveConfig } from '../utils/config'
+
+const TEST_DIR = path.join(os.tmpdir(), `blindpay-cli-test-${Date.now()}`)
+const TEST_CONFIG_DIR = path.join(TEST_DIR, 'blindpay')
+
+beforeEach(() => {
+ process.env.XDG_CONFIG_HOME = TEST_DIR
+ delete process.env.BLINDPAY_API_KEY
+ delete process.env.BLINDPAY_INSTANCE_ID
+ delete process.env.BLINDPAY_API_URL
+ if (fs.existsSync(TEST_CONFIG_DIR))
+ fs.rmSync(TEST_CONFIG_DIR, { recursive: true })
+})
+
+afterEach(() => {
+ delete process.env.XDG_CONFIG_HOME
+ if (fs.existsSync(TEST_DIR))
+ fs.rmSync(TEST_DIR, { recursive: true })
+})
+
+describe('config', () => {
+ test('getConfigPath returns path under XDG_CONFIG_HOME', () => {
+ const p = getConfigPath()
+ expect(p).toBe(path.join(TEST_CONFIG_DIR, 'config.json'))
+ })
+
+ test('getConfig returns defaults when no config file exists', () => {
+ const config = getConfig()
+ expect(config.api_key).toBeNull()
+ expect(config.instance_id).toBeNull()
+ expect(config.base_url).toBeNull()
+ })
+
+ test('setConfig writes and getConfig reads back', () => {
+ setConfig({ api_key: 'sk_test_123', instance_id: 'inst_abc' })
+ const config = getConfig()
+ expect(config.api_key).toBe('sk_test_123')
+ expect(config.instance_id).toBe('inst_abc')
+ expect(config.base_url).toBeNull()
+ })
+
+ test('setConfig merges with existing config', () => {
+ setConfig({ api_key: 'sk_test_123' })
+ setConfig({ instance_id: 'inst_abc' })
+ const config = getConfig()
+ expect(config.api_key).toBe('sk_test_123')
+ expect(config.instance_id).toBe('inst_abc')
+ })
+
+ test('env vars override file config', () => {
+ setConfig({ api_key: 'file_key', instance_id: 'file_id' })
+ process.env.BLINDPAY_API_KEY = 'env_key'
+ process.env.BLINDPAY_INSTANCE_ID = 'env_id'
+ const config = getConfig()
+ expect(config.api_key).toBe('env_key')
+ expect(config.instance_id).toBe('env_id')
+ })
+
+ test('BLINDPAY_API_URL env var overrides base_url', () => {
+ setConfig({ base_url: 'https://file.example.com' })
+ process.env.BLINDPAY_API_URL = 'https://env.example.com'
+ const config = getConfig()
+ expect(config.base_url).toBe('https://env.example.com')
+ })
+
+ test('clearConfig removes config file', () => {
+ setConfig({ api_key: 'sk_test_123' })
+ expect(fs.existsSync(getConfigPath())).toBe(true)
+ const result = clearConfig()
+ expect(result).toBe(true)
+ expect(fs.existsSync(getConfigPath())).toBe(false)
+ })
+
+ test('clearConfig returns false when no file exists', () => {
+ const result = clearConfig()
+ expect(result).toBe(false)
+ })
+
+ test('hasLiveConfig returns false with no config', () => {
+ expect(hasLiveConfig()).toBe(false)
+ })
+
+ test('hasLiveConfig returns true with api_key and instance_id', () => {
+ setConfig({ api_key: 'sk_test_123', instance_id: 'inst_abc' })
+ expect(hasLiveConfig()).toBe(true)
+ })
+
+ test('hasLiveConfig returns false with only api_key', () => {
+ setConfig({ api_key: 'sk_test_123' })
+ expect(hasLiveConfig()).toBe(false)
+ })
+
+ test('config file has restricted permissions', () => {
+ setConfig({ api_key: 'sk_test_123' })
+ const stat = fs.statSync(getConfigPath())
+ const mode = stat.mode & 0o777
+ expect(mode).toBe(0o600)
+ })
+})
diff --git a/src/__tests__/output.test.ts b/src/__tests__/output.test.ts
new file mode 100644
index 0000000..b547e13
--- /dev/null
+++ b/src/__tests__/output.test.ts
@@ -0,0 +1,108 @@
+import { describe, test, expect } from 'bun:test'
+import { formatTable, formatJson, formatKeyValue, formatOutput, truncate } from '../utils/output'
+
+describe('formatTable', () => {
+ test('returns "No data found." for empty array', () => {
+ const result = formatTable([])
+ expect(result).toContain('No data found.')
+ })
+
+ test('renders rows with headers', () => {
+ const data = [
+ { id: '1', name: 'Alice' },
+ { id: '2', name: 'Bob' },
+ ]
+ const result = formatTable(data)
+ expect(result).toContain('id')
+ expect(result).toContain('name')
+ expect(result).toContain('Alice')
+ expect(result).toContain('Bob')
+ })
+
+ test('uses specified columns', () => {
+ const data = [{ id: '1', name: 'Alice', secret: 'hidden' }]
+ const result = formatTable(data, ['id', 'name'])
+ expect(result).toContain('id')
+ expect(result).toContain('name')
+ expect(result).not.toContain('secret')
+ expect(result).not.toContain('hidden')
+ })
+})
+
+describe('formatJson', () => {
+ test('formats object as JSON with indentation', () => {
+ const result = formatJson({ a: 1 })
+ expect(result).toBe('{\n "a": 1\n}')
+ })
+
+ test('formats array as JSON', () => {
+ const result = formatJson([1, 2])
+ expect(result).toBe('[\n 1,\n 2\n]')
+ })
+})
+
+describe('formatKeyValue', () => {
+ test('renders key-value pairs', () => {
+ const result = formatKeyValue({ id: '123', name: 'Test' })
+ expect(result).toContain('id')
+ expect(result).toContain('123')
+ expect(result).toContain('name')
+ expect(result).toContain('Test')
+ })
+
+ test('handles null values', () => {
+ const result = formatKeyValue({ id: '123', optional: null })
+ expect(result).toContain('id')
+ expect(result).toContain('123')
+ })
+
+ test('returns (empty) for empty object', () => {
+ const result = formatKeyValue({})
+ expect(result).toContain('(empty)')
+ })
+
+ test('stringifies nested objects', () => {
+ const result = formatKeyValue({ data: { nested: true } })
+ expect(result).toContain('{"nested":true}')
+ })
+})
+
+describe('formatOutput', () => {
+ test('uses JSON format when json=true', () => {
+ const result = formatOutput({ a: 1 }, true)
+ expect(result).toBe('{\n "a": 1\n}')
+ })
+
+ test('uses table format for arrays when json=false', () => {
+ const result = formatOutput([{ id: '1' }], false)
+ expect(result).toContain('id')
+ expect(result).toContain('1')
+ })
+
+ test('uses key-value format for objects when json=false', () => {
+ const result = formatOutput({ id: '123' }, false)
+ expect(result).toContain('id')
+ expect(result).toContain('123')
+ })
+})
+
+describe('truncate', () => {
+ test('returns short strings unchanged', () => {
+ expect(truncate('hello', 10)).toBe('hello')
+ })
+
+ test('truncates long strings with ellipsis', () => {
+ expect(truncate('a very long string here', 10)).toBe('a very ...')
+ })
+
+ test('handles exact length', () => {
+ expect(truncate('hello', 5)).toBe('hello')
+ })
+
+ test('uses default max of 32', () => {
+ const long = 'a'.repeat(50)
+ const result = truncate(long)
+ expect(result.length).toBe(32)
+ expect(result).toEndWith('...')
+ })
+})
diff --git a/src/commands/resources.ts b/src/commands/resources.ts
new file mode 100644
index 0000000..69ea112
--- /dev/null
+++ b/src/commands/resources.ts
@@ -0,0 +1,811 @@
+import process from 'node:process'
+import * as clack from '@clack/prompts'
+import pc from 'picocolors'
+import { formatOutput, truncate } from '../utils/output'
+import type { ApiContext, ApiError, ValidationErrorItem } from '../utils/api-client'
+import { apiGet, apiPost, apiPut, apiDelete, resolveContext } from '../utils/api-client'
+
+function instancePath(ctx: ApiContext) {
+ return `/v1/instances/${ctx.instanceId}`
+}
+
+function printResult(data: unknown, json: boolean, columns?: string[]) {
+ console.log(formatOutput(data, json, columns))
+}
+
+function formatValidationError(item: ValidationErrorItem): string {
+ const path = Array.isArray(item.path) ? item.path.filter(Boolean).join('.') : ''
+ return path ? `${path}: ${item.message}` : item.message
+}
+
+function handleApiError(err: unknown, json = false): never {
+ const apiErr = err as ApiError
+ const statusCode = apiErr.statusCode
+ const exitCode = statusCode ? 2 : 1
+ const msg = err instanceof Error ? err.message : String(err)
+
+ if (json) {
+ const output: Record = { error: true, message: msg, exitCode }
+ if (statusCode) output.statusCode = statusCode
+ if (apiErr.validationErrors?.length) output.validationErrors = apiErr.validationErrors
+ console.log(JSON.stringify(output, null, 2))
+ }
+ else {
+ clack.log.error(msg)
+ if (apiErr.validationErrors?.length) {
+ for (const ve of apiErr.validationErrors)
+ console.log(pc.dim(` • ${formatValidationError(ve)}`))
+ }
+ }
+ process.exit(exitCode)
+}
+
+function exitWithError(message: string, exitCode: number, json = false): never {
+ if (json) {
+ console.log(JSON.stringify({ error: true, message, exitCode }, null, 2))
+ }
+ else {
+ clack.log.error(message)
+ }
+ process.exit(exitCode)
+}
+
+function parseAmount(value: string | undefined, fallback: number, json: boolean): number {
+ if (value === undefined) return fallback
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ exitWithError(`Invalid amount: "${value}". Must be a non-negative number (in cents).`, 1, json)
+ }
+ return Math.round(parsed)
+}
+
+function extractList(res: any): any[] {
+ if (Array.isArray(res))
+ return res
+ if (res?.data && Array.isArray(res.data))
+ return res.data
+ return []
+}
+
+// Receivers
+export async function listReceivers(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/receivers`)
+ const list = extractList(res)
+ const display = list.map((r: any) => ({
+ id: r.id,
+ type: r.type,
+ name: r.type === 'individual' ? `${r.first_name || ''} ${r.last_name || ''}`.trim() || '-' : r.legal_name || '-',
+ email: r.email,
+ country: r.country,
+ kyc_status: r.kyc_status,
+ }))
+ printResult(options.json ? list : display, options.json, ['id', 'type', 'name', 'email', 'country', 'kyc_status'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getReceiver(id: string, options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const receiver = await apiGet(ctx, `${instancePath(ctx)}/receivers/${id}`)
+ printResult(receiver, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createReceiver(options: {
+ email: string
+ type?: string
+ name?: string
+ firstName?: string
+ lastName?: string
+ legalName?: string
+ country?: string
+ taxId?: string
+ externalId?: string
+ kycStatus?: string
+ json: boolean
+}) {
+ try {
+ const ctx = resolveContext()
+ let first_name = options.firstName ?? null
+ let last_name = options.lastName ?? null
+ if (options.name !== null && options.name !== undefined && String(options.name).trim()) {
+ const parts = String(options.name).trim().split(/\s+/)
+ if (parts.length >= 2) {
+ first_name = parts[0]
+ last_name = parts.slice(1).join(' ')
+ }
+ else {
+ first_name = parts[0]
+ last_name = null
+ }
+ }
+ const body = {
+ type: options.type || 'individual',
+ email: options.email,
+ tax_id: options.taxId ?? null,
+ first_name: first_name ?? null,
+ last_name: last_name ?? null,
+ legal_name: options.legalName ?? null,
+ country: options.country || 'US',
+ external_id: options.externalId ?? null,
+ kyc_status: options.kycStatus ?? 'approved',
+ }
+ const receiver = await apiPost<{ id: string, type: string }>(ctx, `${instancePath(ctx)}/receivers`, body)
+ const displayName = body.type === 'business'
+ ? (body.legal_name || '—')
+ : [body.first_name, body.last_name].filter(Boolean).join(' ').trim() || '—'
+ clack.log.success(`Created receiver ${receiver.id} (${receiver.type}, ${displayName})`)
+ if (options.json)
+ console.log(formatOutput(receiver, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function updateReceiver(
+ id: string,
+ options: {
+ name?: string
+ firstName?: string
+ lastName?: string
+ legalName?: string
+ email?: string
+ country?: string
+ kycStatus?: string
+ json?: boolean
+ },
+) {
+ try {
+ const ctx = resolveContext()
+ const body: Record = {}
+ if (options.firstName !== undefined)
+ body.first_name = options.firstName
+ if (options.lastName !== undefined)
+ body.last_name = options.lastName
+ if (options.legalName !== undefined)
+ body.legal_name = options.legalName
+ if (options.email !== undefined)
+ body.email = options.email
+ if (options.country !== undefined)
+ body.country = options.country
+ if (options.kycStatus !== undefined)
+ body.kyc_status = options.kycStatus
+ if (options.name !== undefined && options.name.trim()) {
+ const parts = options.name.trim().split(/\s+/)
+ if (parts.length >= 2) {
+ body.first_name = parts[0]
+ body.last_name = parts.slice(1).join(' ')
+ }
+ else {
+ body.first_name = parts[0]
+ body.last_name = null
+ }
+ }
+ if (Object.keys(body).length === 0) {
+ exitWithError('Provide at least one field to update (e.g. --name, --kyc-status)', 1, options.json)
+ }
+ const receiver = await apiPut>(ctx, `${instancePath(ctx)}/receivers/${id}`, body)
+ clack.log.success(`Updated receiver ${id}`)
+ if (options.json)
+ console.log(formatOutput(receiver, true))
+ else
+ console.log(formatOutput(receiver, false))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function deleteReceiver(id: string, options: { json?: boolean } = {}) {
+ try {
+ const ctx = resolveContext()
+ await apiDelete(ctx, `${instancePath(ctx)}/receivers/${id}`)
+ clack.log.success(`Deleted receiver ${id}`)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Bank Accounts
+export async function listBankAccounts(options: { receiverId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts`)
+ const list = extractList(res)
+ const display = list.map((a: any) => ({ id: a.id, type: a.type, name: a.name, status: a.status, country: a.country }))
+ printResult(options.json ? list : display, options.json, ['id', 'type', 'name', 'status', 'country'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getBankAccount(id: string, options: { receiverId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const account = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${id}`)
+ printResult(account, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createBankAccount(options: {
+ receiverId: string
+ type?: string
+ name?: string
+ recipientRelationship?: string
+ pixKey?: string
+ beneficiaryName?: string
+ routingNumber?: string
+ accountNumber?: string
+ accountType?: string
+ accountClass?: string
+ country?: string
+ json: boolean
+}) {
+ try {
+ const ctx = resolveContext()
+ const body = {
+ type: options.type || 'ach',
+ name: options.name || 'CLI Bank Account',
+ recipient_relationship: options.recipientRelationship ?? null,
+ pix_key: options.pixKey ?? null,
+ beneficiary_name: options.beneficiaryName ?? null,
+ routing_number: options.routingNumber ?? null,
+ account_number: options.accountNumber ?? null,
+ account_type: options.accountType ?? null,
+ account_class: options.accountClass ?? null,
+ country: options.country ?? null,
+ }
+ const ba = await apiPost<{ id: string, type: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts`, body)
+ clack.log.success(`Created bank account ${ba.id} (${ba.type})`)
+ if (options.json)
+ console.log(formatOutput(ba, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function deleteBankAccount(id: string, options: { receiverId: string, json?: boolean }) {
+ try {
+ const ctx = resolveContext()
+ await apiDelete(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${id}`)
+ clack.log.success(`Deleted bank account ${id}`)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Blockchain Wallets
+export async function listBlockchainWallets(options: { receiverId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets`)
+ const list = extractList(res)
+ const display = list.map((w: any) => ({ id: w.id, address: truncate(w.address, 20), network: w.network }))
+ printResult(options.json ? list : display, options.json, ['id', 'address', 'network'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getBlockchainWallet(id: string, options: { receiverId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const wallet = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets/${id}`)
+ printResult(wallet, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createBlockchainWallet(options: {
+ receiverId: string
+ address: string
+ network?: string
+ name?: string
+ externalId?: string
+ json: boolean
+}) {
+ try {
+ const ctx = resolveContext()
+ const body = {
+ address: options.address,
+ network: options.network || 'base',
+ name: options.name || 'CLI Blockchain Wallet',
+ external_id: options.externalId ?? null,
+ }
+ const wallet = await apiPost<{ id: string, network: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets`, body)
+ clack.log.success(`Created blockchain wallet ${wallet.id} (${wallet.network})`)
+ if (options.json)
+ console.log(formatOutput(wallet, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function deleteBlockchainWallet(id: string, options: { receiverId: string, json?: boolean }) {
+ try {
+ const ctx = resolveContext()
+ await apiDelete(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets/${id}`)
+ clack.log.success(`Deleted blockchain wallet ${id}`)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Payouts
+export async function listPayouts(options: { json: boolean, status?: string }) {
+ try {
+ const ctx = resolveContext()
+ const endpoint = options.status ? `${instancePath(ctx)}/payouts?status=${encodeURIComponent(options.status)}` : `${instancePath(ctx)}/payouts`
+ const res = await apiGet(ctx, endpoint)
+ const list = extractList(res)
+ const display = list.map((p: any) => ({
+ id: p.id,
+ status: p.status,
+ amount: p.sender_amount !== null && p.sender_amount !== undefined ? `${(p.sender_amount / 100)} ${p.token || 'USDC'}` : '-',
+ network: p.network || '-',
+ created_at: p.created_at,
+ }))
+ printResult(options.json ? list : display, options.json, ['id', 'status', 'amount', 'network', 'created_at'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getPayout(id: string, options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const payout = await apiGet(ctx, `${instancePath(ctx)}/payouts/${id}`)
+ printResult(payout, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createPayout(options: { quoteId: string, network?: string, senderWalletAddress: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const network = (options.network ?? 'evm').toLowerCase()
+ if (!['evm', 'solana', 'stellar'].includes(network)) {
+ exitWithError(`Invalid network: ${options.network}. Use evm, solana, or stellar.`, 1, options.json)
+ }
+ const body = {
+ quote_id: options.quoteId,
+ sender_wallet_address: options.senderWalletAddress,
+ }
+ const payout = await apiPost<{ id: string, status: string }>(ctx, `${instancePath(ctx)}/payouts/${network}`, body)
+ clack.log.success(`Created payout ${payout.id} (${payout.status})`)
+ if (options.json)
+ console.log(formatOutput(payout, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Payins
+export async function listPayins(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/payins`)
+ const list = extractList(res)
+ const display = list.map((p: any) => ({
+ id: p.id,
+ status: p.status,
+ amount: p.sender_amount !== null && p.sender_amount !== undefined ? `${(p.sender_amount / 100)} ${p.currency || 'USD'}` : '-',
+ method: p.payment_method || '-',
+ created_at: p.created_at,
+ }))
+ printResult(options.json ? list : display, options.json, ['id', 'status', 'amount', 'method', 'created_at'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getPayin(id: string, options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const payin = await apiGet(ctx, `${instancePath(ctx)}/payins/${id}`)
+ printResult(payin, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createPayin(options: { payinQuoteId: string, network?: string, externalId?: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const network = (options.network ?? 'evm').toLowerCase()
+ if (!['evm', 'solana', 'stellar'].includes(network)) {
+ exitWithError(`Invalid network: ${options.network}. Use evm, solana, or stellar.`, 1, options.json)
+ }
+ const body = {
+ payin_quote_id: options.payinQuoteId,
+ external_id: options.externalId ?? null,
+ }
+ const payin = await apiPost<{ id: string, status: string }>(ctx, `${instancePath(ctx)}/payins/${network}`, body)
+ clack.log.success(`Created payin ${payin.id} (${payin.status})`)
+ if (options.json)
+ console.log(formatOutput(payin, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Payin Quotes
+export async function createPayinQuote(options: { blockchainWalletId: string, paymentMethod: string, amount?: string, currency?: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const body = {
+ blockchain_wallet_id: options.blockchainWalletId,
+ payment_method: options.paymentMethod,
+ request_amount: parseAmount(options.amount, 1000, options.json),
+ currency: options.currency ?? 'USD',
+ }
+ const quote = await apiPost<{ id: string, sender_amount: number, receiver_amount: number, payment_method: string, currency: string }>(ctx, `${instancePath(ctx)}/payin-quotes`, body)
+ clack.log.success(`Created payin quote ${quote.id} (${(quote.sender_amount || 0) / 100} ${quote.currency} via ${quote.payment_method})`)
+ if (!options.json)
+ clack.log.message(`Next: blindpay payins create --payin-quote-id ${quote.id}`)
+ if (options.json)
+ console.log(formatOutput(quote, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Quotes
+export async function createQuote(options: {
+ bankAccountId: string
+ network?: string
+ token?: string
+ amount?: string
+ json: boolean
+}) {
+ try {
+ const ctx = resolveContext()
+ const body = {
+ bank_account_id: options.bankAccountId,
+ network: options.network || 'base',
+ token: options.token || 'USDC',
+ request_amount: parseAmount(options.amount, 1000, options.json),
+ }
+ const quote = await apiPost<{ id: string, sender_amount: number, receiver_amount: number, token?: string, currency?: string }>(ctx, `${instancePath(ctx)}/quotes`, body)
+ const token = quote.token ?? 'USDC'
+ const currency = (quote as any).currency ?? 'USD'
+ clack.log.success(`Created quote ${quote.id} (${(quote.sender_amount || 0) / 100} ${token} -> ${(quote.receiver_amount || 0) / 100} ${currency})`)
+ if (!options.json)
+ clack.log.message(`Next: blindpay payouts create --quote-id ${quote.id}`)
+ if (options.json)
+ console.log(formatOutput(quote, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Webhook Endpoints
+export async function listWebhookEndpoints(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/webhook-endpoints`)
+ const list = extractList(res)
+ const display = list.map((e: any) => ({ id: e.id, url: e.url, description: e.description }))
+ printResult(options.json ? list : display, options.json, ['id', 'url', 'description'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createWebhookEndpoint(options: { url: string, description?: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const endpoint = await apiPost<{ id: string, url: string }>(ctx, `${instancePath(ctx)}/webhook-endpoints`, { url: options.url, description: options.description || null })
+ clack.log.success(`Created webhook endpoint ${endpoint.id} -> ${endpoint.url}`)
+ if (options.json)
+ console.log(formatOutput(endpoint, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function deleteWebhookEndpoint(id: string, options: { json?: boolean } = {}) {
+ try {
+ const ctx = resolveContext()
+ await apiDelete(ctx, `${instancePath(ctx)}/webhook-endpoints/${id}`)
+ clack.log.success(`Deleted webhook endpoint ${id}`)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Partner Fees
+export async function listPartnerFees(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/partner-fees`)
+ const list = extractList(res)
+ const display = list.map((f: any) => ({
+ id: f.id,
+ payout_pct: `${(f.payout_percentage_fee || 0) / 100}%`,
+ payout_flat: `$${(f.payout_flat_fee || 0) / 100}`,
+ payin_pct: `${(f.payin_percentage_fee || 0) / 100}%`,
+ payin_flat: `$${(f.payin_flat_fee || 0) / 100}`,
+ }))
+ printResult(options.json ? list : display, options.json, ['id', 'payout_pct', 'payout_flat', 'payin_pct', 'payin_flat'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createPartnerFee(options: {
+ name?: string
+ payinPercentage?: string
+ payinFlat?: string
+ payoutPercentage?: string
+ payoutFlat?: string
+ evmWallet?: string
+ stellarWallet?: string
+ json: boolean
+}) {
+ try {
+ const ctx = resolveContext()
+ const parseFee = (val: string | undefined, label: string): number => {
+ if (val === undefined) return 0
+ const n = Number(val)
+ if (!Number.isFinite(n) || n < 0) exitWithError(`Invalid ${label}: "${val}". Must be a non-negative number.`, 1, options.json)
+ return n * 100
+ }
+ const body: Record = {
+ name: options.name || 'CLI Partner Fee',
+ payin_percentage_fee: parseFee(options.payinPercentage, 'payin percentage'),
+ payin_flat_fee: parseFee(options.payinFlat, 'payin flat fee'),
+ payout_percentage_fee: parseFee(options.payoutPercentage, 'payout percentage'),
+ payout_flat_fee: parseFee(options.payoutFlat, 'payout flat fee'),
+ evm_wallet_address: options.evmWallet ?? null,
+ stellar_wallet_address: options.stellarWallet ?? null,
+ }
+ const fee = await apiPost<{ id: string }>(ctx, `${instancePath(ctx)}/partner-fees`, body)
+ clack.log.success(`Created partner fee ${fee.id}`)
+ if (options.json)
+ console.log(formatOutput(fee, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function deletePartnerFee(id: string, options: { json?: boolean } = {}) {
+ try {
+ const ctx = resolveContext()
+ await apiDelete(ctx, `${instancePath(ctx)}/partner-fees/${id}`)
+ clack.log.success(`Deleted partner fee ${id}`)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// API Keys
+export async function listApiKeys(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/api-keys`)
+ const list = extractList(res)
+ const maskKey = (s: string | null) => (!s ? '-' : s.length > 8 ? `${s.slice(0, 4)}...${s.slice(-4)}` : '***')
+ const display = list.map((k: any) => ({ id: k.id, name: k.name, key: maskKey(k.key), permission: k.permission }))
+ printResult(options.json ? list : display, options.json, ['id', 'name', 'key', 'permission'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createApiKey(options: { name?: string, permission?: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const body: Record = { name: options.name || 'CLI API Key' }
+ if (options.permission) body.permission = options.permission
+ const key = await apiPost<{ id: string, key: string }>(ctx, `${instancePath(ctx)}/api-keys`, body)
+ clack.log.success(`Created API key ${key.id}`)
+ clack.log.warning(`Secret: ${key.key}`)
+ clack.log.message('Save this key now — it will not be shown again.')
+ if (options.json)
+ console.log(formatOutput(key, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function deleteApiKey(id: string, options: { json?: boolean } = {}) {
+ try {
+ const ctx = resolveContext()
+ await apiDelete(ctx, `${instancePath(ctx)}/api-keys/${id}`)
+ clack.log.success(`Deleted API key ${id}`)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Virtual Accounts
+export async function listVirtualAccounts(options: { receiverId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/virtual-accounts`)
+ const list = extractList(res)
+ const display = list.map((a: any) => ({ id: a.id, account_number: a.account_number, routing_number: a.routing_number, kyc_status: a.kyc_status }))
+ printResult(options.json ? list : display, options.json, ['id', 'account_number', 'routing_number', 'kyc_status'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function createVirtualAccount(options: { receiverId: string, blockchainWalletId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const account = await apiPost<{ id: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/virtual-accounts`, { blockchain_wallet_id: options.blockchainWalletId })
+ clack.log.success(`Created virtual account ${account.id}`)
+ if (options.json)
+ console.log(formatOutput(account, true))
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Offramp Wallets
+export async function listOfframpWallets(options: { receiverId: string, bankAccountId: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${options.bankAccountId}/offramp-wallets`)
+ const list = extractList(res)
+ const display = list.map((w: any) => ({ id: w.id, address: truncate(w.address, 20), network: w.network }))
+ printResult(options.json ? list : display, options.json, ['id', 'address', 'network'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Instances
+export async function getInstance(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const instance = await apiGet(ctx, `${instancePath(ctx)}`)
+ printResult(instance, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function listInstanceMembers(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/members`)
+ const list = extractList(res)
+ const display = list.map((m: any) => ({ id: m.id, email: m.email, role: m.role, name: m.name || '-' }))
+ printResult(options.json ? list : display, options.json, ['id', 'email', 'role', 'name'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function updateInstance(options: {
+ name?: string
+ webhookUrl?: string
+ json: boolean
+}) {
+ try {
+ const ctx = resolveContext()
+ const body: Record = {}
+ if (options.name !== undefined) body.name = options.name
+ if (options.webhookUrl !== undefined) body.webhook_url = options.webhookUrl
+ if (Object.keys(body).length === 0) {
+ exitWithError('Provide at least one field to update (e.g. --name, --webhook-url)', 1, options.json)
+ }
+ const instance = await apiPut>(ctx, `${instancePath(ctx)}`, body)
+ clack.log.success('Instance updated')
+ printResult(instance, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Receiver Limits
+export async function getReceiverLimits(receiverId: string, options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const limits = await apiGet(ctx, `${instancePath(ctx)}/receivers/${receiverId}/limits`)
+ printResult(limits, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getReceiverLimitsIncreaseRequests(receiverId: string, options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${receiverId}/limits-increase-requests`)
+ const list = extractList(res)
+ printResult(list, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// FX Rates
+export async function getQuoteFxRate(options: { from?: string, to?: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const params = new URLSearchParams()
+ if (options.from) params.set('from', options.from)
+ if (options.to) params.set('to', options.to)
+ const qs = params.toString() ? `?${params.toString()}` : ''
+ const rate = await apiGet(ctx, `${instancePath(ctx)}/fx-rates${qs}`)
+ printResult(rate, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+// Available
+export async function listAvailableRails(options: { json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/available/rails`)
+ const list = extractList(res)
+ printResult(list, options.json, ['type', 'currency', 'country', 'name'])
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
+
+export async function getAvailableBankDetails(options: { rail: string, json: boolean }) {
+ try {
+ const ctx = resolveContext()
+ const res = await apiGet(ctx, `${instancePath(ctx)}/available/bank-details/${options.rail}`)
+ printResult(res, options.json)
+ }
+ catch (e) {
+ handleApiError(e, options.json)
+ }
+}
diff --git a/src/commands/schema.ts b/src/commands/schema.ts
new file mode 100644
index 0000000..4a2781d
--- /dev/null
+++ b/src/commands/schema.ts
@@ -0,0 +1,207 @@
+import process from 'node:process'
+import { bankDetailFields } from '../utils/constants'
+
+interface FieldDef {
+ name: string
+ type: 'string' | 'number'
+ required: boolean
+ description: string
+ default?: string
+ enum?: string[]
+}
+
+interface ResourceSchema {
+ resource: string
+ commands: string[]
+ create?: { fields: FieldDef[] }
+ update?: { fields: FieldDef[] }
+}
+
+const schemas: ResourceSchema[] = [
+ {
+ resource: 'receivers',
+ commands: ['list', 'get', 'create', 'update', 'delete'],
+ create: {
+ fields: [
+ { name: 'email', type: 'string', required: true, description: 'Receiver email address' },
+ { name: 'type', type: 'string', required: false, description: 'Receiver type', default: 'individual', enum: ['individual', 'business'] },
+ { name: 'name', type: 'string', required: false, description: 'Full name (individual); auto-splits into first_name and last_name' },
+ { name: 'first_name', type: 'string', required: false, description: 'First name (individual)' },
+ { name: 'last_name', type: 'string', required: false, description: 'Last name (individual)' },
+ { name: 'legal_name', type: 'string', required: false, description: 'Legal name (business)' },
+ { name: 'country', type: 'string', required: false, description: 'ISO 3166 country code', default: 'US' },
+ { name: 'tax_id', type: 'string', required: false, description: 'Tax ID' },
+ { name: 'external_id', type: 'string', required: false, description: 'External reference ID' },
+ { name: 'kyc_status', type: 'string', required: false, description: 'KYC verification status', default: 'approved', enum: ['verifying', 'approved', 'rejected', 'deprecated'] },
+ ],
+ },
+ update: {
+ fields: [
+ { name: 'name', type: 'string', required: false, description: 'Full name (individual); auto-splits into first_name and last_name' },
+ { name: 'first_name', type: 'string', required: false, description: 'First name (individual)' },
+ { name: 'last_name', type: 'string', required: false, description: 'Last name (individual)' },
+ { name: 'legal_name', type: 'string', required: false, description: 'Legal name (business)' },
+ { name: 'email', type: 'string', required: false, description: 'Receiver email address' },
+ { name: 'country', type: 'string', required: false, description: 'ISO 3166 country code' },
+ { name: 'kyc_status', type: 'string', required: false, description: 'KYC verification status', enum: ['verifying', 'approved', 'rejected', 'deprecated'] },
+ ],
+ },
+ },
+ {
+ resource: 'bank_accounts',
+ commands: ['list', 'get', 'create', 'delete'],
+ create: {
+ fields: [
+ { name: 'receiver_id', type: 'string', required: true, description: 'Receiver ID that owns this bank account' },
+ { name: 'type', type: 'string', required: false, description: 'Bank account type / payment rail', default: 'ach', enum: Object.keys(bankDetailFields) },
+ { name: 'name', type: 'string', required: false, description: 'Account display name', default: 'CLI Bank Account' },
+ { name: 'beneficiary_name', type: 'string', required: false, description: 'Beneficiary name on the account' },
+ { name: 'routing_number', type: 'string', required: false, description: 'Bank routing number (ACH/Wire/RTP)' },
+ { name: 'account_number', type: 'string', required: false, description: 'Bank account number' },
+ { name: 'account_type', type: 'string', required: false, description: 'Account type', enum: ['checking', 'saving'] },
+ { name: 'account_class', type: 'string', required: false, description: 'Account class', enum: ['individual', 'business'] },
+ { name: 'pix_key', type: 'string', required: false, description: 'PIX key (for PIX rail)' },
+ { name: 'recipient_relationship', type: 'string', required: false, description: 'Relationship to recipient' },
+ { name: 'country', type: 'string', required: false, description: 'Country code' },
+ ],
+ },
+ },
+ {
+ resource: 'blockchain_wallets',
+ commands: ['list', 'get', 'create', 'delete'],
+ create: {
+ fields: [
+ { name: 'receiver_id', type: 'string', required: true, description: 'Receiver ID that owns this wallet' },
+ { name: 'address', type: 'string', required: true, description: 'Blockchain wallet address' },
+ { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'base', enum: ['base', 'ethereum', 'polygon', 'solana', 'stellar', 'arbitrum', 'optimism'] },
+ { name: 'external_id', type: 'string', required: false, description: 'External reference ID' },
+ ],
+ },
+ },
+ {
+ resource: 'quotes',
+ commands: ['create'],
+ create: {
+ fields: [
+ { name: 'bank_account_id', type: 'string', required: true, description: 'Bank account ID for the payout destination' },
+ { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'base' },
+ { name: 'token', type: 'string', required: false, description: 'Stablecoin token', default: 'USDC', enum: ['USDC', 'USDT', 'USDB'] },
+ { name: 'amount', type: 'number', required: false, description: 'Amount in cents', default: '1000' },
+ ],
+ },
+ },
+ {
+ resource: 'payouts',
+ commands: ['list', 'get', 'create'],
+ create: {
+ fields: [
+ { name: 'quote_id', type: 'string', required: true, description: 'Quote ID from "blindpay quotes create"' },
+ { name: 'sender_wallet_address', type: 'string', required: true, description: 'Sender wallet address' },
+ { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'evm', enum: ['evm', 'solana', 'stellar'] },
+ ],
+ },
+ },
+ {
+ resource: 'payin_quotes',
+ commands: ['create'],
+ create: {
+ fields: [
+ { name: 'blockchain_wallet_id', type: 'string', required: true, description: 'Blockchain wallet ID to receive funds' },
+ { name: 'payment_method', type: 'string', required: true, description: 'Fiat payment method', enum: ['pix', 'ach', 'wire', 'spei', 'transfers', 'pse'] },
+ { name: 'amount', type: 'number', required: false, description: 'Amount in cents', default: '1000' },
+ { name: 'currency', type: 'string', required: false, description: 'Fiat currency', default: 'USD' },
+ ],
+ },
+ },
+ {
+ resource: 'payins',
+ commands: ['list', 'get', 'create'],
+ create: {
+ fields: [
+ { name: 'payin_quote_id', type: 'string', required: true, description: 'Payin quote ID from "blindpay payin_quotes create"' },
+ { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'evm', enum: ['evm', 'solana', 'stellar'] },
+ { name: 'external_id', type: 'string', required: false, description: 'External reference ID' },
+ ],
+ },
+ },
+ {
+ resource: 'webhook_endpoints',
+ commands: ['list', 'create', 'delete'],
+ create: {
+ fields: [
+ { name: 'url', type: 'string', required: true, description: 'Webhook URL to receive events' },
+ { name: 'description', type: 'string', required: false, description: 'Endpoint description' },
+ ],
+ },
+ },
+ {
+ resource: 'partner_fees',
+ commands: ['list', 'create', 'delete'],
+ create: {
+ fields: [
+ { name: 'payout_percentage_fee', type: 'number', required: false, description: 'Payout percentage fee (e.g. 2.5 for 2.5%)', default: '0' },
+ { name: 'payout_flat_fee', type: 'number', required: false, description: 'Payout flat fee in dollars (e.g. 1.00)', default: '0' },
+ { name: 'payin_percentage_fee', type: 'number', required: false, description: 'Payin percentage fee (e.g. 2.5 for 2.5%)', default: '0' },
+ { name: 'payin_flat_fee', type: 'number', required: false, description: 'Payin flat fee in dollars (e.g. 1.00)', default: '0' },
+ { name: 'evm_wallet_address', type: 'string', required: false, description: 'EVM wallet address for fee collection' },
+ { name: 'stellar_wallet_address', type: 'string', required: false, description: 'Stellar wallet address for fee collection' },
+ ],
+ },
+ },
+ {
+ resource: 'api_keys',
+ commands: ['list', 'create', 'delete'],
+ create: {
+ fields: [
+ { name: 'name', type: 'string', required: false, description: 'API key name', default: 'CLI API Key' },
+ ],
+ },
+ },
+ {
+ resource: 'virtual_accounts',
+ commands: ['list', 'create'],
+ create: {
+ fields: [
+ { name: 'receiver_id', type: 'string', required: true, description: 'Receiver ID' },
+ { name: 'blockchain_wallet_id', type: 'string', required: true, description: 'Blockchain wallet ID' },
+ ],
+ },
+ },
+ {
+ resource: 'offramp_wallets',
+ commands: ['list'],
+ },
+]
+
+export function listSchemas() {
+ const summary = schemas.map(s => ({ resource: s.resource, commands: s.commands }))
+ console.log(JSON.stringify(summary, null, 2))
+}
+
+export function getSchema(resource: string, rail?: string) {
+ const schema = schemas.find(s => s.resource === resource)
+ if (!schema) {
+ const available = schemas.map(s => s.resource).join(', ')
+ console.error(JSON.stringify({ error: true, message: `Unknown resource "${resource}". Available: ${available}`, exitCode: 1 }, null, 2))
+ process.exit(1)
+ }
+
+ const output: Record = { ...schema }
+
+ if (resource === 'bank_accounts' && rail) {
+ const fields = bankDetailFields[rail]
+ if (!fields) {
+ const available = Object.keys(bankDetailFields).join(', ')
+ console.error(JSON.stringify({ error: true, message: `Unknown rail "${rail}". Available: ${available}`, exitCode: 1 }, null, 2))
+ process.exit(1)
+ }
+ output.rail = rail
+ output.rail_fields = fields
+ }
+
+ if (resource === 'bank_accounts' && !rail) {
+ output.available_rails = Object.keys(bankDetailFields)
+ }
+
+ console.log(JSON.stringify(output, null, 2))
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..d6b332d
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,606 @@
+import process from 'node:process'
+import { Command } from 'commander'
+import * as clack from '@clack/prompts'
+import { listSchemas, getSchema } from './commands/schema'
+import {
+ listReceivers,
+ getReceiver,
+ createReceiver,
+ updateReceiver,
+ deleteReceiver,
+ getReceiverLimits,
+ getReceiverLimitsIncreaseRequests,
+ listBankAccounts,
+ getBankAccount,
+ createBankAccount,
+ deleteBankAccount,
+ listBlockchainWallets,
+ getBlockchainWallet,
+ createBlockchainWallet,
+ deleteBlockchainWallet,
+ listPayouts,
+ getPayout,
+ createPayout,
+ listPayins,
+ getPayin,
+ createPayin,
+ createPayinQuote,
+ createQuote,
+ getQuoteFxRate,
+ listWebhookEndpoints,
+ createWebhookEndpoint,
+ deleteWebhookEndpoint,
+ listPartnerFees,
+ createPartnerFee,
+ deletePartnerFee,
+ listApiKeys,
+ createApiKey,
+ deleteApiKey,
+ listVirtualAccounts,
+ createVirtualAccount,
+ listOfframpWallets,
+ getInstance,
+ listInstanceMembers,
+ updateInstance,
+ listAvailableRails,
+ getAvailableBankDetails,
+} from './commands/resources'
+import { getConfig, setConfig, clearConfig, getConfigPath } from './utils/config'
+import { CLI_VERSION } from './utils/constants'
+
+const program = new Command()
+
+program
+ .name('blindpay')
+ .description('Blindpay CLI — manage receivers, bank accounts, payouts, payins, and more from the terminal.')
+ .version(CLI_VERSION)
+ .addHelpText('after', `
+Examples:
+ $ blindpay config set --api-key --instance-id
+ $ blindpay receivers list --json
+ $ blindpay payouts list --status processing
+ $ blindpay available rails
+
+Documentation: https://github.com/blindpaylabs/blindpay-cli`)
+
+// ── Config ──────────────────────────────────────────────────────────────
+const configCmd = program.command('config').description('Configure API credentials')
+ .addHelpText('after', `
+Examples:
+ $ blindpay config set --api-key sk_live_... --instance-id inst_...
+ $ blindpay config set --base-url https://api.blindpay.com
+ $ blindpay config get
+ $ blindpay config clear
+ $ blindpay config path`)
+
+configCmd
+ .command('set')
+ .description('Set API key, instance ID, or base URL')
+ .option('--api-key ', 'API key (from Blindpay dashboard)')
+ .option('--instance-id ', 'Instance ID')
+ .option('--base-url ', 'API base URL (default: https://api.blindpay.com)')
+ .action((opts) => {
+ const updates: { api_key?: string, instance_id?: string, base_url?: string } = {}
+ if (opts.apiKey !== undefined)
+ updates.api_key = opts.apiKey
+ if (opts.instanceId !== undefined)
+ updates.instance_id = opts.instanceId
+ if (opts.baseUrl !== undefined)
+ updates.base_url = opts.baseUrl
+ if (Object.keys(updates).length === 0) {
+ clack.log.error('Provide at least one option: --api-key, --instance-id, or --base-url')
+ process.exit(1)
+ }
+ setConfig(updates)
+ clack.log.success('Config updated')
+ })
+
+configCmd
+ .command('get')
+ .description('Show current config (API key masked)')
+ .action(() => {
+ const c = getConfig()
+ const mask = (s: string | null) => (!s ? '-' : s.length < 10 ? '***' : `${s.slice(0, 3)}...${s.slice(-4)}`)
+ console.log(` instance_id: ${c.instance_id ?? '-'}`)
+ console.log(` api_key: ${mask(c.api_key)}`)
+ console.log(` base_url: ${c.base_url ?? 'https://api.blindpay.com (default)'}`)
+ })
+
+configCmd
+ .command('clear')
+ .description('Remove saved config')
+ .action(() => {
+ if (clearConfig())
+ clack.log.success('Config cleared')
+ else
+ clack.log.message('No config file found')
+ })
+
+configCmd
+ .command('path')
+ .description('Print config file path')
+ .action(() => console.log(getConfigPath()))
+
+// ── Receivers ───────────────────────────────────────────────────────────
+const receivers = program.command('receivers').description('Manage receivers')
+ .addHelpText('after', `
+Examples:
+ $ blindpay receivers list
+ $ blindpay receivers list --json
+ $ blindpay receivers get
+ $ blindpay receivers create --email user@example.com --name "John Doe" --country US
+ $ blindpay receivers create --type business --email biz@co.com --legal-name "Acme Inc"
+ $ blindpay receivers update --kyc-status approved
+ $ blindpay receivers delete `)
+
+receivers
+ .command('list')
+ .description('List all receivers')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listReceivers(opts))
+
+receivers
+ .command('get ')
+ .description('Get a receiver by ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getReceiver(id, opts))
+
+receivers
+ .command('create')
+ .description('Create a new receiver')
+ .requiredOption('--email ', 'Receiver email')
+ .option('--type ', 'individual or business', 'individual')
+ .option('--name ', 'Full name (individual); splits into first_name and last_name')
+ .option('--first-name ', 'First name (individual)')
+ .option('--last-name ', 'Last name (individual)')
+ .option('--legal-name ', 'Legal name (business)')
+ .option('--country ', 'ISO 3166 country code', 'US')
+ .option('--tax-id ', 'Tax ID')
+ .option('--external-id ', 'External ID')
+ .option('--kyc-status ', 'KYC status (verifying, approved, rejected, deprecated)', 'approved')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createReceiver(opts))
+
+receivers
+ .command('update ')
+ .description('Update a receiver')
+ .option('--name ', 'Full name (individual); splits into first_name and last_name')
+ .option('--first-name ', 'First name (individual)')
+ .option('--last-name ', 'Last name (individual)')
+ .option('--legal-name ', 'Legal name (business)')
+ .option('--email ', 'Receiver email')
+ .option('--country ', 'ISO 3166 country code')
+ .option('--kyc-status ', 'KYC status (verifying, approved, rejected, deprecated)')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => updateReceiver(id, opts))
+
+receivers
+ .command('delete ')
+ .description('Delete a receiver')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => deleteReceiver(id, opts))
+
+receivers
+ .command('limits ')
+ .description('Get receiver limits')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getReceiverLimits(id, opts))
+
+receivers
+ .command('limits_increase_requests ')
+ .description('Get receiver limits increase requests')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getReceiverLimitsIncreaseRequests(id, opts))
+
+// ── Bank Accounts ───────────────────────────────────────────────────────
+const bankAccounts = program.command('bank_accounts').description('Manage bank accounts')
+ .addHelpText('after', `
+Examples:
+ $ blindpay bank_accounts list --receiver-id
+ $ blindpay bank_accounts get --receiver-id
+ $ blindpay bank_accounts create --receiver-id --type ach --routing-number 021000021 --account-number 123456789
+ $ blindpay bank_accounts create --receiver-id --type pix --pix-key user@email.com
+ $ blindpay bank_accounts delete --receiver-id `)
+
+bankAccounts
+ .command('list')
+ .description('List bank accounts for a receiver')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listBankAccounts(opts))
+
+bankAccounts
+ .command('get ')
+ .description('Get a bank account by ID')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getBankAccount(id, opts))
+
+bankAccounts
+ .command('create')
+ .description('Create a new bank account')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--type ', 'Bank account type (ach, wire, pix, etc.)', 'ach')
+ .option('--name ', 'Account name')
+ .option('--beneficiary-name ', 'Beneficiary name')
+ .option('--routing-number ', 'Routing number')
+ .option('--account-number ', 'Account number')
+ .option('--account-type ', 'checking or saving')
+ .option('--account-class ', 'individual or business')
+ .option('--pix-key ', 'PIX key')
+ .option('--recipient-relationship ', 'Recipient relationship')
+ .option('--country ', 'Country code')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createBankAccount(opts))
+
+bankAccounts
+ .command('delete ')
+ .description('Delete a bank account')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => deleteBankAccount(id, opts))
+
+// ── Blockchain Wallets ──────────────────────────────────────────────────
+const blockchainWallets = program.command('blockchain_wallets').description('Manage blockchain wallets')
+ .addHelpText('after', `
+Examples:
+ $ blindpay blockchain_wallets list --receiver-id
+ $ blindpay blockchain_wallets get --receiver-id
+ $ blindpay blockchain_wallets create --receiver-id --address 0x... --network base
+ $ blindpay blockchain_wallets delete --receiver-id `)
+
+blockchainWallets
+ .command('list')
+ .description('List blockchain wallets for a receiver')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listBlockchainWallets(opts))
+
+blockchainWallets
+ .command('get ')
+ .description('Get a blockchain wallet by ID')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getBlockchainWallet(id, opts))
+
+blockchainWallets
+ .command('create')
+ .description('Create a new blockchain wallet')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .requiredOption('--address ', 'Wallet address')
+ .option('--network ', 'Blockchain network', 'base')
+ .option('--name ', 'Wallet name')
+ .option('--external-id ', 'External ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createBlockchainWallet(opts))
+
+blockchainWallets
+ .command('delete ')
+ .description('Delete a blockchain wallet')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => deleteBlockchainWallet(id, opts))
+
+// ── Quotes ──────────────────────────────────────────────────────────────
+const quotes = program.command('quotes').description('Manage payout quotes')
+ .addHelpText('after', `
+Examples:
+ $ blindpay quotes create --bank-account-id --amount 5000 --network base --token USDC`)
+
+quotes
+ .command('create')
+ .description('Create a new payout quote')
+ .requiredOption('--bank-account-id ', 'Bank account ID')
+ .option('--network ', 'Blockchain network', 'base')
+ .option('--token ', 'Token (USDC, USDT, USDB)', 'USDC')
+ .option('--amount ', 'Amount in cents', '1000')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createQuote(opts))
+
+quotes
+ .command('fx')
+ .description('Get FX rates')
+ .option('--from ', 'Source currency')
+ .option('--to ', 'Target currency')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => getQuoteFxRate(opts))
+
+// ── Payouts ─────────────────────────────────────────────────────────────
+const payouts = program.command('payouts').description('Manage payouts')
+ .addHelpText('after', `
+Examples:
+ $ blindpay payouts list
+ $ blindpay payouts list --status processing --json
+ $ blindpay payouts get
+ $ blindpay payouts create --quote-id --sender-wallet-address 0x... --network evm`)
+
+payouts
+ .command('list')
+ .description('List all payouts')
+ .option('--status ', 'Filter by status (processing, failed, refunded, completed, on_hold)')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listPayouts(opts))
+
+payouts
+ .command('create')
+ .description('Create a payout from a quote')
+ .requiredOption('--quote-id ', 'Payout quote ID from "blindpay quotes create"')
+ .option('--network ', 'Network: evm, solana, or stellar', 'evm')
+ .requiredOption('--sender-wallet-address ', 'Sender wallet address')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createPayout(opts))
+
+payouts
+ .command('get ')
+ .description('Get a payout by ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getPayout(id, opts))
+
+// ── Payin Quotes ────────────────────────────────────────────────────────
+const payinQuotes = program.command('payin_quotes').description('Manage payin quotes')
+ .addHelpText('after', `
+Examples:
+ $ blindpay payin_quotes create --blockchain-wallet-id --payment-method pix --amount 5000 --currency BRL`)
+
+payinQuotes
+ .command('create')
+ .description('Create a new payin quote')
+ .requiredOption('--blockchain-wallet-id ', 'Blockchain wallet ID')
+ .requiredOption('--payment-method ', 'Payment method (pix, ach, wire, spei, transfers, pse)')
+ .option('--amount ', 'Amount in cents', '1000')
+ .option('--currency ', 'Currency', 'USD')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createPayinQuote(opts))
+
+payinQuotes
+ .command('fx')
+ .description('Get FX rates')
+ .option('--from ', 'Source currency')
+ .option('--to ', 'Target currency')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => getQuoteFxRate(opts))
+
+// ── Payins ──────────────────────────────────────────────────────────────
+const payins = program.command('payins').description('Manage payins')
+ .addHelpText('after', `
+Examples:
+ $ blindpay payins list
+ $ blindpay payins get
+ $ blindpay payins create --payin-quote-id --network evm`)
+
+payins
+ .command('create')
+ .description('Create a payin from a payin quote')
+ .requiredOption('--payin-quote-id ', 'Payin quote ID from "blindpay payin_quotes create"')
+ .option('--network ', 'Network: evm, solana, or stellar', 'evm')
+ .option('--external-id ', 'External ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createPayin(opts))
+
+payins
+ .command('list')
+ .description('List all payins')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listPayins(opts))
+
+payins
+ .command('get ')
+ .description('Get a payin by ID')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => getPayin(id, opts))
+
+// ── Webhook Endpoints ───────────────────────────────────────────────────
+const webhookEndpoints = program.command('webhook_endpoints').description('Manage webhook endpoints')
+ .addHelpText('after', `
+Examples:
+ $ blindpay webhook_endpoints list
+ $ blindpay webhook_endpoints create --url https://example.com/webhook --description "Production"
+ $ blindpay webhook_endpoints delete `)
+
+webhookEndpoints
+ .command('list')
+ .description('List webhook endpoints')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listWebhookEndpoints(opts))
+
+webhookEndpoints
+ .command('create')
+ .description('Create a webhook endpoint')
+ .requiredOption('--url ', 'Webhook URL')
+ .option('--description ', 'Description')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createWebhookEndpoint(opts))
+
+webhookEndpoints
+ .command('delete ')
+ .description('Delete a webhook endpoint')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => deleteWebhookEndpoint(id, opts))
+
+// ── Partner Fees ────────────────────────────────────────────────────────
+const partnerFees = program.command('partner_fees').description('Manage partner fees')
+ .addHelpText('after', `
+Examples:
+ $ blindpay partner_fees list
+ $ blindpay partner_fees create --payout-percentage 2.5 --payout-flat 1.00 --evm-wallet 0x...
+ $ blindpay partner_fees delete `)
+
+partnerFees
+ .command('list')
+ .description('List partner fees')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listPartnerFees(opts))
+
+partnerFees
+ .command('create')
+ .description('Create a partner fee')
+ .option('--name ', 'Partner fee name')
+ .option('--payout-percentage ', 'Payout percentage fee')
+ .option('--payout-flat ', 'Payout flat fee')
+ .option('--payin-percentage ', 'Payin percentage fee')
+ .option('--payin-flat ', 'Payin flat fee')
+ .option('--evm-wallet ', 'EVM wallet address')
+ .option('--stellar-wallet ', 'Stellar wallet address')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createPartnerFee(opts))
+
+partnerFees
+ .command('delete ')
+ .description('Delete a partner fee')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => deletePartnerFee(id, opts))
+
+// ── API Keys ────────────────────────────────────────────────────────────
+const apiKeys = program.command('api_keys').description('Manage API keys')
+ .addHelpText('after', `
+Examples:
+ $ blindpay api_keys list
+ $ blindpay api_keys create --name "Production Key"
+ $ blindpay api_keys delete `)
+
+apiKeys
+ .command('list')
+ .description('List API keys')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listApiKeys(opts))
+
+apiKeys
+ .command('create')
+ .description('Create an API key')
+ .option('--name ', 'Key name', 'CLI API Key')
+ .option('--permission ', 'Permission level')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createApiKey(opts))
+
+apiKeys
+ .command('delete ')
+ .description('Delete an API key')
+ .option('--json', 'Output as JSON', false)
+ .action((id, opts) => deleteApiKey(id, opts))
+
+// ── Virtual Accounts ────────────────────────────────────────────────────
+const virtualAccounts = program.command('virtual_accounts').description('Manage virtual accounts')
+ .addHelpText('after', `
+Examples:
+ $ blindpay virtual_accounts list --receiver-id
+ $ blindpay virtual_accounts create --receiver-id --blockchain-wallet-id `)
+
+virtualAccounts
+ .command('list')
+ .description('List virtual accounts for a receiver')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listVirtualAccounts(opts))
+
+virtualAccounts
+ .command('create')
+ .description('Create a virtual account')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .requiredOption('--blockchain-wallet-id ', 'Blockchain wallet ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => createVirtualAccount(opts))
+
+// ── Offramp Wallets ─────────────────────────────────────────────────────
+const offrampWallets = program.command('offramp_wallets').description('Manage offramp wallets')
+ .addHelpText('after', `
+Examples:
+ $ blindpay offramp_wallets list --receiver-id --bank-account-id `)
+
+offrampWallets
+ .command('list')
+ .description('List offramp wallets')
+ .requiredOption('--receiver-id ', 'Receiver ID')
+ .requiredOption('--bank-account-id ', 'Bank account ID')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listOfframpWallets(opts))
+
+// ── Instances ──────────────────────────────────────────────────────────
+const instances = program.command('instances').description('Manage your instance')
+ .addHelpText('after', `
+Examples:
+ $ blindpay instances get
+ $ blindpay instances update --name "My Instance"
+ $ blindpay instances members list`)
+
+instances
+ .command('get')
+ .description('Get instance details')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => getInstance(opts))
+
+instances
+ .command('update')
+ .description('Update instance settings')
+ .option('--name ', 'Instance name')
+ .option('--webhook-url ', 'Default webhook URL')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => updateInstance(opts))
+
+const instanceMembers = instances.command('members').description('Manage instance members')
+
+instanceMembers
+ .command('list')
+ .description('List instance members')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listInstanceMembers(opts))
+
+// ── Available ───────────────────────────────────────────────────────────
+const available = program.command('available').description('Available payment rails and bank details')
+ .addHelpText('after', `
+Examples:
+ $ blindpay available rails
+ $ blindpay available rails --json
+ $ blindpay available bank_details --rail ach
+ $ blindpay available bank_details --rail pix`)
+
+available
+ .command('rails')
+ .description('List available payment rails')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => listAvailableRails(opts))
+
+available
+ .command('bank_details')
+ .description('Show required bank details for a payment rail')
+ .requiredOption('--rail ', 'Rail type (ach, wire, pix, pix_safe, spei_bitso, transfers_bitso, ach_cop_bitso, international_swift)')
+ .option('--json', 'Output as JSON', false)
+ .action(opts => getAvailableBankDetails(opts))
+
+// ── Schema ─────────────────────────────────────────────────────────────
+const schema = program.command('schema').description('Introspect CLI resource schemas (JSON output for LLM/automation use)')
+ .addHelpText('after', `
+Examples:
+ $ blindpay schema # list all resources
+ $ blindpay schema receivers # full schema for receivers
+ $ blindpay schema bank_accounts # schema + available rails
+ $ blindpay schema bank_accounts --rail ach # schema + rail-specific fields`)
+
+schema
+ .argument('[resource]', 'Resource name (e.g. receivers, payouts, bank_accounts)')
+ .option('--rail ', 'Show rail-specific fields (bank_accounts only)')
+ .action((resource, opts) => {
+ if (!resource) {
+ listSchemas()
+ }
+ else {
+ getSchema(resource, opts.rail)
+ }
+ })
+
+// ── Update ──────────────────────────────────────────────────────────────
+program
+ .command('update')
+ .description('Update the Blindpay CLI to the latest version')
+ .action(() => {
+ console.log()
+ clack.log.message('To update the Blindpay CLI, run:')
+ console.log()
+ console.log(' npm install -g @blindpay/cli@latest')
+ console.log()
+ clack.log.message('Or use npx to always run the latest version:')
+ console.log()
+ console.log(' npx @blindpay/cli@latest ')
+ console.log()
+ })
+
+program.parse(process.argv)
diff --git a/src/utils/api-client.ts b/src/utils/api-client.ts
new file mode 100644
index 0000000..4ffb74c
--- /dev/null
+++ b/src/utils/api-client.ts
@@ -0,0 +1,112 @@
+import { getConfig, hasLiveConfig } from './config'
+import { CLI_VERSION, DEFAULT_API_URL } from './constants'
+
+export interface ApiContext {
+ baseUrl: string
+ instanceId: string
+ headers: Record
+}
+
+export type ValidationErrorItem = { path: (string | number)[]; message: string; [k: string]: unknown }
+
+export interface ApiError extends Error {
+ statusCode?: number
+ validationErrors?: ValidationErrorItem[]
+}
+
+const NO_CONFIG_MSG = 'No API key configured. Run: blindpay config set --api-key --instance-id '
+
+export function resolveContext(): ApiContext {
+ if (!hasLiveConfig())
+ throw new Error(NO_CONFIG_MSG)
+
+ const config = getConfig()
+ const baseUrl = (config.base_url ?? DEFAULT_API_URL).replace(/\/$/, '')
+ return {
+ baseUrl,
+ instanceId: config.instance_id!,
+ headers: {
+ 'User-Agent': `blindpay-cli/${CLI_VERSION}`,
+ 'X-Blindpay-Client': 'cli',
+ Authorization: `Bearer ${config.api_key!}`,
+ },
+ }
+}
+
+function buildUrl(ctx: ApiContext, path: string): string {
+ const p = path.startsWith('/') ? path : `/${path}`
+ return `${ctx.baseUrl}${p}`
+}
+
+function parseErrorResponse(status: number, statusText: string, text: string): ApiError {
+ let msg = `Request failed: ${status} ${statusText}`
+ let validationErrors: ValidationErrorItem[] | undefined
+ try {
+ const j = JSON.parse(text) as { message?: string; errors?: ValidationErrorItem[] }
+ if (j.message)
+ msg = j.message
+ if (Array.isArray(j.errors) && j.errors.length > 0)
+ validationErrors = j.errors
+ }
+ catch {
+ if (text)
+ msg = text.slice(0, 200)
+ }
+ const err = new Error(msg) as ApiError
+ err.statusCode = status
+ if (validationErrors)
+ err.validationErrors = validationErrors
+ return err
+}
+
+export async function apiGet(ctx: ApiContext, path: string): Promise {
+ const url = buildUrl(ctx, path)
+ const res = await fetch(url, { headers: ctx.headers })
+ if (!res.ok) {
+ const body = await res.text()
+ throw parseErrorResponse(res.status, res.statusText, body)
+ }
+ return res.json() as Promise
+}
+
+export async function apiPost(ctx: ApiContext, path: string, body?: object): Promise {
+ const url = buildUrl(ctx, path)
+ const headers = { ...ctx.headers, 'Content-Type': 'application/json' }
+ const res = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ })
+ if (!res.ok) {
+ const text = await res.text()
+ throw parseErrorResponse(res.status, res.statusText, text)
+ }
+ return res.json() as Promise
+}
+
+export async function apiPut(ctx: ApiContext, path: string, body: object): Promise {
+ const url = buildUrl(ctx, path)
+ const headers = { ...ctx.headers, 'Content-Type': 'application/json' }
+ const res = await fetch(url, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) {
+ const text = await res.text()
+ throw parseErrorResponse(res.status, res.statusText, text)
+ }
+ return res.json() as Promise
+}
+
+export async function apiDelete(ctx: ApiContext, path: string): Promise {
+ const url = buildUrl(ctx, path)
+ const res = await fetch(url, { method: 'DELETE', headers: ctx.headers })
+ if (!res.ok) {
+ const text = await res.text()
+ throw parseErrorResponse(res.status, res.statusText, text)
+ }
+ const contentType = res.headers.get('content-type')
+ if (contentType?.includes('application/json')) return res.json() as Promise
+ return undefined as T
+}
diff --git a/src/utils/config.ts b/src/utils/config.ts
new file mode 100644
index 0000000..9cb23ca
--- /dev/null
+++ b/src/utils/config.ts
@@ -0,0 +1,115 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const CONFIG_DIR_NAME = 'blindpay'
+const CONFIG_FILE_NAME = 'config.json'
+const CONFIG_FILE_MODE = 0o600
+
+export interface ConfigData {
+ api_key: string | null
+ instance_id: string | null
+ base_url: string | null
+}
+
+const defaultConfig: ConfigData = {
+ api_key: null,
+ instance_id: null,
+ base_url: null,
+}
+
+function getConfigDir(): string {
+ const xdg = process.env.XDG_CONFIG_HOME
+ if (xdg)
+ return path.join(xdg, CONFIG_DIR_NAME)
+ const home = os.homedir()
+ return path.join(home, '.config', CONFIG_DIR_NAME)
+}
+
+export function getConfigPath(): string {
+ return path.join(getConfigDir(), CONFIG_FILE_NAME)
+}
+
+function readConfigFile(): Partial {
+ const filePath = getConfigPath()
+ try {
+ const raw = fs.readFileSync(filePath, 'utf8')
+ const data = JSON.parse(raw) as Partial
+ return {
+ api_key: data.api_key ?? null,
+ instance_id: data.instance_id ?? null,
+ base_url: data.base_url ?? null,
+ }
+ }
+ catch {
+ return {}
+ }
+}
+
+function writeConfigFile(data: ConfigData): void {
+ const dir = getConfigDir()
+ const filePath = getConfigPath()
+ if (!fs.existsSync(dir))
+ fs.mkdirSync(dir, { recursive: true })
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: CONFIG_FILE_MODE })
+}
+
+/**
+ * Returns merged config: file defaults with env var overrides.
+ * Env: BLINDPAY_API_KEY, BLINDPAY_INSTANCE_ID, BLINDPAY_API_URL
+ */
+export function getConfig(): ConfigData {
+ const fromFile = readConfigFile()
+ return {
+ api_key: process.env.BLINDPAY_API_KEY ?? fromFile.api_key ?? null,
+ instance_id: process.env.BLINDPAY_INSTANCE_ID ?? fromFile.instance_id ?? null,
+ base_url: process.env.BLINDPAY_API_URL ?? fromFile.base_url ?? null,
+ }
+}
+
+/**
+ * Update config file with provided values (only set keys that are defined).
+ * Env vars override at read time; this only persists to file.
+ */
+export function setConfig(updates: Partial): void {
+ const current = readConfigFile()
+ const next: ConfigData = {
+ api_key: updates.api_key !== undefined ? updates.api_key : (current.api_key ?? defaultConfig.api_key),
+ instance_id: updates.instance_id !== undefined ? updates.instance_id : (current.instance_id ?? defaultConfig.instance_id),
+ base_url: updates.base_url !== undefined ? updates.base_url : (current.base_url ?? defaultConfig.base_url),
+ }
+ writeConfigFile(next)
+}
+
+/**
+ * Remove config file and directory if empty.
+ */
+export function clearConfig(): boolean {
+ const filePath = getConfigPath()
+ try {
+ if (fs.existsSync(filePath)) {
+ fs.unlinkSync(filePath)
+ const dir = getConfigDir()
+ try {
+ if (fs.readdirSync(dir).length === 0)
+ fs.rmdirSync(dir)
+ }
+ catch {
+ // ignore
+ }
+ return true
+ }
+ }
+ catch {
+ // ignore
+ }
+ return false
+}
+
+/**
+ * Whether the config has enough to call the live API (api_key and instance_id).
+ */
+export function hasLiveConfig(): boolean {
+ const c = getConfig()
+ return Boolean(c.api_key && c.instance_id)
+}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
new file mode 100644
index 0000000..7efca0e
--- /dev/null
+++ b/src/utils/constants.ts
@@ -0,0 +1,14 @@
+export const CLI_VERSION = '0.1.0'
+export const DEFAULT_API_URL = 'https://api.blindpay.com'
+
+export const bankDetailFields: Record = {
+ ach: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'],
+ wire: ['beneficiary_name', 'routing_number', 'account_number', 'address_line_1', 'city', 'state_province_region', 'country', 'postal_code'],
+ rtp: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'],
+ pix: ['pix_key'],
+ pix_safe: ['beneficiary_name', 'account_number', 'account_type', 'pix_safe_bank_code', 'pix_safe_branch_code', 'pix_safe_cpf_cnpj'],
+ spei_bitso: ['beneficiary_name', 'spei_protocol', 'spei_clabe'],
+ transfers_bitso: ['beneficiary_name', 'transfers_type', 'transfers_account'],
+ ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'],
+ international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'],
+}
diff --git a/src/utils/output.ts b/src/utils/output.ts
new file mode 100644
index 0000000..e564466
--- /dev/null
+++ b/src/utils/output.ts
@@ -0,0 +1,64 @@
+import pc from 'picocolors'
+
+export function formatTable(data: Record[], columns?: string[]): string {
+ if (data.length === 0)
+ return pc.dim(' No data found.')
+
+ const keys = columns || Object.keys(data[0])
+ const widths = keys.map((key) => {
+ const maxDataWidth = Math.max(...data.map(row => String(row[key] ?? '').length))
+ return Math.max(key.length, maxDataWidth, 4)
+ })
+
+ const header = keys.map((key, i) => pc.bold(key.padEnd(widths[i]))).join(' ')
+ const separator = keys.map((_, i) => pc.dim('-'.repeat(widths[i]))).join(' ')
+ const rows = data.map(row =>
+ keys.map((key, i) => {
+ const val = row[key] ?? ''
+ return String(val).padEnd(widths[i])
+ }).join(' '),
+ )
+
+ return ['', ` ${header}`, ` ${separator}`, ...rows.map(r => ` ${r}`), ''].join('\n')
+}
+
+export function formatJson(data: any): string {
+ return JSON.stringify(data, null, 2)
+}
+
+/** Key-value table for a single object (non-JSON human-readable get output). */
+export function formatKeyValue(obj: Record): string {
+ if (obj === null || obj === undefined || typeof obj !== 'object')
+ return String(obj)
+ const keys = Object.keys(obj)
+ if (keys.length === 0)
+ return pc.dim(' (empty)')
+ const maxKey = Math.max(...keys.map(k => k.length), 4)
+ const lines = keys.map((key) => {
+ const val = obj[key]
+ const display = val === null || val === undefined
+ ? ''
+ : typeof val === 'object'
+ ? JSON.stringify(val)
+ : String(val)
+ return ` ${key.padEnd(maxKey)} ${display}`
+ })
+ return ['', ...lines, ''].join('\n')
+}
+
+export function formatOutput(data: any, json: boolean, columns?: string[]): string {
+ if (json)
+ return formatJson(data)
+ if (Array.isArray(data))
+ return formatTable(data, columns)
+ if (data !== null && data !== undefined && typeof data === 'object' && !Array.isArray(data))
+ return formatKeyValue(data)
+ return formatJson(data)
+}
+
+export function truncate(str: string | null | undefined, max: number = 32): string {
+ if (!str) return '-'
+ if (str.length <= max) return str
+ if (max <= 3) return '...'
+ return `${str.slice(0, max - 3)}...`
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..31487ca
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "rootDir": "./src",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "types": ["bun-types"],
+ "strict": true,
+ "declaration": true,
+ "noEmit": true,
+ "outDir": "./dist",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}