diff --git a/README.md b/README.md index 14ba5f0..6967ce7 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ Go to [app.agentaos.ai](https://app.agentaos.ai), create a wallet, set your guar ```bash npm install -g agentaos -agenta login -agenta init -agenta send 0xRecipient 0.01 +agenta login # opens browser — sign in + activate wallet +agenta status # account overview +agenta pay checkout -a 50 -c EUR # create a payment checkout +agenta sub init --create --name trading-bot # create an agent sub-account +agenta sub send 0xRecipient 0.01 # send ETH from sub-account ``` ### From code @@ -82,6 +84,7 @@ agent.destroy(); |---------|-----|--------------| | [`@agentaos/sdk`](packages/signer) | [![npm](https://img.shields.io/npm/v/@agentaos/sdk)](https://www.npmjs.com/package/@agentaos/sdk) | Threshold signing SDK — load shares, sign transactions, viem integration | | [`agentaos`](packages/wallet) | [![npm](https://img.shields.io/npm/v/agentaos)](https://www.npmjs.com/package/agentaos) | CLI + MCP server for AI assistants | +| [`@agentaos/pay`](packages/pay) | [![npm](https://img.shields.io/npm/v/@agentaos/pay)](https://www.npmjs.com/package/@agentaos/pay) | Payment SDK — create checkouts, track payments | | [`@agentaos/core`](packages/core) | [![npm](https://img.shields.io/npm/v/@agentaos/core)](https://www.npmjs.com/package/@agentaos/core) | Interfaces and types (zero deps) | | [`@agentaos/engine`](packages/schemes) | [![npm](https://img.shields.io/npm/v/@agentaos/engine)](https://www.npmjs.com/package/@agentaos/engine) | CGGMP24 threshold ECDSA scheme | | [`@agentaos/chains`](packages/chains) | [![npm](https://img.shields.io/npm/v/@agentaos/chains)](https://www.npmjs.com/package/@agentaos/chains) | Ethereum chain adapter (viem) | @@ -159,21 +162,43 @@ agent.destroy(); // Wipes share material from memory npm install -g agentaos ``` +**Account** + +| Command | What it does | +|---------|--------------| +| `agenta login` | Sign in via browser (device-code flow) | +| `agenta status` | Account, wallet, and readiness overview | +| `agenta logout` | Clear session | + +**Payments** — accept payments from humans and AI agents + +| Command | What it does | +|---------|--------------| +| `agenta pay checkout -a 50` | Create a checkout session | +| `agenta pay get ` | Get checkout details | +| `agenta pay list` | List your checkouts | + +**Agent Sub-accounts** — autonomous wallets with guardrails + | Command | What it does | |---------|--------------| -| `agenta init` | Create or import a signer | -| `agenta status` | Signer info and connection status | -| `agenta balance` | ETH balance | -| `agenta send ` | Send ETH — threshold-signed, policy-checked | -| `agenta sign-message ` | Sign an arbitrary message | -| `agenta deploy ` | Deploy a smart contract | -| `agenta proxy` | JSON-RPC signing proxy for Foundry/Hardhat | +| `agenta sub init --create --name bot1` | Create a sub-account | +| `agenta sub init --import --name bot1 --api-key ... --api-secret ...` | Import existing | +| `agenta sub send ` | Send ETH — threshold-signed, policy-checked | +| `agenta sub balance` | ETH + token balances | +| `agenta sub policies get` | View signing policies | +| `agenta sub policies set --file p.json` | Set policies from JSON | +| `agenta sub pause` / `resume` | Pause or resume signing | +| `agenta sub proxy` | JSON-RPC signing proxy for Foundry/Hardhat | + +All commands support `--json` for machine-readable output (AI agents, CI/CD). ```bash -agenta init -agenta send 0xRecipient 0.01 --network base-sepolia -agenta sign-message "proof-of-liveness" -agenta proxy --port 8545 # then: forge script Deploy.s.sol --rpc-url http://localhost:8545 --broadcast +agenta login +agenta pay checkout -a 50 -c EUR -d "Invoice #42" +agenta sub init --create --name trading-bot +agenta sub send 0xRecipient 0.01 --network base-sepolia +agenta sub proxy --port 8545 # then: forge script Deploy.s.sol --rpc-url http://localhost:8545 --broadcast ``` --- @@ -189,34 +214,51 @@ Connect any AI assistant to AgentaOS. Claude, Cursor, Windsurf — they sign tra "mcpServers": { "agenta": { "command": "npx", - "args": ["-y", "agentaos", "mcp"], + "args": ["-y", "agentaos"], "env": { "AGENTA_API_KEY": "your-api-key", - "AGENTA_API_SECRET": "your-api-secret" + "AGENTA_API_SECRET": "your-api-secret", + "AGENTAOS_GATEWAY_KEY": "sk_live_your-gateway-key" } } } } ``` -Your **API Key** and **API Secret** are generated when you create a wallet at [app.agentaos.ai](https://app.agentaos.ai). Copy them from the credentials screen. +- **API Key** + **API Secret** — for wallet tools. Generated when you create a sub-account. +- **Gateway Key** — for payment tools. Generated at [app.agentaos.ai](https://app.agentaos.ai) → API Keys. + +### Tools (21 total) + +**Payments** + +| Tool | What it does | +|------|--------------| +| `agenta_pay_create_checkout` | Create a checkout session | +| `agenta_pay_get_checkout` | Get checkout status | +| `agenta_pay_list_checkouts` | List checkouts | + +**Wallet** -### Tools +| Tool | What it does | +|------|--------------| +| `agenta_wallet_overview` | Address, balance, network | +| `agenta_get_balances` | ETH + ERC-20 balances | +| `agenta_send_eth` | Send ETH | +| `agenta_send_token` | Send ERC-20 tokens | +| `agenta_sign_message` | Sign a message | +| `agenta_sign_typed_data` | Sign EIP-712 typed data | +| `agenta_call_contract` | Write to a contract | +| `agenta_read_contract` | Read contract state | +| `agenta_simulate` | Simulate a transaction | + +**x402 (agent-to-agent payments)** | Tool | What it does | |------|--------------| -| `wallet_overview` | Address, balance, network | -| `get_balances` | ETH + ERC-20 balances | -| `send_eth` | Send ETH | -| `send_token` | Send ERC-20 tokens | -| `sign_message` | Sign a message | -| `sign_typed_data` | Sign EIP-712 typed data | -| `call_contract` | Write to a contract | -| `read_contract` | Read contract state | -| `simulate` | Simulate a transaction | -| `list_signers` | List configured signers | -| `list_networks` | Supported networks | -| `get_audit_log` | Signing history | +| `agenta_x402_check` | Check if a URL requires payment | +| `agenta_x402_discover` | Discover x402 payment requirements | +| `agenta_x402_fetch` | Fetch a resource with x402 payment | --- @@ -281,7 +323,7 @@ const sendETH = new DynamicStructuredTool({ ### Foundry / Hardhat ```bash -agenta proxy --port 8545 +agenta sub proxy --port 8545 forge script Deploy.s.sol --rpc-url http://localhost:8545 --broadcast ``` @@ -330,8 +372,9 @@ Ethereum, Base, Arbitrum, Optimism, Polygon — mainnet and testnet. All EVM cha | Variable | Description | |----------|-------------| -| `AGENTA_API_KEY` | API key — generated when you create a wallet | +| `AGENTA_API_KEY` | API key — generated when you create a sub-account | | `AGENTA_API_SECRET` | API secret — your signing credential, shown once at creation | +| `AGENTAOS_GATEWAY_KEY` | Gateway key for payment tools (`sk_live_...` or `sk_test_...`) | | `AGENTA_SERVER` | Optional — defaults to `https://api.agentaos.ai` | --- diff --git a/examples/forge-proxy/README.md b/examples/forge-proxy/README.md index 0a9d9ad..f379a84 100644 --- a/examples/forge-proxy/README.md +++ b/examples/forge-proxy/README.md @@ -11,10 +11,11 @@ Deploy smart contracts using Foundry through AgentaOS signing proxy. The full pr ## Setup ```bash -# Configure AgentaOS CLI -agenta init +# Sign in and create a sub-account +agenta login +agenta sub init --create --name forge-deployer -# Verify your signer is active +# Verify status agenta status ``` @@ -27,7 +28,7 @@ chmod +x deploy.sh ## How It Works -1. `agenta proxy` starts an RPC proxy on port 8545 +1. `agenta sub proxy` starts an RPC proxy on port 8545 2. The proxy intercepts `eth_sendTransaction` and `eth_signTransaction` calls 3. Each transaction is signed via the interactive CGGMP24 protocol (2-of-3 MPC) 4. The AgentaOS server enforces all configured policies before co-signing @@ -39,7 +40,7 @@ This means you can use **any** Ethereum tooling (Foundry, Hardhat, ethers.js) wi ```bash # Start proxy in one terminal -agenta proxy --port 8545 +agenta sub proxy --port 8545 # In another terminal, deploy with Forge forge create --rpc-url http://localhost:8545 --unlocked \ diff --git a/examples/full-lifecycle/README.md b/examples/full-lifecycle/README.md index f2d9b6d..f367035 100644 --- a/examples/full-lifecycle/README.md +++ b/examples/full-lifecycle/README.md @@ -34,32 +34,34 @@ chmod +x demo.sh ## Manual Flow ```bash -# Create a signer at app.agentaos.ai -# The wizard gives you an API Key and API Secret (copy both). +# Sign in and activate wallet +agenta login -# Configure the CLI -agenta init +# Create a sub-account +agenta sub init --create --name demo # Send a transaction -agenta send 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 0.001 +agenta sub send 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 0.001 # Sign a message -agenta sign-message "Hello from AgentaOS" +agenta sub sign-message "Hello from AgentaOS" # Check status agenta status # Check balance -agenta balance +agenta sub balance + +# Create a payment checkout +agenta pay checkout -a 50 -c EUR ``` ## Policy Enforcement -Add policies via [app.agentaos.ai](https://app.agentaos.ai): +Set policies via the CLI or [app.agentaos.ai](https://app.agentaos.ai): ```bash -# After adding a spending limit (max 0.1 ETH per tx) in the browser app, -# try to send more than 0.1 ETH — it will be blocked with 403 -agenta send 0x... 0.5 +# After adding a spending limit, try to exceed it — blocked with 403 +agenta sub send 0x... 0.5 # Error: Policy violation: spending_limit — amount exceeds limit ``` diff --git a/examples/full-lifecycle/demo.sh b/examples/full-lifecycle/demo.sh index 4c4717b..39c9e55 100755 --- a/examples/full-lifecycle/demo.sh +++ b/examples/full-lifecycle/demo.sh @@ -46,26 +46,26 @@ echo "" echo "" # Step 2: CLI init + status -echo "==> Step 2: Initialize CLI + check signer status" -${CLI} init --server "${SERVER}" --non-interactive 2>/dev/null || echo " (already initialized)" +echo "==> Step 2: Initialize sub-account + check status" +${CLI} sub init --create --name demo --server "${SERVER}" 2>/dev/null || echo " (already initialized)" ${CLI} status echo "" # Step 3: Balance -echo "==> Step 3: Check signer balance" -${CLI} balance +echo "==> Step 3: Check sub-account balance" +${CLI} sub balance echo "" # Step 4: Sign a message echo "==> Step 4: Sign proof-of-liveness message" MSG="agenta-lifecycle-demo::$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo " Message: ${MSG}" -${CLI} sign-message "${MSG}" +${CLI} sub sign-message "${MSG}" echo "" # Step 5: Send a transaction echo "==> Step 5: Send 0.000001 ETH (Base Sepolia)" -${CLI} send 0x0000000000000000000000000000000000000001 0.000001 +${CLI} sub send 0x0000000000000000000000000000000000000001 0.000001 echo "" # Step 6: Audit log diff --git a/packages/pay/README.md b/packages/pay/README.md index e1c32cf..153fb74 100644 --- a/packages/pay/README.md +++ b/packages/pay/README.md @@ -87,7 +87,8 @@ const checkout = await agentaos.checkouts.create({ |-------|------|-------------| | `id` | `string` | Session UUID | | `sessionId` | `string` | Public session ID (used in checkout URL) | -| `checkoutUrl` | `string` | URL to send your customer to | +| `checkoutUrl` | `string` | URL to send your human customer to | +| `x402Url` | `string` | x402 protocol URL for AI agent payments | | `status` | `'open' \| 'completed' \| 'expired' \| 'cancelled'` | Current status | | `amountOverride` | `number \| null` | Amount for this session | | `currency` | `string` | Settlement currency | @@ -152,7 +153,6 @@ console.log(link.checkoutUrl); |-------|------|-------------| | `id` | `string` | Link UUID | | `checkoutUrl` | `string` | Shareable payment URL | -| `x402Url` | `string` | x402 protocol URL (for agent payments) | | `amount` | `number` | Payment amount | | `currency` | `string` | Settlement currency | | `status` | `'active' \| 'cancelled'` | Link status | diff --git a/packages/pay/src/client.ts b/packages/pay/src/client.ts index 74e937f..2f336f6 100644 --- a/packages/pay/src/client.ts +++ b/packages/pay/src/client.ts @@ -26,8 +26,11 @@ export class AgentaOS { ); } - // Validate key format - if (!apiKey || (!apiKey.startsWith('sk_live_') && !apiKey.startsWith('sk_test_'))) { + // Detect auth mode: API key (sk_live_/sk_test_) or JWT (Bearer token from CLI) + const isApiKey = apiKey.startsWith('sk_live_') || apiKey.startsWith('sk_test_'); + const isJwt = !isApiKey && apiKey.includes('.') && apiKey.split('.').length === 3; + + if (!isApiKey && !isJwt) { throw new Error( 'Invalid API key format. Must start with sk_live_ or sk_test_.\n' + 'Get your key at: https://app.agentaos.ai → Settings → API Keys', @@ -40,6 +43,7 @@ export class AgentaOS { maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES, debug: options?.debug ?? false, logger: options?.logger as ((level: string, message: string) => void) | undefined, + authMode: isApiKey ? ('api-key' as const) : ('jwt' as const), }; this.checkouts = new CheckoutsResource(baseUrl, apiKey, resourceOptions); diff --git a/packages/pay/src/resources/base.ts b/packages/pay/src/resources/base.ts index 89577f1..96f5862 100644 --- a/packages/pay/src/resources/base.ts +++ b/packages/pay/src/resources/base.ts @@ -9,6 +9,7 @@ export class BaseResource { maxRetries: number; debug?: boolean; logger?: (level: string, message: string) => void; + authMode?: 'api-key' | 'jwt'; }, ) {} @@ -26,6 +27,7 @@ export class BaseResource { maxRetries: this.options.maxRetries, debug: this.options.debug, logger: this.options.logger, + authMode: this.options.authMode, }); } @@ -41,6 +43,7 @@ export class BaseResource { idempotencyKey, debug: this.options.debug, logger: this.options.logger, + authMode: this.options.authMode, }); } @@ -54,6 +57,7 @@ export class BaseResource { maxRetries: this.options.maxRetries, debug: this.options.debug, logger: this.options.logger, + authMode: this.options.authMode, }); } @@ -71,6 +75,7 @@ export class BaseResource { maxRetries: this.options.maxRetries, debug: this.options.debug, logger: this.options.logger, + authMode: this.options.authMode, }); } @@ -88,6 +93,7 @@ export class BaseResource { maxRetries: this.options.maxRetries, debug: this.options.debug, logger: this.options.logger, + authMode: this.options.authMode, }); } } diff --git a/packages/pay/src/types.ts b/packages/pay/src/types.ts index f789735..545a435 100644 --- a/packages/pay/src/types.ts +++ b/packages/pay/src/types.ts @@ -93,7 +93,6 @@ export interface PaymentLink { description: string | null; status: 'active' | 'cancelled'; checkoutUrl: string; - x402Url: string; metadata: Record; checkoutFields: CheckoutField[]; webhookUrl: string | null; @@ -141,6 +140,8 @@ export interface CreateCheckoutParams { cancelUrl?: string; /** seconds, 300-86400, default 1800 */ expiresIn?: number; + /** CAIP-2 network IDs (e.g. ['eip155:8453']). Defaults to Base mainnet. */ + supportedNetworks?: string[]; } export interface ListCheckoutParams extends ListParams { @@ -153,6 +154,7 @@ export interface Checkout { orgId: string; sessionId: string; checkoutUrl: string; + x402Url: string; status: 'open' | 'completed' | 'expired' | 'cancelled'; amountOverride: number | null; currency: string; diff --git a/packages/pay/src/utils/fetch.ts b/packages/pay/src/utils/fetch.ts index d432160..0506be6 100644 --- a/packages/pay/src/utils/fetch.ts +++ b/packages/pay/src/utils/fetch.ts @@ -23,6 +23,7 @@ interface RequestOptions { idempotencyKey?: string; debug?: boolean; logger?: (level: string, message: string) => void; + authMode?: 'api-key' | 'jwt'; } function buildUrl( @@ -114,9 +115,13 @@ export async function request(options: RequestOptions): Promise { const url = buildUrl(baseUrl, path, query); const headers: Record = { - 'x-api-key': apiKey, accept: 'application/json', }; + if (options.authMode === 'jwt') { + headers.authorization = `Bearer ${apiKey}`; + } else { + headers['x-api-key'] = apiKey; + } if (method === 'POST' && body !== undefined) { headers['content-type'] = 'application/json'; @@ -223,9 +228,12 @@ export async function requestRaw(options: RequestOptions): Promise { const { baseUrl, apiKey, path, query, timeout } = options; const url = buildUrl(baseUrl, path, query); - const headers: Record = { - 'x-api-key': apiKey, - }; + const headers: Record = {}; + if (options.authMode === 'jwt') { + headers.authorization = `Bearer ${apiKey}`; + } else { + headers['x-api-key'] = apiKey; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); @@ -266,9 +274,12 @@ export async function requestText(options: RequestOptions): Promise { const { baseUrl, apiKey, path, query, timeout } = options; const url = buildUrl(baseUrl, path, query); - const headers: Record = { - 'x-api-key': apiKey, - }; + const headers: Record = {}; + if (options.authMode === 'jwt') { + headers.authorization = `Bearer ${apiKey}`; + } else { + headers['x-api-key'] = apiKey; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); diff --git a/packages/schemes/package.json b/packages/schemes/package.json index 3e5c52f..fbf3373 100644 --- a/packages/schemes/package.json +++ b/packages/schemes/package.json @@ -31,7 +31,8 @@ "scripts": { "build": "tsc", "lint": "biome check src/", - "test": "vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests --exclude='**/*.integration.test.*'", + "test:integration": "vitest run --testPathPattern=integration", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, diff --git a/packages/wallet/README.md b/packages/wallet/README.md index 09cf22d..4e6ebe7 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -19,21 +19,21 @@ npx agentaos --help ## Quick Start ```bash -# Create a new signer (no dashboard required) -agenta init +# Sign in via browser (creates account + wallet) +agenta login -# Check status +# Check account status agenta status -# Send ETH -agenta send 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 0.001 +# Create a payment checkout +agenta pay checkout -a 50 -c EUR -# Manage policies -agenta admin unlock -agenta admin policies -``` +# Create an agent sub-account +agenta sub init --create --name trading-bot -`agenta init` walks you through everything interactively -- create a new signer on the server, or import an existing one from the dashboard. +# Send ETH from sub-account +agenta sub send 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 0.001 +``` ## CLI @@ -41,61 +41,72 @@ agenta admin policies agenta --help ``` -### Commands +### Account | Command | Description | |---------|-------------| -| `agenta init` | Create a new signer or import an existing one | -| `agenta status` | Show signer info, server health, and balances | -| `agenta info` | Show raw signer config (debug) | -| `agenta balance` | Show ETH and token balances | -| `agenta send ` | Send ETH to an address | -| `agenta sign-message ` | Sign a message (EIP-191) | -| `agenta deploy ` | Deploy a contract | -| `agenta proxy` | Start a JSON-RPC signing proxy for Foundry/Hardhat | -| `agenta admin` | Admin commands (policies, pause/resume, audit) | +| `agenta login` | Sign in via browser (device-code flow) | +| `agenta status` | Account, wallet, and readiness overview | +| `agenta logout` | Clear session | -### Multi-Signer +### Payments -Each signer gets its own config file under `~/.agenta/signers/`. +| Command | Description | +|---------|-------------| +| `agenta pay checkout -a ` | Create a checkout session | +| `agenta pay get ` | Get checkout details | +| `agenta pay list` | List checkouts | -```bash -# Create multiple signers -agenta init # creates "my-agent" -agenta init # creates "trading-bot" +### Agent Sub-accounts -# Use a specific signer -agenta --signer trading-bot send 0x... 0.01 -agenta --signer my-agent balance +| Command | Description | +|---------|-------------| +| `agenta sub init --create --name ` | Create a sub-account | +| `agenta sub init --import --name --api-key ... --api-secret ...` | Import existing | +| `agenta sub info ` | Show sub-account details | +| `agenta sub balance` | ETH and token balances | +| `agenta sub send ` | Send ETH — threshold-signed, policy-checked | +| `agenta sub sign-message ` | Sign a message (EIP-191) | +| `agenta sub deploy ` | Deploy a contract | +| `agenta sub proxy` | JSON-RPC signing proxy for Foundry/Hardhat | +| `agenta sub policies get` | View signing policies | +| `agenta sub policies set --file p.json` | Set policies from JSON | +| `agenta sub pause` / `resume` | Pause or resume signing | +| `agenta sub audit` | View signing audit log | -# Default signer is auto-detected (single signer) or set during init +All commands support `--json` for machine-readable output. + +### Multiple Sub-accounts + +Each sub-account gets its own config under `~/.agenta/signers/`. + +```bash +agenta sub init --create --name my-agent +agenta sub init --create --name trading-bot + +# Use a specific sub-account +agenta --signer trading-bot sub send 0x... 0.01 +agenta --signer my-agent sub balance ``` -### Admin Commands +### Policies -Manage policies, pause/resume, and view audit logs for your signers. Requires an admin unlock (user share from OS keychain). +Manage signing policies for your sub-accounts. Requires `agenta login`. ```bash -# Unlock admin access (retrieves user share from keychain, computes auth token) -agenta admin unlock +# View current policy +agenta sub policies get --json -# Policy management -agenta admin policies # list policies -agenta admin policies add # interactive policy creation -agenta admin policies remove # remove a policy -agenta admin policies toggle # enable/disable a policy +# Set policy from JSON file +agenta sub policies set --file policy.json -# Signer control -agenta admin pause # pause signer (blocks all signing) -agenta admin resume # resume signer +# Pause/resume signing +agenta sub pause +agenta sub resume # Audit log -agenta admin audit # last 20 signing requests -agenta admin audit --limit 50 # more results -agenta admin audit --status blocked # filter by status - -# Lock admin (remove token from disk) -agenta admin lock +agenta sub audit # last 20 signing requests +agenta sub audit --limit 50 # more results ``` ### Config Layout @@ -197,10 +208,10 @@ Add to `.cursor/mcp.json`: ## JSON-RPC Proxy -The `agenta proxy` command starts a local HTTP server that acts as an Ethereum JSON-RPC endpoint. It intercepts signing methods (`eth_sendTransaction`, `eth_signTransaction`, `eth_sign`, `personal_sign`) and routes them through Agenta's threshold signing, while forwarding all other calls to the upstream RPC. +The `agenta sub proxy` command starts a local HTTP server that acts as an Ethereum JSON-RPC endpoint. It intercepts signing methods (`eth_sendTransaction`, `eth_signTransaction`, `eth_sign`, `personal_sign`) and routes them through Agenta's threshold signing, while forwarding all other calls to the upstream RPC. ```bash -agenta proxy --port 8545 --rpc-url https://sepolia.base.org +agenta sub proxy --port 8545 --rpc-url https://sepolia.base.org # Use with Foundry forge script Deploy.s.sol --rpc-url http://localhost:8545 diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 6fb1537..457505f 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -35,6 +35,7 @@ "dependencies": { "@agentaos/core": "workspace:*", "@agentaos/engine": "workspace:*", + "@agentaos/pay": "workspace:^", "@agentaos/sdk": "workspace:*", "@inquirer/prompts": "^8.2.1", "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/packages/wallet/src/__tests__/mcp-server.test.ts b/packages/wallet/src/__tests__/mcp-server.test.ts index 42e9f00..276434b 100644 --- a/packages/wallet/src/__tests__/mcp-server.test.ts +++ b/packages/wallet/src/__tests__/mcp-server.test.ts @@ -27,6 +27,10 @@ const EXPECTED_TOOLS = [ 'agenta_x402_check', 'agenta_x402_discover', 'agenta_x402_fetch', + // Merchant payments + 'agenta_pay_create_checkout', + 'agenta_pay_get_checkout', + 'agenta_pay_list_checkouts', ]; describe('AgentaOS Terminal MCP Server', () => { diff --git a/packages/wallet/src/cli/commands/admin.command.ts b/packages/wallet/src/cli/commands/admin.command.ts index 6b4ffd2..3da7921 100644 --- a/packages/wallet/src/cli/commands/admin.command.ts +++ b/packages/wallet/src/cli/commands/admin.command.ts @@ -547,12 +547,71 @@ const auditCommand = new Command('audit') // Main command group // --------------------------------------------------------------------------- -const policiesCommand = new Command('policies').description('Manage signing policies').action( - withErrorHandler(async (_opts: unknown, command: CommandType) => { - await handlePoliciesList(command); - }), -); +const policiesCommand = new Command('policies').description('Manage signing policies'); + +// agenta sub policies get [--json] +policiesCommand + .command('get') + .description('Show current policy') + .option('--json', 'Output as JSON (agent-friendly)') + .action( + withErrorHandler(async (opts: { json?: boolean }, command: CommandType) => { + if (opts.json) { + const ctx = await getAdminContext(command); + let doc: PolicyDocument | null = null; + try { + doc = await adminFetch( + ctx.config.serverUrl, + `/signers/${ctx.signerId}/policy`, + ctx.headers, + ); + } catch { + /* no policy */ + } + console.log(JSON.stringify(doc ?? { rules: [] })); + } else { + await handlePoliciesList(command); + } + }), + ); +// agenta sub policies set --file | --json +policiesCommand + .command('set') + .description('Set policy from JSON file or string') + .option('--file ', 'Read policy from JSON file') + .option('--json ', 'Policy as inline JSON string') + .action( + withErrorHandler(async (opts: { file?: string; json?: string }, command: CommandType) => { + if (!opts.file && !opts.json) { + throw new Error('Specify --file or --json \'{"rules":[...]}\''); + } + + let rules: Record[]; + if (opts.file) { + const { readFileSync, existsSync } = await import('node:fs'); + if (!existsSync(opts.file)) throw new Error(`File not found: ${opts.file}`); + const content = readFileSync(opts.file, 'utf-8'); + const parsed = JSON.parse(content) as PolicyDocument; + rules = parsed.rules ?? []; + } else { + const parsed = JSON.parse(opts.json!) as PolicyDocument; + rules = parsed.rules ?? []; + } + + const ctx = await getAdminContext(command); + await adminFetch( + ctx.config.serverUrl, + `/signers/${ctx.signerId}/policy`, + ctx.headers, + 'PUT', + { rules }, + ); + console.log(JSON.stringify({ success: true, ruleCount: rules.length })); + }), + ); + +// Keep interactive edit as hidden subcommand for backward compat policiesCommand.addCommand(policiesEditCommand); export const adminCommand = new Command('admin') @@ -561,3 +620,5 @@ export const adminCommand = new Command('admin') .addCommand(pauseCommand) .addCommand(resumeCommand) .addCommand(auditCommand); + +export { policiesCommand, pauseCommand, resumeCommand, auditCommand }; diff --git a/packages/wallet/src/cli/commands/init.command.ts b/packages/wallet/src/cli/commands/init.command.ts index 33d4f3d..18d6a3f 100644 --- a/packages/wallet/src/cli/commands/init.command.ts +++ b/packages/wallet/src/cli/commands/init.command.ts @@ -1,34 +1,18 @@ import { existsSync } from 'node:fs'; -import { createInterface } from 'node:readline'; -import { input, password, select } from '@inquirer/prompts'; import chalk from 'chalk'; import { Command } from 'commander'; -import ora from 'ora'; import { type SignerConfig, createClientFromConfig, - getDefaultSignerName, getSignerConfigPath, - listSigners, - loadSignerConfig, saveSignerConfig, setDefaultSigner, validateSignerName, } from '../../lib/config.js'; -import { getSession, isKeychainAvailable, storeUserShare } from '../../lib/keychain.js'; -import { - BRAND_BANNER, - brand, - brandBold, - brandDot, - dim, - failMark, - hint, - promptTheme, - section, - success, - successMark, -} from '../theme.js'; +import { ensureSession } from '../../lib/ensure-session.js'; +import { storeUserShare } from '../../lib/keychain.js'; +import { isJsonMode } from '../output.js'; +import { brand, dim, failMark, successMark } from '../theme.js'; interface PublicCreateResponse { signerId: string; @@ -39,186 +23,91 @@ interface PublicCreateResponse { } // --------------------------------------------------------------------------- -// Visual helpers -// --------------------------------------------------------------------------- - -// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping is intentional -const ANSI_RE = /\u001B\[[0-9;]*m/g; - -function pad(str: string, width: number): string { - const visible = str.replace(ANSI_RE, ''); - return str + ' '.repeat(Math.max(0, width - visible.length)); -} - -function fmtAddr(address: string): string { - if (!address || address.length < 10) return dim('—'); - return dim(`${address.slice(0, 6)}…${address.slice(-4)}`); -} - -function walletCard(name: string, address: string, extra?: string): void { - console.log(''); - console.log(` ${brand('●')} ${brandBold(name)}`); - console.log(` ${dim(address)}`); - if (extra) console.log(` ${dim(extra)}`); -} - -function step(icon: string, text: string): void { - console.log(` ${icon} ${text}`); -} - -// --------------------------------------------------------------------------- -// Command +// agenta sub init --create --name +// agenta sub init --import --name --api-key --api-secret // --------------------------------------------------------------------------- export const initCommand = new Command('init') - .description('Set up your AgentaOS wallet') - .action(async () => { - console.log(BRAND_BANNER); - - try { - const existing = listSigners(); - const defaultName = getDefaultSignerName(); - - const choices: { name: string; value: string; description: string }[] = [ - { - name: 'Create a new wallet', - value: 'create', - description: 'Get started — takes about 10 seconds', - }, - { - name: 'I already have a wallet', - value: 'import', - description: 'Connect using an API Key and API Secret', - }, - ]; - if (existing.length > 1) { - choices.push({ - name: 'Switch active wallet', - value: 'switch', - description: `Current: ${defaultName ?? 'none'}`, - }); - } - - const choice = await select({ - message: 'What would you like to do?', - choices, - loop: false, - theme: promptTheme, - }); - - switch (choice) { - case 'create': - await handleCreate(); - break; - case 'import': - await handleImport(); - break; - case 'switch': - await handleSwitch(); - break; - } - } catch (error: unknown) { - if (error instanceof Error && error.name === 'ExitPromptError') { - console.log(dim('\n Cancelled.\n')); - return; + .description('Create or import a sub-account') + .requiredOption('--name ', 'Sub-account name') + .option('--create', 'Create a new sub-account') + .option('--import', 'Import an existing sub-account') + .option('--server ', 'Server URL') + .option('--api-key ', 'API key (for --import)') + .option('--api-secret ', 'API secret base64 (for --import)') + .option('--storage ', 'Recovery key storage: keychain or file', 'keychain') + .action( + async (opts: { + name: string; + create?: boolean; + import?: boolean; + server?: string; + apiKey?: string; + apiSecret?: string; + storage?: string; + }) => { + try { + // Validate name + const nameErr = validateSignerName(opts.name); + if (nameErr) throw new Error(nameErr); + if (existsSync(getSignerConfigPath(opts.name))) + throw new Error(`"${opts.name}" already exists.`); + + if (!opts.create && !opts.import) { + throw new Error('Specify --create or --import. Run agenta sub init --help for usage.'); + } + + if (opts.storage && opts.storage !== 'keychain' && opts.storage !== 'file') { + throw new Error(`Invalid --storage "${opts.storage}". Use "keychain" or "file".`); + } + const storage = (opts.storage ?? 'keychain') as 'keychain' | 'file'; + + if (opts.create) { + await handleCreate(opts.name, opts.server, storage); + } else if (opts.import) { + if (!opts.apiKey) throw new Error('--api-key is required for --import'); + if (!opts.apiSecret) throw new Error('--api-secret is required for --import'); + await handleImport(opts.name, opts.apiKey, opts.apiSecret, opts.server, storage); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (isJsonMode()) { + console.error(JSON.stringify({ error: message })); + } else { + console.error(`\n ${failMark(message)}\n`); + } + process.exitCode = 1; } - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`\n ${failMark(message)}\n`); - process.exitCode = 1; - } - }); + }, + ); // --------------------------------------------------------------------------- // Create // --------------------------------------------------------------------------- -async function handleCreate(): Promise { - // ── 0. Require login ───────────────────────────────────────────────── - const token = await getSession(); - if (!token) { - console.log(''); - console.log(` ${failMark(`Not logged in. Run ${chalk.bold('agenta login')} first.`)}`); - console.log(''); - process.exitCode = 1; - return; - } - - // ── 1. Basics ───────────────────────────────────────────────────────── - section('Basics'); +async function handleCreate( + name: string, + serverOverride?: string, + storage: 'keychain' | 'file' = 'keychain', +): Promise { + const session = await ensureSession(); + if (!session.ok) throw new Error('Not logged in. Run agenta login first.'); - const name = await input({ - message: 'Wallet name', - theme: promptTheme, - validate: (v) => { - const err = validateSignerName(v); - if (err) return err; - if (existsSync(getSignerConfigPath(v))) - return `"${v}" already exists. Pick a different name.`; - return true; - }, - }); + const serverUrl = serverOverride || session.serverUrl; - const serverUrl = process.env.AGENTA_SERVER ?? 'https://api.agentaos.ai'; - - // ── 2. Security ─────────────────────────────────────────────────────── - section('Security'); - hint('Your recovery key lets you manage policies and sign without the server.'); - - const keychainOk = await isKeychainAvailable(); - let storage: 'keychain' | 'file' = 'file'; - - if (keychainOk) { - console.log(''); - storage = await select<'keychain' | 'file'>({ - message: 'Recovery key storage', - choices: [ - { - name: 'System keychain (recommended)', - value: 'keychain', - description: 'Encrypted by your OS, Touch ID protected', - }, - { - name: 'Local file', - value: 'file', - description: `Saved in ${getSignerConfigPath(name).replace(/\.json$/, '.user-share')}`, - }, - ], - loop: false, - theme: promptTheme, - }); - } else { - hint('System keychain not available — recovery key will be saved as a local file.'); - } - - // ── 3. Generate ─────────────────────────────────────────────────────── - section('Creating wallet'); - hint('Running distributed key generation — this takes a few seconds.'); - console.log(''); - const spinner = ora({ text: 'Generating wallet…', indent: 2 }).start(); - - const url = `${serverUrl.replace(/\/+$/, '')}/api/v1/signers`; - const response = await fetch(url, { + const response = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/v1/signers`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.token}` }, body: JSON.stringify({ name }), signal: AbortSignal.timeout(120_000), }); if (!response.ok) { const text = await response.text(); - spinner.fail('Failed'); throw new Error(`Server returned ${response.status}: ${text}`); } const result = (await response.json()) as PublicCreateResponse; - spinner.succeed('Wallet created'); - - // ── Save everything ─────────────────────────────────────────────────── - // Store user share FIRST — if this fails, we haven't written config yet. - // Once config is saved, the signer is "registered" locally. await storeUserShare(name, result.userShare, storage); const config: SignerConfig = { @@ -234,121 +123,43 @@ async function handleCreate(): Promise { saveSignerConfig(name, config); setDefaultSigner(name); - // ── Summary ─────────────────────────────────────────────────────────── - walletCard(name, result.ethAddress, `API key: ${result.apiKey.slice(0, 16)}…`); - - console.log(''); - step( - successMark( - storage === 'keychain' ? 'Recovery key → system keychain' : 'Recovery key → local file', - ), - '', - ); - step(successMark('Set as active wallet'), ''); - - console.log(''); - console.log(` ${dim(`Config: ${getSignerConfigPath(name)}`)}`); - console.log(''); - console.log(` ${success('Done!')} Run ${chalk.bold('agenta status')} to see your wallets.`); - console.log(` Run ${chalk.bold('agenta admin policies')} to manage policies.`); - console.log(''); -} - -// --------------------------------------------------------------------------- -// Secret input — hidden with live character counter -// --------------------------------------------------------------------------- - -function readSecret(label: string): Promise { - return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, terminal: false }); - - // Mute stdin echo so the huge base64 blob never renders - if (process.stdin.isTTY) process.stdin.setRawMode(true); - process.stdout.write(` ${chalk.bold('?')} ${chalk.bold(label)}: `); - - let buf = ''; - const onData = (key: Buffer) => { - const ch = key.toString(); - // Enter - if (ch === '\r' || ch === '\n') { - if (process.stdin.isTTY) process.stdin.setRawMode(false); - process.stdin.removeListener('data', onData); - rl.close(); - // Clear the counter line and move to next line - process.stdout.write('\r\x1b[K'); - process.stdout.write( - ` ${chalk.bold('?')} ${chalk.bold(label)}: ${chalk.dim(`[${buf.length.toLocaleString()} chars]`)}\n`, - ); - resolve(buf); - return; - } - // Ctrl+C - if (ch === '\x03') { - if (process.stdin.isTTY) process.stdin.setRawMode(false); - rl.close(); - process.exit(1); - } - // Backspace - if (ch === '\x7f' || ch === '\b') { - buf = buf.slice(0, -1); - } else { - buf += ch; - } - // Rewrite the prompt with live char count - process.stdout.write('\r\x1b[K'); - process.stdout.write( - ` ${chalk.bold('?')} ${chalk.bold(label)}: ${chalk.dim(`[${buf.length.toLocaleString()} chars]`)}`, - ); - }; - process.stdin.on('data', onData); - process.stdin.resume(); - }); + const out = { + name, + address: result.ethAddress || null, + signerId: result.signerId || null, + apiKey: result.apiKey || null, + configPath: getSignerConfigPath(name), + }; + if (isJsonMode()) { + console.log(JSON.stringify(out)); + } else { + console.log(`\n ${successMark('Sub-account created')}`); + console.log(` ${chalk.bold('Name:')} ${name}`); + console.log(` ${chalk.bold('Address:')} ${brand(result.ethAddress)}`); + console.log(` ${chalk.bold('ID:')} ${dim(result.signerId)}`); + console.log(` ${chalk.bold('API Key:')} ${dim(result.apiKey)}`); + console.log(` ${chalk.bold('Config:')} ${dim(getSignerConfigPath(name))}\n`); + } } // --------------------------------------------------------------------------- // Import // --------------------------------------------------------------------------- -async function handleImport(): Promise { - // ── 1. Basics ───────────────────────────────────────────────────────── - section('Basics'); - - const name = await input({ - message: 'Wallet name', - theme: promptTheme, - validate: (v) => { - const err = validateSignerName(v); - if (err) return err; - if (existsSync(getSignerConfigPath(v))) - return `"${v}" already exists. Pick a different name.`; - return true; - }, - }); - - const serverUrl = process.env.AGENTA_SERVER ?? 'https://api.agentaos.ai'; - - // ── 2. Credentials ─────────────────────────────────────────────────── - section('Credentials'); - hint('Find your API Key and API Secret in AgentaOS.'); - console.log(''); - - const apiKey = await input({ - message: 'API Key', - theme: promptTheme, - validate: (v) => (v.trim() ? true : 'API Key is required.'), - }); - - console.log(''); - - const apiSecret = await readSecret('API Secret'); - if (!apiSecret) throw new Error('API Secret is required.'); - console.log( - ` ${chalk.green('✓')} Received ${apiSecret.length.toLocaleString()} chars ${chalk.dim(`(${apiSecret.slice(0, 8)}…${apiSecret.slice(-4)})`)}`, - ); +async function handleImport( + name: string, + apiKey: string, + apiSecret: string, + serverOverride?: string, + storage: 'keychain' | 'file' = 'keychain', +): Promise { + const { getSessionServerUrl } = await import('../../lib/keychain.js'); + const serverUrl = + serverOverride || + (await getSessionServerUrl()) || + process.env.AGENTA_SERVER || + 'https://api.agentaos.ai'; - // ── 3. Connect ──────────────────────────────────────────────────────── - section('Connecting'); - const spinner = ora({ text: 'Verifying with server…', indent: 2 }).start(); let signerId: string | undefined; let ethAddress = ''; @@ -359,14 +170,22 @@ async function handleImport(): Promise { if (s) { signerId = s.id; ethAddress = s.ethAddress; - spinner.succeed('Connected'); - } else { - spinner.warn('Connected — no wallet found for this API key'); } - } catch { - spinner.warn('Server unreachable — config saved, you can connect later'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : ''; + if ( + msg.includes('401') || + msg.includes('403') || + msg.includes('nauthorized') || + msg.includes('orbidden') + ) { + throw new Error('Invalid API key or secret'); + } + // Server unreachable — save config anyway } + await storeUserShare(name, apiSecret, storage); + const config: SignerConfig = { version: 1, serverUrl, @@ -380,65 +199,20 @@ async function handleImport(): Promise { saveSignerConfig(name, config); setDefaultSigner(name); - if (ethAddress) { - walletCard(name, ethAddress); - } - - console.log(''); - step(successMark('Config saved'), ''); - step(successMark('Set as active wallet'), ''); - - console.log(''); - console.log(` ${dim(`Config: ${getSignerConfigPath(name)}`)}`); - console.log(''); - console.log(` ${success('Done!')} Run ${chalk.bold('agenta status')} to see your wallets.`); - console.log(''); -} - -// --------------------------------------------------------------------------- -// Switch -// --------------------------------------------------------------------------- - -async function handleSwitch(): Promise { - const current = listSigners(); - if (current.length === 0) { - console.log(chalk.yellow('\n No wallets found. Create one first.\n')); - return; + const out = { + name, + address: ethAddress || null, + signerId: signerId || null, + apiKey: apiKey || null, + configPath: getSignerConfigPath(name), + }; + if (isJsonMode()) { + console.log(JSON.stringify(out)); + } else { + console.log(`\n ${successMark('Config saved')}`); + console.log(` ${chalk.bold('Name:')} ${name}`); + if (ethAddress) console.log(` ${chalk.bold('Address:')} ${brand(ethAddress)}`); + if (signerId) console.log(` ${chalk.bold('ID:')} ${dim(signerId)}`); + console.log(` ${chalk.bold('Config:')} ${dim(getSignerConfigPath(name))}\n`); } - - const defaultName = getDefaultSignerName(); - const nw = Math.max(4, ...current.map((n) => n.length)) + 3; - - const choices = current.map((name) => { - const isCurrent = name === defaultName; - let addr = ''; - try { - const config = loadSignerConfig(name); - if (config.ethAddress) addr = fmtAddr(config.ethAddress); - } catch { - // ignore - } - - const dot = brandDot(isCurrent); - const label = isCurrent ? brandBold(name) : name; - const tag = isCurrent ? dim(' (current)') : ''; - - return { - name: `${dot} ${pad(label, nw)} ${addr}${tag}`, - value: name, - }; - }); - - const selected = await select({ - message: 'Switch to', - choices, - default: defaultName ?? undefined, - loop: false, - theme: promptTheme, - }); - - setDefaultSigner(selected); - console.log(''); - step(successMark(`${chalk.bold(selected)} is now active`), ''); - console.log(''); } diff --git a/packages/wallet/src/cli/commands/login.command.ts b/packages/wallet/src/cli/commands/login.command.ts index fd5da92..e9d6eea 100644 --- a/packages/wallet/src/cli/commands/login.command.ts +++ b/packages/wallet/src/cli/commands/login.command.ts @@ -1,4 +1,4 @@ -import { input, password } from '@inquirer/prompts'; +import { execFile } from 'node:child_process'; import chalk from 'chalk'; import { Command } from 'commander'; import ora from 'ora'; @@ -10,13 +10,13 @@ import { getSessionServerUrl, storeSession, } from '../../lib/keychain.js'; -import { dim, failMark, promptTheme, section, success, successMark } from '../theme.js'; +import { dim, failMark, section, successMark } from '../theme.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -async function fetchJson(url: string, body: unknown): Promise { +async function postJson(url: string, body: unknown): Promise { const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -26,7 +26,6 @@ async function fetchJson(url: string, body: unknown): Promise { if (!response.ok) { const text = await response.text(); - // Extract human-readable message from JSON error responses try { const json = JSON.parse(text) as { message?: string }; if (json.message) throw new Error(json.message); @@ -39,16 +38,22 @@ async function fetchJson(url: string, body: unknown): Promise { return response.json() as Promise; } +/** Open a URL in the default browser (best-effort, no dependency). */ +function openBrowser(url: string): void { + const cmd = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + execFile(cmd, [url], () => {}); +} + // --------------------------------------------------------------------------- -// agenta login +// agenta login — device-code flow (browser-based, human-interactive) // --------------------------------------------------------------------------- export const loginCommand = new Command('login') - .description('Log in with email + OTP') + .description('Log in via browser') .option('--server ', 'Server URL', process.env.AGENTA_SERVER ?? 'https://api.agentaos.ai') .action(async (opts: { server: string }) => { try { - // Check if already logged in const existing = await getSession(); if (existing) { console.log( @@ -57,68 +62,85 @@ export const loginCommand = new Command('login') return; } + const baseUrl = opts.server.replace(/\/+$/, ''); + section('Login'); - const email = await input({ - message: 'Email', - theme: promptTheme, - validate: (v) => { - if (!v.includes('@')) return 'Enter a valid email address'; - return true; - }, - }); + const { deviceCode, userCode, verificationUrl, interval } = await postJson<{ + deviceCode: string; + userCode: string; + verificationUrl: string; + interval: number; + }>(`${baseUrl}/api/v1/auth/device-code`, {}); - const baseUrl = opts.server.replace(/\/+$/, ''); + console.log(''); + console.log(' Open this URL in your browser:'); + console.log(` ${chalk.bold.underline(verificationUrl)}`); + console.log(''); + console.log(` Verification code: ${chalk.bold(userCode)}`); + console.log(''); - // Send OTP - const spinner = ora({ text: 'Sending verification code…', indent: 2 }).start(); - try { - await fetchJson(`${baseUrl}/api/v1/auth/login`, { email, sendOtp: true }); - spinner.succeed('Verification code sent'); - } catch (err) { - spinner.fail('Failed to send verification code'); - throw err; - } + openBrowser(verificationUrl); + + const spinner = ora({ text: 'Waiting for browser confirmation…', indent: 2 }).start(); + const deadline = Date.now() + 10 * 60 * 1000; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, interval * 1000)); + + const result = await postJson<{ + status: 'pending' | 'completed' | 'expired' | 'denied'; + token?: string; + refreshToken?: string; + email?: string; + orgName?: string; + walletAddress?: string; + }>(`${baseUrl}/api/v1/auth/device-code/poll`, { deviceCode }); + + if (result.status === 'completed' && result.token) { + await storeSession(result.token, baseUrl, result.refreshToken); + + if (result.refreshToken) { + try { + const refreshRes = await postJson<{ token?: string; refreshToken?: string }>( + `${baseUrl}/api/v1/auth/refresh`, + { refreshToken: result.refreshToken }, + ); + if (refreshRes.token) { + await storeSession(refreshRes.token, baseUrl, refreshRes.refreshToken); + } + } catch { + /* best-effort */ + } + } + + spinner.succeed('Logged in'); + console.log(''); + console.log(` ${successMark(`Authenticated as ${chalk.bold(result.email ?? 'user')}`)}`); + if (result.orgName) console.log(` ${dim('Organization:')} ${result.orgName}`); + if (result.walletAddress) { + const short = `${result.walletAddress.slice(0, 6)}...${result.walletAddress.slice(-4)}`; + console.log(` ${dim('Wallet:')} ${short}`); + } + console.log(` ${dim(`Session stored in ${getConfigDir()}`)}`); + console.log(''); + console.log(` ${dim('Next:')} ${chalk.bold('agenta status')}`); + console.log(''); + return; + } - console.log(''); + if (result.status === 'expired') { + spinner.fail('Login expired. Run agenta login again.'); + return; + } - const code = await password({ - message: 'Enter the 6-digit code from your email', - mask: '*', - theme: promptTheme, - }); - - if (!code) throw new Error('Code is required'); - - // Verify OTP - const verifySpinner = ora({ text: 'Verifying…', indent: 2 }).start(); - let result: { - token: string; - refreshToken?: string; - userId: string; - email: string; - address?: string; - }; - try { - result = await fetchJson(`${baseUrl}/api/v1/auth/verify-otp`, { email, code }); - } catch (err) { - verifySpinner.fail('Verification failed'); - throw err; + if (result.status === 'denied') { + spinner.fail('Login denied.'); + return; + } } - // Store JWT + refresh token + server URL (so `agenta receive` works without `agenta init`) - await storeSession(result.token, baseUrl, result.refreshToken); - verifySpinner.succeed('Logged in'); - - console.log(''); - console.log(` ${successMark(`Authenticated as ${chalk.bold(result.email)}`)}`); - console.log(` ${dim(`Session stored in ${getConfigDir()}`)}`); - console.log(''); - console.log(` ${success('Done!')} Run ${chalk.bold('agenta init')} to create a wallet,`); - console.log( - ` or ${chalk.bold('agenta receive')} to import a share from another device.`, - ); - console.log(''); + spinner.fail('Login timed out.'); } catch (error: unknown) { if (error instanceof Error && error.name === 'ExitPromptError') { console.log(dim('\n Cancelled.\n')); @@ -138,7 +160,6 @@ export const logoutCommand = new Command('logout') .description('Log out (clear session)') .action(async () => { try { - // Revoke refresh tokens server-side before clearing local session const token = await getSession(); const refreshToken = await getRefreshToken(); const serverUrl = await getSessionServerUrl(); @@ -154,7 +175,7 @@ export const logoutCommand = new Command('logout') signal: AbortSignal.timeout(5_000), }); } catch { - // Best-effort — still clear local session even if server unreachable + // Best-effort } } diff --git a/packages/wallet/src/cli/commands/pay.command.ts b/packages/wallet/src/cli/commands/pay.command.ts new file mode 100644 index 0000000..df9728d --- /dev/null +++ b/packages/wallet/src/cli/commands/pay.command.ts @@ -0,0 +1,202 @@ +import { AgentaOS } from '@agentaos/pay'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora from 'ora'; +import { ensureSession } from '../../lib/ensure-session.js'; +import { isJsonMode, output, outputError } from '../output.js'; +import { brand, dim } from '../theme.js'; + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +async function requirePayClient(): Promise { + const session = await ensureSession(); + if (!session.ok) { + outputError( + session.reason === 'session-expired' + ? 'Session expired. Run agenta login.' + : 'Not logged in. Run agenta login.', + ); + process.exitCode = 1; + return null; + } + return new AgentaOS(session.token, { baseUrl: session.serverUrl }); +} + +// --------------------------------------------------------------------------- +// agenta pay +// --------------------------------------------------------------------------- + +export const payCommand = new Command('pay').description('Payment operations'); + +// --------------------------------------------------------------------------- +// agenta pay checkout +// --------------------------------------------------------------------------- + +payCommand + .command('checkout') + .description('Create a checkout session') + .requiredOption('-a, --amount ', 'Payment amount (e.g. 50.00)') + .option('-c, --currency ', 'Currency code (e.g. EUR, USD)') + .option('-d, --description ', 'Description shown on checkout page') + .option('--email ', 'Pre-populate buyer email') + .option('--json', 'Output as JSON') + .action( + async (opts: { amount: string; currency?: string; description?: string; email?: string }) => { + const amount = Number.parseFloat(opts.amount); + if (Number.isNaN(amount) || amount <= 0) { + outputError('Amount must be a positive number.'); + process.exitCode = 1; + return; + } + + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Creating checkout...', indent: 2 }).start(); + + try { + const checkout = await client.checkouts.create({ + amount, + currency: opts.currency, + description: opts.description, + buyerEmail: opts.email, + }); + const data = checkout as unknown as Record; + + output({ + sessionId: checkout.sessionId, + status: checkout.status, + amount, + currency: checkout.currency, + checkoutUrl: checkout.checkoutUrl, + x402Url: data.x402Url ?? null, + expiresAt: checkout.expiresAt, + hint: 'Share the checkoutUrl with your customer.', + }); + spinner?.succeed('Checkout created'); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }, + ); + +// --------------------------------------------------------------------------- +// agenta pay get +// --------------------------------------------------------------------------- + +payCommand + .command('get ') + .description('Get checkout session status') + .option('--json', 'Output as JSON') + .action(async (sessionId: string) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching checkout...', indent: 2 }).start(); + + try { + const checkout = await client.checkouts.retrieve(sessionId); + const data = checkout as unknown as Record; + spinner?.stop(); + + output({ + sessionId: checkout.sessionId, + status: checkout.status, + amount: checkout.amountOverride, + currency: checkout.currency, + checkoutUrl: checkout.checkoutUrl, + x402Url: data.x402Url ?? null, + expiresAt: checkout.expiresAt, + createdAt: checkout.createdAt, + }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); + +// --------------------------------------------------------------------------- +// agenta pay list +// --------------------------------------------------------------------------- + +payCommand + .command('list') + .description('List checkout sessions') + .option('--status ', 'Filter: open, completed, expired, cancelled') + .option('--limit ', 'Results per page (default 10)', '10') + .option('--json', 'Output as JSON') + .action(async (opts: { status?: string; limit: string }) => { + const client = await requirePayClient(); + if (!client) return; + + const json = isJsonMode(); + const spinner = json ? null : ora({ text: 'Fetching checkouts...', indent: 2 }).start(); + + try { + const data = await client.checkouts.list({ + status: opts.status as 'open' | 'completed' | 'expired' | 'cancelled' | undefined, + limit: Number.parseInt(opts.limit, 10) || 10, + }); + spinner?.stop(); + + if (json) { + console.log( + JSON.stringify({ + total: data.total, + hasMore: data.hasMore, + items: data.items.map((c) => { + const raw = c as unknown as Record; + return { + sessionId: c.sessionId, + status: c.status, + currency: c.currency, + amount: raw.amount ?? c.amountOverride ?? null, + description: raw.description ?? null, + checkoutUrl: c.checkoutUrl, + expiresAt: c.expiresAt, + }; + }), + }), + ); + } else { + if (!data.items.length) { + console.log(dim('\n No checkouts found.\n')); + return; + } + console.log(`\n ${chalk.bold(`Checkouts (${data.total} total)`)}\n`); + for (const c of data.items) { + const raw = c as unknown as Record; + const amt = raw.amount ?? c.amountOverride ?? ''; + const desc = raw.description ? dim(` ${raw.description}`) : ''; + console.log( + ` ${c.status.padEnd(12)} ${String(amt).padEnd(8)} ${c.currency.padEnd(6)} ${dim(c.sessionId)}${desc}`, + ); + } + if (data.hasMore) console.log(dim(`\n ${data.items.length} of ${data.total} shown.`)); + console.log(''); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (spinner) { + spinner.fail(msg); + } else { + outputError(msg); + } + process.exitCode = 1; + } + }); diff --git a/packages/wallet/src/cli/commands/status.command.ts b/packages/wallet/src/cli/commands/status.command.ts index 0e73c7e..cb9ce8d 100644 --- a/packages/wallet/src/cli/commands/status.command.ts +++ b/packages/wallet/src/cli/commands/status.command.ts @@ -5,14 +5,35 @@ import { formatUnits } from 'viem'; import { type SignerConfig, createClientFromConfig, + getConfigDir, getDefaultSignerName, listSigners, loadSignerConfig, } from '../../lib/config.js'; -import { brand, brandBold, brandDot, dim, statusColor } from '../theme.js'; +import { decodeJwt, ensureSession } from '../../lib/ensure-session.js'; +import { isJsonMode } from '../output.js'; +import { brand, brandBold, brandDot, dim, failMark, statusColor, successMark } from '../theme.js'; // --------------------------------------------------------------------------- -// Helpers +// JWT decode (display only, no verification) +// --------------------------------------------------------------------------- + +const decodeJwtPayload = decodeJwt; + +function formatExpiry(exp: number): string { + const remaining = exp * 1000 - Date.now(); + if (remaining <= 0) return chalk.red('Expired'); + const hours = Math.floor(remaining / 3_600_000); + const mins = Math.floor((remaining % 3_600_000) / 60_000); + const timeStr = new Date(exp * 1000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ' UTC'); + return `${timeStr} (${hours}h ${mins}m remaining)`; +} + +// --------------------------------------------------------------------------- +// Wallet helpers (existing) // --------------------------------------------------------------------------- // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping is intentional @@ -28,17 +49,6 @@ function fmtAddr(address: string): string { return dim(`${address.slice(0, 6)}…${address.slice(-4)}`); } -function fmtBal(weiStr: string | undefined): string { - if (!weiStr || weiStr === '0') return dim('0 ETH'); - const eth = formatUnits(BigInt(weiStr), 18); - const clean = eth.includes('.') ? eth.replace(/\.?0+$/, '') : eth; - return `${clean} ETH`; -} - -// --------------------------------------------------------------------------- -// Data -// --------------------------------------------------------------------------- - interface WalletInfo { name: string; address: string; @@ -48,7 +58,7 @@ interface WalletInfo { isDefault: boolean; } -async function fetchInfo(name: string, config: SignerConfig): Promise { +async function fetchWalletInfo(name: string, config: SignerConfig): Promise { const info: WalletInfo = { name, address: config.ethAddress || '', @@ -57,7 +67,6 @@ async function fetchInfo(name: string, config: SignerConfig): Promise { - const names = listSigners(); + try { + const session = await ensureSession(); - if (names.length === 0) { - console.log(chalk.yellow('\n No wallets found. Run `agenta init` to create one.\n')); - return; - } + // --- JSON mode --- + if (isJsonMode()) { + await outputStatusJson(session); + return; + } + + // --- Human mode --- + console.log(''); + console.log(` ${chalk.bold('AgentaOS')} ${dim('CLI')}`); + console.log(` ${dim('─'.repeat(35))}`); + + // --- Account info from JWT --- + if (!session.ok) { + if (session.reason === 'session-expired') { + console.log(` ${dim('Account:')} ${chalk.red('Session expired')}`); + console.log( + ` ${dim(' ')} Run ${chalk.bold('agenta login')} to re-authenticate.`, + ); + } else { + console.log(` ${dim('Account:')} ${chalk.yellow('Not logged in')}`); + console.log( + ` ${dim(' ')} Run ${chalk.bold('agenta login')} to get started.`, + ); + } + } + + const token = session.ok ? session.token : null; + const serverUrl = session.ok ? session.serverUrl : null; + const payload = token ? decodeJwtPayload(token) : null; + const email = payload?.email as string | undefined; + const exp = payload?.exp as number | undefined; - const defaultName = getDefaultSignerName(); - const spinner = ora({ - text: `Checking ${names.length} wallet${names.length > 1 ? 's' : ''}…`, - indent: 2, - }).start(); + if (email) console.log(` ${dim('Account:')} ${email}`); - const wallets: WalletInfo[] = await Promise.all( - names.map(async (name) => { + // --- Fetch org info from server --- + let orgName: string | undefined; + let walletAddress: string | null = null; + if (token && serverUrl) { try { - const config = loadSignerConfig(name); - const info = await fetchInfo(name, config); - info.isDefault = name === defaultName; - return info; + const orgsRes = await fetch(`${serverUrl}/api/v1/orgs`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + if (orgsRes.ok) { + const orgs = (await orgsRes.json()) as Array<{ + name?: string; + wallet_address?: string; + }>; + if (orgs[0]) { + orgName = orgs[0].name; + walletAddress = orgs[0].wallet_address ?? null; + } + } } catch { - return { - name, - address: '', - status: 'error', - balance: undefined, - policies: undefined, - isDefault: name === defaultName, - }; + // Offline — show what we can from JWT } - }), - ); + } - spinner.stop(); + if (orgName) console.log(` ${dim('Organization:')} ${orgName}`); + if (walletAddress) { + const short = `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; + console.log(` ${dim('Wallet:')} ${short}`); + } else if (orgName) { + console.log(` ${dim('Wallet:')} ${chalk.yellow('Not activated')}`); + } + if (exp) console.log(` ${dim('JWT expires:')} ${formatExpiry(exp)}`); + if (serverUrl) console.log(` ${dim('Server:')} ${serverUrl}`); + console.log(` ${dim('Config:')} ${getConfigDir()}`); - const nw = Math.max(4, ...wallets.map((w) => w.name.length)) + 3; - const aw = 15; - const sw = 10; + // --- Tool readiness --- + console.log(''); + if (walletAddress) { + console.log(` ${dim('Payment tools:')} ${successMark('Ready')}`); + } else { + console.log( + ` ${dim('Payment tools:')} ${failMark('Activate wallet first')} → ${dim('agenta login')}`, + ); + } - console.log(''); + const signerNames = listSigners(); + if (signerNames.length > 0) { + console.log( + ` ${dim('Agent accounts:')} ${successMark(`Ready (${signerNames.length} sub-account${signerNames.length > 1 ? 's' : ''})`)}`, + ); + } else { + console.log( + ` ${dim('Agent accounts:')} ${dim('Run')} ${chalk.bold('agenta init')} ${dim('to create a sub-account')}`, + ); + } - for (const w of wallets) { - const dot = brandDot(w.isDefault); - const name = w.isDefault ? brandBold(w.name) : w.name; - const addr = fmtAddr(w.address); - const status = statusColor(w.status); - const pol = w.policies != null ? dim(`${w.policies} pol`) : ''; + // --- Next steps --- + console.log(''); + if (!walletAddress) { + console.log( + ` ${dim('Next:')} ${chalk.bold('agenta login')} ${dim('to activate your wallet')}`, + ); + } else if (signerNames.length === 0) { + console.log( + ` ${dim('Next:')} ${chalk.bold('agenta pay checkout -a 50')} ${dim('to create a checkout')}`, + ); + console.log( + ` ${chalk.bold('agenta init')} ${dim('to create an agent sub-account')}`, + ); + } else { + console.log( + ` ${dim('Next:')} ${chalk.bold('agenta pay checkout -a 50')} ${dim('to create a checkout')}`, + ); + console.log(` ${chalk.bold('agenta pay list')} ${dim('to view your checkouts')}`); + } - console.log(` ${dot} ${pad(name, nw)} ${pad(addr, aw)} ${pad(status, sw)} ${pol}`); - } + // --- Signer wallets (if any) --- + if (signerNames.length > 0) { + const defaultName = getDefaultSignerName(); + const spinner = ora({ + text: `Checking ${signerNames.length} sub-account${signerNames.length > 1 ? 's' : ''}…`, + indent: 2, + }).start(); + + const wallets = await Promise.all( + signerNames.map(async (name) => { + try { + const config = loadSignerConfig(name); + const info = await fetchWalletInfo(name, config); + info.isDefault = name === defaultName; + return info; + } catch { + return { + name, + address: '', + status: 'error', + balance: undefined, + policies: undefined, + isDefault: name === defaultName, + }; + } + }), + ); + + spinner.stop(); + console.log(''); + console.log(` ${dim('Sub-accounts:')}`); - console.log(''); - const activeLabel = defaultName ? brand(defaultName) : dim('none'); - console.log( - dim(' Active: ') + - activeLabel + - dim(` · ${wallets.length} wallet${wallets.length > 1 ? 's' : ''}`), - ); - console.log(dim(` Run ${chalk.reset('agenta info ')} for full details`)); - console.log(''); + const nw = Math.max(4, ...wallets.map((w) => w.name.length)) + 3; + for (const w of wallets) { + const dot = brandDot(w.isDefault); + const name = w.isDefault ? brandBold(w.name) : w.name; + const addr = fmtAddr(w.address); + const status = statusColor(w.status); + console.log(` ${dot} ${pad(name, nw)} ${pad(addr, 15)} ${status}`); + } + } + + console.log(''); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (isJsonMode()) { + console.error(JSON.stringify({ error: message })); + } else { + console.error(`\n ${failMark(message)}\n`); + } + process.exitCode = 1; + } }); + +// --------------------------------------------------------------------------- +// JSON output for AI/scripts +// --------------------------------------------------------------------------- + +import type { SessionResult } from '../../lib/ensure-session.js'; + +async function outputStatusJson(session: SessionResult): Promise { + const result: Record = {}; + + if (!session.ok) { + result.account = { + authenticated: false, + reason: session.reason, + hint: + session.reason === 'session-expired' + ? 'Run agenta login to re-authenticate.' + : 'Run agenta login to get started.', + }; + } else { + const { token, serverUrl } = session; + const payload = decodeJwt(token); + const exp = typeof payload?.exp === 'number' ? payload.exp : null; + + const account: Record = { + authenticated: true, + email: (payload?.email as string) ?? null, + server: serverUrl, + configDir: getConfigDir(), + }; + if (exp) { + account.jwtExpiresAt = new Date(exp * 1000).toISOString(); + account.jwtSecondsRemaining = Math.max(0, Math.floor((exp * 1000 - Date.now()) / 1000)); + } + + try { + const orgsRes = await fetch(`${serverUrl}/api/v1/orgs`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + if (orgsRes.ok) { + const orgs = (await orgsRes.json()) as Array<{ name?: string; wallet_address?: string }>; + if (orgs[0]) { + account.organization = orgs[0].name ?? null; + account.walletAddress = orgs[0].wallet_address ?? null; + account.walletActivated = !!orgs[0].wallet_address; + } + } + } catch { + account.serverReachable = false; + } + + const walletReady = !!account.walletAddress; + account.paymentTools = { + ready: walletReady, + hint: walletReady + ? 'Run agenta pay checkout -a 50 to create a checkout.' + : 'Activate wallet first via agenta login.', + }; + result.account = account; + } + + const signerNames = listSigners(); + result.subAccounts = { + count: signerNames.length, + hint: + signerNames.length === 0 + ? 'Run agenta sub init --create --name to create a sub-account.' + : `${signerNames.length} sub-account(s). Run agenta sub info for details.`, + items: signerNames.map((name) => { + try { + const c = loadSignerConfig(name); + return { name, address: c.ethAddress || null, isDefault: name === getDefaultSignerName() }; + } catch { + return { name, address: null, isDefault: false }; + } + }), + }; + + console.log(JSON.stringify(result)); +} diff --git a/packages/wallet/src/cli/index.ts b/packages/wallet/src/cli/index.ts index 41817e8..5c9f6ed 100644 --- a/packages/wallet/src/cli/index.ts +++ b/packages/wallet/src/cli/index.ts @@ -1,9 +1,14 @@ import { createRequire } from 'node:module'; import { Command } from 'commander'; -import { adminCommand } from './commands/admin.command.js'; const require = createRequire(import.meta.url); const { version } = require('../../package.json') as { version: string }; +import { + auditCommand, + pauseCommand, + policiesCommand, + resumeCommand, +} from './commands/admin.command.js'; import { balanceCommand } from './commands/balance.command.js'; import { deployCommand } from './commands/deploy.command.js'; import { infoCommand } from './commands/info.command.js'; @@ -11,6 +16,7 @@ import { initCommand } from './commands/init.command.js'; import { linkCommand } from './commands/link.command.js'; import { loginCommand, logoutCommand } from './commands/login.command.js'; import { networkCommand } from './commands/network.command.js'; +import { payCommand } from './commands/pay.command.js'; import { proxyCommand } from './commands/proxy.command.js'; import { receiveCommand } from './commands/receive.command.js'; import { sendCommand } from './commands/send.command.js'; @@ -18,6 +24,31 @@ import { signMessageCommand } from './commands/sign.command.js'; import { statusCommand } from './commands/status.command.js'; import { BRAND_BANNER, dim } from './theme.js'; +// --------------------------------------------------------------------------- +// agenta sub — agent sub-account commands +// --------------------------------------------------------------------------- + +const subCommand = new Command('sub') + .description('Agent sub-account operations (send, sign, deploy)') + .addCommand(initCommand) + .addCommand(infoCommand) + .addCommand(balanceCommand) + .addCommand(sendCommand) + .addCommand(signMessageCommand) + .addCommand(policiesCommand) + .addCommand(pauseCommand) + .addCommand(resumeCommand) + .addCommand(auditCommand) + .addCommand(deployCommand) + .addCommand(proxyCommand) + .addCommand(networkCommand) + .addCommand(linkCommand) + .addCommand(receiveCommand); + +// --------------------------------------------------------------------------- +// Main CLI +// --------------------------------------------------------------------------- + export async function runCli(): Promise { const program = new Command(); @@ -25,17 +56,31 @@ export async function runCli(): Promise { .name('agenta') .description(BRAND_BANNER) .version(version) - .option('-s, --signer ', 'Signer name (default: auto-detected)') .addHelpText( 'after', ` ${dim('Getting started:')} - $ agenta login Log in with email + OTP - $ agenta init Create your first wallet - $ agenta status See all your wallets - $ agenta send 0x... 0.01 Send ETH - $ agenta link Export share to another device - $ agenta receive Import share from another device + $ agenta login Sign in via browser + $ agenta status Account, wallet & readiness overview + $ agenta logout Clear session + +${dim('Payments (accept & track):')} + $ agenta pay checkout -a 50 Create a checkout session + $ agenta pay get Get checkout details + $ agenta pay list List your checkouts + +${dim('Agent sub-accounts (send & sign):')} + $ agenta sub init --create --name bot1 Create a sub-account + $ agenta sub init --import --name bot1 \\ + --api-key gw_... --api-secret Import existing + $ agenta sub info Sub-account details + $ agenta sub balance ETH & token balances + $ agenta sub send 0x... 0.01 Send ETH + $ agenta sub sign-message "hello" Sign a message + $ agenta sub policies get [--json] View policies + $ agenta sub policies set --file p.json Set policies from JSON + $ agenta sub pause / resume Pause or resume signing + $ agenta sub audit View signing audit log ${dim('Docs: https://github.com/AgentaOS/agentaos')} `, @@ -43,18 +88,9 @@ ${dim('Docs: https://github.com/AgentaOS/agentaos')} program.addCommand(loginCommand); program.addCommand(logoutCommand); - program.addCommand(initCommand); program.addCommand(statusCommand); - program.addCommand(infoCommand); - program.addCommand(balanceCommand); - program.addCommand(sendCommand); - program.addCommand(signMessageCommand); - program.addCommand(deployCommand); - program.addCommand(proxyCommand); - program.addCommand(networkCommand); - program.addCommand(linkCommand); - program.addCommand(receiveCommand); - program.addCommand(adminCommand); + program.addCommand(payCommand); + program.addCommand(subCommand); await program.parseAsync(); } diff --git a/packages/wallet/src/cli/output.ts b/packages/wallet/src/cli/output.ts new file mode 100644 index 0000000..ad821f5 --- /dev/null +++ b/packages/wallet/src/cli/output.ts @@ -0,0 +1,34 @@ +import chalk from 'chalk'; + +/** + * Output helper — respects --json flag or non-TTY for AI-parseable output. + * Human mode: chalk-formatted key-value pairs. + * JSON mode: single JSON line on stdout. + */ +export function isJsonMode(): boolean { + return process.argv.includes('--json') || !process.stdout.isTTY; +} + +export function output(data: Record): void { + if (isJsonMode()) { + console.log(JSON.stringify(data)); + return; + } + // Human-friendly + console.log(''); + for (const [key, value] of Object.entries(data)) { + if (value === null || value === undefined) continue; + if (typeof value === 'object' && !Array.isArray(value)) continue; // skip nested + const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()); + console.log(` ${chalk.bold(`${label}:`)} ${value}`); + } + console.log(''); +} + +export function outputError(message: string): void { + if (isJsonMode()) { + console.error(JSON.stringify({ error: message })); + } else { + console.error(`\n ${chalk.red('✕')} ${message}\n`); + } +} diff --git a/packages/wallet/src/lib/ensure-session.ts b/packages/wallet/src/lib/ensure-session.ts new file mode 100644 index 0000000..fae346d --- /dev/null +++ b/packages/wallet/src/lib/ensure-session.ts @@ -0,0 +1,63 @@ +import { getRefreshToken, getSession, getSessionServerUrl, storeSession } from './keychain.js'; + +/** Decode JWT payload without verification (display + expiry check only). */ +export function decodeJwt(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1] as string, 'base64url').toString('utf-8')); + } catch { + return null; + } +} + +export type SessionResult = + | { ok: true; token: string; serverUrl: string } + | { ok: false; reason: 'not-logged-in' | 'session-expired' }; + +/** + * Get a valid session token, auto-refreshing if expired. + * Returns the session or a reason why it failed. + */ +export async function ensureSession(): Promise { + const token = await getSession(); + if (!token) return { ok: false, reason: 'not-logged-in' }; + + const serverUrl = + (await getSessionServerUrl()) || process.env.AGENTA_SERVER || 'https://api.agentaos.ai'; + const jwt = decodeJwt(token); + const exp = typeof jwt?.exp === 'number' ? jwt.exp : undefined; + const needsUpgrade = jwt?.scope === 'setup'; // passkey was set up after token was issued + + // Still valid and full scope (with 30s buffer) + if (exp && exp * 1000 > Date.now() + 30_000 && !needsUpgrade) { + return { ok: true, token, serverUrl }; + } + + // Expired or needs scope upgrade — try refresh + const refreshToken = await getRefreshToken(); + if (!refreshToken) return { ok: false, reason: 'session-expired' }; + + try { + const res = await fetch(`${serverUrl}/api/v1/auth/refresh`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) return { ok: false, reason: 'session-expired' }; + + const data = (await res.json()) as { + token?: string; + refreshToken?: string; + }; + + if (!data.token) return { ok: false, reason: 'session-expired' }; + + await storeSession(data.token, serverUrl, data.refreshToken); + return { ok: true, token: data.token, serverUrl }; + } catch { + return { ok: false, reason: 'session-expired' }; + } +} diff --git a/packages/wallet/src/mcp/index.ts b/packages/wallet/src/mcp/index.ts index 52a5310..687e87b 100644 --- a/packages/wallet/src/mcp/index.ts +++ b/packages/wallet/src/mcp/index.ts @@ -33,6 +33,11 @@ import { registerX402Check } from './tools/x402-check.js'; import { registerX402Discover } from './tools/x402-discover.js'; import { registerX402Fetch } from './tools/x402-fetch.js'; +// Merchant payment tools (agenta_pay_*) — uses @agentaos/pay SDK +import { registerPayCreateCheckout } from './tools/pay-create-checkout.js'; +import { registerPayGetCheckout } from './tools/pay-get-checkout.js'; +import { registerPayListCheckouts } from './tools/pay-list-checkouts.js'; + /** * Start the AgentaOS MCP server with all tools. * Connects via stdio transport. @@ -75,6 +80,11 @@ export async function runMcp() { registerX402Discover(server); registerX402Fetch(server, signerManager); + // Merchant payment tools — no signerManager needed, uses @agentaos/pay SDK + registerPayCreateCheckout(server); + registerPayGetCheckout(server); + registerPayListCheckouts(server); + // Graceful shutdown — wipe key material const shutdown = () => { signerManager.destroy(); diff --git a/packages/wallet/src/mcp/tools/pay-create-checkout.ts b/packages/wallet/src/mcp/tools/pay-create-checkout.ts new file mode 100644 index 0000000..d7b6521 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-create-checkout.ts @@ -0,0 +1,69 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +export function registerPayCreateCheckout(server: McpServer) { + server.registerTool( + 'agenta_pay_create_checkout', + { + description: + 'Create a payment checkout session. Returns a checkout URL (for human customers) and an x402 URL (for AI agent payments).', + inputSchema: { + amount: z.number().positive().describe('Amount in currency units (e.g. 10.00)'), + currency: z + .string() + .optional() + .describe("Currency code (e.g. 'EUR', 'USD'). Defaults to org setting."), + description: z.string().optional().describe('Description shown on the checkout page.'), + buyerEmail: z.string().email().optional().describe('Pre-populate buyer email for receipt.'), + webhookUrl: z + .string() + .url() + .optional() + .describe('HTTPS webhook URL for payment notifications.'), + successUrl: z.string().url().optional().describe('Redirect URL after successful payment.'), + expiresIn: z + .number() + .int() + .min(300) + .max(86400) + .optional() + .describe('Session TTL in seconds (300-86400, default 1800).'), + }, + }, + async ({ amount, currency, description, buyerEmail, webhookUrl, successUrl, expiresIn }) => { + try { + const client = createPayClient(); + const checkout = await client.checkouts.create({ + amount, + currency, + description, + buyerEmail, + webhookUrl, + successUrl, + expiresIn, + }); + + // x402Url exists at runtime but may not be in the published types yet + const data = checkout as unknown as Record; + + const lines = [ + 'Checkout created', + '', + ` Session ID: ${checkout.sessionId}`, + ` Amount: ${amount} ${checkout.currency}`, + ` Status: ${checkout.status}`, + ` Checkout URL: ${checkout.checkoutUrl}`, + data.x402Url ? ` x402 URL: ${data.x402Url}` : null, + ` Expires: ${checkout.expiresAt}`, + ] + .filter(Boolean) + .join('\n'); + + return { content: [{ type: 'text' as const, text: lines }] }; + } catch (error) { + return formatPayError(error, 'Checkout creation failed'); + } + }, + ); +} diff --git a/packages/wallet/src/mcp/tools/pay-get-checkout.ts b/packages/wallet/src/mcp/tools/pay-get-checkout.ts new file mode 100644 index 0000000..29f6e83 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-get-checkout.ts @@ -0,0 +1,38 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +export function registerPayGetCheckout(server: McpServer) { + server.registerTool( + 'agenta_pay_get_checkout', + { + description: + 'Get checkout session details — status, amount, currency, expiry. Use to check if a payment was completed.', + inputSchema: { + sessionId: z.string().describe('The checkout session ID'), + }, + }, + async ({ sessionId }) => { + try { + const client = createPayClient(); + const checkout = await client.checkouts.retrieve(sessionId); + + // amountOverride is for link-based checkouts; standalone checkouts use the session amount + const displayAmount = + checkout.amountOverride ?? (checkout as unknown as Record).amount; + const lines = [ + `Checkout: ${checkout.sessionId}`, + `Status: ${checkout.status}`, + displayAmount + ? `Amount: ${displayAmount} ${checkout.currency}` + : `Currency: ${checkout.currency}`, + `Expires: ${checkout.expiresAt}`, + ]; + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } catch (error) { + return formatPayError(error, 'Failed to get checkout'); + } + }, + ); +} diff --git a/packages/wallet/src/mcp/tools/pay-list-checkouts.ts b/packages/wallet/src/mcp/tools/pay-list-checkouts.ts new file mode 100644 index 0000000..b272b71 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-list-checkouts.ts @@ -0,0 +1,62 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { createPayClient, formatPayError } from './pay-utils.js'; + +export function registerPayListCheckouts(server: McpServer) { + server.registerTool( + 'agenta_pay_list_checkouts', + { + description: + 'List checkout sessions. Filter by status (open, completed, expired, cancelled).', + inputSchema: { + status: z + .enum(['open', 'completed', 'expired', 'cancelled']) + .optional() + .describe('Filter by status'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Results per page (default 10)'), + offset: z.number().int().min(0).optional().describe('Pagination offset'), + }, + }, + async ({ status, limit, offset }) => { + try { + const client = createPayClient(); + const data = await client.checkouts.list({ + status, + limit: limit ?? 10, + offset: offset ?? 0, + }); + + if (!data.items.length) { + return { content: [{ type: 'text' as const, text: 'No checkouts found.' }] }; + } + + const lines = [`Checkouts (${data.total} total)`, '']; + for (const c of data.items) { + const raw = c as unknown as Record; + const amt = raw.amount ?? c.amountOverride ?? ''; + const desc = raw.description ? ` — ${raw.description}` : ''; + lines.push(`• ${c.status.padEnd(10)} ${amt} ${c.currency}${desc}`); + lines.push(` ID: ${c.sessionId}`); + lines.push(` Checkout: ${c.checkoutUrl}`); + lines.push(''); + } + + if (data.hasMore) { + lines.push( + `Showing ${data.items.length} of ${data.total}. Use offset=${(offset ?? 0) + (limit ?? 10)} for more.`, + ); + } + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } catch (error) { + return formatPayError(error, 'Failed to list checkouts'); + } + }, + ); +} diff --git a/packages/wallet/src/mcp/tools/pay-utils.ts b/packages/wallet/src/mcp/tools/pay-utils.ts new file mode 100644 index 0000000..2514ab8 --- /dev/null +++ b/packages/wallet/src/mcp/tools/pay-utils.ts @@ -0,0 +1,27 @@ +import { AgentaOS } from '@agentaos/pay'; + +/** + * Create an AgentaOS Pay SDK client from environment. + * Requires AGENTAOS_GATEWAY_KEY (sk_live_... or sk_test_...). + */ +export function createPayClient(): AgentaOS { + const apiKey = process.env.AGENTAOS_GATEWAY_KEY; + if (!apiKey) { + throw new Error( + 'Not authenticated for payment tools.\n' + + 'Set AGENTAOS_GATEWAY_KEY in your MCP server config or environment.\n' + + 'Get your key at: https://app.agentaos.ai → Settings → API Keys', + ); + } + const baseUrl = process.env.AGENTA_SERVER; + return new AgentaOS(apiKey, baseUrl ? { baseUrl } : undefined); +} + +/** Format SDK errors for MCP tool responses. */ +export function formatPayError(error: unknown, prefix: string) { + const msg = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text' as const, text: `${prefix}: ${msg}` }], + isError: true, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c5262f..275b8b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: '@agentaos/engine': specifier: workspace:* version: link:../schemes + '@agentaos/pay': + specifier: workspace:^ + version: link:../pay '@agentaos/sdk': specifier: workspace:* version: link:../signer