- {transaction.user?.name ?? 'x402 Users'}
+ {transaction.user?.name ??
+ (transaction.user?.email
+ ? `${transaction.user.id}`
+ : 'Unknown User')}
{' '}
made {transaction.callCount} requests
diff --git a/packages/app/control/src/app/(app)/app/[id]/users/_components/users.tsx b/packages/app/control/src/app/(app)/app/[id]/users/_components/users.tsx
index acb7a8e8b..96a7ea864 100644
--- a/packages/app/control/src/app/(app)/app/[id]/users/_components/users.tsx
+++ b/packages/app/control/src/app/(app)/app/[id]/users/_components/users.tsx
@@ -56,7 +56,7 @@ export const UsersTable: React.FC
= ({ appId }) => {
...rows.map(row =>
[
`"${row.name ?? ''}"`,
- `"${row.email || ''}"`,
+ `"${row.email ?? ''}"`,
row.usage.totalTransactions,
row.usage.rawCost,
row.usage.markupProfit,
@@ -187,7 +187,9 @@ const UserRow = ({ user, showEmail }: { user: User; showEmail: boolean }) => {
-
{user.name}
+
+ {user.name ?? (user.email ? `${user.id}` : 'Unknown User')}
+
{showEmail && (
diff --git a/packages/app/control/src/services/db/apps/transactions.ts b/packages/app/control/src/services/db/apps/transactions.ts
index a9c2b885b..afca0ad64 100644
--- a/packages/app/control/src/services/db/apps/transactions.ts
+++ b/packages/app/control/src/services/db/apps/transactions.ts
@@ -49,6 +49,7 @@ export const listAppTransactions = async (
select: {
id: true,
name: true,
+ email: true,
image: true,
},
},
@@ -69,6 +70,7 @@ export const listAppTransactions = async (
user: {
id: string | null;
name: string | null;
+ email: string | null;
image: string | null;
};
date: Date;
@@ -93,6 +95,7 @@ export const listAppTransactions = async (
id: transaction.id,
user: {
id: transaction.userId,
+ email: transaction.user?.email ?? null,
name:
transaction.user?.name ??
(transaction.userId === null ? 'x402 Users' : null),
diff --git a/templates/README.md b/templates/README.md
index 056f7b8e9..d95c84c8e 100644
--- a/templates/README.md
+++ b/templates/README.md
@@ -67,6 +67,46 @@ npx echo-start@latest --template assistant-ui
---
+### CLI Templates
+
+#### Echo CLI (`echo-cli`)
+
+A command-line interface for AI chat powered by Echo with support for both API key and crypto wallet payments.
+
+**Note:** This template is different from web-based templates. Install it directly from the repository:
+
+```bash
+git clone https://github.com/Merit-Systems/echo.git
+cd echo/templates/echo-cli
+pnpm install
+pnpm build
+```
+
+**Features:**
+
+- Dual authentication: Echo API keys or WalletConnect
+- Multi-model support (GPT-4o, GPT-5, etc.)
+- X402 protocol for crypto payments
+- Conversation history and resume
+- Secure OS keychain credential storage
+- Export conversations as JSON
+- Profile and usage management
+
+**Usage:**
+
+```bash
+echodex login # Authenticate
+echodex # Start chat
+echodex model # Select AI model
+echodex resume # Resume conversation
+echodex history # View history
+echodex export # Export as JSON
+echodex profile # View profile
+echodex logout # Sign out
+```
+
+---
+
### Feature-Specific Templates
#### Next.js Chat (`next-chat`)
diff --git a/templates/echo-cli/.env.local b/templates/echo-cli/.env.local
new file mode 100644
index 000000000..58a8a44d2
--- /dev/null
+++ b/templates/echo-cli/.env.local
@@ -0,0 +1,2 @@
+ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc"
+NEXT_PUBLIC_ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc"
\ No newline at end of file
diff --git a/templates/echo-cli/.gitignore b/templates/echo-cli/.gitignore
new file mode 100644
index 000000000..d4be2573c
--- /dev/null
+++ b/templates/echo-cli/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+.env*
+!.env.local
+.pnpm-store
\ No newline at end of file
diff --git a/templates/echo-cli/README.md b/templates/echo-cli/README.md
new file mode 100644
index 000000000..49f36cce0
--- /dev/null
+++ b/templates/echo-cli/README.md
@@ -0,0 +1,322 @@
+# Echodex
+
+A command-line interface for AI chat powered by [Echo](https://echo.merit.systems) with support for API keys, WalletConnect, and self-custodied local wallets.
+
+## Features
+
+- **Triple Authentication Options**:
+ - Echo API keys for managed accounts
+ - WalletConnect for mobile wallet integration
+ - Local Wallet for full self-custody (NEW!)
+- **Multi-Model Support**: GPT-4o, GPT-5, GPT-5 Mini, GPT-5 Nano
+- **X402 Protocol**: Pay-per-use with crypto wallets via the X402 payment protocol
+- **Conversation Management**: Resume previous conversations and export chat history
+- **Secure Storage**: OS keychain integration for credential and private key storage
+- **Real-time Balance Tracking**: Check USDC balance and usage in real-time
+- **Multi-Chain Support**: Base, Ethereum, Optimism, Polygon, Arbitrum
+
+## Prerequisites
+
+- Node.js 18.0.0 or higher
+- pnpm 10.0.0 or higher
+- An Echo account (sign up at [echo.merit.systems](https://echo.merit.systems))
+
+## Installation
+
+Clone the repository and install dependencies:
+
+```bash
+git clone https://github.com/Merit-Systems/echo.git
+cd echo/templates/echo-cli
+pnpm install
+pnpm build
+```
+
+### Global Installation (Optional)
+
+To use `echodex` globally:
+
+```bash
+pnpm link --global
+```
+
+Or use it directly:
+
+```bash
+pnpm start
+```
+
+## Quick Start
+
+1. **Authenticate**
+
+ ```bash
+ echodex login
+ ```
+
+ Choose your authentication method:
+ - **Echo API Key**: Browser-based API key creation
+ - **WalletConnect**: Mobile wallet via QR code
+ - **Local Wallet**: Generate and self-custody your own wallet (NEW!)
+
+2. **Start chatting**
+
+ ```bash
+ echodex
+ ```
+
+3. **Select a model** (optional)
+
+ ```bash
+ echodex model
+ ```
+
+## Commands
+
+### Authentication
+
+```bash
+echodex login # Authenticate with Echo (API key or wallet)
+echodex logout # Sign out and clear credentials
+```
+
+### Chat
+
+```bash
+echodex # Start a new chat session
+echodex resume # Resume your last conversation
+```
+
+### Model Management
+
+```bash
+echodex model # Select a different AI model
+```
+
+### History
+
+```bash
+echodex history # View your conversation history
+echodex export # Export conversations as JSON
+echodex clear-history # Clear all conversation history
+```
+
+### Profile
+
+```bash
+echodex profile # View your profile and balance
+```
+
+### Local Wallet Management (Self-Custody)
+
+```bash
+echodex wallet-balance # Show USDC balance
+echodex wallet-address # Display wallet address and QR code
+echodex fund-wallet # Show QR code and wait for USDC deposit
+echodex export-private-key # Export private key for backup (⚠️ SENSITIVE)
+```
+
+## Authentication Methods
+
+### Echo API Key
+
+Managed account with web-based API key creation:
+
+1. Run `echodex login` and select "Echo API Key"
+2. Your browser opens to echo.merit.systems
+3. Sign in and create an API key
+4. The key is securely stored in your OS keychain
+5. Pay for AI usage via Echo's account system
+
+### WalletConnect
+
+Connect your mobile wallet via WalletConnect:
+
+1. Run `echodex login` and select "WalletConnect"
+2. Scan the QR code with your mobile wallet (MetaMask, Rainbow, etc.)
+3. Approve the connection
+4. Fund your wallet with USDC on supported chains
+5. Pay for AI usage directly from your wallet via X402
+
+### Local Wallet (Self-Custody) - NEW!
+
+Generate and manage your own wallet locally:
+
+1. Run `echodex login` and select "Local Wallet (Self Custody)"
+2. Select your preferred blockchain (Ethereum, Base, Optimism, Polygon, Arbitrum)
+3. Your private key is generated and stored securely in your OS keychain
+4. A QR code is displayed to fund your wallet with USDC
+5. Scan with any wallet and send USDC to your generated address
+6. The CLI waits for confirmation or press Ctrl+C to continue later
+7. Use `echodex wallet-balance` to check your balance anytime
+8. Use `echodex fund-wallet` to fund later if needed
+
+**Security Features**:
+- ✅ Private keys stored in OS keychain (macOS Keychain, Windows Credential Vault, Linux Secret Service)
+- ✅ You have full custody of your keys
+- ✅ Backup your key with `echodex export-private-key`
+- ✅ Keys are deleted from keychain on logout
+- ✅ No central authority controls your funds
+
+**Supported Networks**:
+- Ethereum Mainnet (chainId 1)
+- Base (chainId 8453)
+- Optimism (chainId 10)
+- Polygon (chainId 137)
+- Arbitrum (chainId 42161)
+
+## Configuration
+
+### Required Setup
+
+Before running the CLI, update the configuration in `src/constants.ts`:
+
+1. **Echo App ID**:
+ - Visit [echo.merit.systems](https://echo.merit.systems)
+ - Create or retrieve your Echo App ID
+ - Replace `YOUR_ECHO_APP_ID` in `src/constants.ts`
+
+2. **WalletConnect Project ID**:
+ - Visit [walletconnect.com](https://walletconnect.com)
+ - Create a new project and get your Project ID
+ - Replace `YOUR_WALLETCONNECT_PROJECT_ID` in `src/constants.ts`
+
+After updating these values, rebuild the project:
+
+```bash
+pnpm build
+```
+
+### Runtime Configuration
+
+Configuration is stored in:
+- **Credentials**: OS keychain (secure)
+- **Settings**: `~/.config/echodex/` (platform-specific)
+
+### Available Models
+
+- GPT-4o
+- GPT-5
+- GPT-5 Mini
+- GPT-5 Nano
+
+Switch models anytime with `echodex model`.
+
+## Tech Stack
+
+- **TypeScript**: ESNext with strict mode
+- **Echo SDK**: TypeScript SDK for Echo integration
+- **Vercel AI SDK**: Streaming AI responses
+- **WalletConnect**: Crypto wallet authentication
+- **X402 Protocol**: Decentralized payment protocol
+- **Keytar**: Secure credential storage
+- **Conf**: Configuration management
+- **Zod**: Runtime validation
+- **Commander**: CLI framework
+- **Clack Prompts**: Interactive CLI prompts
+
+## Development
+
+### Running in Development
+
+```bash
+pnpm dev
+```
+
+### Building
+
+```bash
+pnpm build
+```
+
+### Project Structure
+
+```
+src/
+├── auth/ # Authentication logic (API key, WalletConnect, Local Wallet)
+│ ├── login.ts # Echo API key login
+│ ├── wallet.ts # WalletConnect login
+│ ├── local-wallet.ts # Local wallet initialization
+│ └── providers.ts # AI provider setup
+├── config/ # Configuration, models, and constants
+├── core/ # Core features (chat, history, profile)
+│ └── local-wallet.ts # Wallet management commands
+├── utils/ # Utility functions
+│ ├── signer.ts # Wallet client creation
+│ └── local-wallet.ts # Wallet utilities & balance queries
+├── validation/ # Zod schemas and validators
+└── index.ts # CLI entry point
+```
+
+## Troubleshooting
+
+### Keychain Access
+
+**macOS**:
+- Grant Terminal/iTerm2 access in System Preferences → Privacy & Security
+
+**Linux**:
+- Ensure Secret Service is running: `systemctl --user status secrets-service`
+- Install if needed: `sudo apt-get install gnome-keyring`
+
+**Windows**:
+- Credential Manager should work automatically
+
+### Local Wallet Issues
+
+**Private Key Not Found**:
+- Run `echodex logout` then `echodex login` again
+- Your private key should be stored in OS keychain
+- If issues persist, check keychain/credential vault settings
+
+**Balance Not Showing**:
+- Ensure USDC is sent on the correct network (shown on wallet address screen)
+- Allow 10-30 seconds for blockchain confirmation
+- Run `echodex wallet-balance` to force a refresh
+- Check your address on a block explorer (e.g., Etherscan for Ethereum)
+
+**Deposit Not Being Detected**:
+- Verify the USDC token address matches your network
+- Wait for block confirmation (usually 12-15 seconds)
+- Try `echodex fund-wallet` again to continue monitoring
+- Use block explorer to verify transaction completed
+
+### Connection Issues (WalletConnect)
+
+If WalletConnect fails:
+- Ensure you have a stable internet connection
+- Try regenerating the QR code
+- Check that your wallet supports WalletConnect v2
+
+### Balance Issues (Echo/WalletConnect)
+
+If your balance isn't updating:
+- Run `echodex profile` to refresh
+- Ensure you're authenticated (run `echodex login` again if needed)
+
+### Private Key Security
+
+**Lost or Compromised Key**:
+1. Immediately run `echodex logout` (deletes local key)
+2. Create a new wallet: `echodex login` → select "Local Wallet (Self Custody)"
+3. Transfer remaining USDC from old address to new address (if needed)
+4. Never reuse compromised private keys
+
+## Support
+
+- **Documentation**: [echo.merit.systems/docs](https://echo.merit.systems/docs)
+- **Platform**: [echo.merit.systems](https://echo.merit.systems)
+- **GitHub**: [github.com/Merit-Systems/echo](https://github.com/Merit-Systems/echo)
+- **Discord**: [discord.gg/merit](https://discord.gg/merit)
+
+## Contributing
+
+See [CONTRIBUTING.md](../../CONTRIBUTING.md) for contribution guidelines.
+
+## License
+
+MIT
+
+---
+
+Built with ❤️ by [Merit Systems](https://merit.systems)
diff --git a/templates/echo-cli/package.json b/templates/echo-cli/package.json
new file mode 100644
index 000000000..355a42dbe
--- /dev/null
+++ b/templates/echo-cli/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "echodex",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "type": "module",
+ "bin": {
+ "echodex": "./dist/index.js"
+ },
+ "scripts": {
+ "start": "tsx src/index.ts",
+ "dev": "tsx src/index.ts",
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "typecheck": "tsc --noEmit"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "MIT",
+ "packageManager": "pnpm@10.19.0",
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "tsx": "^4.20.6"
+ },
+ "dependencies": {
+ "@ai-sdk/openai": "^2.0.64",
+ "@clack/prompts": "^0.11.0",
+ "@merit-systems/ai-x402": "^0.1.3",
+ "@merit-systems/echo-typescript-sdk": "^1.0.23",
+ "@types/qrcode-terminal": "^0.12.2",
+ "@types/react": "^19.2.2",
+ "@walletconnect/ethereum-provider": "^2.23.0",
+ "@walletconnect/keyvaluestorage": "^1.1.1",
+ "ai": "^5.0.89",
+ "chalk": "^5.6.2",
+ "cli-table3": "^0.6.5",
+ "commander": "^14.0.2",
+ "conf": "^15.0.2",
+ "ink": "^6.4.0",
+ "keytar": "^7.9.0",
+ "open": "^10.2.0",
+ "ora": "^9.0.0",
+ "pino": "^10.1.0",
+ "qrcode-terminal": "^0.12.0",
+ "react": "^19.2.0",
+ "viem": "^2.38.6",
+ "x402": "0.6.6",
+ "zod": "^4.1.12"
+ }
+}
diff --git a/templates/echo-cli/src/auth/client.ts b/templates/echo-cli/src/auth/client.ts
new file mode 100644
index 000000000..cd114b961
--- /dev/null
+++ b/templates/echo-cli/src/auth/client.ts
@@ -0,0 +1,22 @@
+import { EchoClient } from '@merit-systems/echo-typescript-sdk'
+import { storage } from '@/config'
+
+let echoClientInstance: EchoClient | null = null
+
+export async function getEchoClient(): Promise {
+ const apiKey = await storage.getApiKey()
+
+ if (!apiKey) {
+ return null
+ }
+
+ if (!echoClientInstance) {
+ echoClientInstance = new EchoClient({ apiKey })
+ }
+
+ return echoClientInstance
+}
+
+export function clearEchoClient(): void {
+ echoClientInstance = null
+}
diff --git a/templates/echo-cli/src/auth/index.ts b/templates/echo-cli/src/auth/index.ts
new file mode 100644
index 000000000..061285b1d
--- /dev/null
+++ b/templates/echo-cli/src/auth/index.ts
@@ -0,0 +1,6 @@
+export { loginWithEcho } from './login'
+export { logout } from './logout'
+export { getEchoClient, clearEchoClient } from './client'
+export { loginWithWallet, getEthereumProvider, clearEthereumProvider } from './wallet'
+export { initLocalWallet } from './local-wallet'
+export { getAIProvider } from './providers'
diff --git a/templates/echo-cli/src/auth/local-wallet.ts b/templates/echo-cli/src/auth/local-wallet.ts
new file mode 100644
index 000000000..efc94c2a9
--- /dev/null
+++ b/templates/echo-cli/src/auth/local-wallet.ts
@@ -0,0 +1,112 @@
+import { select, isCancel } from '@clack/prompts'
+import { storage } from '@/config'
+import { CHAIN_OPTIONS } from '@/config/wallet'
+import { clearEchoClient } from './client'
+import { info, warning, success, header } from '@/print'
+import {
+ generateWallet,
+ generateQRCodeForAddress,
+ getUSDCBalance,
+ formatAddress,
+ getChainName,
+ displayAppError,
+ createError,
+ ErrorCode
+} from '@/utils'
+
+export async function initLocalWallet(): Promise {
+ try {
+ header('Local Wallet Setup')
+ info('Creating a new self-custodied wallet...')
+
+ // Prompt user to select chain
+ const chainId = await select({
+ message: 'Select blockchain network:',
+ options: CHAIN_OPTIONS
+ })
+
+ if (isCancel(chainId)) {
+ warning('\nWallet setup cancelled')
+ return false
+ }
+
+ if (typeof chainId !== 'number') {
+ warning('Wallet setup cancelled')
+ return false
+ }
+
+ // Generate new wallet
+ const wallet = generateWallet()
+
+ // Store private key securely
+ await storage.setLocalWalletPrivateKey(wallet.privateKey)
+
+ // Store session data
+ await storage.setLocalWalletSession({
+ address: wallet.address,
+ chainId,
+ createdAt: new Date().toISOString()
+ })
+
+ // Set auth method
+ await storage.setAuthMethod('local-wallet')
+ clearEchoClient()
+
+ // Display wallet information
+ success('\n✓ Local wallet created successfully!')
+ info(`\nWallet Address: ${wallet.address}`)
+ info(`Short Address: ${formatAddress(wallet.address)}`)
+ info(`Network: ${getChainName(chainId)} (${chainId})`)
+
+ warning('\n⚠️ IMPORTANT: You are responsible for your private key!')
+ warning('⚠️ Your key is stored securely in your OS keychain.')
+ warning('⚠️ Use "echodex export-private-key" to backup your key.')
+
+ // Display QR code for funding
+ info('\n📱 Scan this QR code to send USDC to your wallet:\n')
+ generateQRCodeForAddress(wallet.address)
+
+ // Start balance polling
+ info('\n💰 Waiting for USDC deposit...')
+ info(' (You can press Ctrl+C to cancel and continue later)\n')
+
+ let cancelled = false
+ const handleCancel = () => {
+ cancelled = true
+ info('\n\n✓ Wallet setup complete!')
+ info(' Fund your wallet later using: echodex fund-wallet')
+ process.exit(0)
+ }
+
+ process.on('SIGINT', handleCancel)
+
+ try {
+ // Poll for balance
+ while (!cancelled) {
+ const balance = await getUSDCBalance(wallet.address, chainId)
+ const balanceNum = parseFloat(balance)
+
+ if (balanceNum > 0) {
+ success(`\n✓ Received ${balance} USDC!`)
+ success('✓ Your wallet is ready to use!')
+ break
+ }
+
+ // Wait 3 seconds before next check
+ await new Promise(resolve => setTimeout(resolve, 3000))
+ }
+ } finally {
+ process.off('SIGINT', handleCancel)
+ }
+
+ return true
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Local wallet setup failed',
+ originalError: err
+ }))
+ return false
+ }
+}
+
diff --git a/templates/echo-cli/src/auth/login.ts b/templates/echo-cli/src/auth/login.ts
new file mode 100644
index 000000000..5b420e3f0
--- /dev/null
+++ b/templates/echo-cli/src/auth/login.ts
@@ -0,0 +1,59 @@
+import { text, isCancel } from '@clack/prompts'
+import open from 'open'
+import { EchoClient } from '@merit-systems/echo-typescript-sdk'
+import { storage } from '@/config'
+import { ECHO_KEYS_URL } from '@/constants'
+import { clearEchoClient } from './client'
+import { isValid, ApiKeySchema } from '@/validation'
+import { info, warning, success, error, header } from '@/print'
+import { displayAppError, createError, ErrorCode } from '@/utils'
+
+export async function loginWithEcho(): Promise {
+ try {
+ header('Echo API Authentication')
+ info('Opening Echo to create your API key...')
+ await open(ECHO_KEYS_URL)
+
+ const apiKey = await text({
+ message: 'Enter your API key:',
+ placeholder: 'echo_...',
+ validate: (value) => {
+ if (!value || typeof value !== 'string') {
+ return 'API key is required'
+ }
+ if (!isValid(ApiKeySchema, value)) {
+ return 'Invalid API key format (must start with echo_)'
+ }
+ }
+ })
+
+ if (isCancel(apiKey)) {
+ warning('\nLogin cancelled')
+ return false
+ }
+
+ if (typeof apiKey !== 'string') {
+ warning('Login cancelled')
+ return false
+ }
+
+ info('Verifying API key...')
+ const testClient = new EchoClient({ apiKey })
+ await testClient.users.getUserInfo()
+
+ await storage.setApiKey(apiKey)
+ await storage.setAuthMethod('echo')
+ clearEchoClient()
+
+ success('✓ Successfully logged in!')
+ return true
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Login failed',
+ originalError: err
+ }))
+ return false
+ }
+}
+
diff --git a/templates/echo-cli/src/auth/logout.ts b/templates/echo-cli/src/auth/logout.ts
new file mode 100644
index 000000000..354a67bfd
--- /dev/null
+++ b/templates/echo-cli/src/auth/logout.ts
@@ -0,0 +1,29 @@
+import { storage } from '@/config'
+import { clearEchoClient } from './client'
+import { clearEthereumProvider } from './wallet'
+import { success, warning } from '@/print'
+
+export async function logout(): Promise {
+ const authMethod = await storage.getAuthMethod()
+
+ if (!authMethod) {
+ warning('Not currently authenticated')
+ return
+ }
+
+ await storage.deleteApiKey()
+ await storage.deleteWalletSession()
+ await storage.deleteLocalWalletPrivateKey()
+ await storage.deleteLocalWalletSession()
+ await storage.deleteAuthMethod()
+
+ clearEchoClient()
+ clearEthereumProvider()
+
+ if (authMethod === 'local-wallet') {
+ success('✓ Successfully logged out')
+ success('✓ Local wallet private key has been deleted from keychain')
+ } else {
+ success('✓ Successfully logged out and cleared all credentials')
+ }
+}
diff --git a/templates/echo-cli/src/auth/providers.ts b/templates/echo-cli/src/auth/providers.ts
new file mode 100644
index 000000000..06d702bfd
--- /dev/null
+++ b/templates/echo-cli/src/auth/providers.ts
@@ -0,0 +1,76 @@
+import { createEchoOpenAI } from '@merit-systems/echo-typescript-sdk'
+import { createX402OpenAI } from '@merit-systems/ai-x402'
+import type { LanguageModel } from 'ai'
+import { storage } from '@/config'
+import { ECHO_APP_ID, APP } from '@/constants'
+import { createWalletSigner, createLocalWalletSigner, throwError, ErrorCode } from '@/utils'
+
+interface AIProvider {
+ (modelId: string): LanguageModel
+}
+
+export async function getAIProvider(): Promise {
+ const authMethod = await storage.getAuthMethod()
+
+ if (authMethod === 'echo') {
+ return createEchoProvider()
+ } else if (authMethod === 'wallet') {
+ return createWalletProvider()
+ } else if (authMethod === 'local-wallet') {
+ return createLocalWalletProvider()
+ }
+
+ return null
+}
+
+async function createEchoProvider(): Promise {
+ const apiKey = await storage.getApiKey()
+
+ if (!apiKey) {
+ throwError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Echo API key not found'
+ })
+ }
+
+ const openai = createEchoOpenAI(
+ { appId: ECHO_APP_ID },
+ async () => apiKey
+ )
+
+ return openai
+}
+
+async function createWalletProvider(): Promise {
+ const signer = await createWalletSigner()
+
+ if (!signer) {
+ throwError({
+ code: ErrorCode.WALLET_SESSION_EXPIRED,
+ message: 'Wallet session expired or disconnected'
+ })
+ }
+
+ return createX402OpenAI({
+ walletClient: signer,
+ baseRouterUrl: APP.echoRouterUrl,
+ echoAppId: ECHO_APP_ID
+ })
+}
+
+async function createLocalWalletProvider(): Promise {
+ const signer = await createLocalWalletSigner()
+
+ if (!signer) {
+ throwError({
+ code: ErrorCode.WALLET_SESSION_EXPIRED,
+ message: 'Local wallet session expired or not found'
+ })
+ }
+
+ return createX402OpenAI({
+ walletClient: signer,
+ baseRouterUrl: APP.echoRouterUrl,
+ echoAppId: ECHO_APP_ID
+ })
+}
diff --git a/templates/echo-cli/src/auth/wallet.ts b/templates/echo-cli/src/auth/wallet.ts
new file mode 100644
index 000000000..3d824cb44
--- /dev/null
+++ b/templates/echo-cli/src/auth/wallet.ts
@@ -0,0 +1,162 @@
+import QRCode from 'qrcode-terminal'
+import { storage } from '@/config'
+import { clearEchoClient } from './client'
+import { info, warning, success, header } from '@/print'
+import {
+ getChainName,
+ formatAddress,
+ initializeEthereumProvider,
+ clearWalletSession,
+ displayAppError,
+ createError,
+ ErrorCode,
+ type EthereumProviderInstance
+} from '@/utils'
+import type { WalletConnectSession } from '@/validation'
+
+let ethereumProvider: EthereumProviderInstance | null = null
+
+export async function loginWithWallet(): Promise {
+ try {
+ header('Wallet Authentication')
+ info('Connecting to your mobile wallet via WalletConnect...')
+
+ const provider = await initializeEthereumProvider()
+ ethereumProvider = provider
+
+ return new Promise((resolve) => {
+ provider.on('display_uri', (uri: string) => {
+ info('\n📱 Scan QR code with your mobile wallet:\n')
+ QRCode.generate(uri, { small: true }, (qr: string) => {
+ console.log(qr)
+ })
+ info('\nWaiting for connection...')
+ })
+
+ provider.on('connect', async () => {
+ try {
+ const accounts = provider.accounts
+ const chainId = provider.chainId
+
+ if (!accounts || accounts.length === 0) {
+ displayAppError(createError({
+ code: ErrorCode.WALLET_DISCONNECTED,
+ message: 'No accounts found in wallet'
+ }))
+ resolve(false)
+ return
+ }
+
+ const address = accounts[0]
+
+ const walletSession: WalletConnectSession = {
+ topic: provider.session?.topic || '',
+ address,
+ chainId,
+ expiry: provider.session?.expiry
+ }
+
+ await storage.setWalletSession(walletSession)
+ await storage.setAuthMethod('wallet')
+ clearEchoClient()
+
+ provider.on('disconnect', async () => {
+ await clearWalletSession()
+ ethereumProvider = null
+ })
+
+ success(`\n✓ Connected to wallet: ${formatAddress(address)}`)
+ success(`✓ Chain: ${getChainName(chainId)} (${chainId})`)
+ success('✓ Wallet authentication configured!')
+
+ resolve(true)
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Failed to process wallet connection',
+ originalError: err
+ }))
+ resolve(false)
+ }
+ })
+
+ provider.on('disconnect', () => {
+ warning('Wallet disconnected during setup')
+ resolve(false)
+ })
+
+ provider.connect().catch((err: unknown) => {
+ displayAppError(createError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Failed to initiate connection',
+ originalError: err
+ }))
+ resolve(false)
+ })
+ })
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Wallet login failed',
+ originalError: err
+ }))
+ return false
+ }
+}
+
+export async function getEthereumProvider(): Promise {
+ if (ethereumProvider?.session) {
+ return ethereumProvider
+ }
+
+ const session = await storage.getWalletSession()
+ if (!session) {
+ return null
+ }
+
+ try {
+ const provider = await initializeEthereumProvider()
+
+ if (!provider.session) {
+ await clearWalletSession()
+ ethereumProvider = null
+ return null
+ }
+
+ const now = Date.now()
+ if (provider.session.expiry && provider.session.expiry * 1000 < now) {
+ await clearWalletSession()
+ ethereumProvider = null
+ return null
+ }
+
+ if (provider.session.topic !== session.topic) {
+ const updatedSession: WalletConnectSession = {
+ topic: provider.session.topic,
+ address: provider.accounts[0],
+ chainId: provider.chainId,
+ expiry: provider.session.expiry
+ }
+ await storage.setWalletSession(updatedSession)
+ }
+
+ ethereumProvider = provider
+
+ provider.on('disconnect', async () => {
+ await clearWalletSession()
+ ethereumProvider = null
+ })
+
+ return ethereumProvider
+ } catch (err) {
+ ethereumProvider = null
+ return null
+ }
+}
+
+export function clearEthereumProvider(): void {
+ if (ethereumProvider?.session) {
+ ethereumProvider.disconnect().catch(() => {})
+ }
+ ethereumProvider = null
+}
diff --git a/templates/echo-cli/src/config/ascii.ts b/templates/echo-cli/src/config/ascii.ts
new file mode 100644
index 000000000..525cf4c9d
--- /dev/null
+++ b/templates/echo-cli/src/config/ascii.ts
@@ -0,0 +1,10 @@
+export const ECHODEX_ASCII_ART = `
+ _____ ____ _ _ ___ ____ _______ __
+| ____/ ___| | | |/ _ \| _ \| ____\ \/ /
+| _|| | | |_| | | | | | | | _| \ /
+| |__| |___| _ | |_| | |_| | |___ / \
+|_____\____|_| |_|\___/|____/|_____/_/\_\\
+
+CLI Coding Agent Powered by Echo
+`
+
diff --git a/templates/echo-cli/src/config/index.ts b/templates/echo-cli/src/config/index.ts
new file mode 100644
index 000000000..9ba856e34
--- /dev/null
+++ b/templates/echo-cli/src/config/index.ts
@@ -0,0 +1,137 @@
+import { Storage, StorageType } from './store'
+import { ApiKeySchema, ModelSchema, AuthMethodSchema, WalletConnectSessionSchema, LocalWalletSessionSchema, validate } from '@/validation'
+import { DEFAULT_MODEL } from './models'
+import type { Model, AuthMethod, WalletConnectSession, LocalWalletSession } from '@/validation'
+
+class EchodexStorage extends Storage {
+ constructor() {
+ super({
+ serviceName: 'echodex',
+ configName: 'echodex'
+ })
+ }
+
+ async getApiKey(): Promise {
+ return this.get('apiKey', {
+ type: StorageType.SECURE,
+ schema: ApiKeySchema
+ })
+ }
+
+ async setApiKey(apiKey: string): Promise {
+ const validatedKey = validate(ApiKeySchema, apiKey)
+ await this.set('apiKey', validatedKey, { type: StorageType.SECURE })
+ }
+
+ async deleteApiKey(): Promise {
+ await this.delete('apiKey', StorageType.SECURE)
+ }
+
+ async hasApiKey(): Promise {
+ return this.has('apiKey', StorageType.SECURE)
+ }
+
+ async getAuthMethod(): Promise {
+ return this.get('authMethod', {
+ type: StorageType.NORMAL,
+ schema: AuthMethodSchema
+ })
+ }
+
+ async setAuthMethod(method: AuthMethod): Promise {
+ const validatedMethod = validate(AuthMethodSchema, method)
+ await this.set('authMethod', validatedMethod, { type: StorageType.NORMAL })
+ }
+
+ async deleteAuthMethod(): Promise {
+ await this.delete('authMethod', StorageType.NORMAL)
+ }
+
+ async getWalletSession(): Promise {
+ return this.get('walletSession', {
+ type: StorageType.NORMAL,
+ schema: WalletConnectSessionSchema
+ })
+ }
+
+ async setWalletSession(session: WalletConnectSession): Promise {
+ const validatedSession = validate(WalletConnectSessionSchema, session)
+ await this.set('walletSession', validatedSession, { type: StorageType.NORMAL })
+ }
+
+ async deleteWalletSession(): Promise {
+ await this.delete('walletSession', StorageType.NORMAL)
+ }
+
+ async hasWalletSession(): Promise {
+ return this.has('walletSession', StorageType.NORMAL)
+ }
+
+ async getLocalWalletPrivateKey(): Promise {
+ return this.get('localWalletPrivateKey', {
+ type: StorageType.SECURE
+ })
+ }
+
+ async setLocalWalletPrivateKey(key: string): Promise {
+ await this.set('localWalletPrivateKey', key, { type: StorageType.SECURE })
+ }
+
+ async deleteLocalWalletPrivateKey(): Promise {
+ await this.delete('localWalletPrivateKey', StorageType.SECURE)
+ }
+
+ async hasLocalWalletPrivateKey(): Promise {
+ return this.has('localWalletPrivateKey', StorageType.SECURE)
+ }
+
+ async getLocalWalletSession(): Promise {
+ return this.get('localWalletSession', {
+ type: StorageType.NORMAL,
+ schema: LocalWalletSessionSchema
+ })
+ }
+
+ async setLocalWalletSession(session: LocalWalletSession): Promise {
+ const validatedSession = validate(LocalWalletSessionSchema, session)
+ await this.set('localWalletSession', validatedSession, { type: StorageType.NORMAL })
+ }
+
+ async deleteLocalWalletSession(): Promise {
+ await this.delete('localWalletSession', StorageType.NORMAL)
+ }
+
+ async hasLocalWalletSession(): Promise {
+ return this.has('localWalletSession', StorageType.NORMAL)
+ }
+
+ async isAuthenticated(): Promise {
+ const method = await this.getAuthMethod()
+ if (!method) return false
+
+ if (method === 'echo') {
+ return this.hasApiKey()
+ } else if (method === 'wallet') {
+ return this.hasWalletSession()
+ } else if (method === 'local-wallet') {
+ return this.hasLocalWalletSession()
+ }
+ return false
+ }
+
+ async getModel(): Promise {
+ const model = await this.get('model', {
+ type: StorageType.NORMAL,
+ schema: ModelSchema
+ })
+ return model ?? DEFAULT_MODEL
+ }
+
+ async setModel(model: Model): Promise {
+ const validatedModel = validate(ModelSchema, model)
+ await this.set('model', validatedModel, { type: StorageType.NORMAL })
+ }
+}
+
+export const storage = new EchodexStorage()
+export { StorageType, StorageAdapter } from './store'
diff --git a/templates/echo-cli/src/config/messages.ts b/templates/echo-cli/src/config/messages.ts
new file mode 100644
index 000000000..203fb5d71
--- /dev/null
+++ b/templates/echo-cli/src/config/messages.ts
@@ -0,0 +1,26 @@
+export const MESSAGE_MODES = {
+ CHAT: 'chat',
+ AGENT: 'agent'
+} as const
+
+export const THINKING_MESSAGES = [
+ 'doodling',
+ 'pondering',
+ 'musing',
+ 'reflecting',
+ 'considering',
+ 'ideating',
+ 'ruminating',
+ 'cogitating',
+ 'synthesizing'
+] as const
+
+export const THINKING_INTERVAL = 1500
+
+export const THINKING_COLORS = [
+ 'blue',
+ 'cyan',
+ 'green',
+ 'yellow',
+ 'magenta'
+] as const
diff --git a/templates/echo-cli/src/config/models.ts b/templates/echo-cli/src/config/models.ts
new file mode 100644
index 000000000..f6a61cda5
--- /dev/null
+++ b/templates/echo-cli/src/config/models.ts
@@ -0,0 +1,10 @@
+export const MODELS = [
+ { value: 'gpt-4o', label: 'GPT-4o' },
+ { value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
+ { value: 'gpt-5', label: 'GPT-5' },
+ { value: 'gpt-5-mini', label: 'GPT-5 Mini' },
+ { value: 'gpt-5-nano', label: 'GPT-5 Nano' }
+] as const
+
+export const DEFAULT_MODEL = 'gpt-5-mini' as const
+
diff --git a/templates/echo-cli/src/config/store.ts b/templates/echo-cli/src/config/store.ts
new file mode 100644
index 000000000..09168fdd3
--- /dev/null
+++ b/templates/echo-cli/src/config/store.ts
@@ -0,0 +1,128 @@
+import Conf from 'conf'
+import keytar from 'keytar'
+import { z } from 'zod'
+import { validate } from '@/validation'
+import { IKeyValueStorage } from '@walletconnect/keyvaluestorage'
+
+export enum StorageType {
+ SECURE = 'secure',
+ NORMAL = 'normal'
+}
+
+export interface StorageOptions {
+ serviceName: string
+ configName: string
+}
+
+export interface GetOptions {
+ type?: StorageType
+ schema?: z.ZodSchema
+}
+
+export interface SetOptions {
+ type?: StorageType
+}
+
+export abstract class Storage {
+ protected conf: Conf
+ protected serviceName: string
+
+ constructor(options: StorageOptions) {
+ this.serviceName = options.serviceName
+ this.conf = new Conf({
+ projectName: options.configName,
+ clearInvalidConfig: true
+ })
+ }
+
+ async get(key: string, options?: GetOptions): Promise {
+ const type = options?.type ?? StorageType.NORMAL
+ const schema = options?.schema
+
+ let rawValue: unknown
+
+ if (type === StorageType.SECURE) {
+ const value = await keytar.getPassword(this.serviceName, key)
+ rawValue = value ? JSON.parse(value) : undefined
+ } else {
+ rawValue = this.conf.get(key)
+ }
+
+ if (rawValue === undefined) {
+ return undefined
+ }
+
+ if (schema) {
+ return validate(schema, rawValue)
+ }
+
+ return rawValue as T
+ }
+
+ async set(key: string, value: T, options?: SetOptions): Promise {
+ const type = options?.type ?? StorageType.NORMAL
+
+ if (type === StorageType.SECURE) {
+ await keytar.setPassword(this.serviceName, key, JSON.stringify(value))
+ } else {
+ this.conf.set(key, value)
+ }
+ }
+
+ async delete(key: string, type: StorageType = StorageType.NORMAL): Promise {
+ if (type === StorageType.SECURE) {
+ await keytar.deletePassword(this.serviceName, key)
+ } else {
+ this.conf.delete(key)
+ }
+ }
+
+ async has(key: string, type: StorageType = StorageType.NORMAL): Promise {
+ if (type === StorageType.SECURE) {
+ const value = await keytar.getPassword(this.serviceName, key)
+ return value !== null
+ }
+ return this.conf.has(key)
+ }
+
+ clear(): void {
+ this.conf.clear()
+ }
+
+ getAllKeys(): string[] {
+ return Object.keys(this.conf.store)
+ }
+
+ getAllEntries(): [string, T][] {
+ return Object.entries(this.conf.store) as [string, T][]
+ }
+}
+
+export class StorageAdapter extends IKeyValueStorage {
+ private storage: Storage
+
+ constructor(storage: Storage) {
+ super()
+ this.storage = storage
+ }
+
+ async getKeys(): Promise {
+ return this.storage.getAllKeys()
+ }
+
+ async getEntries(): Promise<[string, T][]> {
+ return this.storage.getAllEntries()
+ }
+
+ async getItem(key: string): Promise {
+ return this.storage.get(key)
+ }
+
+ async setItem(key: string, value: T): Promise {
+ await this.storage.set(key, value)
+ }
+
+ async removeItem(key: string): Promise {
+ await this.storage.delete(key)
+ }
+}
diff --git a/templates/echo-cli/src/config/wallet.ts b/templates/echo-cli/src/config/wallet.ts
new file mode 100644
index 000000000..0de7303ee
--- /dev/null
+++ b/templates/echo-cli/src/config/wallet.ts
@@ -0,0 +1,24 @@
+export const WALLET_CHAINS = [1, 8453, 10, 137, 42161]
+
+export const WALLET_OPTIONAL_METHODS = [
+ 'eth_sendTransaction',
+ 'eth_signTransaction',
+ 'eth_sign',
+ 'personal_sign',
+ 'eth_signTypedData',
+ 'eth_signTypedData_v4'
+]
+
+export const AUTH_OPTIONS = [
+ { value: 'echo', label: 'Echo API Key' },
+ { value: 'wallet', label: 'WalletConnect' },
+ { value: 'local-wallet', label: 'Local Wallet (Self Custody)' }
+]
+
+export const CHAIN_OPTIONS = [
+ { value: 1, label: 'Ethereum Mainnet' },
+ { value: 8453, label: 'Base' },
+ { value: 10, label: 'Optimism' },
+ { value: 137, label: 'Polygon' },
+ { value: 42161, label: 'Arbitrum' }
+]
diff --git a/templates/echo-cli/src/constants.ts b/templates/echo-cli/src/constants.ts
new file mode 100644
index 000000000..ca4207529
--- /dev/null
+++ b/templates/echo-cli/src/constants.ts
@@ -0,0 +1,36 @@
+import { MODELS } from '@/config/models'
+
+export { ECHODEX_ASCII_ART } from '@/config/ascii'
+export { MODELS, DEFAULT_MODEL } from '@/config/models'
+export { MESSAGE_MODES, THINKING_MESSAGES, THINKING_INTERVAL, THINKING_COLORS } from '@/config/messages'
+export { WALLET_CHAINS, WALLET_OPTIONAL_METHODS, AUTH_OPTIONS } from '@/config/wallet'
+
+const ECHO_URL = 'https://echo.merit.systems'
+const ECHO_API_URL = 'https://api.echo.merit.systems/v1'
+const ECHO_ROUTER_URL = 'https://echo.router.merit.systems'
+
+export const AGENT_NAME = 'echodex' as const
+export const AVAILABLE_MODELS = MODELS
+
+export const ECHO_APP_ID = 'dbfe663c-b54d-4a64-bcc1-1cb24f4da32f'
+export const WALLETCONNECT_PROJECT_ID = '592e3344e57cbc26ad91d191e82a4185'
+export const ECHO_KEYS_URL = `${ECHO_URL}/app/${ECHO_APP_ID}/keys`
+
+export const APP = {
+ name: 'Echodex',
+ description: 'CLI Coding Agent Powered by Echo',
+ version: '1.0.0',
+ echoAppId: ECHO_APP_ID,
+ walletConnectProjectId: WALLETCONNECT_PROJECT_ID,
+ echoKeysUrl: `${ECHO_URL}/app/${ECHO_APP_ID}/keys`,
+ echoUrl: ECHO_URL,
+ echoApiUrl: ECHO_API_URL,
+ echoRouterUrl: ECHO_ROUTER_URL
+} as const
+
+export const APP_METADATA = {
+ name: APP.name,
+ description: APP.description,
+ url: APP.echoUrl,
+ icons: [`${APP.echoUrl}/favicon.ico`]
+}
diff --git a/templates/echo-cli/src/core/chat.ts b/templates/echo-cli/src/core/chat.ts
new file mode 100644
index 000000000..d396e0cee
--- /dev/null
+++ b/templates/echo-cli/src/core/chat.ts
@@ -0,0 +1,152 @@
+import { text, isCancel } from '@clack/prompts'
+import chalk from 'chalk'
+import { getAIProvider } from '@/auth'
+import { AGENT_NAME, MESSAGE_MODES } from '@/constants'
+import { storage } from '@/config'
+import { streamText, ModelMessage } from 'ai'
+import { consumeStream, createThinkingSpinner, isAuthenticated, displayAppError, isErrorCode, ErrorCode } from '@/utils'
+import { warning, hint, blankLine, write, newLine, error, header } from '@/print'
+import { createThread, addMessageToThread, selectThreadToResume } from './history'
+import { Thread } from '@/validation'
+
+async function runChatLoop(thread: Thread, isResume: boolean = false): Promise {
+ const authenticated = await isAuthenticated()
+
+ if (!authenticated) {
+ warning('Not authenticated. Please run: echodex login')
+ return
+ }
+
+ let provider
+ try {
+ provider = await getAIProvider()
+ } catch (err) {
+ if (err instanceof Error) {
+ displayAppError(err as any)
+ } else {
+ error('Failed to initialize AI provider')
+ }
+ return
+ }
+
+ if (!provider) {
+ error('Failed to initialize AI provider')
+ return
+ }
+
+ const conversationHistory: ModelMessage[] = thread.messages.map((msg) => ({
+ role: msg.role,
+ content: msg.content
+ }))
+
+ const mode = MESSAGE_MODES.CHAT
+ const modeDisplay = mode === MESSAGE_MODES.CHAT ? 'Chat' : 'Agent'
+
+ header(`${AGENT_NAME} - ${modeDisplay}${isResume ? ' (Resumed)' : ''}`)
+
+ // Display previous messages if resuming
+ if (isResume && thread.messages.length > 0) {
+ hint('Previous conversation:')
+ blankLine()
+
+ thread.messages.forEach((msg) => {
+ const displayName = msg.role === 'user' ? 'You' : AGENT_NAME
+ const color = msg.role === 'user' ? chalk.green : chalk.blue
+ write(color(`${displayName}: `))
+ write(`${msg.content}\n`)
+ })
+
+ blankLine()
+ }
+
+ hint(`Type "exit" to quit.`)
+ hint(`Using model: ${thread.model}\n`)
+
+ while (true) {
+ const userInput = await text({
+ message: 'You:',
+ placeholder: 'Type your message...'
+ })
+
+ if (isCancel(userInput)) {
+ newLine()
+ blankLine()
+ break
+ }
+
+ if (typeof userInput !== 'string') {
+ break
+ }
+
+ if (userInput.toLowerCase() === 'exit') {
+ blankLine()
+ break
+ }
+
+ conversationHistory.push({
+ role: 'user',
+ content: userInput
+ })
+
+ await addMessageToThread(thread, 'user', userInput, mode)
+
+ const spinner = createThinkingSpinner()
+
+ try {
+ const stream = streamText({
+ model: provider(thread.model),
+ messages: conversationHistory,
+ })
+
+ const assistantMessage = await consumeStream(stream.textStream, (chunk, isFirst) => {
+ if (isFirst) {
+ spinner.stop()
+ write(chalk.blue(`${AGENT_NAME}: `))
+ }
+ write(chunk)
+ })
+
+ conversationHistory.push({
+ role: 'assistant',
+ content: assistantMessage
+ })
+
+ await addMessageToThread(thread, 'assistant', assistantMessage, mode)
+
+ newLine()
+ newLine()
+ } catch (err) {
+ spinner.stop()
+ newLine()
+
+ if (isErrorCode(err, ErrorCode.INSUFFICIENT_FUNDS) || isErrorCode(err, ErrorCode.WRONG_CHAIN)) {
+ return
+ }
+
+ if (isErrorCode(err, ErrorCode.PAYMENT_FAILED)) {
+ error('Payment processing failed')
+ hint('Please try again or check your wallet connection')
+ blankLine()
+ return
+ }
+
+ displayAppError(err as any)
+ }
+ }
+}
+
+export async function startChatSession(): Promise {
+ const model = await storage.getModel()
+ const thread = await createThread(model)
+ await runChatLoop(thread)
+}
+
+export async function resumeChatSession(): Promise {
+ const thread = await selectThreadToResume()
+
+ if (!thread) {
+ return
+ }
+
+ await runChatLoop(thread, true)
+}
diff --git a/templates/echo-cli/src/core/history.ts b/templates/echo-cli/src/core/history.ts
new file mode 100644
index 000000000..978b28f21
--- /dev/null
+++ b/templates/echo-cli/src/core/history.ts
@@ -0,0 +1,186 @@
+import { storage, StorageType } from '@/config'
+import { Thread, ThreadsSchema, ThreadMessage, Model } from '@/validation'
+import { randomUUID } from 'crypto'
+import { select, isCancel } from '@clack/prompts'
+import { info, warning, success, error, label, hint, blankLine } from '@/print'
+import { MESSAGE_MODES } from '@/constants'
+import { writeFile } from 'fs/promises'
+import { join } from 'path'
+import { displayAppError, createError, ErrorCode } from '@/utils'
+
+const THREADS_STORAGE_KEY = 'conversation_threads'
+
+export async function createThread(model: Model): Promise {
+ const now = new Date().toISOString()
+ return {
+ id: randomUUID(),
+ createdAt: now,
+ updatedAt: now,
+ messages: [],
+ model
+ }
+}
+
+export async function getThreads(): Promise {
+ try {
+ const threads = await storage.get(THREADS_STORAGE_KEY, {
+ type: StorageType.NORMAL,
+ schema: ThreadsSchema
+ })
+ return threads || []
+ } catch {
+ return []
+ }
+}
+
+export async function getThread(threadId: string): Promise {
+ const threads = await getThreads()
+ return threads.find(t => t.id === threadId) || null
+}
+
+export async function saveThread(thread: Thread): Promise {
+ const threads = await getThreads()
+ const existingIndex = threads.findIndex(t => t.id === thread.id)
+
+ thread.updatedAt = new Date().toISOString()
+
+ if (existingIndex >= 0) {
+ threads[existingIndex] = thread
+ } else {
+ threads.push(thread)
+ }
+
+ await storage.set(THREADS_STORAGE_KEY, threads, {
+ type: StorageType.NORMAL
+ })
+}
+
+export async function addMessageToThread(
+ thread: Thread,
+ role: 'user' | 'assistant',
+ content: string,
+ mode: typeof MESSAGE_MODES.CHAT | typeof MESSAGE_MODES.AGENT = MESSAGE_MODES.CHAT
+): Promise {
+ const message: ThreadMessage = {
+ role,
+ content,
+ mode,
+ timestamp: new Date().toISOString()
+ }
+
+ thread.messages.push(message)
+ await saveThread(thread)
+}
+
+export async function showConversationHistory(): Promise {
+ const threads = await getThreads()
+
+ if (threads.length === 0) {
+ info('No conversation history found.')
+ return
+ }
+
+ info(`Found ${threads.length} conversation thread${threads.length > 1 ? 's' : ''}:\n`)
+
+ threads
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
+ .forEach((thread, index) => {
+ const date = new Date(thread.createdAt).toLocaleString()
+ const messageCount = thread.messages.length
+ const preview = thread.messages[0]?.content.slice(0, 60) || 'Empty thread'
+
+ label(`${index + 1}.`, `${date}`)
+ hint(` Model: ${thread.model}`)
+ hint(` Messages: ${messageCount}`)
+ hint(` Preview: ${preview}${preview.length >= 60 ? '...' : ''}`)
+ blankLine()
+ })
+}
+
+export async function exportConversationHistory(): Promise {
+ try {
+ const threads = await getThreads()
+
+ if (threads.length === 0) {
+ warning('No conversation history to export.')
+ return false
+ }
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
+ const filename = `echodex-history-${timestamp}.json`
+ const filepath = join(process.cwd(), filename)
+
+ await writeFile(filepath, JSON.stringify(threads, null, 2), 'utf-8')
+
+ success(`Exported ${threads.length} thread${threads.length > 1 ? 's' : ''} to: ${filename}`)
+ return true
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.API_ERROR,
+ message: 'Failed to export history',
+ originalError: err
+ }))
+ return false
+ }
+}
+
+export async function selectThreadToResume(): Promise {
+ const threads = await getThreads()
+
+ if (threads.length === 0) {
+ warning('No conversation history found.')
+ return null
+ }
+
+ const sortedThreads = threads.sort(
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
+ )
+
+ const choices = sortedThreads.map(thread => {
+ const date = new Date(thread.createdAt).toLocaleString()
+ const preview = thread.messages[0]?.content.slice(0, 50) || 'Empty thread'
+ return {
+ value: thread.id,
+ label: `${date} - ${preview}${preview.length >= 50 ? '...' : ''}`,
+ hint: `${thread.messages.length} messages, ${thread.model}`
+ }
+ })
+
+ const selectedId = await select({
+ message: 'Select a conversation to resume:',
+ options: choices
+ })
+
+ if (isCancel(selectedId)) {
+ warning('Cancelled.')
+ return null
+ }
+
+ return threads.find(t => t.id === selectedId) || null
+}
+
+export async function clearConversationHistory(): Promise {
+ try {
+ const threads = await getThreads()
+
+ if (threads.length === 0) {
+ warning('No conversation history to clear.')
+ return false
+ }
+
+ await storage.set(THREADS_STORAGE_KEY, [], {
+ type: StorageType.NORMAL
+ })
+
+ success(`Cleared ${threads.length} thread${threads.length > 1 ? 's' : ''} from history.`)
+ return true
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.API_ERROR,
+ message: 'Failed to clear history',
+ originalError: err
+ }))
+ return false
+ }
+}
+
diff --git a/templates/echo-cli/src/core/index.ts b/templates/echo-cli/src/core/index.ts
new file mode 100644
index 000000000..355b88f0a
--- /dev/null
+++ b/templates/echo-cli/src/core/index.ts
@@ -0,0 +1,5 @@
+export { startChatSession, resumeChatSession } from './chat'
+export { showProfile } from './profile'
+export { selectModel } from './model'
+export { showConversationHistory, exportConversationHistory, clearConversationHistory } from './history'
+export { showLocalWalletBalance, showLocalWalletAddress, exportPrivateKey, fundWallet } from './local-wallet'
diff --git a/templates/echo-cli/src/core/local-wallet.ts b/templates/echo-cli/src/core/local-wallet.ts
new file mode 100644
index 000000000..8d5f06ec4
--- /dev/null
+++ b/templates/echo-cli/src/core/local-wallet.ts
@@ -0,0 +1,223 @@
+import { confirm, isCancel } from '@clack/prompts'
+import chalk from 'chalk'
+import Table from 'cli-table3'
+import { storage } from '@/config'
+import { info, warning, success, error, header, blankLine } from '@/print'
+import {
+ getUSDCBalance,
+ generateQRCodeForAddress,
+ formatAddress,
+ getChainName,
+ displayAppError,
+ createError,
+ ErrorCode
+} from '@/utils'
+
+async function checkLocalWalletAuth(): Promise {
+ const authMethod = await storage.getAuthMethod()
+
+ if (authMethod !== 'local-wallet') {
+ warning('This command requires local wallet authentication')
+ info('Please run: echodex login')
+ info('Then select: Local Wallet (Self Custody)')
+ return false
+ }
+
+ const session = await storage.getLocalWalletSession()
+ if (!session) {
+ warning('Local wallet session not found. Please run: echodex login')
+ return false
+ }
+
+ return true
+}
+
+export async function showLocalWalletBalance(): Promise {
+ try {
+ if (!await checkLocalWalletAuth()) {
+ return
+ }
+
+ const session = await storage.getLocalWalletSession()
+ if (!session) return
+
+ header('Local Wallet Balance')
+
+ info('Fetching balance...')
+ const balance = await getUSDCBalance(session.address as `0x${string}`, session.chainId)
+
+ const table = new Table({
+ head: ['Property', 'Value'],
+ colWidths: [20, 60],
+ style: {
+ head: ['cyan']
+ }
+ })
+
+ table.push(
+ ['Chain', `${getChainName(session.chainId)} (${session.chainId})`],
+ ['Address', formatAddress(session.address)],
+ ['USDC Balance', `${balance} USDC`]
+ )
+
+ blankLine()
+ console.log(table.toString())
+ blankLine()
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.WALLET_DISCONNECTED,
+ message: 'Failed to fetch balance',
+ originalError: err
+ }))
+ }
+}
+
+export async function showLocalWalletAddress(): Promise {
+ try {
+ if (!await checkLocalWalletAuth()) {
+ return
+ }
+
+ const session = await storage.getLocalWalletSession()
+ if (!session) return
+
+ blankLine()
+ header('=== Local Wallet Address ===')
+ blankLine()
+ info(`Full Address: ${chalk.white(session.address)}`)
+ info(`Short Address: ${chalk.gray(formatAddress(session.address))}`)
+ info(`Network: ${chalk.green(getChainName(session.chainId))} (${session.chainId})`)
+ blankLine()
+ info('📱 Scan QR code to send USDC:\n')
+ generateQRCodeForAddress(session.address)
+ blankLine()
+ info('💡 Tip: Send USDC on the correct network to fund your wallet')
+ blankLine()
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.WALLET_DISCONNECTED,
+ message: 'Failed to display address',
+ originalError: err
+ }))
+ }
+}
+
+export async function exportPrivateKey(): Promise {
+ try {
+ if (!await checkLocalWalletAuth()) {
+ return
+ }
+
+ blankLine()
+ header('⚠️ EXPORT PRIVATE KEY ⚠️')
+ blankLine()
+ warning('WARNING: Your private key provides FULL ACCESS to your wallet!')
+ warning('Never share it with anyone or paste it into untrusted applications.')
+ warning('Anyone with your private key can steal all your funds.')
+ blankLine()
+
+ const confirmed = await confirm({
+ message: 'Do you understand the risks and want to proceed?'
+ })
+
+ if (isCancel(confirmed) || !confirmed) {
+ info('\nExport cancelled')
+ return
+ }
+
+ const privateKey = await storage.getLocalWalletPrivateKey()
+ if (!privateKey) {
+ error('Private key not found')
+ return
+ }
+
+ blankLine()
+ error('═══════════════════════════════════════════════════════════')
+ error(' YOUR PRIVATE KEY')
+ error('═══════════════════════════════════════════════════════════')
+ blankLine()
+ console.log(chalk.red.bold(privateKey))
+ blankLine()
+ error('═══════════════════════════════════════════════════════════')
+ blankLine()
+ warning('⚠️ Keep this key safe and secure!')
+ warning('⚠️ Delete this from your terminal history!')
+ warning('⚠️ Anyone with this key can access your funds!')
+ blankLine()
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.AUTHENTICATION_FAILED,
+ message: 'Failed to export private key',
+ originalError: err
+ }))
+ }
+}
+
+export async function fundWallet(): Promise {
+ try {
+ if (!await checkLocalWalletAuth()) {
+ return
+ }
+
+ const session = await storage.getLocalWalletSession()
+ if (!session) return
+
+ blankLine()
+ header('=== Fund Local Wallet ===')
+ blankLine()
+ info(`Wallet Address: ${session.address}`)
+ info(`Network: ${getChainName(session.chainId)} (${session.chainId})`)
+ blankLine()
+
+ const currentBalance = await getUSDCBalance(session.address as `0x${string}`, session.chainId)
+ info(`Current USDC Balance: ${currentBalance} USDC`)
+ blankLine()
+
+ info('📱 Scan QR code to send USDC:\n')
+ generateQRCodeForAddress(session.address)
+ blankLine()
+
+ // Start balance polling
+ info('💰 Waiting for USDC deposit...')
+ info(' (Press Ctrl+C to stop monitoring)\n')
+
+ const startBalance = parseFloat(currentBalance)
+ let cancelled = false
+
+ const handleCancel = () => {
+ cancelled = true
+ info('\n\n✓ Stopped monitoring for deposits')
+ process.exit(0)
+ }
+
+ process.on('SIGINT', handleCancel)
+
+ try {
+ while (!cancelled) {
+ const balance = await getUSDCBalance(session.address as `0x${string}`, session.chainId)
+ const balanceNum = parseFloat(balance)
+
+ if (balanceNum > startBalance) {
+ const diff = (balanceNum - startBalance).toFixed(2)
+ success(`\n✓ Received ${diff} USDC!`)
+ success(`✓ New balance: ${balance} USDC`)
+ break
+ }
+
+ // Wait 3 seconds before next check
+ await new Promise(resolve => setTimeout(resolve, 3000))
+ }
+ } finally {
+ process.off('SIGINT', handleCancel)
+ }
+
+ blankLine()
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.WALLET_DISCONNECTED,
+ message: 'Failed to fund wallet',
+ originalError: err
+ }))
+ }
+}
+
diff --git a/templates/echo-cli/src/core/model.ts b/templates/echo-cli/src/core/model.ts
new file mode 100644
index 000000000..13bfe585f
--- /dev/null
+++ b/templates/echo-cli/src/core/model.ts
@@ -0,0 +1,49 @@
+import { select, isCancel } from '@clack/prompts'
+import { storage } from '@/config'
+import { AVAILABLE_MODELS } from '@/constants'
+import { isValid, ModelSchema } from '@/validation'
+import { warning, success, error } from '@/print'
+import { displayAppError, createError, ErrorCode } from '@/utils'
+
+export async function selectModel(): Promise {
+ try {
+ const currentModel = await storage.getModel()
+
+ const selectedModel = await select({
+ message: 'Select a model:',
+ options: AVAILABLE_MODELS.map((model: typeof AVAILABLE_MODELS[number]) => ({
+ value: model.value,
+ label: model.label,
+ hint: model.value === currentModel ? `current` : undefined
+ })),
+ initialValue: currentModel
+ })
+
+ if (isCancel(selectedModel)) {
+ warning('Model selection cancelled')
+ return false
+ }
+
+ if (typeof selectedModel !== 'string') {
+ warning('Model selection cancelled')
+ return false
+ }
+
+ if (!isValid(ModelSchema, selectedModel)) {
+ error('Invalid model selected')
+ return false
+ }
+
+ await storage.setModel(selectedModel)
+ success(`✓ Model set to ${selectedModel}`)
+ return true
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.API_ERROR,
+ message: 'Failed to set model',
+ originalError: err
+ }))
+ return false
+ }
+}
+
diff --git a/templates/echo-cli/src/core/profile.ts b/templates/echo-cli/src/core/profile.ts
new file mode 100644
index 000000000..03af7ab45
--- /dev/null
+++ b/templates/echo-cli/src/core/profile.ts
@@ -0,0 +1,113 @@
+import chalk from 'chalk'
+import { getEchoClient } from '@/auth'
+import { storage } from '@/config'
+import { validate, UserInfoSchema, BalanceSchema } from '@/validation'
+import { warning, header, label, blankLine, error } from '@/print'
+import { getChainName, formatAddress, getUSDCBalance, displayAppError, createError, ErrorCode } from '@/utils'
+
+export async function showProfile(): Promise {
+ const authMethod = await storage.getAuthMethod()
+
+ if (!authMethod) {
+ warning('Not authenticated. Please run: echodex login')
+ return
+ }
+
+ if (authMethod === 'echo') {
+ await showEchoProfile()
+ } else if (authMethod === 'wallet') {
+ await showWalletProfile()
+ } else if (authMethod === 'local-wallet') {
+ await showLocalWalletProfile()
+ }
+}
+
+async function showEchoProfile(): Promise {
+ const client = await getEchoClient()
+
+ if (!client) {
+ warning('Echo authentication not found. Please run: echodex login')
+ return
+ }
+
+ try {
+ const [rawUser, rawBalance] = await Promise.all([
+ client.users.getUserInfo(),
+ client.balance.getBalance()
+ ])
+
+ const user = validate(UserInfoSchema, rawUser)
+ const balance = validate(BalanceSchema, rawBalance)
+
+ blankLine()
+ header('=== Echo Profile ===')
+ blankLine()
+ label('Auth Method:', chalk.cyan('Echo API Key'))
+ label('Email:', chalk.white(user.email))
+ label('Balance:', chalk.green(`$${balance.balance.toFixed(4)}`))
+ label('Total Spent:', chalk.yellow(`$${balance.totalSpent.toFixed(4)}`))
+ label('Created:', chalk.white(new Date(user.createdAt).toLocaleDateString()))
+ blankLine()
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.API_ERROR,
+ message: 'Failed to fetch profile',
+ originalError: err
+ }))
+ }
+}
+
+async function showWalletProfile(): Promise {
+ const session = await storage.getWalletSession()
+
+ if (!session) {
+ warning('Wallet session not found. Please run: echodex login')
+ return
+ }
+
+ blankLine()
+ header('=== Wallet Profile ===')
+ blankLine()
+ label('Auth Method:', chalk.cyan('WalletConnect'))
+ label('Address:', chalk.white(session.address))
+ label('Short Address:', chalk.gray(formatAddress(session.address)))
+ label('Chain:', chalk.green(`${getChainName(session.chainId, true)} (${session.chainId})`))
+ label('Session Topic:', chalk.dim(session.topic.slice(0, 32) + '...'))
+ if (session.expiry) {
+ const expiryDate = new Date(session.expiry * 1000)
+ label('Session Expires:', chalk.white(expiryDate.toLocaleString()))
+ }
+ blankLine()
+}
+
+async function showLocalWalletProfile(): Promise {
+ const session = await storage.getLocalWalletSession()
+
+ if (!session) {
+ warning('Local wallet session not found. Please run: echodex login')
+ return
+ }
+
+ try {
+ const balance = await getUSDCBalance(session.address, session.chainId)
+
+ blankLine()
+ header('=== Local Wallet Profile ===')
+ blankLine()
+ label('Auth Method:', chalk.cyan('Local Wallet (Self Custody)'))
+ label('Address:', chalk.white(session.address))
+ label('Short Address:', chalk.gray(formatAddress(session.address)))
+ label('Chain:', chalk.green(`${getChainName(session.chainId, true)} (${session.chainId})`))
+ label('USDC Balance:', chalk.green(`${balance} USDC`))
+ label('Created:', chalk.white(new Date(session.createdAt).toLocaleDateString()))
+ blankLine()
+ label('Security:', chalk.yellow('🔐 Private key stored in OS keychain'))
+ blankLine()
+ } catch (err) {
+ displayAppError(createError({
+ code: ErrorCode.WALLET_DISCONNECTED,
+ message: 'Failed to fetch wallet profile',
+ originalError: err
+ }))
+ }
+}
diff --git a/templates/echo-cli/src/index.ts b/templates/echo-cli/src/index.ts
new file mode 100644
index 000000000..a0af972e4
--- /dev/null
+++ b/templates/echo-cli/src/index.ts
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+
+import { Command } from 'commander'
+import { select, isCancel } from '@clack/prompts'
+import { loginWithEcho, loginWithWallet, initLocalWallet, logout } from '@/auth'
+import {
+ startChatSession,
+ resumeChatSession,
+ showProfile,
+ selectModel,
+ showConversationHistory,
+ exportConversationHistory,
+ clearConversationHistory,
+ showLocalWalletBalance,
+ showLocalWalletAddress,
+ exportPrivateKey,
+ fundWallet
+} from '@/core'
+import { isAuthenticated } from '@/utils'
+import { ECHODEX_ASCII_ART, AUTH_OPTIONS } from '@/constants'
+import { info, warning, header } from '@/print'
+
+const program = new Command()
+
+program
+ .name('echodex')
+ .description('CLI Coding Agent Powered by Echo')
+ .version('1.0.0')
+
+program
+ .command('login')
+ .description('Authenticate with Echo or Wallet')
+ .action(async () => {
+ header('Authentication')
+
+ const authMethod = await select({
+ message: 'Choose authentication method:',
+ options: AUTH_OPTIONS
+ })
+
+ if (isCancel(authMethod)) {
+ warning('\nLogin cancelled')
+ process.exit(1)
+ }
+
+ let success = false
+ if (authMethod === 'echo') {
+ success = await loginWithEcho()
+ } else if (authMethod === 'wallet') {
+ success = await loginWithWallet()
+ } else if (authMethod === 'local-wallet') {
+ success = await initLocalWallet()
+ }
+
+ process.exit(success ? 0 : 1)
+ })
+
+program
+ .command('logout')
+ .description('Log out from Echo')
+ .action(async () => {
+ await logout()
+ process.exit(0)
+ })
+
+program
+ .command('profile')
+ .description('Show user profile information')
+ .action(async () => {
+ await showProfile()
+ process.exit(0)
+ })
+
+program
+ .command('model')
+ .description('Select the AI model to use for chat')
+ .action(async () => {
+ const success = await selectModel()
+ process.exit(success ? 0 : 1)
+ })
+
+program
+ .command('history')
+ .description('View conversation history')
+ .action(async () => {
+ await showConversationHistory()
+ process.exit(0)
+ })
+
+program
+ .command('export')
+ .description('Export conversation history as JSON')
+ .action(async () => {
+ const success = await exportConversationHistory()
+ process.exit(success ? 0 : 1)
+ })
+
+program
+ .command('clear-history')
+ .description('Clear all conversation history')
+ .action(async () => {
+ const success = await clearConversationHistory()
+ process.exit(success ? 0 : 1)
+ })
+
+program
+ .command('resume')
+ .description('Resume a conversation from history')
+ .action(async () => {
+ await resumeChatSession()
+ process.exit(0)
+ })
+
+program
+ .command('wallet-balance')
+ .description('Show local wallet USDC balance')
+ .action(async () => {
+ await showLocalWalletBalance()
+ process.exit(0)
+ })
+
+program
+ .command('wallet-address')
+ .description('Show local wallet address and QR code')
+ .action(async () => {
+ await showLocalWalletAddress()
+ process.exit(0)
+ })
+
+program
+ .command('export-private-key')
+ .description('Export local wallet private key (⚠️ SENSITIVE)')
+ .action(async () => {
+ await exportPrivateKey()
+ process.exit(0)
+ })
+
+program
+ .command('fund-wallet')
+ .description('Show QR code and wait for USDC deposit')
+ .action(async () => {
+ await fundWallet()
+ process.exit(0)
+ })
+
+program.action(async () => {
+ const authenticated = await isAuthenticated()
+
+ if (!authenticated) {
+ info(ECHODEX_ASCII_ART)
+ program.help()
+ process.exit(0)
+ } else {
+ await startChatSession()
+ process.exit(0)
+ }
+})
+
+program.parse()
diff --git a/templates/echo-cli/src/print.ts b/templates/echo-cli/src/print.ts
new file mode 100644
index 000000000..e228ce2c9
--- /dev/null
+++ b/templates/echo-cli/src/print.ts
@@ -0,0 +1,50 @@
+import chalk from 'chalk'
+
+export function info(message: string): void {
+ console.log(chalk.cyan(message))
+}
+
+export function success(message: string): void {
+ console.log(chalk.green(message))
+}
+
+export function warning(message: string): void {
+ console.log(chalk.yellow(message))
+}
+
+export function error(label: string, message?: string): void {
+ if (message) {
+ console.log(chalk.red(label), message)
+ } else {
+ console.log(chalk.red(label))
+ }
+}
+
+export function header(message: string): void {
+ console.log(chalk.bold(message))
+}
+
+export function label(label: string, value: string): void {
+ console.log(chalk.gray(label), value)
+}
+
+export function hint(message: string): void {
+ console.log(chalk.dim(message))
+}
+
+export function aiResponse(agentName: string, message: string): void {
+ process.stdout.write(chalk.blue(`${agentName}: `) + message)
+}
+
+export function write(text: string): void {
+ process.stdout.write(text)
+}
+
+export function newLine(): void {
+ process.stdout.write('\n')
+}
+
+export function blankLine(): void {
+ console.log()
+}
+
diff --git a/templates/echo-cli/src/utils/auth.ts b/templates/echo-cli/src/utils/auth.ts
new file mode 100644
index 000000000..28fa9a6c4
--- /dev/null
+++ b/templates/echo-cli/src/utils/auth.ts
@@ -0,0 +1,5 @@
+import { storage } from '@/config'
+
+export async function isAuthenticated(): Promise {
+ return storage.isAuthenticated()
+}
diff --git a/templates/echo-cli/src/utils/chains.ts b/templates/echo-cli/src/utils/chains.ts
new file mode 100644
index 000000000..5513961d2
--- /dev/null
+++ b/templates/echo-cli/src/utils/chains.ts
@@ -0,0 +1,24 @@
+export const CHAIN_NAMES: Record = {
+ 1: 'Ethereum',
+ 8453: 'Base',
+ 10: 'Optimism',
+ 137: 'Polygon',
+ 42161: 'Arbitrum'
+}
+
+export const CHAIN_NAMES_FULL: Record = {
+ 1: 'Ethereum Mainnet',
+ 8453: 'Base',
+ 10: 'Optimism',
+ 137: 'Polygon',
+ 42161: 'Arbitrum One'
+}
+
+export function getChainName(chainId: number, full: boolean = false): string {
+ const chains = full ? CHAIN_NAMES_FULL : CHAIN_NAMES
+ return chains[chainId] || 'Unknown'
+}
+
+export function isSupported(chainId: number): boolean {
+ return chainId in CHAIN_NAMES
+}
diff --git a/templates/echo-cli/src/utils/errors.ts b/templates/echo-cli/src/utils/errors.ts
new file mode 100644
index 000000000..c5ec46c8e
--- /dev/null
+++ b/templates/echo-cli/src/utils/errors.ts
@@ -0,0 +1,227 @@
+import { error, warning, hint, blankLine } from '@/print'
+
+export enum ErrorCode {
+ VALIDATION_ERROR = 'VALIDATION_ERROR',
+ AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
+ WALLET_SESSION_EXPIRED = 'WALLET_SESSION_EXPIRED',
+ WALLET_DISCONNECTED = 'WALLET_DISCONNECTED',
+ WRONG_CHAIN = 'WRONG_CHAIN',
+ INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS',
+ PAYMENT_FAILED = 'PAYMENT_FAILED',
+ API_ERROR = 'API_ERROR',
+ NOT_FOUND = 'NOT_FOUND',
+ CANCELLED = 'CANCELLED',
+ UNKNOWN = 'UNKNOWN'
+}
+
+export interface ErrorContext {
+ code: ErrorCode
+ message: string
+ details?: string
+ originalError?: unknown
+ responseBody?: string
+ requiredChainId?: number
+ currentChainId?: number
+}
+
+export class AppError extends Error {
+ code: ErrorCode
+ details?: string
+ originalError?: unknown
+ responseBody?: string
+ requiredChainId?: number
+ currentChainId?: number
+
+ constructor(context: ErrorContext) {
+ super(context.message)
+ this.name = 'AppError'
+ this.code = context.code
+ this.details = context.details
+ this.originalError = context.originalError
+ this.responseBody = context.responseBody
+ this.requiredChainId = context.requiredChainId
+ this.currentChainId = context.currentChainId
+ Object.setPrototypeOf(this, AppError.prototype)
+ }
+}
+
+export function createError(context: ErrorContext): AppError {
+ return new AppError(context)
+}
+
+export function throwError(context: ErrorContext): never {
+ throw createError(context)
+}
+
+export function handleError(err: unknown, fallbackMessage: string = 'An error occurred'): void {
+ if (err instanceof AppError) {
+ displayAppError(err)
+ } else if (err instanceof Error) {
+ error(err.message)
+ } else {
+ error(fallbackMessage)
+ }
+}
+
+export function displayAppError(err: unknown): void {
+ // Convert to AppError if needed
+ let appError: AppError
+
+ if (isAppError(err)) {
+ appError = err
+ } else if (is402PaymentError(err)) {
+ appError = transformPaymentError(err)
+ } else if (err instanceof Error) {
+ appError = createError({
+ code: ErrorCode.UNKNOWN,
+ message: err.message,
+ originalError: err
+ })
+ } else {
+ appError = createError({
+ code: ErrorCode.UNKNOWN,
+ message: 'An unexpected error occurred',
+ originalError: err
+ })
+ }
+
+ blankLine()
+
+ switch (appError.code) {
+ case ErrorCode.VALIDATION_ERROR:
+ error('Validation Error')
+ if (appError.details) {
+ hint(appError.details)
+ }
+ break
+
+ case ErrorCode.AUTHENTICATION_FAILED:
+ error('Authentication Failed')
+ hint(appError.message)
+ hint('Please check your credentials and try again')
+ break
+
+ case ErrorCode.WALLET_SESSION_EXPIRED:
+ error('Wallet Session Expired')
+ warning('Your WalletConnect session has expired or was disconnected')
+ hint('Please reconnect your wallet: echodex login')
+ break
+
+ case ErrorCode.WALLET_DISCONNECTED:
+ warning('Wallet Disconnected')
+ hint('Please reconnect your wallet: echodex login')
+ break
+
+ case ErrorCode.WRONG_CHAIN:
+ error('Wrong Blockchain Network')
+ hint(`Your wallet is on chain ${appError.currentChainId}`)
+ hint(`Please switch to chain ${appError.requiredChainId} in your mobile wallet`)
+ break
+
+ case ErrorCode.INSUFFICIENT_FUNDS:
+ error('💰 Insufficient USDC Balance')
+ warning('Your wallet does not have enough USDC to complete this request')
+ blankLine()
+ hint('To fund your wallet:')
+ hint(' • Run: echodex fund-wallet')
+ hint(' • Or check balance: echodex wallet-balance')
+ hint(' • Or view address: echodex wallet-address')
+ if (appError.details) {
+ blankLine()
+ hint(appError.details)
+ }
+ break
+
+ case ErrorCode.PAYMENT_FAILED:
+ error('Payment Processing Failed')
+ hint('Please try again or check your wallet connection')
+ break
+
+ case ErrorCode.API_ERROR:
+ error('API Error')
+ hint(appError.message)
+ break
+
+ case ErrorCode.NOT_FOUND:
+ warning('Not Found')
+ hint(appError.message)
+ break
+
+ case ErrorCode.CANCELLED:
+ warning(appError.message)
+ break
+
+ default:
+ error(appError.message)
+ }
+
+ blankLine()
+}
+
+export function extractErrorMessage(err: unknown): string {
+ if (err instanceof AppError) {
+ return err.message
+ }
+ if (err instanceof Error) {
+ return err.message
+ }
+ return 'Unknown error'
+}
+
+export function isAppError(err: unknown): err is AppError {
+ return err instanceof AppError
+}
+
+export function isErrorCode(err: unknown, code: ErrorCode): boolean {
+ return isAppError(err) && err.code === code
+}
+
+export function is402PaymentError(err: unknown): boolean {
+ if (err && typeof err === 'object' && 'statusCode' in err) {
+ return (err as any).statusCode === 402
+ }
+ return false
+}
+
+export function transformPaymentError(err: unknown): AppError {
+ const errorData = err as any
+
+ // Check if it's a 402 payment error
+ if (errorData.statusCode === 402) {
+ let details: string | undefined
+ try {
+ if (errorData.responseBody) {
+ const parsed = JSON.parse(errorData.responseBody)
+ details = parsed.error
+ }
+ } catch {
+ // Ignore JSON parse errors
+ }
+
+ return createError({
+ code: ErrorCode.INSUFFICIENT_FUNDS,
+ message: 'Insufficient USDC balance to complete this request',
+ details,
+ originalError: err
+ })
+ }
+
+ // Check for other payment-related errors
+ if (errorData.message?.includes('Payment Required') ||
+ errorData.message?.includes('payment') ||
+ errorData.message?.includes('402')) {
+ return createError({
+ code: ErrorCode.INSUFFICIENT_FUNDS,
+ message: 'Payment required - insufficient funds',
+ originalError: err
+ })
+ }
+
+ // Default to payment failed
+ return createError({
+ code: ErrorCode.PAYMENT_FAILED,
+ message: 'Payment processing failed',
+ originalError: err
+ })
+}
+
diff --git a/templates/echo-cli/src/utils/index.ts b/templates/echo-cli/src/utils/index.ts
new file mode 100644
index 000000000..5ee026ec8
--- /dev/null
+++ b/templates/echo-cli/src/utils/index.ts
@@ -0,0 +1,35 @@
+export { isAuthenticated } from './auth'
+export { consumeStream } from './stream'
+export { createThinkingSpinner } from './spinner'
+export { getChainName, isSupported, CHAIN_NAMES, CHAIN_NAMES_FULL } from './chains'
+export {
+ formatAddress,
+ parseCAIP10Address,
+ initializeEthereumProvider,
+ clearWalletSession,
+ type EthereumProviderInstance
+} from './wallet'
+export { createWalletSigner, createLocalWalletSigner } from './signer'
+export {
+ generateWallet,
+ getLocalWalletAddress,
+ formatPrivateKeyForDisplay,
+ generateQRCodeForAddress,
+ getUSDCBalance,
+ clearLocalWalletSession,
+ type GeneratedWallet
+} from './local-wallet'
+export {
+ ErrorCode,
+ AppError,
+ createError,
+ throwError,
+ handleError,
+ displayAppError,
+ extractErrorMessage,
+ isAppError,
+ isErrorCode,
+ is402PaymentError,
+ transformPaymentError,
+ type ErrorContext
+} from './errors'
diff --git a/templates/echo-cli/src/utils/local-wallet.ts b/templates/echo-cli/src/utils/local-wallet.ts
new file mode 100644
index 000000000..3a6a564d1
--- /dev/null
+++ b/templates/echo-cli/src/utils/local-wallet.ts
@@ -0,0 +1,101 @@
+import QRCode from 'qrcode-terminal'
+import { createPublicClient, http, type Address } from 'viem'
+import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'
+import { mainnet, base, optimism, polygon, arbitrum } from 'viem/chains'
+import { storage } from '@/config'
+
+const CHAIN_MAP = {
+ 1: mainnet,
+ 8453: base,
+ 10: optimism,
+ 137: polygon,
+ 42161: arbitrum
+} as const
+
+const USDC_ADDRESSES: Record = {
+ 1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ 8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85',
+ 137: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
+ 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
+}
+
+const USDC_ABI = [
+ {
+ constant: true,
+ inputs: [{ name: '_owner', type: 'address' }],
+ name: 'balanceOf',
+ outputs: [{ name: 'balance', type: 'uint256' }],
+ type: 'function'
+ }
+] as const
+
+export interface GeneratedWallet {
+ privateKey: `0x${string}`
+ address: Address
+}
+
+export function generateWallet(): GeneratedWallet {
+ const privateKey = generatePrivateKey()
+ const account = privateKeyToAccount(privateKey)
+
+ return {
+ privateKey,
+ address: account.address
+ }
+}
+
+export function getLocalWalletAddress(privateKey: `0x${string}`): Address {
+ const account = privateKeyToAccount(privateKey)
+ return account.address
+}
+
+export function formatPrivateKeyForDisplay(key: string): string {
+ if (key.length <= 10) {
+ return key
+ }
+ return `${key.slice(0, 6)}...${key.slice(-4)}`
+}
+
+export function generateQRCodeForAddress(address: string): void {
+ QRCode.generate(address, { small: true }, (qr: string) => {
+ console.log(qr)
+ })
+}
+
+export async function getUSDCBalance(address: Address, chainId: number): Promise {
+ const chain = CHAIN_MAP[chainId as keyof typeof CHAIN_MAP]
+ const usdcAddress = USDC_ADDRESSES[chainId]
+
+ if (!chain || !usdcAddress) {
+ throw new Error(`Unsupported chain: ${chainId}`)
+ }
+
+ const publicClient = createPublicClient({
+ chain,
+ transport: http()
+ })
+
+ try {
+ const balance = await publicClient.readContract({
+ address: usdcAddress,
+ abi: USDC_ABI,
+ functionName: 'balanceOf',
+ args: [address]
+ }) as bigint
+
+ // USDC has 6 decimals
+ const balanceNumber = Number(balance) / 1_000_000
+ return balanceNumber.toFixed(2)
+ } catch (err) {
+ // If balance query fails, return 0
+ return '0.00'
+ }
+}
+
+export async function clearLocalWalletSession(): Promise {
+ await storage.deleteLocalWalletPrivateKey()
+ await storage.deleteLocalWalletSession()
+ await storage.deleteAuthMethod()
+}
+
diff --git a/templates/echo-cli/src/utils/signer.ts b/templates/echo-cli/src/utils/signer.ts
new file mode 100644
index 000000000..9f5f32084
--- /dev/null
+++ b/templates/echo-cli/src/utils/signer.ts
@@ -0,0 +1,62 @@
+import { createWalletClient, custom, http, type WalletClient } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { mainnet, base, optimism, polygon, arbitrum } from 'viem/chains'
+import { getEthereumProvider } from '@/auth'
+import { storage } from '@/config'
+import type { Signer } from 'x402/types'
+
+const CHAIN_MAP = {
+ 1: mainnet,
+ 8453: base,
+ 10: optimism,
+ 137: polygon,
+ 42161: arbitrum
+}
+
+export async function createWalletSigner(): Promise {
+ const provider = await getEthereumProvider()
+ const session = await storage.getWalletSession()
+
+ if (!provider || !session) {
+ return null
+ }
+
+ const chain = CHAIN_MAP[session.chainId as keyof typeof CHAIN_MAP]
+
+ if (!chain) {
+ return null
+ }
+
+ const walletClient: WalletClient = createWalletClient({
+ account: session.address as `0x${string}`,
+ chain,
+ transport: custom(provider)
+ })
+
+ return walletClient as unknown as Signer
+}
+
+export async function createLocalWalletSigner(): Promise {
+ const privateKey = await storage.getLocalWalletPrivateKey()
+ const session = await storage.getLocalWalletSession()
+
+ if (!privateKey || !session) {
+ return null
+ }
+
+ const chain = CHAIN_MAP[session.chainId as keyof typeof CHAIN_MAP]
+
+ if (!chain) {
+ return null
+ }
+
+ const account = privateKeyToAccount(privateKey as `0x${string}`)
+
+ const walletClient: WalletClient = createWalletClient({
+ account,
+ chain,
+ transport: http()
+ })
+
+ return walletClient as unknown as Signer
+}
diff --git a/templates/echo-cli/src/utils/spinner.ts b/templates/echo-cli/src/utils/spinner.ts
new file mode 100644
index 000000000..a9eec38e8
--- /dev/null
+++ b/templates/echo-cli/src/utils/spinner.ts
@@ -0,0 +1,35 @@
+import ora, { Ora } from 'ora'
+import chalk, { ChalkInstance } from 'chalk'
+import { THINKING_COLORS, THINKING_MESSAGES, THINKING_INTERVAL } from '@/constants'
+
+export function createThinkingSpinner(): Ora {
+ const randomMessage = () => THINKING_MESSAGES[Math.floor(Math.random() * THINKING_MESSAGES.length)]
+ const randomColor = () => THINKING_COLORS[Math.floor(Math.random() * THINKING_COLORS.length)] as 'blue' | 'cyan' | 'green' | 'yellow' | 'magenta' | 'red'
+
+ const getColoredText = (color: string) => {
+ const message = randomMessage()
+ return (chalk[color as keyof ChalkInstance] as typeof chalk.blue)(message)
+ }
+
+ let currentColor = randomColor()
+ const spinner = ora({
+ text: getColoredText(currentColor),
+ color: currentColor,
+ spinner: 'dots'
+ }).start()
+
+ const interval = setInterval(() => {
+ currentColor = randomColor()
+ spinner.color = currentColor
+ spinner.text = getColoredText(currentColor)
+ }, THINKING_INTERVAL)
+
+ const originalStop = spinner.stop.bind(spinner)
+ spinner.stop = () => {
+ clearInterval(interval)
+ return originalStop()
+ }
+
+ return spinner
+}
+
diff --git a/templates/echo-cli/src/utils/stream.ts b/templates/echo-cli/src/utils/stream.ts
new file mode 100644
index 000000000..f07628e3a
--- /dev/null
+++ b/templates/echo-cli/src/utils/stream.ts
@@ -0,0 +1,16 @@
+export async function consumeStream(
+ stream: AsyncIterable,
+ onChunk: (chunk: string, isFirst: boolean) => void
+): Promise {
+ let result = ''
+ let isFirst = true
+
+ for await (const chunk of stream) {
+ onChunk(chunk, isFirst)
+ result += chunk
+ isFirst = false
+ }
+
+ return result
+}
+
diff --git a/templates/echo-cli/src/utils/wallet.ts b/templates/echo-cli/src/utils/wallet.ts
new file mode 100644
index 000000000..4a660e748
--- /dev/null
+++ b/templates/echo-cli/src/utils/wallet.ts
@@ -0,0 +1,45 @@
+import { EthereumProvider } from '@walletconnect/ethereum-provider'
+import { storage, StorageAdapter } from '@/config'
+import { WALLETCONNECT_PROJECT_ID, APP_METADATA, WALLET_CHAINS, WALLET_OPTIONAL_METHODS } from '@/constants'
+import pino from 'pino'
+
+export type EthereumProviderInstance = Awaited>
+
+export function formatAddress(address: string, prefixLength: number = 6, suffixLength: number = 4): string {
+ if (address.length <= prefixLength + suffixLength) {
+ return address
+ }
+ return `${address.slice(0, prefixLength)}...${address.slice(-suffixLength)}`
+}
+
+export function parseCAIP10Address(caip10: string): { chainId: number; address: string } | null {
+ const parts = caip10.split(':')
+
+ if (parts.length !== 3 || parts[0] !== 'eip155') {
+ return null
+ }
+
+ return {
+ chainId: parseInt(parts[1]),
+ address: parts[2]
+ }
+}
+
+export async function initializeEthereumProvider(): Promise {
+ const storageAdapter = new StorageAdapter(storage)
+
+ return EthereumProvider.init({
+ projectId: WALLETCONNECT_PROJECT_ID,
+ metadata: APP_METADATA,
+ showQrModal: false,
+ optionalChains: WALLET_CHAINS as [number, ...number[]],
+ optionalMethods: WALLET_OPTIONAL_METHODS,
+ storage: storageAdapter,
+ logger: pino({ level: 'silent' })
+ })
+}
+
+export async function clearWalletSession(): Promise {
+ await storage.deleteWalletSession()
+ await storage.deleteAuthMethod()
+}
diff --git a/templates/echo-cli/src/validation/index.ts b/templates/echo-cli/src/validation/index.ts
new file mode 100644
index 000000000..cfb208793
--- /dev/null
+++ b/templates/echo-cli/src/validation/index.ts
@@ -0,0 +1,25 @@
+export { validate, isValid, ValidationError } from './validator'
+export {
+ ApiKeySchema,
+ UserInfoSchema,
+ BalanceSchema,
+ StorageKeySchema,
+ ModelSchema,
+ MessageModeSchema,
+ ThreadMessageSchema,
+ ThreadSchema,
+ ThreadsSchema,
+ AuthMethodSchema,
+ WalletConnectSessionSchema,
+ LocalWalletSessionSchema,
+ type ApiKey,
+ type UserInfo,
+ type Balance,
+ type Model,
+ type MessageMode,
+ type ThreadMessage,
+ type Thread,
+ type AuthMethod,
+ type WalletConnectSession,
+ type LocalWalletSession
+} from './schemas'
diff --git a/templates/echo-cli/src/validation/schemas.ts b/templates/echo-cli/src/validation/schemas.ts
new file mode 100644
index 000000000..6cedbe1df
--- /dev/null
+++ b/templates/echo-cli/src/validation/schemas.ts
@@ -0,0 +1,73 @@
+import { z } from 'zod'
+import { MODELS } from '@/config/models'
+import { MESSAGE_MODES } from '@/config/messages'
+
+export const ApiKeySchema = z.string()
+ .min(1, 'API key cannot be empty')
+ .startsWith('echo_', 'API key must start with echo_')
+
+export const StorageKeySchema = z.string().min(1)
+
+export const UserInfoSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ image: z.url(),
+ createdAt: z.string(),
+ email: z.email(),
+ updatedAt: z.string(),
+ picture: z.url()
+})
+
+export const BalanceSchema = z.object({
+ totalPaid: z.number().nonnegative(),
+ totalSpent: z.number().nonnegative(),
+ balance: z.number().nonnegative(),
+ currency: z.string()
+})
+
+export const ModelSchema = z.enum(MODELS.map(model => model.value) as [string, ...string[]])
+
+export const MessageModeSchema = z.enum([MESSAGE_MODES.CHAT, MESSAGE_MODES.AGENT] as [string, string])
+
+export const ThreadMessageSchema = z.object({
+ role: z.enum(['user', 'assistant', 'system']),
+ content: z.string(),
+ mode: MessageModeSchema,
+ timestamp: z.string()
+})
+
+export const ThreadSchema = z.object({
+ id: z.string(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ messages: z.array(ThreadMessageSchema),
+ model: ModelSchema
+})
+
+export const ThreadsSchema = z.array(ThreadSchema)
+
+export const AuthMethodSchema = z.enum(['echo', 'wallet', 'local-wallet'] as const)
+
+export const WalletConnectSessionSchema = z.object({
+ topic: z.string(),
+ address: z.string(),
+ chainId: z.number(),
+ expiry: z.number().optional()
+})
+
+export const LocalWalletSessionSchema = z.object({
+ address: z.string(),
+ chainId: z.number(),
+ createdAt: z.string()
+})
+
+export type ApiKey = z.infer
+export type UserInfo = z.infer
+export type Balance = z.infer
+export type Model = z.infer
+export type MessageMode = z.infer
+export type ThreadMessage = z.infer
+export type Thread = z.infer
+export type AuthMethod = z.infer
+export type WalletConnectSession = z.infer
+export type LocalWalletSession = z.infer
diff --git a/templates/echo-cli/src/validation/validator.ts b/templates/echo-cli/src/validation/validator.ts
new file mode 100644
index 000000000..7b24567e2
--- /dev/null
+++ b/templates/echo-cli/src/validation/validator.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod'
+import { throwError, ErrorCode } from '@/utils'
+
+export class ValidationError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'ValidationError'
+ }
+}
+
+export function validate(schema: z.ZodSchema, data: unknown): T {
+ const result = schema.safeParse(data)
+
+ if (!result.success) {
+ const errors = result.error.issues.map((e: z.ZodIssue) => `${e.path.join('.')}: ${e.message}`).join(', ')
+ throwError({
+ code: ErrorCode.VALIDATION_ERROR,
+ message: 'Validation failed',
+ details: errors
+ })
+ }
+
+ return result.data
+}
+
+export function isValid(schema: z.ZodSchema, data: unknown): data is T {
+ return schema.safeParse(data).success
+}
+
diff --git a/templates/echo-cli/tsconfig.json b/templates/echo-cli/tsconfig.json
new file mode 100644
index 000000000..db46dd927
--- /dev/null
+++ b/templates/echo-cli/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022"],
+ "types": ["node"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "baseUrl": "./src",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}