Skip to content

Commit d4a31e1

Browse files
katspaughclaude
andcommitted
feat: add AI-powered command suggestions for unrecognized commands
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 <[email protected]>
1 parent b28b5da commit d4a31e1

File tree

3 files changed

+488
-0
lines changed

3 files changed

+488
-0
lines changed

src/cli.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { pullTransactions } from './commands/tx/pull.js'
2929
import { syncTransactions } from './commands/tx/sync.js'
3030
import { handleError } from './utils/errors.js'
3131
import { setGlobalOptions, type GlobalOptions } from './types/global-options.js'
32+
import { getAISuggestionService } from './services/ai-suggestion-service.js'
3233

3334
const program = new Command()
3435

@@ -53,6 +54,60 @@ program
5354
})
5455
})
5556

57+
/**
58+
* Recursively extracts all commands from a Commander program/command.
59+
* Returns commands in format like "config init", "wallet list", etc.
60+
*/
61+
function getAvailableCommands(cmd: Command, prefix: string = ''): string[] {
62+
const commands: string[] = []
63+
64+
for (const subCmd of cmd.commands) {
65+
const cmdName = subCmd.name()
66+
const fullName = prefix ? `${prefix} ${cmdName}` : cmdName
67+
const usage = subCmd.usage()
68+
69+
// Add the command with its usage (arguments)
70+
if (usage) {
71+
commands.push(`${fullName} ${usage}`)
72+
} else {
73+
commands.push(fullName)
74+
}
75+
76+
// Recursively get subcommands
77+
commands.push(...getAvailableCommands(subCmd, fullName))
78+
}
79+
80+
return commands
81+
}
82+
83+
// Handle unknown commands with AI suggestions
84+
program.on('command:*', async (operands: string[]) => {
85+
const unknownCommand = operands[0]
86+
const args = operands.slice(1)
87+
88+
console.error(`error: unknown command '${unknownCommand}'`)
89+
90+
// Try to get AI suggestion
91+
const aiService = getAISuggestionService()
92+
console.error('\nAsking AI for suggestions...')
93+
94+
try {
95+
const availableCommands = getAvailableCommands(program)
96+
const suggestion = await aiService.getSuggestion(unknownCommand, args, availableCommands)
97+
if (suggestion) {
98+
console.error('\nAI suggestion:')
99+
console.error(suggestion)
100+
} else {
101+
console.error('\nNo AI tools available for suggestions.')
102+
console.error(`Run 'safe --help' to see available commands.`)
103+
}
104+
} catch {
105+
console.error(`\nRun 'safe --help' to see available commands.`)
106+
}
107+
108+
process.exit(1)
109+
})
110+
56111
// Config commands
57112
const config = program.command('config').description('Manage CLI configuration')
58113

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { spawn } from 'child_process'
2+
3+
interface AITool {
4+
name: string
5+
command: string
6+
args: (prompt: string) => string[]
7+
available?: boolean
8+
}
9+
10+
interface AddressMapping {
11+
placeholder: string
12+
original: string
13+
}
14+
15+
/**
16+
* Service for getting AI-powered command suggestions when a command is not recognized.
17+
* Uses cascading fallback through multiple AI CLI tools.
18+
*/
19+
export class AISuggestionService {
20+
private aiTools: AITool[] = [
21+
{
22+
name: 'ollama',
23+
command: 'ollama',
24+
args: (prompt: string) => ['run', '__AUTO_DETECT__', '--quiet', prompt],
25+
},
26+
{
27+
name: 'claude',
28+
command: 'claude',
29+
args: (prompt: string) => ['--print', '--model', 'haiku', prompt],
30+
},
31+
{
32+
name: 'copilot',
33+
command: 'copilot',
34+
args: (prompt: string) => ['--prompt', prompt, '--model', 'claude-haiku-4.5'],
35+
},
36+
]
37+
38+
private ollamaModel: string | null = null
39+
40+
/**
41+
* Detects the best available ollama model (prefers 3B-8B models for speed/quality balance).
42+
*/
43+
private async detectOllamaModel(): Promise<string | null> {
44+
if (this.ollamaModel !== null) {
45+
return this.ollamaModel || null
46+
}
47+
48+
try {
49+
const output = await this.runCommand('ollama', ['list'], 5000)
50+
const lines = output.split('\n').slice(1) // Skip header
51+
52+
// Parse model names and sizes, prefer smaller capable models
53+
const models: { name: string; sizeGB: number }[] = []
54+
for (const line of lines) {
55+
const match = line.match(/^(\S+)\s+\S+\s+([\d.]+)\s*GB/)
56+
if (match) {
57+
models.push({ name: match[1], sizeGB: parseFloat(match[2]) })
58+
}
59+
}
60+
61+
if (models.length === 0) {
62+
this.ollamaModel = ''
63+
return null
64+
}
65+
66+
// Sort by size (prefer 2-5GB models, then larger ones)
67+
models.sort((a, b) => {
68+
const aScore = a.sizeGB >= 2 && a.sizeGB <= 5 ? 0 : a.sizeGB < 2 ? 1 : 2
69+
const bScore = b.sizeGB >= 2 && b.sizeGB <= 5 ? 0 : b.sizeGB < 2 ? 1 : 2
70+
if (aScore !== bScore) return aScore - bScore
71+
return a.sizeGB - b.sizeGB
72+
})
73+
74+
this.ollamaModel = models[0].name
75+
return this.ollamaModel
76+
} catch {
77+
this.ollamaModel = ''
78+
return null
79+
}
80+
}
81+
82+
private addressMappings: AddressMapping[] = []
83+
private placeholderIndex = 0
84+
85+
/**
86+
* Strips ANSI escape codes from a string.
87+
*/
88+
private stripAnsi(str: string): string {
89+
// eslint-disable-next-line no-control-regex
90+
return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
91+
}
92+
93+
/**
94+
* Masks all 0x addresses in the input with placeholders like 0xAAAA, 0xBBBB, etc.
95+
*/
96+
maskAddresses(input: string): string {
97+
this.addressMappings = []
98+
this.placeholderIndex = 0
99+
100+
// Match Ethereum addresses (0x followed by 40 hex characters) or shorter hex strings starting with 0x
101+
const addressRegex = /0x[a-fA-F0-9]+/g
102+
103+
return input.replace(addressRegex, (match) => {
104+
// Check if we already have a mapping for this address
105+
const existing = this.addressMappings.find((m) => m.original === match)
106+
if (existing) {
107+
return existing.placeholder
108+
}
109+
110+
const placeholder = this.generatePlaceholder()
111+
this.addressMappings.push({ placeholder, original: match })
112+
return placeholder
113+
})
114+
}
115+
116+
/**
117+
* Unmasks placeholders back to original addresses in the AI response.
118+
*/
119+
unmaskAddresses(response: string): string {
120+
let result = response
121+
for (const mapping of this.addressMappings) {
122+
// Replace all occurrences of the placeholder (case-insensitive)
123+
const regex = new RegExp(mapping.placeholder, 'gi')
124+
result = result.replace(regex, mapping.original)
125+
}
126+
return result
127+
}
128+
129+
/**
130+
* Generates a placeholder like 0xAAAA, 0xBBBB, 0xCCCC, etc.
131+
*/
132+
private generatePlaceholder(): string {
133+
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
134+
const letter = letters[this.placeholderIndex % letters.length]
135+
const repeat = Math.floor(this.placeholderIndex / letters.length) + 1
136+
this.placeholderIndex++
137+
return `0x${letter.repeat(4 * repeat)}`
138+
}
139+
140+
/**
141+
* Checks if a command exists on the system.
142+
*/
143+
private async commandExists(command: string): Promise<boolean> {
144+
return new Promise((resolve) => {
145+
const check = spawn('which', [command], {
146+
stdio: ['pipe', 'pipe', 'pipe'],
147+
})
148+
check.on('close', (code) => {
149+
resolve(code === 0)
150+
})
151+
check.on('error', () => {
152+
resolve(false)
153+
})
154+
})
155+
}
156+
157+
/**
158+
* Runs a command and returns its output.
159+
*/
160+
private runCommand(command: string, args: string[], timeoutMs: number = 30000): Promise<string> {
161+
return new Promise((resolve, reject) => {
162+
const proc = spawn(command, args, {
163+
stdio: ['pipe', 'pipe', 'pipe'],
164+
env: { ...process.env, NO_COLOR: '1' },
165+
})
166+
167+
let stdout = ''
168+
let stderr = ''
169+
170+
proc.stdout?.on('data', (data) => {
171+
stdout += data.toString()
172+
})
173+
174+
proc.stderr?.on('data', (data) => {
175+
stderr += data.toString()
176+
})
177+
178+
const timeout = setTimeout(() => {
179+
proc.kill('SIGTERM')
180+
reject(new Error('Command timed out'))
181+
}, timeoutMs)
182+
183+
proc.on('close', (code) => {
184+
clearTimeout(timeout)
185+
if (code === 0 && stdout.trim()) {
186+
resolve(stdout.trim())
187+
} else {
188+
reject(new Error(stderr || `Command exited with code ${code}`))
189+
}
190+
})
191+
192+
proc.on('error', (err) => {
193+
clearTimeout(timeout)
194+
reject(err)
195+
})
196+
})
197+
}
198+
199+
/**
200+
* Gets a command suggestion from AI tools using cascading fallback.
201+
* @param invalidCommand The command that was not recognized
202+
* @param args The arguments that were passed
203+
* @param availableCommands List of available commands to help the AI
204+
* @returns AI suggestion or null if all tools fail
205+
*/
206+
async getSuggestion(
207+
invalidCommand: string,
208+
args: string[],
209+
availableCommands: string[]
210+
): Promise<string | null> {
211+
const fullCommand = [invalidCommand, ...args].join(' ')
212+
const maskedCommand = this.maskAddresses(fullCommand)
213+
214+
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:
215+
216+
safe ${maskedCommand}
217+
218+
Available commands are:
219+
${availableCommands.map((cmd) => `- safe ${cmd}`).join('\n')}
220+
221+
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.`
222+
223+
for (const tool of this.aiTools) {
224+
try {
225+
const exists = await this.commandExists(tool.command)
226+
if (!exists) {
227+
continue
228+
}
229+
230+
let toolArgs = tool.args(prompt)
231+
232+
// Handle ollama model auto-detection
233+
if (tool.name === 'ollama' && toolArgs.includes('__AUTO_DETECT__')) {
234+
const model = await this.detectOllamaModel()
235+
if (!model) {
236+
continue
237+
}
238+
toolArgs = toolArgs.map((arg) => (arg === '__AUTO_DETECT__' ? model : arg))
239+
}
240+
241+
let response = await this.runCommand(tool.command, toolArgs)
242+
243+
if (response) {
244+
// Strip ANSI escape codes (e.g., from ollama's spinner)
245+
response = this.stripAnsi(response).trim()
246+
const unmaskedResponse = this.unmaskAddresses(response)
247+
return unmaskedResponse
248+
}
249+
} catch {
250+
// Tool failed, try next one
251+
continue
252+
}
253+
}
254+
255+
return null
256+
}
257+
258+
/**
259+
* Gets available AI tools on the system.
260+
*/
261+
async getAvailableTools(): Promise<string[]> {
262+
const available: string[] = []
263+
for (const tool of this.aiTools) {
264+
if (await this.commandExists(tool.command)) {
265+
available.push(tool.name)
266+
}
267+
}
268+
return available
269+
}
270+
}
271+
272+
// Singleton instance
273+
let instance: AISuggestionService | null = null
274+
275+
export function getAISuggestionService(): AISuggestionService {
276+
if (!instance) {
277+
instance = new AISuggestionService()
278+
}
279+
return instance
280+
}

0 commit comments

Comments
 (0)