diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 788f9d0bb..ea1936b12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -188,6 +188,17 @@ echo/ - **Echo TS SDK** (`packages/sdk/ts`): Foundation for all framework-specific SDKs - **Echo React SDK** (`packages/sdk/react`): React hooks and components - **Echo Next SDK** (`packages/sdk/next`): Next.js App Router integration +- **Echo CLI** (`templates/echo-cli`): Command-line AI agent with authentication options + +### Authentication Methods in Echo + +Echo supports multiple authentication and payment methods: + +1. **Echo API Keys**: Managed accounts with centralized billing +2. **WalletConnect**: Connect mobile wallets for decentralized payments +3. **Local Wallet (Self-Custody)**: Generate and manage private keys locally for full custody + +All methods integrate with the X402 payment protocol for USDC-based AI payments. ## Coding Standards @@ -197,6 +208,9 @@ echo/ - Prefer explicit types over `any` - Use interfaces for public APIs, types for internal structures - Enable strict mode in `tsconfig.json` +- Import from correct module paths: + - `privateKeyToAccount` from `viem/accounts`, not `viem` + - Use relative paths or absolute imports with `@` alias ### Imports @@ -308,21 +322,30 @@ The scope indicates which package is affected: - `react-sdk`: React SDK - `next-sdk`: Next.js SDK - `ts-sdk`: TypeScript SDK +- `cli`: Echo CLI template - `templates`: Starter templates - `docs`: Documentation +When working on the Echo CLI or templates, use `cli` or `templates` scope. + ### Examples ```bash feat(react-sdk): add support for streaming responses +feat(cli): add local wallet self-custody authentication + fix(server): correct token counting for Claude models docs(templates): add environment setup guide for next-chat +docs(cli): update readme with local wallet instructions + refactor(control): simplify balance calculation logic test(ts-sdk): add integration tests for provider initialization + +test(cli): add tests for wallet generation and signing ``` ## Pull Request Process diff --git a/README.md b/README.md index bba3874ae..54d3f0c7b 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,14 @@ Available templates: - **[next](./templates/next)** - Next.js application with Echo - **[react](./templates/react)** - Vite React application with Echo -- **[nextjsChatbot](./templates/nextjs-chatbot)** - Next.js with Echo and Vercel AI SDK -- **[assistantUi](./templates/assistant-ui)** - Next.js with Echo and Assistant UI +- **[next-chat](./templates/next-chat)** - Next.js chatbot with Echo and Vercel AI SDK +- **[assistant-ui](./templates/assistant-ui)** - Next.js with Echo and Assistant UI +- **[echo-cli](./templates/echo-cli)** - CLI tool for AI chat with Echo (API keys + crypto wallets) Or run `npx echo-start my-app` to choose interactively. +**Note:** The CLI template (`echo-cli`) requires manual installation from the repository as it's a command-line tool rather than a web application. See the [templates README](./templates/README.md) for details. + # Development Fill out `packages/app/control/.env` and `packages/app/server/.env`. Then... diff --git a/packages/app/control/docs/getting-started/templates.mdx b/packages/app/control/docs/getting-started/templates.mdx index ac0be7640..34e7fcb60 100644 --- a/packages/app/control/docs/getting-started/templates.mdx +++ b/packages/app/control/docs/getting-started/templates.mdx @@ -416,4 +416,84 @@ Next.js application demonstrating Echo as an Auth.js provider for authentication - \ No newline at end of file + + +## Echo CLI (Echodex) + +A command-line interface for AI chat powered by Echo with support for both API key and crypto wallet payments via the X402 protocol. + +[View on GitHub](https://github.com/Merit-Systems/echo/tree/master/templates/echo-cli) + + + **Note:** Unlike web-based templates, the CLI tool requires manual installation from the repository as it's not available through `echo-start`. + + +### Features + +- **Dual Authentication**: Echo API keys or WalletConnect for flexible payment options +- **Multi-Model Support**: GPT-4o, GPT-5, GPT-5 Mini, GPT-5 Nano +- **X402 Protocol**: Pay-per-use with crypto wallets +- **Conversation Management**: Resume and export chat history +- **Secure Storage**: OS keychain integration +- **Real-time Usage Tracking**: View balance and costs + + + + ### Create an Echo App + Go to [echo.merit.systems/new](https://echo.merit.systems/new) to get an `app_id`. + + + + ### Clone and Install + The CLI template is available in the Echo repository: + + ```sh lineNumbers + git clone https://github.com/Merit-Systems/echo.git + cd echo/templates/echo-cli + pnpm install + pnpm build + ``` + + + + ### Authenticate + Start by logging in to Echo: + + ```sh lineNumbers + echodex login + ``` + + Choose between: + - **Echo API Key**: Opens your browser to create an API key + - **WalletConnect**: Displays a QR code for mobile wallet authentication + + + + ### Start Chatting + Once authenticated, start a chat session: + + ```sh lineNumbers + echodex + ``` + + Or use other commands: + ```sh lineNumbers + echodex model # Select AI model + echodex resume # Resume last conversation + echodex history # View conversation history + echodex export # Export as JSON + echodex profile # View profile and balance + ``` + + + +### Global Installation (Optional) + +To use `echodex` globally on your system: + +```sh lineNumbers +cd echo/templates/echo-cli +pnpm link --global +``` + +For more details, see the [CLI template README](https://github.com/Merit-Systems/echo/tree/master/templates/echo-cli#readme). \ No newline at end of file diff --git a/packages/app/control/docs/index.mdx b/packages/app/control/docs/index.mdx index 8290b9240..6ff8b50c9 100644 --- a/packages/app/control/docs/index.mdx +++ b/packages/app/control/docs/index.mdx @@ -129,6 +129,7 @@ We provide a variety of ready-to-use templates to help you get started quickly w - **Auth.js (NextAuth)** — Next.js app with Echo as an Auth.js provider - **React Chat** — Chat interface for React apps - **React Image** — Image generation for React apps +- **CLI** — Command-line tool for AI chat with Echo (API keys + crypto wallets) All templates are available through `echo-start` or in the [GitHub repository](https://github.com/Merit-Systems/echo/tree/master/templates). Visit our [templates documentation](/docs/getting-started/templates) for detailed setup instructions. diff --git a/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/setup/connection/index.tsx b/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/setup/connection/index.tsx index 2521285af..b0abcca73 100644 --- a/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/setup/connection/index.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/setup/connection/index.tsx @@ -50,7 +50,12 @@ export const Connection: React.FC = ({ appId }) => { refetchInterval: shouldRefetch ? 2500 : undefined, } ); - const [numApiKeys] = api.user.apiKeys.count.useSuspenseQuery({ appId }); + const [numApiKeys] = api.user.apiKeys.count.useSuspenseQuery( + { appId }, + { + refetchInterval: shouldRefetch ? 2500 : undefined, + } + ); const isConnected = useMemo(() => { return numTokens > 0 || numApiKeys > 0; diff --git a/packages/app/control/src/app/(app)/app/[id]/_hooks/use-app-setup.ts b/packages/app/control/src/app/(app)/app/[id]/_hooks/use-app-setup.ts index 2665159bf..b5ffd9e7a 100644 --- a/packages/app/control/src/app/(app)/app/[id]/_hooks/use-app-setup.ts +++ b/packages/app/control/src/app/(app)/app/[id]/_hooks/use-app-setup.ts @@ -25,18 +25,10 @@ export const useAppConnectionSetup = (appId: string) => { refetchInterval: shouldRefetchConnection ? 2500 : undefined, } ); - const [transactionsCount] = api.apps.app.transactions.count.useSuspenseQuery( - { - appId, - }, - { - refetchInterval: shouldRefetchTransactions ? 2500 : undefined, - } - ); const isConnected = useMemo(() => { - return numTokens > 0 || numApiKeys > 0 || transactionsCount > 0; - }, [numTokens, numApiKeys, transactionsCount]); + return numTokens > 0 || numApiKeys > 0; + }, [numTokens, numApiKeys]); useEffect(() => { setShouldRefetchConnection(!isConnected); diff --git a/packages/app/control/src/app/(app)/app/[id]/transactions/_components/transactions.tsx b/packages/app/control/src/app/(app)/app/[id]/transactions/_components/transactions.tsx index 3d6c0a5bf..976abde38 100644 --- a/packages/app/control/src/app/(app)/app/[id]/transactions/_components/transactions.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/transactions/_components/transactions.tsx @@ -26,6 +26,7 @@ interface Transaction { user?: { id: string | null; name: string | null; + email: string | null; image: string | null; }; date: Date; @@ -111,7 +112,10 @@ const TransactionRow = ({ transaction }: { transaction: Transaction }) => {

- {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"] +}