From 2b752df5c081f4cad54d66164844ba29a91e095c Mon Sep 17 00:00:00 2001 From: katspaugh Date: Mon, 1 Dec 2025 02:47:08 +0100 Subject: [PATCH 1/6] feat: add AI-powered command suggestions for unrecognized commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CLI command is not recognized, the CLI now asks an AI tool to suggest the correct command. Features: - Cascading fallback through AI tools: ollama → claude → copilot - Address masking: 0x addresses are replaced with placeholders (0xAAAA, 0xBBBB, etc.) before sending to AI and restored in the response - Auto-detection of ollama models (prefers 2-5GB models) - Dynamic extraction of available commands from Commander.js AI tool configurations: - ollama: auto-detects available model, uses --quiet flag - claude: uses haiku model with --print flag - copilot: uses claude-haiku-4.5 model with --prompt flag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- AGENTS.md | 276 +---------------- CLAUDE.md | 275 +++++++++++++++++ src/cli.ts | 74 +++++ src/services/ai-suggestion-service.ts | 283 ++++++++++++++++++ .../services/ai-suggestion-service.test.ts | 153 ++++++++++ src/ui/screens/AISuggestionScreen.tsx | 41 +++ src/ui/screens/index.ts | 3 + 7 files changed, 830 insertions(+), 275 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/services/ai-suggestion-service.ts create mode 100644 src/tests/unit/services/ai-suggestion-service.test.ts create mode 100644 src/ui/screens/AISuggestionScreen.tsx diff --git a/AGENTS.md b/AGENTS.md index 0b95721..a43d362 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,275 +1 @@ -# Safe CLI Development Guide - -## 🚨 CRITICAL SAFETY WARNING 🚨 - -**NEVER run tests without isolated storage!** Integration tests were previously written in a dangerous way that could **DELETE YOUR ACTUAL WALLET DATA AND SAFE CONFIGURATIONS**. - -### Mandatory Safety Rules: - -1. **ALL integration tests MUST use `createTestStorage()`** from `src/tests/helpers/test-storage.ts` -2. **NEVER instantiate storage classes without the `cwd` option** in test mode -3. **ALWAYS verify tests are using `/tmp` directories** before running -4. **Backup your config before running tests** if unsure - -The storage classes now have built-in safety checks that will throw an error if you try to use non-temp directories in test mode. - -### Safe Test Pattern (REQUIRED): - -```typescript -import { createTestStorage } from '../helpers/test-storage.js' -import { WalletStorageService } from '../../storage/wallet-store.js' - -describe('My Test', () => { - let testStorage: ReturnType - let walletStorage: WalletStorageService - - beforeEach(() => { - // REQUIRED: Create isolated test storage - testStorage = createTestStorage('my-test') - walletStorage = new WalletStorageService({ cwd: testStorage.configDir }) - }) - - afterEach(() => { - // REQUIRED: Cleanup test directories - testStorage.cleanup() - }) -}) -``` - -### Dangerous Pattern (FORBIDDEN): - -```typescript -// ❌ NEVER DO THIS IN TESTS - touches real user config! -const walletStorage = new WalletStorageService() -walletStorage.getAllWallets().forEach(w => walletStorage.removeWallet(w.id)) // DELETES REAL DATA! -``` - -## Pre-Commit Checklist - -Run the following commands before committing: - -```bash -npm run lint # Check code style and potential issues -npm run format # Format code with Prettier -npm run typecheck # Run TypeScript type checking -npm run test # Run unit and integration tests -``` - -If any errors pop up, fix them before committing. - -## Development Workflow - -### Testing - -#### Unit Tests -Unit tests are located in `src/tests/unit/` and cover: -- Services (`src/services/*`) -- Utilities (`src/utils/*`) -- Storage (`src/storage/*`) - -Run unit tests: -```bash -npm test # Run all tests (excluding integration/e2e) -npm test -- --watch # Run tests in watch mode -npm test -- --ui # Run tests with Vitest UI -``` - -#### Integration Tests -Integration tests are in `src/tests/integration/` and test: -- Full workflows (wallet import, Safe creation, transaction lifecycle) -- Service integration -- Storage integration -- Transaction building and parsing - -Run integration tests explicitly (they require blockchain access): -```bash -npm test src/tests/integration/integration-*.test.ts -``` - -#### E2E Tests -E2E tests verify the CLI commands work correctly: -- `e2e-cli.test.ts` - Basic CLI functionality -- `e2e-wallet-commands.test.ts` - Wallet operations -- `e2e-config-commands.test.ts` - Configuration management -- `e2e-account-commands.test.ts` - Account operations -- `e2e-tx-commands.test.ts` - Transaction commands -- `integration-full-workflow.test.ts` - Complete end-to-end workflow - -Run E2E tests: -```bash -# Build the CLI first -npm run build - -# Run E2E tests (requires TEST_WALLET_PK environment variable) -TEST_WALLET_PK=0x... npm test src/tests/integration/e2e-*.test.ts -``` - -#### Coverage -Check test coverage: -```bash -npm test -- --coverage # Generate coverage report -``` - -Coverage thresholds are configured in `vitest.config.ts`: -- Lines: 30% -- Functions: 69% -- Branches: 85% -- Statements: 30% - -### Project Structure - -``` -src/ -├── commands/ # CLI command implementations (0% coverage - tested via E2E) -│ ├── account/ # Safe account management -│ ├── config/ # Configuration management -│ ├── tx/ # Transaction operations -│ └── wallet/ # Wallet management -├── services/ # Business logic (87% coverage) -│ ├── abi-service.ts -│ ├── api-service.ts -│ ├── contract-service.ts -│ ├── ledger-service.ts -│ ├── safe-service.ts -│ ├── transaction-builder.ts -│ ├── transaction-service.ts -│ └── validation-service.ts -├── storage/ # Data persistence (81% coverage) -│ ├── config-store.ts -│ ├── safe-store.ts -│ ├── transaction-store.ts -│ └── wallet-store.ts -├── ui/ # CLI interface (0% coverage - interactive components) -│ ├── components/ -│ ├── hooks/ -│ └── screens/ -├── utils/ # Utilities (96% coverage) -│ ├── balance.ts -│ ├── eip3770.ts -│ ├── errors.ts -│ ├── ethereum.ts -│ └── validation.ts -└── tests/ - ├── fixtures/ # Test data and mocks - ├── helpers/ # Test utilities - ├── integration/ # Integration and E2E tests - └── unit/ # Unit tests -``` - -### Configuration and Storage - -If in the course of development or testing you need to clear or modify the local configs, back up the existing ones first, and restore them when finished. - -Configuration is stored in: -- Config: `~/.config/@safe-global/safe-cli/config.json` -- Data: `~/.local/share/@safe-global/safe-cli/` - -For testing with isolated directories, use `XDG_CONFIG_HOME` and `XDG_DATA_HOME`: -```bash -XDG_CONFIG_HOME=/tmp/test-config XDG_DATA_HOME=/tmp/test-data npm run dev -``` - -### Adding New Features - -1. **Create the service/utility** - Write the core logic with tests -2. **Add storage layer** (if needed) - Implement data persistence -3. **Create command** - Implement the CLI command in `src/commands/` -4. **Add E2E test** - Verify the command works end-to-end -5. **Update documentation** - Add to README if user-facing - -### Debugging - -Run CLI in development mode: -```bash -npm run dev -- # Run with tsx (fast reload) -DEBUG=* npm run dev -- # Run with debug logging -``` - -Build and run production version: -```bash -npm run build -node dist/index.js -``` - -### Code Style - -- TypeScript strict mode enabled -- ESLint for linting -- Prettier for formatting -- Husky for pre-commit hooks -- lint-staged for staged file checking - -### Common Patterns - -#### Error Handling -Use custom error classes from `src/utils/errors.ts`: -```typescript -import { ValidationError, SafeError } from '../utils/errors.js' - -throw new ValidationError('Invalid address format') -throw new SafeError('Failed to create Safe') -``` - -#### Address Validation -Support both plain and EIP-3770 addresses: -```typescript -import { parseEIP3770Address } from '../utils/eip3770.js' -import { validateAddress } from '../utils/validation.js' - -const { chainId, address } = parseEIP3770Address('sep:0x...') -validateAddress(address) // throws if invalid -``` - -#### Storage -All storage services follow the same pattern: -```typescript -import { ConfigStore } from '../storage/config-store.js' - -const store = new ConfigStore() -store.set('key', value) -const value = store.get('key') -``` - -### Testing Best Practices - -1. **Isolate test data** - Use temporary directories for test configs/data -2. **Mock external dependencies** - Mock API calls and blockchain interactions -3. **Test error cases** - Verify error handling and edge cases -4. **Use factories** - Use test helpers from `src/tests/helpers/factories.ts` -5. **Clean up after tests** - Remove temporary files/directories in `afterEach` - -### Environment Variables - -- `TEST_WALLET_PK` - Private key for E2E tests (Sepolia testnet) -- `XDG_CONFIG_HOME` - Custom config directory -- `XDG_DATA_HOME` - Custom data directory -- `NODE_ENV` - Set to 'test' during testing -- `CI` - Set to 'true' for non-interactive mode - -### Blockchain Testing - -E2E tests that interact with blockchain require: -- A funded Sepolia test wallet -- `TEST_WALLET_PK` environment variable set -- Network access to Sepolia RPC and Safe API - -Get Sepolia ETH: -- [Sepolia Faucet](https://sepoliafaucet.com/) -- [Alchemy Sepolia Faucet](https://sepoliafaucet.com/) - -### Troubleshooting - -**Tests timing out:** -- Increase timeout in test: `{ timeout: 60000 }` -- Check network connectivity -- Verify RPC endpoints are accessible - -**Interactive prompts in tests:** -- Use `CLITestHelper.execWithInput()` for tests with prompts -- Set `CI=true` environment variable for non-interactive mode -- Consider adding `--yes` flags to commands - -**Storage conflicts:** -- Use isolated directories with `XDG_CONFIG_HOME` and `XDG_DATA_HOME` -- Clean up in `afterEach` hooks -- Use `mkdtempSync()` for temporary directories +Read @CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0b95721 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,275 @@ +# Safe CLI Development Guide + +## 🚨 CRITICAL SAFETY WARNING 🚨 + +**NEVER run tests without isolated storage!** Integration tests were previously written in a dangerous way that could **DELETE YOUR ACTUAL WALLET DATA AND SAFE CONFIGURATIONS**. + +### Mandatory Safety Rules: + +1. **ALL integration tests MUST use `createTestStorage()`** from `src/tests/helpers/test-storage.ts` +2. **NEVER instantiate storage classes without the `cwd` option** in test mode +3. **ALWAYS verify tests are using `/tmp` directories** before running +4. **Backup your config before running tests** if unsure + +The storage classes now have built-in safety checks that will throw an error if you try to use non-temp directories in test mode. + +### Safe Test Pattern (REQUIRED): + +```typescript +import { createTestStorage } from '../helpers/test-storage.js' +import { WalletStorageService } from '../../storage/wallet-store.js' + +describe('My Test', () => { + let testStorage: ReturnType + let walletStorage: WalletStorageService + + beforeEach(() => { + // REQUIRED: Create isolated test storage + testStorage = createTestStorage('my-test') + walletStorage = new WalletStorageService({ cwd: testStorage.configDir }) + }) + + afterEach(() => { + // REQUIRED: Cleanup test directories + testStorage.cleanup() + }) +}) +``` + +### Dangerous Pattern (FORBIDDEN): + +```typescript +// ❌ NEVER DO THIS IN TESTS - touches real user config! +const walletStorage = new WalletStorageService() +walletStorage.getAllWallets().forEach(w => walletStorage.removeWallet(w.id)) // DELETES REAL DATA! +``` + +## Pre-Commit Checklist + +Run the following commands before committing: + +```bash +npm run lint # Check code style and potential issues +npm run format # Format code with Prettier +npm run typecheck # Run TypeScript type checking +npm run test # Run unit and integration tests +``` + +If any errors pop up, fix them before committing. + +## Development Workflow + +### Testing + +#### Unit Tests +Unit tests are located in `src/tests/unit/` and cover: +- Services (`src/services/*`) +- Utilities (`src/utils/*`) +- Storage (`src/storage/*`) + +Run unit tests: +```bash +npm test # Run all tests (excluding integration/e2e) +npm test -- --watch # Run tests in watch mode +npm test -- --ui # Run tests with Vitest UI +``` + +#### Integration Tests +Integration tests are in `src/tests/integration/` and test: +- Full workflows (wallet import, Safe creation, transaction lifecycle) +- Service integration +- Storage integration +- Transaction building and parsing + +Run integration tests explicitly (they require blockchain access): +```bash +npm test src/tests/integration/integration-*.test.ts +``` + +#### E2E Tests +E2E tests verify the CLI commands work correctly: +- `e2e-cli.test.ts` - Basic CLI functionality +- `e2e-wallet-commands.test.ts` - Wallet operations +- `e2e-config-commands.test.ts` - Configuration management +- `e2e-account-commands.test.ts` - Account operations +- `e2e-tx-commands.test.ts` - Transaction commands +- `integration-full-workflow.test.ts` - Complete end-to-end workflow + +Run E2E tests: +```bash +# Build the CLI first +npm run build + +# Run E2E tests (requires TEST_WALLET_PK environment variable) +TEST_WALLET_PK=0x... npm test src/tests/integration/e2e-*.test.ts +``` + +#### Coverage +Check test coverage: +```bash +npm test -- --coverage # Generate coverage report +``` + +Coverage thresholds are configured in `vitest.config.ts`: +- Lines: 30% +- Functions: 69% +- Branches: 85% +- Statements: 30% + +### Project Structure + +``` +src/ +├── commands/ # CLI command implementations (0% coverage - tested via E2E) +│ ├── account/ # Safe account management +│ ├── config/ # Configuration management +│ ├── tx/ # Transaction operations +│ └── wallet/ # Wallet management +├── services/ # Business logic (87% coverage) +│ ├── abi-service.ts +│ ├── api-service.ts +│ ├── contract-service.ts +│ ├── ledger-service.ts +│ ├── safe-service.ts +│ ├── transaction-builder.ts +│ ├── transaction-service.ts +│ └── validation-service.ts +├── storage/ # Data persistence (81% coverage) +│ ├── config-store.ts +│ ├── safe-store.ts +│ ├── transaction-store.ts +│ └── wallet-store.ts +├── ui/ # CLI interface (0% coverage - interactive components) +│ ├── components/ +│ ├── hooks/ +│ └── screens/ +├── utils/ # Utilities (96% coverage) +│ ├── balance.ts +│ ├── eip3770.ts +│ ├── errors.ts +│ ├── ethereum.ts +│ └── validation.ts +└── tests/ + ├── fixtures/ # Test data and mocks + ├── helpers/ # Test utilities + ├── integration/ # Integration and E2E tests + └── unit/ # Unit tests +``` + +### Configuration and Storage + +If in the course of development or testing you need to clear or modify the local configs, back up the existing ones first, and restore them when finished. + +Configuration is stored in: +- Config: `~/.config/@safe-global/safe-cli/config.json` +- Data: `~/.local/share/@safe-global/safe-cli/` + +For testing with isolated directories, use `XDG_CONFIG_HOME` and `XDG_DATA_HOME`: +```bash +XDG_CONFIG_HOME=/tmp/test-config XDG_DATA_HOME=/tmp/test-data npm run dev +``` + +### Adding New Features + +1. **Create the service/utility** - Write the core logic with tests +2. **Add storage layer** (if needed) - Implement data persistence +3. **Create command** - Implement the CLI command in `src/commands/` +4. **Add E2E test** - Verify the command works end-to-end +5. **Update documentation** - Add to README if user-facing + +### Debugging + +Run CLI in development mode: +```bash +npm run dev -- # Run with tsx (fast reload) +DEBUG=* npm run dev -- # Run with debug logging +``` + +Build and run production version: +```bash +npm run build +node dist/index.js +``` + +### Code Style + +- TypeScript strict mode enabled +- ESLint for linting +- Prettier for formatting +- Husky for pre-commit hooks +- lint-staged for staged file checking + +### Common Patterns + +#### Error Handling +Use custom error classes from `src/utils/errors.ts`: +```typescript +import { ValidationError, SafeError } from '../utils/errors.js' + +throw new ValidationError('Invalid address format') +throw new SafeError('Failed to create Safe') +``` + +#### Address Validation +Support both plain and EIP-3770 addresses: +```typescript +import { parseEIP3770Address } from '../utils/eip3770.js' +import { validateAddress } from '../utils/validation.js' + +const { chainId, address } = parseEIP3770Address('sep:0x...') +validateAddress(address) // throws if invalid +``` + +#### Storage +All storage services follow the same pattern: +```typescript +import { ConfigStore } from '../storage/config-store.js' + +const store = new ConfigStore() +store.set('key', value) +const value = store.get('key') +``` + +### Testing Best Practices + +1. **Isolate test data** - Use temporary directories for test configs/data +2. **Mock external dependencies** - Mock API calls and blockchain interactions +3. **Test error cases** - Verify error handling and edge cases +4. **Use factories** - Use test helpers from `src/tests/helpers/factories.ts` +5. **Clean up after tests** - Remove temporary files/directories in `afterEach` + +### Environment Variables + +- `TEST_WALLET_PK` - Private key for E2E tests (Sepolia testnet) +- `XDG_CONFIG_HOME` - Custom config directory +- `XDG_DATA_HOME` - Custom data directory +- `NODE_ENV` - Set to 'test' during testing +- `CI` - Set to 'true' for non-interactive mode + +### Blockchain Testing + +E2E tests that interact with blockchain require: +- A funded Sepolia test wallet +- `TEST_WALLET_PK` environment variable set +- Network access to Sepolia RPC and Safe API + +Get Sepolia ETH: +- [Sepolia Faucet](https://sepoliafaucet.com/) +- [Alchemy Sepolia Faucet](https://sepoliafaucet.com/) + +### Troubleshooting + +**Tests timing out:** +- Increase timeout in test: `{ timeout: 60000 }` +- Check network connectivity +- Verify RPC endpoints are accessible + +**Interactive prompts in tests:** +- Use `CLITestHelper.execWithInput()` for tests with prompts +- Set `CI=true` environment variable for non-interactive mode +- Consider adding `--yes` flags to commands + +**Storage conflicts:** +- Use isolated directories with `XDG_CONFIG_HOME` and `XDG_DATA_HOME` +- Clean up in `afterEach` hooks +- Use `mkdtempSync()` for temporary directories diff --git a/src/cli.ts b/src/cli.ts index f2f02e0..3b207b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,9 @@ import { pullTransactions } from './commands/tx/pull.js' import { syncTransactions } from './commands/tx/sync.js' import { handleError } from './utils/errors.js' import { setGlobalOptions, type GlobalOptions } from './types/global-options.js' +import { getAISuggestionService } from './services/ai-suggestion-service.js' +import { renderScreen } from './ui/render.js' +import { AISuggestionScreen } from './ui/screens/index.js' const program = new Command() @@ -53,8 +56,75 @@ program }) }) +/** + * Recursively extracts all commands from a Commander program/command. + * Returns commands in format like "config init", "wallet list", etc. + */ +function getAvailableCommands(cmd: Command, prefix: string = ''): string[] { + const commands: string[] = [] + + for (const subCmd of cmd.commands) { + const cmdName = subCmd.name() + const fullName = prefix ? `${prefix} ${cmdName}` : cmdName + const usage = subCmd.usage() + + // Add the command with its usage (arguments) + if (usage) { + commands.push(`${fullName} ${usage}`) + } else { + commands.push(fullName) + } + + // Recursively get subcommands + commands.push(...getAvailableCommands(subCmd, fullName)) + } + + return commands +} + +/** + * Adds an unknown command handler with AI suggestions to a command. + * @param cmd The command to add the handler to + * @param cmdPath The path to this command (e.g., "tx" or "config chains") + */ +function addUnknownCommandHandler(cmd: Command, cmdPath: string = ''): void { + cmd.on('command:*', async (operands: string[]) => { + const unknownCommand = operands[0] + const args = operands.slice(1) + const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand + + // Show error immediately (use write for instant flush) + process.stderr.write(`error: unknown command '${fullUnknown}'\n\n`) + process.stderr.write('Asking AI for suggestions...\n\n') + + // Try to get AI suggestion + const aiService = getAISuggestionService() + let suggestion: string | null = null + + try { + const availableCommands = getAvailableCommands(program) + suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands) + } catch { + // AI suggestion failed, will show null + } + + // Render the suggestion + if (suggestion) { + await renderScreen(AISuggestionScreen, { suggestion }) + } else { + console.error(`No AI tools available. Run 'safe --help' to see available commands.`) + } + + process.exit(1) + }) +} + +// Handle unknown commands at root level +addUnknownCommandHandler(program) + // Config commands const config = program.command('config').description('Manage CLI configuration') +addUnknownCommandHandler(config, 'config') config .command('init') @@ -80,6 +150,7 @@ config // Config chains commands const chains = config.command('chains').description('Manage chain configurations') +addUnknownCommandHandler(chains, 'config chains') chains .command('list') @@ -127,6 +198,7 @@ chains // Wallet commands const wallet = program.command('wallet').description('Manage wallets and signers') +addUnknownCommandHandler(wallet, 'wallet') wallet .command('import') @@ -207,6 +279,7 @@ wallet // Account commands const account = program.command('account').description('Manage Safe accounts') +addUnknownCommandHandler(account, 'account') account .command('create') @@ -316,6 +389,7 @@ account // Transaction commands const tx = program.command('tx').description('Manage Safe transactions') +addUnknownCommandHandler(tx, 'tx') tx.command('create') .description('Create a new transaction') diff --git a/src/services/ai-suggestion-service.ts b/src/services/ai-suggestion-service.ts new file mode 100644 index 0000000..3c95a6d --- /dev/null +++ b/src/services/ai-suggestion-service.ts @@ -0,0 +1,283 @@ +import { spawn } from 'child_process' + +interface AITool { + name: string + command: string + args: (prompt: string) => string[] + available?: boolean +} + +interface AddressMapping { + placeholder: string + original: string +} + +/** + * Service for getting AI-powered command suggestions when a command is not recognized. + * Uses cascading fallback through multiple AI CLI tools. + */ +export class AISuggestionService { + private aiTools: AITool[] = [ + { + name: 'claude', + command: 'claude', + args: (prompt: string) => ['--print', '--model', 'haiku', prompt], + }, + { + name: 'copilot', + command: 'copilot', + args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'], + }, + { + name: 'ollama', + command: 'ollama', + args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt], + }, + ] + + private ollamaModel: string | null = null + + /** + * Detects the best available ollama model (prefers 3B-8B models for speed/quality balance). + */ + private async detectOllamaModel(): Promise { + if (this.ollamaModel !== null) { + return this.ollamaModel || null + } + + try { + const output = await this.runCommand('ollama', ['list'], 5000) + const lines = output.split('\n').slice(1) // Skip header + + // Parse model names and sizes, prefer smaller capable models + const models: { name: string; sizeGB: number }[] = [] + for (const line of lines) { + const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/) + if (match) { + models.push({ name: match[1], sizeGB: parseFloat(match[2]) }) + } + } + + if (models.length === 0) { + this.ollamaModel = '' + return null + } + + // Sort by size (prefer 2-5GB models, then larger ones) + models.sort((a, b) => { + const aScore = a.sizeGB >= 2 && a.sizeGB <= 5 ? 0 : a.sizeGB < 2 ? 1 : 2 + const bScore = b.sizeGB >= 2 && b.sizeGB <= 5 ? 0 : b.sizeGB < 2 ? 1 : 2 + if (aScore !== bScore) return aScore - bScore + return a.sizeGB - b.sizeGB + }) + + this.ollamaModel = models[0].name + return this.ollamaModel + } catch { + this.ollamaModel = '' + return null + } + } + + private addressMappings: AddressMapping[] = [] + private placeholderIndex = 0 + + /** + * Strips ANSI escape codes from a string. + */ + private stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') + } + + /** + * Masks all 0x addresses in the input with placeholders like 0xAAAA, 0xBBBB, etc. + */ + maskAddresses(input: string): string { + this.addressMappings = [] + this.placeholderIndex = 0 + + // Match Ethereum addresses (0x followed by 40 hex characters) or shorter hex strings starting with 0x + const addressRegex = /0x[a-fA-F0-9]+/g + + return input.replace(addressRegex, (match) => { + // Check if we already have a mapping for this address + const existing = this.addressMappings.find((m) => m.original === match) + if (existing) { + return existing.placeholder + } + + const placeholder = this.generatePlaceholder() + this.addressMappings.push({ placeholder, original: match }) + return placeholder + }) + } + + /** + * Unmasks placeholders back to original addresses in the AI response. + */ + unmaskAddresses(response: string): string { + let result = response + for (const mapping of this.addressMappings) { + // Replace all occurrences of the placeholder (case-insensitive) + const regex = new RegExp(mapping.placeholder, 'gi') + result = result.replace(regex, mapping.original) + } + return result + } + + /** + * Generates a placeholder like 0xAAAA, 0xBBBB, 0xCCCC, etc. + */ + private generatePlaceholder(): string { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + const letter = letters[this.placeholderIndex % letters.length] + const repeat = Math.floor(this.placeholderIndex / letters.length) + 1 + this.placeholderIndex++ + return `0x${letter.repeat(4 * repeat)}` + } + + /** + * Checks if a command exists on the system. + */ + private async commandExists(command: string): Promise { + return new Promise((resolve) => { + const check = spawn('which', [command], { + stdio: ['pipe', 'pipe', 'pipe'], + }) + check.on('close', (code) => { + resolve(code === 0) + }) + check.on('error', () => { + resolve(false) + }) + }) + } + + /** + * Runs a command and returns its output. + */ + private runCommand(command: string, args: string[], timeoutMs: number = 30000): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }) + + let stdout = '' + let stderr = '' + + proc.stdout?.on('data', (data) => { + stdout += data.toString() + }) + + proc.stderr?.on('data', (data) => { + stderr += data.toString() + }) + + const timeout = setTimeout(() => { + proc.kill('SIGTERM') + reject(new Error('Command timed out')) + }, timeoutMs) + + proc.on('close', (code) => { + clearTimeout(timeout) + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()) + } else { + reject(new Error(stderr || `Command exited with code ${code}`)) + } + }) + + proc.on('error', (err) => { + clearTimeout(timeout) + reject(err) + }) + }) + } + + /** + * Gets a command suggestion from AI tools using cascading fallback. + * @param invalidCommand The command that was not recognized + * @param args The arguments that were passed + * @param availableCommands List of available commands to help the AI + * @returns AI suggestion or null if all tools fail + */ + async getSuggestion( + invalidCommand: string, + args: string[], + availableCommands: string[] + ): Promise { + const fullCommand = [invalidCommand, ...args].join(' ') + const maskedCommand = this.maskAddresses(fullCommand) + + const prompt = `CLI "safe" command not recognized: safe ${maskedCommand} + +Available: ${availableCommands.join(', ')} + +Reply in this exact format (plain text, no markdown/backticks): +Did you mean: + safe + + +Keep it to 2-3 lines total.` + + for (const tool of this.aiTools) { + try { + const exists = await this.commandExists(tool.command) + if (!exists) { + continue + } + + let toolArgs = tool.args(prompt) + + // Handle ollama model auto-detection + if (tool.name === 'ollama' && toolArgs.includes('__AUTO_DETECT__')) { + const model = await this.detectOllamaModel() + if (!model) { + continue + } + toolArgs = toolArgs.map((arg) => (arg === '__AUTO_DETECT__' ? model : arg)) + } + + // Use 30s timeout (cloud APIs may have cold start latency) + let response = await this.runCommand(tool.command, toolArgs, 30000) + + if (response) { + // Strip ANSI escape codes (e.g., from ollama's spinner) + response = this.stripAnsi(response).trim() + const unmaskedResponse = this.unmaskAddresses(response) + return unmaskedResponse + } + } catch { + // Tool failed, try next one + continue + } + } + + return null + } + + /** + * Gets available AI tools on the system. + */ + async getAvailableTools(): Promise { + const available: string[] = [] + for (const tool of this.aiTools) { + if (await this.commandExists(tool.command)) { + available.push(tool.name) + } + } + return available + } +} + +// Singleton instance +let instance: AISuggestionService | null = null + +export function getAISuggestionService(): AISuggestionService { + if (!instance) { + instance = new AISuggestionService() + } + return instance +} diff --git a/src/tests/unit/services/ai-suggestion-service.test.ts b/src/tests/unit/services/ai-suggestion-service.test.ts new file mode 100644 index 0000000..fc83dd1 --- /dev/null +++ b/src/tests/unit/services/ai-suggestion-service.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { AISuggestionService } from '../../../services/ai-suggestion-service.js' + +describe('AISuggestionService', () => { + let service: AISuggestionService + + beforeEach(() => { + service = new AISuggestionService() + }) + + describe('maskAddresses', () => { + it('should mask a single Ethereum address', () => { + const input = 'send 0x1234567890abcdef1234567890abcdef12345678 100' + const result = service.maskAddresses(input) + expect(result).toBe('send 0xAAAA 100') + }) + + it('should mask multiple different addresses with unique placeholders', () => { + const input = + 'transfer 0x1111111111111111111111111111111111111111 to 0x2222222222222222222222222222222222222222' + const result = service.maskAddresses(input) + expect(result).toBe('transfer 0xAAAA to 0xBBBB') + }) + + it('should use the same placeholder for repeated addresses', () => { + const input = + 'from 0x1234567890abcdef1234567890abcdef12345678 to 0x9999999999999999999999999999999999999999 via 0x1234567890abcdef1234567890abcdef12345678' + const result = service.maskAddresses(input) + expect(result).toBe('from 0xAAAA to 0xBBBB via 0xAAAA') + }) + + it('should handle short hex values starting with 0x', () => { + const input = 'value 0x123 and 0xabc' + const result = service.maskAddresses(input) + expect(result).toBe('value 0xAAAA and 0xBBBB') + }) + + it('should return input unchanged if no addresses', () => { + const input = 'wallet list' + const result = service.maskAddresses(input) + expect(result).toBe('wallet list') + }) + + it('should handle empty string', () => { + const input = '' + const result = service.maskAddresses(input) + expect(result).toBe('') + }) + + it('should handle many addresses and cycle through placeholders', () => { + const addresses = Array.from( + { length: 5 }, + (_, i) => `0x${(i + 1).toString().repeat(40).slice(0, 40)}` + ) + const input = addresses.join(' ') + const result = service.maskAddresses(input) + expect(result).toBe('0xAAAA 0xBBBB 0xCCCC 0xDDDD 0xEEEE') + }) + }) + + describe('unmaskAddresses', () => { + it('should unmask a single address', () => { + const originalInput = 'send 0x1234567890abcdef1234567890abcdef12345678 100' + service.maskAddresses(originalInput) + + const aiResponse = 'You should use: safe tx create --to 0xAAAA --value 100' + const result = service.unmaskAddresses(aiResponse) + expect(result).toBe( + 'You should use: safe tx create --to 0x1234567890abcdef1234567890abcdef12345678 --value 100' + ) + }) + + it('should unmask multiple addresses', () => { + const originalInput = + 'transfer 0x1111111111111111111111111111111111111111 to 0x2222222222222222222222222222222222222222' + service.maskAddresses(originalInput) + + const aiResponse = 'Try: safe tx create from 0xAAAA to 0xBBBB' + const result = service.unmaskAddresses(aiResponse) + expect(result).toBe( + 'Try: safe tx create from 0x1111111111111111111111111111111111111111 to 0x2222222222222222222222222222222222222222' + ) + }) + + it('should handle case-insensitive placeholder matching', () => { + const originalInput = 'send 0x1234567890abcdef1234567890abcdef12345678 100' + service.maskAddresses(originalInput) + + const aiResponse = 'Use 0xaaaa or 0xAAAA' + const result = service.unmaskAddresses(aiResponse) + expect(result).toBe( + 'Use 0x1234567890abcdef1234567890abcdef12345678 or 0x1234567890abcdef1234567890abcdef12345678' + ) + }) + + it('should return response unchanged if no placeholders to unmask', () => { + service.maskAddresses('wallet list') // No addresses masked + + const aiResponse = 'Try: safe wallet list' + const result = service.unmaskAddresses(aiResponse) + expect(result).toBe('Try: safe wallet list') + }) + + it('should handle response with no matching placeholders', () => { + const originalInput = 'send 0x1234567890abcdef1234567890abcdef12345678 100' + service.maskAddresses(originalInput) + + const aiResponse = 'Invalid command, try safe --help' + const result = service.unmaskAddresses(aiResponse) + expect(result).toBe('Invalid command, try safe --help') + }) + }) + + describe('mask and unmask roundtrip', () => { + it('should correctly roundtrip a complex command', () => { + const originalInput = + 'account add-owner sep:0xAbCdEf1234567890AbCdEf1234567890AbCdEf12 0x9876543210fedcba9876543210fedcba98765432' + const masked = service.maskAddresses(originalInput) + + expect(masked).not.toContain('0xAbCdEf1234567890AbCdEf1234567890AbCdEf12') + expect(masked).not.toContain('0x9876543210fedcba9876543210fedcba98765432') + expect(masked).toContain('0xAAAA') + expect(masked).toContain('0xBBBB') + + const aiResponse = `It looks like you want to add an owner. Try: +safe account add-owner sep:0xAAAA 0xBBBB --threshold 2` + + const unmasked = service.unmaskAddresses(aiResponse) + expect(unmasked).toContain('0xAbCdEf1234567890AbCdEf1234567890AbCdEf12') + expect(unmasked).toContain('0x9876543210fedcba9876543210fedcba98765432') + }) + + it('should preserve non-address parts of the command', () => { + const originalInput = 'tx create --to 0x1234567890123456789012345678901234567890 --value 1000' + const masked = service.maskAddresses(originalInput) + + expect(masked).toBe('tx create --to 0xAAAA --value 1000') + + const aiResponse = 'Use: safe tx create --to 0xAAAA --value 1000 --data 0x' + const unmasked = service.unmaskAddresses(aiResponse) + + expect(unmasked).toContain('0x1234567890123456789012345678901234567890') + expect(unmasked).toContain('--value 1000') + }) + }) + + describe('getAvailableTools', () => { + it('should return an array', async () => { + const tools = await service.getAvailableTools() + expect(Array.isArray(tools)).toBe(true) + }) + }) +}) diff --git a/src/ui/screens/AISuggestionScreen.tsx b/src/ui/screens/AISuggestionScreen.tsx new file mode 100644 index 0000000..d1d6716 --- /dev/null +++ b/src/ui/screens/AISuggestionScreen.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react' +import { Box, Text } from 'ink' +import { theme } from '../theme.js' + +export interface AISuggestionScreenProps { + suggestion: string + onExit?: () => void +} + +/** + * AISuggestionScreen displays AI-powered command suggestions + * when an unrecognized command is entered. + */ +export function AISuggestionScreen({ + suggestion, + onExit, +}: AISuggestionScreenProps): React.ReactElement { + useEffect(() => { + if (onExit) { + onExit() + } + }, [onExit]) + + return ( + + {suggestion.split('\n').map((line, index) => { + // Check if this is the indented command line + if (line.startsWith(' safe ')) { + return ( + + + {line.trim()} + + ) + } + // Other lines (Did you mean, explanation, etc.) + return {line} + })} + + ) +} diff --git a/src/ui/screens/index.ts b/src/ui/screens/index.ts index ca89073..5a15faf 100644 --- a/src/ui/screens/index.ts +++ b/src/ui/screens/index.ts @@ -108,4 +108,7 @@ export type { TransactionImportBuilderSuccessScreenProps } from './TransactionIm export { TransactionImportSuccessScreen } from './TransactionImportSuccessScreen.js' export type { TransactionImportSuccessScreenProps } from './TransactionImportSuccessScreen.js' +export { AISuggestionScreen } from './AISuggestionScreen.js' +export type { AISuggestionScreenProps } from './AISuggestionScreen.js' + // TODO: Phase 4 - Add remaining screen components as commands are migrated From 7671be262935bcafc630032b40258585ddfc8519 Mon Sep 17 00:00:00 2001 From: katspaugh Date: Mon, 1 Dec 2025 03:30:12 +0100 Subject: [PATCH 2/6] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add outer try-catch wrapper around async command handler to prevent unhandled promise rejections - Add clarifying comment for ollamaModel caching logic (empty string means no models available) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli.ts | 44 +++++++++++++++------------ src/services/ai-suggestion-service.ts | 1 + 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3b207b1..2cf82c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -89,30 +89,34 @@ function getAvailableCommands(cmd: Command, prefix: string = ''): string[] { */ function addUnknownCommandHandler(cmd: Command, cmdPath: string = ''): void { cmd.on('command:*', async (operands: string[]) => { - const unknownCommand = operands[0] - const args = operands.slice(1) - const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand + try { + const unknownCommand = operands[0] + const args = operands.slice(1) + const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand - // Show error immediately (use write for instant flush) - process.stderr.write(`error: unknown command '${fullUnknown}'\n\n`) - process.stderr.write('Asking AI for suggestions...\n\n') + // Show error immediately (use write for instant flush) + process.stderr.write(`error: unknown command '${fullUnknown}'\n\n`) + process.stderr.write('Asking AI for suggestions...\n\n') - // Try to get AI suggestion - const aiService = getAISuggestionService() - let suggestion: string | null = null + // Try to get AI suggestion + const aiService = getAISuggestionService() + let suggestion: string | null = null - try { - const availableCommands = getAvailableCommands(program) - suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands) - } catch { - // AI suggestion failed, will show null - } + try { + const availableCommands = getAvailableCommands(program) + suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands) + } catch { + // AI suggestion failed, will show null + } - // Render the suggestion - if (suggestion) { - await renderScreen(AISuggestionScreen, { suggestion }) - } else { - console.error(`No AI tools available. Run 'safe --help' to see available commands.`) + // Render the suggestion + if (suggestion) { + await renderScreen(AISuggestionScreen, { suggestion }) + } else { + console.error(`No AI tools available. Run 'safe --help' to see available commands.`) + } + } catch (error) { + console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`) } process.exit(1) diff --git a/src/services/ai-suggestion-service.ts b/src/services/ai-suggestion-service.ts index 3c95a6d..3c89c64 100644 --- a/src/services/ai-suggestion-service.ts +++ b/src/services/ai-suggestion-service.ts @@ -41,6 +41,7 @@ export class AISuggestionService { * Detects the best available ollama model (prefers 3B-8B models for speed/quality balance). */ private async detectOllamaModel(): Promise { + // Return cached result (empty string means no models available) if (this.ollamaModel !== null) { return this.ollamaModel || null } From 72296ff19718b5f2b63d55c023c6bfa3b18b5fb6 Mon Sep 17 00:00:00 2001 From: katspaugh Date: Mon, 1 Dec 2025 08:54:29 +0100 Subject: [PATCH 3/6] fix: address additional PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix comment "3B-8B models" to "2-5GB models" to match implementation - Change address regex to only match full 40-char Ethereum addresses (was matching any hex string like 0x1, 0xabc which could be tx data) - Escape placeholder string in RegExp constructor for safety - Update test to reflect new address matching behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/services/ai-suggestion-service.ts | 11 ++++++----- src/tests/unit/services/ai-suggestion-service.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/services/ai-suggestion-service.ts b/src/services/ai-suggestion-service.ts index 3c89c64..738df84 100644 --- a/src/services/ai-suggestion-service.ts +++ b/src/services/ai-suggestion-service.ts @@ -38,7 +38,7 @@ export class AISuggestionService { private ollamaModel: string | null = null /** - * Detects the best available ollama model (prefers 3B-8B models for speed/quality balance). + * Detects the best available ollama model (prefers 2-5GB models for speed/quality balance). */ private async detectOllamaModel(): Promise { // Return cached result (empty string means no models available) @@ -98,8 +98,8 @@ export class AISuggestionService { this.addressMappings = [] this.placeholderIndex = 0 - // Match Ethereum addresses (0x followed by 40 hex characters) or shorter hex strings starting with 0x - const addressRegex = /0x[a-fA-F0-9]+/g + // Match Ethereum addresses (0x followed by exactly 40 hex characters) + const addressRegex = /0x[a-fA-F0-9]{40}/g return input.replace(addressRegex, (match) => { // Check if we already have a mapping for this address @@ -120,8 +120,9 @@ export class AISuggestionService { unmaskAddresses(response: string): string { let result = response for (const mapping of this.addressMappings) { - // Replace all occurrences of the placeholder (case-insensitive) - const regex = new RegExp(mapping.placeholder, 'gi') + // Replace all occurrences of the placeholder (case-insensitive, escaped for safety) + const escaped = mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(escaped, 'gi') result = result.replace(regex, mapping.original) } return result diff --git a/src/tests/unit/services/ai-suggestion-service.test.ts b/src/tests/unit/services/ai-suggestion-service.test.ts index fc83dd1..ddfab0c 100644 --- a/src/tests/unit/services/ai-suggestion-service.test.ts +++ b/src/tests/unit/services/ai-suggestion-service.test.ts @@ -29,10 +29,10 @@ describe('AISuggestionService', () => { expect(result).toBe('from 0xAAAA to 0xBBBB via 0xAAAA') }) - it('should handle short hex values starting with 0x', () => { + it('should not mask short hex values (only 40-char addresses)', () => { const input = 'value 0x123 and 0xabc' const result = service.maskAddresses(input) - expect(result).toBe('value 0xAAAA and 0xBBBB') + expect(result).toBe('value 0x123 and 0xabc') }) it('should return input unchanged if no addresses', () => { From f8a77eeefad80097a8c68a6d6ca01dc5de700fb2 Mon Sep 17 00:00:00 2001 From: katspaugh Date: Mon, 1 Dec 2025 08:55:35 +0100 Subject: [PATCH 4/6] refactor: use raw output flags instead of stripping ANSI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `-o raw=true` to claude CLI args for clean output - Remove stripAnsi method (no longer needed with --quiet and raw flags) - Keep NO_COLOR env var as additional fallback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/services/ai-suggestion-service.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/services/ai-suggestion-service.ts b/src/services/ai-suggestion-service.ts index 738df84..8303b32 100644 --- a/src/services/ai-suggestion-service.ts +++ b/src/services/ai-suggestion-service.ts @@ -21,7 +21,7 @@ export class AISuggestionService { { name: 'claude', command: 'claude', - args: (prompt: string) => ['--print', '--model', 'haiku', prompt], + args: (prompt: string) => ['--print', '--model', 'haiku', '-o', 'raw=true', prompt], }, { name: 'copilot', @@ -83,14 +83,6 @@ export class AISuggestionService { private addressMappings: AddressMapping[] = [] private placeholderIndex = 0 - /** - * Strips ANSI escape codes from a string. - */ - private stripAnsi(str: string): string { - // eslint-disable-next-line no-control-regex - return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') - } - /** * Masks all 0x addresses in the input with placeholders like 0xAAAA, 0xBBBB, etc. */ @@ -243,12 +235,10 @@ Keep it to 2-3 lines total.` } // Use 30s timeout (cloud APIs may have cold start latency) - let response = await this.runCommand(tool.command, toolArgs, 30000) + const response = await this.runCommand(tool.command, toolArgs, 30000) if (response) { - // Strip ANSI escape codes (e.g., from ollama's spinner) - response = this.stripAnsi(response).trim() - const unmaskedResponse = this.unmaskAddresses(response) + const unmaskedResponse = this.unmaskAddresses(response.trim()) return unmaskedResponse } } catch { From 357270dbb5b78ac03cb96fd7c35d91f47bd55456 Mon Sep 17 00:00:00 2001 From: katspaugh Date: Mon, 1 Dec 2025 08:56:45 +0100 Subject: [PATCH 5/6] refactor: prioritize ollama over cloud AI tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ollama to the top of the AI tools fallback list since it: - Works offline without API keys - Has faster response times when running locally - Avoids network latency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/services/ai-suggestion-service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/ai-suggestion-service.ts b/src/services/ai-suggestion-service.ts index 8303b32..ab35b84 100644 --- a/src/services/ai-suggestion-service.ts +++ b/src/services/ai-suggestion-service.ts @@ -18,6 +18,11 @@ interface AddressMapping { */ export class AISuggestionService { private aiTools: AITool[] = [ + { + name: 'ollama', + command: 'ollama', + args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt], + }, { name: 'claude', command: 'claude', @@ -28,11 +33,6 @@ export class AISuggestionService { command: 'copilot', args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'], }, - { - name: 'ollama', - command: 'ollama', - args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt], - }, ] private ollamaModel: string | null = null From 44226e01999ef1f5ba8085f65b8a15bdbce33518 Mon Sep 17 00:00:00 2001 From: katspaugh Date: Mon, 1 Dec 2025 09:07:45 +0100 Subject: [PATCH 6/6] fix: revert address regex to match all hex strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep masking any hex string starting with 0x (not just 40-char addresses) since we want to mask transaction hashes, calldata, and other hex values for privacy, not just Ethereum addresses. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/services/ai-suggestion-service.ts | 4 ++-- src/tests/unit/services/ai-suggestion-service.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/ai-suggestion-service.ts b/src/services/ai-suggestion-service.ts index ab35b84..273335c 100644 --- a/src/services/ai-suggestion-service.ts +++ b/src/services/ai-suggestion-service.ts @@ -90,8 +90,8 @@ export class AISuggestionService { this.addressMappings = [] this.placeholderIndex = 0 - // Match Ethereum addresses (0x followed by exactly 40 hex characters) - const addressRegex = /0x[a-fA-F0-9]{40}/g + // Match any hex strings starting with 0x (addresses, hashes, tx data, etc.) + const addressRegex = /0x[a-fA-F0-9]+/g return input.replace(addressRegex, (match) => { // Check if we already have a mapping for this address diff --git a/src/tests/unit/services/ai-suggestion-service.test.ts b/src/tests/unit/services/ai-suggestion-service.test.ts index ddfab0c..cb938e0 100644 --- a/src/tests/unit/services/ai-suggestion-service.test.ts +++ b/src/tests/unit/services/ai-suggestion-service.test.ts @@ -29,10 +29,10 @@ describe('AISuggestionService', () => { expect(result).toBe('from 0xAAAA to 0xBBBB via 0xAAAA') }) - it('should not mask short hex values (only 40-char addresses)', () => { + it('should mask short hex values starting with 0x', () => { const input = 'value 0x123 and 0xabc' const result = service.maskAddresses(input) - expect(result).toBe('value 0x123 and 0xabc') + expect(result).toBe('value 0xAAAA and 0xBBBB') }) it('should return input unchanged if no addresses', () => {