Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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 |
67 changes: 57 additions & 10 deletions ask-llm-cli.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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":"<shell 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');

Expand Down Expand Up @@ -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 <what you want to do>\n');
if (query.length === 0) {
process.stderr.write('Usage: ask-llm-cli [--backend claude|apfel] <what you want to do>\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);
Expand Down