-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add AI-powered command suggestions for unrecognized commands #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
d4a31e1 to
5ad25d1
Compare
5ad25d1 to
e733ee3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds AI-powered command suggestions for unrecognized CLI commands, enhancing the user experience by automatically suggesting corrections when a command is not recognized. The implementation uses a cascading fallback strategy through multiple AI tools (ollama → claude → copilot) and includes privacy protection by masking Ethereum addresses before sending to external AI services.
Key Changes:
- Implements
AISuggestionServicewith address masking/unmasking for privacy protection - Adds cascading AI tool fallback with auto-detection of ollama models
- Integrates unknown command handler in CLI that triggers AI suggestions
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
src/services/ai-suggestion-service.ts |
New service implementing AI-powered command suggestions with address privacy masking, ollama model auto-detection, and cascading fallback through multiple AI tools |
src/tests/unit/services/ai-suggestion-service.test.ts |
Test coverage for address masking/unmasking functionality with 15 test cases covering various edge cases and roundtrip scenarios |
src/cli.ts |
CLI integration adding unknown command handler that extracts available commands and triggers AI suggestions when commands are not recognized |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private async detectOllamaModel(): Promise<string | null> { | ||
| 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 | ||
| } | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The detectOllamaModel method lacks test coverage. Given the repository's comprehensive testing standards, consider adding tests that mock the ollama command execution and verify the model selection logic, especially the preference for 2-5GB models and the fallback behavior when no models are available.
src/cli.ts
Outdated
| console.error('\nAI suggestion:') | ||
| console.error(suggestion) | ||
| } else { |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The catch block silently swallows all errors without logging them. For debugging purposes, consider logging the error (e.g., using a debug logger) while still showing the user-friendly fallback message. This would help troubleshoot issues with the AI suggestion feature in production.
| */ | ||
| private async detectOllamaModel(): Promise<string | null> { | ||
| if (this.ollamaModel !== null) { | ||
| return this.ollamaModel || null |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition this.ollamaModel !== null followed by return this.ollamaModel || null is unnecessarily complex. When this.ollamaModel is an empty string (set at lines 62 and 77), the expression this.ollamaModel || null converts it to null, which is correct. However, this could be simplified to just return this.ollamaModel if ollamaModel is typed as string | null (which it already is). Alternatively, document why the || null coercion is needed.
| return this.ollamaModel || null | |
| return this.ollamaModel |
| } | ||
|
|
||
| /** | ||
| * Adds an unknown command handler with AI suggestions to a command. |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The async handler for 'command:*' event could potentially throw unhandled promise rejections. While there is a try-catch block inside, consider wrapping the entire handler body in a try-catch or adding .catch() to handle any unexpected errors that might occur before the try block. Alternatively, ensure Commander.js properly handles async event handlers.
| // Match Ethereum addresses (0x followed by 40 hex characters) or shorter hex strings starting with 0x | ||
| const addressRegex = /0x[a-fA-F0-9]+/g |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex /0x[a-fA-F0-9]+/g will match any hex string starting with 0x, including short values like 0x1, 0x00, or transaction data like 0xaabbccdd. This is overly broad and could mask data that isn't an address. Consider making the regex more specific (e.g., requiring at least 40 characters for Ethereum addresses) or adding validation to ensure only actual addresses are masked, not arbitrary hex data.
| // 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 | ||
| const existing = this.addressMappings.find((m) => m.original === match) | ||
| if (existing) { | ||
| return existing.placeholder | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using find on every regex match results in O(n²) complexity where n is the number of unique addresses. For better performance, consider using a Map<string, string> to store the address-to-placeholder mappings instead of an array, which would make lookups O(1).
| private ollamaModel: string | null = null | ||
|
|
||
| /** | ||
| * Detects the best available ollama model (prefers 3B-8B models for speed/quality balance). |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code comment says "prefers 3B-8B models" but the implementation actually prefers 2-5GB models. The comment should be corrected to match the implementation logic in lines 66-72.
| * 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 commandExists(command: string): Promise<boolean> { | ||
| return new Promise((resolve) => { | ||
| const check = spawn('which', [command], { |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The which command is not available on Windows by default. This will cause the command existence check to fail on Windows platforms. Consider using a cross-platform solution such as checking where on Windows or using a library like which from npm that handles platform differences automatically.
| let result = response | ||
| for (const mapping of this.addressMappings) { | ||
| // Replace all occurrences of the placeholder (case-insensitive) | ||
| const regex = new RegExp(mapping.placeholder, 'gi') |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The placeholder is used directly in a RegExp constructor without escaping special regex characters. While the current implementation with placeholders like "0xAAAA" is safe, this could cause issues if the placeholder format changes. Consider escaping the placeholder using mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') or a similar escaping function for safety.
| async getSuggestion( | ||
| invalidCommand: string, | ||
| args: string[], | ||
| availableCommands: string[] | ||
| ): Promise<string | null> { | ||
| const fullCommand = [invalidCommand, ...args].join(' ') | ||
| const maskedCommand = this.maskAddresses(fullCommand) | ||
|
|
||
| const prompt = `I'm using a CLI tool called "safe" for managing Safe Smart Account (multisig wallets). The user tried to run this command but it was not recognized: | ||
| safe ${maskedCommand} | ||
| Available commands are: | ||
| ${availableCommands.map((cmd) => `- safe ${cmd}`).join('\n')} | ||
| Based on the invalid command, suggest what the user probably meant to type. Be very concise - just provide the corrected command and a one-line explanation. If you can't determine what they meant, say so briefly.` | ||
|
|
||
| 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)) | ||
| } | ||
|
|
||
| // Show which tool is being used | ||
| process.stderr.write(`Trying ${tool.name}... `) | ||
|
|
||
| // Use shorter timeout for faster fallback (15s) | ||
| let response = await this.runCommand(tool.command, toolArgs, 15000) | ||
|
|
||
| if (response) { | ||
| // Strip ANSI escape codes (e.g., from ollama's spinner) | ||
| response = this.stripAnsi(response).trim() | ||
| const unmaskedResponse = this.unmaskAddresses(response) | ||
| process.stderr.write('done\n') | ||
| return unmaskedResponse | ||
| } | ||
| } catch { | ||
| // Tool failed, try next one | ||
| process.stderr.write('failed\n') |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getSuggestion method, which is the core functionality of this service, lacks test coverage. Given the repository's comprehensive testing standards, consider adding tests that mock the command execution and verify the cascading fallback behavior, prompt construction, and address masking/unmasking integration.
e733ee3 to
ec2d1f7
Compare
ec2d1f7 to
8d50cc1
Compare
8d50cc1 to
1a27a6e
Compare
1a27a6e to
d6059cf
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (this.ollamaModel !== null) { | ||
| return this.ollamaModel || null | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This condition is problematic. When this.ollamaModel is an empty string (set on line 62 or 77), the condition this.ollamaModel !== null will be true, but this.ollamaModel || null will return null. This creates inconsistent behavior where the function will return null instead of properly checking if detection has already been attempted. Consider using a sentinel value or boolean flag to track whether detection has been attempted.
| const letter = letters[this.placeholderIndex % letters.length] | ||
| const repeat = Math.floor(this.placeholderIndex / letters.length) + 1 | ||
| this.placeholderIndex++ | ||
| return `0x${letter.repeat(4 * repeat)}` |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The placeholder generation logic has a bug. For placeholderIndex >= 26, it will generate placeholders like 0xAAAAAAAA (8 characters), 0xAAAAAAAAAAAAAA (12 characters), etc. This creates variable-length placeholders. For example, when placeholderIndex = 26, repeat = 2, resulting in 0xAAAAAAAA. This might cause issues with regex matching in unmaskAddresses since the placeholders won't be consistent with the expected 4-character format (like 0xAAAA).
| const letter = letters[this.placeholderIndex % letters.length] | |
| const repeat = Math.floor(this.placeholderIndex / letters.length) + 1 | |
| this.placeholderIndex++ | |
| return `0x${letter.repeat(4 * repeat)}` | |
| let n = this.placeholderIndex++ | |
| let placeholder = '' | |
| for (let i = 0; i < 4; i++) { | |
| placeholder = letters[n % 26] + placeholder | |
| n = Math.floor(n / 26) | |
| } | |
| return `0x${placeholder}` |
| cmd.on('command:*', async (operands: string[]) => { | ||
| const unknownCommand = operands[0] | ||
| const args = operands.slice(1) | ||
| const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand | ||
|
|
||
| console.error(`error: unknown command '${fullUnknown}'`) | ||
|
|
||
| // Try to get AI suggestion | ||
| const aiService = getAISuggestionService() | ||
| console.error('\nAsking AI for suggestions...\n') | ||
|
|
||
| try { | ||
| const availableCommands = getAvailableCommands(program) | ||
| const suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands) | ||
| if (suggestion) { | ||
| console.error(suggestion) | ||
| } else { | ||
| console.error(`No AI tools available. Run 'safe --help' to see available commands.`) | ||
| } | ||
| } catch { | ||
| console.error(`Run 'safe --help' to see available commands.`) | ||
| } | ||
|
|
||
| process.exit(1) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The async event handler is registered but errors thrown in the catch block at line 110 won't be properly handled. The process.exit(1) at line 114 will execute regardless of whether the AI suggestion succeeds or fails. Additionally, since this is an async callback in an event handler, unhandled promise rejections could occur. Consider making this handler more robust or using .catch() on the promise.
| cmd.on('command:*', async (operands: string[]) => { | |
| const unknownCommand = operands[0] | |
| const args = operands.slice(1) | |
| const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand | |
| console.error(`error: unknown command '${fullUnknown}'`) | |
| // Try to get AI suggestion | |
| const aiService = getAISuggestionService() | |
| console.error('\nAsking AI for suggestions...\n') | |
| try { | |
| const availableCommands = getAvailableCommands(program) | |
| const suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands) | |
| if (suggestion) { | |
| console.error(suggestion) | |
| } else { | |
| console.error(`No AI tools available. Run 'safe --help' to see available commands.`) | |
| } | |
| } catch { | |
| console.error(`Run 'safe --help' to see available commands.`) | |
| } | |
| process.exit(1) | |
| cmd.on('command:*', (operands: string[]) => { | |
| (async () => { | |
| const unknownCommand = operands[0] | |
| const args = operands.slice(1) | |
| const fullUnknown = cmdPath ? `${cmdPath} ${unknownCommand}` : unknownCommand | |
| console.error(`error: unknown command '${fullUnknown}'`) | |
| // Try to get AI suggestion | |
| const aiService = getAISuggestionService() | |
| console.error('\nAsking AI for suggestions...\n') | |
| try { | |
| const availableCommands = getAvailableCommands(program) | |
| const suggestion = await aiService.getSuggestion(fullUnknown, args, availableCommands) | |
| if (suggestion) { | |
| console.error(suggestion) | |
| } else { | |
| console.error(`No AI tools available. Run 'safe --help' to see available commands.`) | |
| } | |
| } catch { | |
| console.error(`Run 'safe --help' to see available commands.`) | |
| } | |
| process.exit(1) | |
| })().catch((err) => { | |
| console.error('An unexpected error occurred while handling unknown command:', err) | |
| process.exit(1) | |
| }) |
| - Use isolated directories with `XDG_CONFIG_HOME` and `XDG_DATA_HOME` | ||
| - Clean up in `afterEach` hooks | ||
| - Use `mkdtempSync()` for temporary directories | ||
| Read @CLAUDE.md |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reference format @CLAUDE.md is non-standard. The @ symbol is typically used for mentions or npm packages, not file references. Consider using a clearer reference like See CLAUDE.md or a proper markdown link [Development Guide](CLAUDE.md).
| Read @CLAUDE.md | |
| Read [CLAUDE.md](CLAUDE.md) |
| } | ||
|
|
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message is truncated to 50 characters which might cut off important diagnostic information in the middle of a word. Consider using a word boundary or adding ellipsis to indicate truncation (e.g., ${errMsg.slice(0, 50)}...). This would make it clearer to users that the message is incomplete.
| const check = spawn('which', [command], { | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }) | ||
| check.on('close', (code) => { | ||
| resolve(code === 0) | ||
| }) | ||
| check.on('error', () => { | ||
| resolve(false) | ||
| }) | ||
| }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using which command is not cross-platform. This will fail on Windows systems where which is not available by default. Consider using where on Windows or a cross-platform solution like the which npm package, or checking for command availability by attempting to run it with a benign flag.
| const check = spawn('which', [command], { | |
| stdio: ['pipe', 'pipe', 'pipe'], | |
| }) | |
| check.on('close', (code) => { | |
| resolve(code === 0) | |
| }) | |
| check.on('error', () => { | |
| resolve(false) | |
| }) | |
| }) | |
| const isWindows = process.platform === 'win32'; | |
| const cmd = isWindows ? 'where' : 'which'; | |
| const check = spawn(cmd, [command], { | |
| stdio: ['pipe', 'pipe', 'pipe'], | |
| }); | |
| check.on('close', (code) => { | |
| resolve(code === 0); | |
| }); | |
| check.on('error', () => { | |
| resolve(false); | |
| }); | |
| }); |
| 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') | ||
| }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test expects 5 unique placeholders (0xAAAA through 0xEEEE), but there's no test coverage for what happens when more than 26 addresses need to be masked. Based on the generatePlaceholder implementation, the 27th address would create an 8-character placeholder (0xAAAAAAAA), which would fail the unmask logic. Consider adding a test case for this edge case or documenting the limitation.
src/cli.ts
Outdated
| cmd.on('command:*', async (operands: string[]) => { | ||
| const unknownCommand = operands[0] | ||
| const args = operands.slice(1) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The variable name operands is unclear in this context. Commander.js uses this term internally, but for command handlers, a more descriptive name like unknownArgs or commandParts would better convey that this contains the unrecognized command name and its arguments.
| cmd.on('command:*', async (operands: string[]) => { | |
| const unknownCommand = operands[0] | |
| const args = operands.slice(1) | |
| cmd.on('command:*', async (commandParts: string[]) => { | |
| const unknownCommand = commandParts[0] | |
| const args = commandParts.slice(1) |
| this.addressMappings = [] | ||
| this.placeholderIndex = 0 |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The maskAddresses method resets instance state (addressMappings and placeholderIndex) every time it's called. This makes the service stateful and not thread-safe if used concurrently. If getSuggestion is called multiple times simultaneously (though unlikely in CLI context), the mappings could be corrupted. Consider making the masking logic return both the masked string and the mappings, rather than storing them as instance state.
| 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 |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The unmaskAddresses method creates a new regex and performs a string replacement for each mapping sequentially. For a large number of addresses, this could be inefficient. Consider building a single regex pattern from all placeholders and doing one pass with a replacer function, or at minimum compile the regex once per mapping if the case-insensitive flag is needed.
| 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 | |
| if (!this.addressMappings || this.addressMappings.length === 0) { | |
| return response | |
| } | |
| // Build a single regex that matches any placeholder, escaping special regex characters | |
| const placeholders = this.addressMappings.map(m => m.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) | |
| const regex = new RegExp(placeholders.join('|'), 'gi') | |
| // Map for quick lookup (case-insensitive) | |
| const mappingMap = new Map<string, string>() | |
| for (const m of this.addressMappings) { | |
| mappingMap.set(m.placeholder.toLowerCase(), m.original) | |
| } | |
| return response.replace(regex, (matched) => { | |
| return mappingMap.get(matched.toLowerCase()) ?? matched | |
| }) |
d6059cf to
f193278
Compare
f193278 to
942959b
Compare
f5ea51c to
b0085d2
Compare
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 <noreply@anthropic.com>
b0085d2 to
2b752df
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
src/ui/screens/AISuggestionScreen.tsx:43
- Using array index as React key is an anti-pattern that can cause rendering issues. Since the suggestion text is static, consider using a stable key like
key={line-${index}}or if lines are unique, use the line content as the key. Using index alone can lead to incorrect re-renders if the suggestion changes.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private ollamaModel: string | null = null | ||
|
|
||
| /** | ||
| * Detects the best available ollama model (prefers 3B-8B models for speed/quality balance). |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment states '3B-8B models' but the actual code at line 68 uses a range of 2-5GB. This inconsistency between the comment and implementation could confuse future developers. Please update the comment to match the actual implementation: 'prefers 2-5GB models for speed/quality balance'.
| * 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). |
| let result = response | ||
| for (const mapping of this.addressMappings) { | ||
| // Replace all occurrences of the placeholder (case-insensitive) | ||
| const regex = new RegExp(mapping.placeholder, 'gi') |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The placeholder string is not escaped before being used in a RegExp constructor, which could cause issues if the placeholder contains special regex characters. While current placeholders like '0xAAAA' are safe, the 'x' character has special meaning in regex. Use new RegExp(mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&'), 'gi') or a library function to escape regex special characters.
| const regex = new RegExp(mapping.placeholder, 'gi') | |
| const regex = new RegExp(mapping.placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') |
| if (onExit) { | ||
| onExit() | ||
| } | ||
| }, [onExit]) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The useEffect calls onExit immediately on render, which triggers the screen to unmount before the user can see the content. The screen displays 'Asking AI for suggestions...' but the AI suggestion has already been fetched (passed as a prop). This screen should either remove the premature onExit call or wait for user input/timeout before exiting. Compare with other success screens which correctly display content before calling onExit.
| if (onExit) { | |
| onExit() | |
| } | |
| }, [onExit]) | |
| if (!onExit) return; | |
| const timeout = setTimeout(() => { | |
| onExit(); | |
| }, 2000); // Show the suggestion for 2 seconds before exiting | |
| return () => clearTimeout(timeout); | |
| }, [onExit]); |
| describe('getAvailableTools', () => { | ||
| it('should return an array', async () => { | ||
| const tools = await service.getAvailableTools() | ||
| expect(Array.isArray(tools)).toBe(true) | ||
| }) | ||
| }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test for getAvailableTools only verifies that it returns an array, but doesn't test the actual functionality of detecting available AI tools. Consider adding tests that mock commandExists to verify the function correctly identifies which tools are available vs. unavailable.
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 12 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async getSuggestion( | ||
| invalidCommand: string, | ||
| args: string[], | ||
| availableCommands: string[] | ||
| ): Promise<string | null> { | ||
| 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 <suggested command here> | ||
| <one line explanation> | ||
| Keep it to 2-3 lines total.` |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The prompt construction uses string interpolation with unsanitized user input (invalidCommand and args). While the AI tools are spawned with separate arguments (preventing shell injection), the user input could contain special characters or newlines that could manipulate the AI's response format.
Consider sanitizing the input or at least documenting this as a potential concern:
const fullCommand = [invalidCommand, ...args].join(' ').replace(/[\r\n]/g, ' ')
const maskedCommand = this.maskAddresses(fullCommand)| private aiTools: AITool[] = [ | ||
| { | ||
| name: 'ollama', | ||
| command: 'ollama', | ||
| args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt], | ||
| }, | ||
| { | ||
| name: 'claude', | ||
| command: 'claude', | ||
| args: (prompt: string) => ['--print', '--model', 'haiku', '-o', 'raw=true', prompt], | ||
| }, | ||
| { | ||
| name: 'copilot', | ||
| command: 'copilot', | ||
| args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'], | ||
| }, | ||
| ] |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR description states the fallback order is "ollama → claude → copilot", but the aiTools array has them in the order claude, copilot, ollama. Since the getSuggestion method iterates through this array in order, the actual fallback order is claude → copilot → ollama, which contradicts the documented behavior.
Reorder the array to match the documented behavior:
private aiTools: AITool[] = [
{
name: 'ollama',
command: 'ollama',
args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt],
},
{
name: 'claude',
command: 'claude',
args: (prompt: string) => ['--print', '--model', 'haiku', '-o', 'raw=true', prompt],
},
{
name: 'copilot',
command: 'copilot',
args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'],
},
]| - Use isolated directories with `XDG_CONFIG_HOME` and `XDG_DATA_HOME` | ||
| - Clean up in `afterEach` hooks | ||
| - Use `mkdtempSync()` for temporary directories | ||
| Read @CLAUDE.md |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AGENTS.md file has been replaced with a single line redirecting to CLAUDE.md. This removes all the valuable development documentation that was previously in AGENTS.md.
If the intention is to consolidate documentation into CLAUDE.md, consider:
- Keeping both files with different purposes (e.g., AGENTS.md for AI agent instructions, CLAUDE.md for human developer guidance)
- Adding a more informative redirect message like "See CLAUDE.md for development guidelines"
- Documenting the reason for this change
The current change makes it unclear why AGENTS.md exists at all if it only contains a redirect.
| Read @CLAUDE.md | |
| # AGENTS.md Deprecated | |
| The documentation previously found in this file has been consolidated into [CLAUDE.md](CLAUDE.md) to streamline development guidelines and reduce duplication. | |
| Please refer to [CLAUDE.md](CLAUDE.md) for all current development documentation and agent instructions. | |
| _Note: AGENTS.md previously contained AI agent instructions. For historical versions, see the project history._ |
| // 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 |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The model sorting logic is hard to understand. The scoring system (0 for 2-5GB, 1 for <2GB, 2 for >5GB) and the comment "prefer 2-5GB models, then larger ones" don't align - the code actually prefers 2-5GB models, then smaller ones (<2GB), then larger ones (>5GB).
Consider adding inline comments to clarify the logic or extracting it into a helper function with a descriptive name:
const getModelPreferenceScore = (sizeGB: number): number => {
if (sizeGB >= 2 && sizeGB <= 5) return 0 // Most preferred: medium-sized models
if (sizeGB < 2) return 1 // Second: smaller models
return 2 // Least preferred: large models
}
models.sort((a, b) => {
const scoreDiff = getModelPreferenceScore(a.sizeGB) - getModelPreferenceScore(b.sizeGB)
return scoreDiff !== 0 ? scoreDiff : a.sizeGB - b.sizeGB
})| // 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 | |
| // Sort by preference: 2-5GB models (best), then smaller (<2GB), then larger (>5GB) | |
| const getModelPreferenceScore = (sizeGB: number): number => { | |
| if (sizeGB >= 2 && sizeGB <= 5) return 0 // Most preferred: medium-sized models | |
| if (sizeGB < 2) return 1 // Next: smaller models | |
| return 2 // Least preferred: large models | |
| } | |
| models.sort((a, b) => { | |
| const scoreDiff = getModelPreferenceScore(a.sizeGB) - getModelPreferenceScore(b.sizeGB) | |
| return scoreDiff !== 0 ? scoreDiff : a.sizeGB - b.sizeGB |
| 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 | ||
| }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The address masking logic has a case-sensitivity issue. When the same address appears with different casing (e.g., 0xAbCdEf... and 0xabcdef...), they will be treated as different addresses and assigned different placeholders, even though Ethereum addresses are case-insensitive.
Consider normalizing addresses to lowercase before comparison:
return input.replace(addressRegex, (match) => {
const normalizedMatch = match.toLowerCase()
const existing = this.addressMappings.find((m) => m.original.toLowerCase() === normalizedMatch)
if (existing) {
return existing.placeholder
}
const placeholder = this.generatePlaceholder()
this.addressMappings.push({ placeholder, original: match })
return placeholder
})| 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 not mask short hex values (only 40-char addresses)', () => { | ||
| const input = 'value 0x123 and 0xabc' | ||
| const result = service.maskAddresses(input) | ||
| expect(result).toBe('value 0x123 and 0xabc') | ||
| }) | ||
|
|
||
| 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) | ||
| }) | ||
| }) | ||
| }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test suite is missing tests for the main getSuggestion method, which is the core functionality of this service. While address masking/unmasking is well-tested, the actual AI tool invocation, fallback logic, and error handling are not covered.
Consider adding tests for:
- Successful suggestion from the first available tool
- Fallback to the next tool when one fails
- Handling when all tools fail (returns null)
- Ollama model auto-detection
- Timeout handling
- Command existence checking
These tests could use mocks to avoid depending on actual AI tools being installed.
| cmd.on('command:*', async (operands: string[]) => { | ||
| 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') | ||
|
|
||
| // 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.`) | ||
| } | ||
| } catch (error) { | ||
| console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`) | ||
| } | ||
|
|
||
| process.exit(1) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The command:* event handler is marked as async, but Commander.js does not await async event handlers. The event handler returns a Promise that is never awaited, which means the async operations inside (fetching AI suggestions and rendering) may not complete before the process exits at line 122.
To fix this, you should ensure the async operations complete before calling process.exit(). One approach is to not make the handler async but wrap the logic in an immediately-invoked async function:
cmd.on('command:*', (operands: string[]) => {
void (async () => {
try {
// ... existing async logic including process.exit(1)
} catch (error) {
console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}
})()
})This ensures Node.js doesn't exit prematurely while waiting for the async operations.
| cmd.on('command:*', async (operands: string[]) => { | |
| 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') | |
| // 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.`) | |
| } | |
| } catch (error) { | |
| console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`) | |
| } | |
| process.exit(1) | |
| cmd.on('command:*', (operands: string[]) => { | |
| void (async () => { | |
| 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') | |
| // 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.`) | |
| } | |
| } catch (error) { | |
| console.error(`Unexpected error: ${error instanceof Error ? error.message : error}`) | |
| } | |
| process.exit(1) | |
| })() |
| const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/) | ||
| if (match) { | ||
| models.push({ name: match[1], sizeGB: parseFloat(match[2]) }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex /^(\S+)\s+\S+\s+([\d.]+)\s*GB/ for parsing ollama model list output assumes a specific format with exactly two whitespace-separated fields before the size. This may be fragile if ollama changes its output format or if model names/IDs contain special characters.
Consider making the regex more flexible and adding error handling:
const match = line.match(/(\S+)\s+.*?([\d.]+)\s*GB/)Additionally, consider validating that parseFloat(match[2]) returns a valid number before using it.
| const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/) | |
| if (match) { | |
| models.push({ name: match[1], sizeGB: parseFloat(match[2]) }) | |
| const match = line.match(/(\S+)\s+.*?([\d.]+)\s*GB/) | |
| if (match) { | |
| const size = parseFloat(match[2]) | |
| if (!isNaN(size)) { | |
| models.push({ name: match[1], sizeGB: size }) | |
| } |
| <Box key={index}> | ||
| <Text> </Text> | ||
| <Text color={theme.colors.primary}>{line.trim()}</Text> | ||
| </Box> | ||
| ) | ||
| } | ||
| // Other lines (Did you mean, explanation, etc.) | ||
| return <Text key={index}>{line}</Text> |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using array index as the React key is an anti-pattern when rendering dynamic content. While it works here since the suggestion lines are static, it's better practice to use a more stable key or combine the index with the line content to ensure uniqueness.
Consider using a combination of index and content:
{suggestion.split('\n').map((line, index) => {
const key = `${index}-${line.slice(0, 20)}`
// ... rest of the codeOr simply use the line content itself if lines are guaranteed to be unique:
{suggestion.split('\n').map((line, index) => {
// ... render logic with key={line}| <Box key={index}> | |
| <Text> </Text> | |
| <Text color={theme.colors.primary}>{line.trim()}</Text> | |
| </Box> | |
| ) | |
| } | |
| // Other lines (Did you mean, explanation, etc.) | |
| return <Text key={index}>{line}</Text> | |
| <Box key={`${index}-${line.slice(0, 20)}`}> | |
| <Text> </Text> | |
| <Text color={theme.colors.primary}>{line.trim()}</Text> | |
| </Box> | |
| ) | |
| } | |
| // Other lines (Did you mean, explanation, etc.) | |
| return <Text key={`${index}-${line.slice(0, 20)}`}>{line}</Text> |
| private async commandExists(command: string): Promise<boolean> { | ||
| 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) | ||
| }) | ||
| }) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The commandExists function uses which, which is not available on Windows systems. This will cause the AI suggestion feature to fail silently on Windows.
Consider using a cross-platform approach:
private async commandExists(command: string): Promise<boolean> {
return new Promise((resolve) => {
const isWindows = process.platform === 'win32'
const checkCommand = isWindows ? 'where' : 'which'
const check = spawn(checkCommand, [command], {
stdio: ['pipe', 'pipe', 'pipe'],
})
check.on('close', (code) => {
resolve(code === 0)
})
check.on('error', () => {
resolve(false)
})
})
}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 <noreply@anthropic.com>
Summary
Example
AI Tool Configurations
ollama run <auto-detect> --quietclaude --print --model haikucopilot --prompt --model claude-haiku-4.5Test plan
🤖 Generated with Claude Code