diff --git a/README.md b/README.md index 188a5eb..480aa60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ask-llm-cli -Describe what you want to do in plain English, get a shell command back. Powered by Claude. +Describe what you want to do in plain English, get a shell command back. Powered by Claude or [apfel](https://github.com/Arthur-Ficial/apfel) (local on-device macOS LLM). ![Shell integration demo](shell-integration-demo.gif) @@ -9,7 +9,8 @@ It places the suggested command directly on your prompt line so you can review, ## Prerequisites - Node.js >= 18 -- An [Anthropic API key](https://console.anthropic.com/) set in ASK_LLM_CLI_ANTHROPIC_API_KEY environment variable +- **Claude backend** (default): An [Anthropic API key](https://console.anthropic.com/) set in `ASK_LLM_CLI_ANTHROPIC_API_KEY` environment variable +- **apfel backend** (local, free): [apfel](https://github.com/Arthur-Ficial/apfel) installed via `brew install apfel` (requires Apple Silicon + macOS Tahoe) ## Installation @@ -24,11 +25,19 @@ npm install -g Add this function to your `~/.zshrc`: ```zsh +# Using Claude (default) ask() { local cmd cmd=$(command ask-llm-cli "$@") || return print -z "$cmd" } + +# Or using apfel (local, no API key needed) +ask() { + local cmd + cmd=$(command ask-llm-cli --backend apfel "$@") || return + print -z "$cmd" +} ``` Then reload your shell: @@ -47,3 +56,10 @@ ask "show disk usage by directory" ``` Commands flagged as potentially dangerous by the model are shown with a warning. + +## Backends + +| Backend | Model | Requires | Best for | +|---------|-------|----------|----------| +| `claude` (default) | Claude Sonnet | API key + internet | Complex commands, high accuracy | +| `apfel` | Apple on-device 3B | Apple Silicon + macOS Tahoe | Simple commands, offline use, zero cost | diff --git a/ask-llm-cli.js b/ask-llm-cli.js index ad60e6d..a16797d 100755 --- a/ask-llm-cli.js +++ b/ask-llm-cli.js @@ -1,9 +1,8 @@ #!/usr/bin/env node -// requires ASK_LLM_CLI_ANTHROPIC_API_KEY env var - const fs = require('fs'); const tty = require('tty'); +const {execFile} = require('node:child_process'); const ANTHROPIC_API_KEY = process.env.ASK_LLM_CLI_ANTHROPIC_API_KEY; @@ -71,6 +70,32 @@ async function callClaudeAPI(userRequest) { return await response.json(); } +async function callApfelCLI(userRequest) { + const prompt = `macOS zsh command for: ${userRequest}\nRespond ONLY with JSON: {"command":"","safety":"SAFE or UNSAFE"}\nSAFE=read-only. UNSAFE=modifies or deletes data.`; + + return new Promise((resolve, reject) => { + const child = execFile('apfel', [prompt], {timeout: 30000}, (err, stdout) => { + if (err) { + if (err.code === 'ENOENT') { + return reject(new Error('apfel not found. Install it with: brew install apfel')); + } + return reject(new Error(`apfel failed: ${err.message}`)); + } + const raw = stdout.trim(); + if (!raw) return reject(new Error('apfel returned empty response')); + const cleaned = raw.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim(); + try { + const parsed = JSON.parse(cleaned); + if (!parsed.command) throw new Error('missing command'); + return resolve({cmd: parsed.command, isSafe: parsed.safety === 'SAFE'}); + } catch { + return resolve({cmd: cleaned, isSafe: false}); + } + }); + child.stdin.end(); + }); +} + function parseResponse(response) { const toolUse = response?.content?.find((block) => block.type === 'tool_use'); @@ -131,27 +156,49 @@ function prompt(question) { }); } +function parseArgs(argv) { + const raw = argv.slice(2); + let backend = 'claude'; + const rest = []; + + for (let i = 0; i < raw.length; i++) { + if (raw[i] === '--backend' && i + 1 < raw.length) { + backend = raw[++i]; + } else { + rest.push(raw[i]); + } + } + + return {backend, query: rest}; +} + async function main() { - const args = process.argv.slice(2); + const {backend, query} = parseArgs(process.argv); - if (args.length === 0) { - process.stderr.write('Usage: ask \n'); + if (query.length === 0) { + process.stderr.write('Usage: ask-llm-cli [--backend claude|apfel] \n'); process.exit(1); } - if (!ANTHROPIC_API_KEY) { + if (backend === 'claude' && !ANTHROPIC_API_KEY) { process.stderr.write('❌ ASK_LLM_CLI_ANTHROPIC_API_KEY environment variable is required\n'); process.exit(1); } - const userRequest = args.join(' '); + const userRequest = query.join(' '); const stopSpinner = startSpinner('⏳ Asking LLM'); try { - const response = await callClaudeAPI(userRequest); - stopSpinner(); + let cmd, isSafe; - let {cmd, isSafe} = parseResponse(response); + if (backend === 'apfel') { + ({cmd, isSafe} = await callApfelCLI(userRequest)); + } else { + const response = await callClaudeAPI(userRequest); + ({cmd, isSafe} = parseResponse(response)); + } + + stopSpinner(); if (isSafe) { process.stdout.write(cmd);