@hasna/terminal 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.js +20 -6
- package/dist/ai.js +81 -38
- package/dist/cache.js +41 -0
- package/dist/history.js +2 -0
- package/package.json +1 -1
- package/src/App.tsx +38 -8
- package/src/ai.ts +98 -48
- package/src/cache.ts +43 -0
- package/src/history.ts +3 -0
package/dist/App.js
CHANGED
|
@@ -4,9 +4,12 @@ import { Box, Text, useInput, useApp } from "ink";
|
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
5
|
import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
|
|
6
6
|
import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
|
|
7
|
+
import { loadCache } from "./cache.js";
|
|
7
8
|
import Onboarding from "./Onboarding.js";
|
|
8
9
|
import StatusBar from "./StatusBar.js";
|
|
9
10
|
import Spinner from "./Spinner.js";
|
|
11
|
+
// warm cache on startup
|
|
12
|
+
loadCache();
|
|
10
13
|
const MAX_LINES = 20;
|
|
11
14
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
12
15
|
function insertAt(s, pos, ch) { return s.slice(0, pos) + ch + s.slice(pos); }
|
|
@@ -42,7 +45,7 @@ export default function App() {
|
|
|
42
45
|
});
|
|
43
46
|
const allNl = [...nlHistory, ...sessionNl];
|
|
44
47
|
const finishOnboarding = (perms) => {
|
|
45
|
-
const next = { onboarded: true, permissions: perms };
|
|
48
|
+
const next = { onboarded: true, confirm: false, permissions: perms };
|
|
46
49
|
setConfig(next);
|
|
47
50
|
saveConfig(next);
|
|
48
51
|
};
|
|
@@ -131,9 +134,11 @@ export default function App() {
|
|
|
131
134
|
await runPhase(nl, nl, true);
|
|
132
135
|
return;
|
|
133
136
|
}
|
|
134
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
137
|
+
setPhase({ type: "thinking", nl, raw: false, partial: "" });
|
|
135
138
|
try {
|
|
136
|
-
const command = await translateToCommand(nl, config.permissions, sessionCmds)
|
|
139
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds, (partial) => {
|
|
140
|
+
setPhase({ type: "thinking", nl, raw: false, partial });
|
|
141
|
+
});
|
|
137
142
|
const blocked = checkPermissions(command, config.permissions);
|
|
138
143
|
if (blocked) {
|
|
139
144
|
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
@@ -141,6 +146,11 @@ export default function App() {
|
|
|
141
146
|
return;
|
|
142
147
|
}
|
|
143
148
|
const danger = isIrreversible(command);
|
|
149
|
+
// skip confirm unless user opted in OR command is dangerous
|
|
150
|
+
if (!config.confirm && !danger) {
|
|
151
|
+
await runPhase(nl, command, false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
144
154
|
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
145
155
|
}
|
|
146
156
|
catch (e) {
|
|
@@ -167,7 +177,7 @@ export default function App() {
|
|
|
167
177
|
}
|
|
168
178
|
if (input === "?") {
|
|
169
179
|
const { nl, command } = phase;
|
|
170
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
180
|
+
setPhase({ type: "thinking", nl, raw: false, partial: "" });
|
|
171
181
|
try {
|
|
172
182
|
const explanation = await explainCommand(command);
|
|
173
183
|
setPhase({ type: "explain", nl, command, explanation });
|
|
@@ -208,10 +218,14 @@ export default function App() {
|
|
|
208
218
|
}
|
|
209
219
|
if (input === "y" || input === "Y" || key.return) {
|
|
210
220
|
const { nl, command, errorOutput } = phase;
|
|
211
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
221
|
+
setPhase({ type: "thinking", nl, raw: false, partial: "" });
|
|
212
222
|
try {
|
|
213
223
|
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
214
224
|
const danger = isIrreversible(fixed);
|
|
225
|
+
if (!config.confirm && !danger) {
|
|
226
|
+
await runPhase(nl, fixed, false);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
215
229
|
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
216
230
|
}
|
|
217
231
|
catch (e) {
|
|
@@ -240,5 +254,5 @@ export default function App() {
|
|
|
240
254
|
}
|
|
241
255
|
const isRaw = phase.type === "input" && phase.raw;
|
|
242
256
|
// ── render ─────────────────────────────────────────────────────────────────
|
|
243
|
-
return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), entry.nl !== entry.cmd && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: entry.cmd })] })), entry.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 4, children: [entry.lines.map((line, j) => (_jsx(Text, { color: entry.error ? "red" : undefined, children: line }, j))), entry.truncated && !entry.expanded && (_jsx(Text, { dimColor: true, children: "\u2026 (space to expand)" }))] }))] }, i))), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command }), phase.danger && _jsx(Text, { color: "red", children: " \u26A0 irreversible" })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "enter n e ?" }) })] })), phase.type === "explain" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: phase.explanation }) }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "any key to continue" }) })] })), phase.type === "autofix" && (_jsx(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: " command failed \u2014 retry with fix? [enter / n]" }) })), phase.type === "thinking" && _jsx(Spinner, { label: "translating" }), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.command })] }), streamLines.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: streamLines.slice(-MAX_LINES).map((line, i) => (_jsx(Text, { children: line }, i))) })), _jsx(Spinner, { label: "ctrl+c to cancel" })] })), phase.type === "error" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "red", children: phase.message }) })), phase.type === "input" && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: isRaw ? "$" : "›" }), _jsxs(Box, { children: [_jsx(Text, { children: phase.value.slice(0, phase.cursor) }), _jsx(Text, { inverse: true, children: phase.value[phase.cursor] ?? " " }), _jsx(Text, { children: phase.value.slice(phase.cursor + 1) })] })] })), _jsx(StatusBar, { permissions: config.permissions })] }));
|
|
257
|
+
return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), entry.nl !== entry.cmd && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: entry.cmd })] })), entry.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 4, children: [entry.lines.map((line, j) => (_jsx(Text, { color: entry.error ? "red" : undefined, children: line }, j))), entry.truncated && !entry.expanded && (_jsx(Text, { dimColor: true, children: "\u2026 (space to expand)" }))] }))] }, i))), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command }), phase.danger && _jsx(Text, { color: "red", children: " \u26A0 irreversible" })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "enter n e ?" }) })] })), phase.type === "explain" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: phase.explanation }) }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "any key to continue" }) })] })), phase.type === "autofix" && (_jsx(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: " command failed \u2014 retry with fix? [enter / n]" }) })), phase.type === "thinking" && (phase.partial ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.partial })] })] })) : (_jsx(Spinner, { label: "translating" }))), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.command })] }), streamLines.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: streamLines.slice(-MAX_LINES).map((line, i) => (_jsx(Text, { children: line }, i))) })), _jsx(Spinner, { label: "ctrl+c to cancel" })] })), phase.type === "error" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "red", children: phase.message }) })), phase.type === "input" && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: isRaw ? "$" : "›" }), _jsxs(Box, { children: [_jsx(Text, { children: phase.value.slice(0, phase.cursor) }), _jsx(Text, { inverse: true, children: phase.value[phase.cursor] ?? " " }), _jsx(Text, { children: phase.value.slice(phase.cursor + 1) })] })] })), _jsx(StatusBar, { permissions: config.permissions })] }));
|
|
244
258
|
}
|
package/dist/ai.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { cacheGet, cacheSet } from "./cache.js";
|
|
2
3
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
4
|
+
// ── model routing ─────────────────────────────────────────────────────────────
|
|
5
|
+
// Simple queries → haiku (fast). Complex/ambiguous → sonnet.
|
|
6
|
+
const COMPLEX_SIGNALS = [
|
|
7
|
+
/\b(undo|revert|rollback|previous|last)\b/i,
|
|
8
|
+
/\b(all files?|recursively|bulk|batch)\b/i,
|
|
9
|
+
/\b(pipeline|chain|then|and then|after)\b/i,
|
|
10
|
+
/\b(if|when|unless|only if)\b/i,
|
|
11
|
+
/[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
|
|
12
|
+
];
|
|
13
|
+
function pickModel(nl) {
|
|
14
|
+
const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
|
|
15
|
+
return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
|
|
16
|
+
}
|
|
3
17
|
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
4
18
|
const IRREVERSIBLE_PATTERNS = [
|
|
5
19
|
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
6
|
-
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
20
|
+
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
7
21
|
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
8
22
|
];
|
|
9
23
|
export function isIrreversible(command) {
|
|
@@ -32,35 +46,82 @@ export function checkPermissions(command, perms) {
|
|
|
32
46
|
function buildSystemPrompt(perms, sessionCmds) {
|
|
33
47
|
const restrictions = [];
|
|
34
48
|
if (!perms.destructive)
|
|
35
|
-
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data
|
|
49
|
+
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
|
|
36
50
|
if (!perms.network)
|
|
37
|
-
restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh,
|
|
51
|
+
restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
|
|
38
52
|
if (!perms.sudo)
|
|
39
|
-
restrictions.push("- NEVER generate commands
|
|
53
|
+
restrictions.push("- NEVER generate commands requiring sudo");
|
|
40
54
|
if (!perms.write_outside_cwd)
|
|
41
|
-
restrictions.push("- NEVER
|
|
55
|
+
restrictions.push("- NEVER write to paths outside the current working directory");
|
|
42
56
|
if (!perms.install)
|
|
43
|
-
restrictions.push("- NEVER
|
|
57
|
+
restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
|
|
44
58
|
const restrictionBlock = restrictions.length > 0
|
|
45
|
-
? `\n\
|
|
59
|
+
? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
|
|
46
60
|
: "";
|
|
47
61
|
const contextBlock = sessionCmds.length > 0
|
|
48
|
-
? `\n\
|
|
62
|
+
? `\n\nSESSION HISTORY:\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
|
|
49
63
|
: "";
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
|
|
65
|
+
cwd: ${process.cwd()}
|
|
66
|
+
shell: zsh / macOS${restrictionBlock}${contextBlock}`;
|
|
67
|
+
}
|
|
68
|
+
// ── streaming translate ───────────────────────────────────────────────────────
|
|
69
|
+
export async function translateToCommand(nl, perms, sessionCmds, onToken) {
|
|
70
|
+
// cache hit — instant
|
|
71
|
+
const cached = cacheGet(nl);
|
|
72
|
+
if (cached) {
|
|
73
|
+
onToken?.(cached);
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
const model = pickModel(nl);
|
|
77
|
+
let result = "";
|
|
78
|
+
if (onToken) {
|
|
79
|
+
// streaming path
|
|
80
|
+
const stream = await client.messages.stream({
|
|
81
|
+
model,
|
|
82
|
+
max_tokens: 256,
|
|
83
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
84
|
+
messages: [{ role: "user", content: nl }],
|
|
85
|
+
});
|
|
86
|
+
for await (const chunk of stream) {
|
|
87
|
+
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
|
88
|
+
result += chunk.delta.text;
|
|
89
|
+
onToken(result.trim());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const message = await client.messages.create({
|
|
95
|
+
model,
|
|
96
|
+
max_tokens: 256,
|
|
97
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
98
|
+
messages: [{ role: "user", content: nl }],
|
|
99
|
+
});
|
|
100
|
+
const block = message.content[0];
|
|
101
|
+
if (block.type !== "text")
|
|
102
|
+
throw new Error("Unexpected response type");
|
|
103
|
+
result = block.text;
|
|
104
|
+
}
|
|
105
|
+
const text = result.trim();
|
|
106
|
+
if (text.startsWith("BLOCKED:"))
|
|
107
|
+
throw new Error(text);
|
|
108
|
+
cacheSet(nl, text);
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
// ── prefetch ──────────────────────────────────────────────────────────────────
|
|
112
|
+
// Silently warm the cache after a command runs — no await, fire and forget
|
|
113
|
+
export function prefetchNext(lastNl, perms, sessionCmds) {
|
|
114
|
+
// Only prefetch if we don't have it cached already
|
|
115
|
+
if (cacheGet(lastNl))
|
|
116
|
+
return;
|
|
117
|
+
translateToCommand(lastNl, perms, sessionCmds).catch(() => { });
|
|
57
118
|
}
|
|
58
119
|
// ── explain ───────────────────────────────────────────────────────────────────
|
|
59
120
|
export async function explainCommand(command) {
|
|
60
121
|
const message = await client.messages.create({
|
|
61
122
|
model: "claude-haiku-4-5-20251001",
|
|
62
123
|
max_tokens: 128,
|
|
63
|
-
system: "Explain what this shell command does in one plain English sentence. No markdown
|
|
124
|
+
system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
|
|
64
125
|
messages: [{ role: "user", content: command }],
|
|
65
126
|
});
|
|
66
127
|
const block = message.content[0];
|
|
@@ -71,31 +132,13 @@ export async function explainCommand(command) {
|
|
|
71
132
|
// ── auto-fix ──────────────────────────────────────────────────────────────────
|
|
72
133
|
export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
|
|
73
134
|
const message = await client.messages.create({
|
|
74
|
-
model: "claude-
|
|
135
|
+
model: "claude-sonnet-4-6",
|
|
75
136
|
max_tokens: 256,
|
|
76
137
|
system: buildSystemPrompt(perms, sessionCmds),
|
|
77
|
-
messages: [
|
|
78
|
-
{
|
|
138
|
+
messages: [{
|
|
79
139
|
role: "user",
|
|
80
|
-
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
});
|
|
84
|
-
const block = message.content[0];
|
|
85
|
-
if (block.type !== "text")
|
|
86
|
-
throw new Error("Unexpected response type");
|
|
87
|
-
const text = block.text.trim();
|
|
88
|
-
if (text.startsWith("BLOCKED:"))
|
|
89
|
-
throw new Error(text);
|
|
90
|
-
return text;
|
|
91
|
-
}
|
|
92
|
-
// ── translate ─────────────────────────────────────────────────────────────────
|
|
93
|
-
export async function translateToCommand(nl, perms, sessionCmds) {
|
|
94
|
-
const message = await client.messages.create({
|
|
95
|
-
model: "claude-opus-4-6",
|
|
96
|
-
max_tokens: 256,
|
|
97
|
-
system: buildSystemPrompt(perms, sessionCmds),
|
|
98
|
-
messages: [{ role: "user", content: nl }],
|
|
140
|
+
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
|
|
141
|
+
}],
|
|
99
142
|
});
|
|
100
143
|
const block = message.content[0];
|
|
101
144
|
if (block.type !== "text")
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// In-memory LRU cache + disk persistence for command translations
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
|
|
6
|
+
const MAX_ENTRIES = 500;
|
|
7
|
+
let mem = {};
|
|
8
|
+
export function loadCache() {
|
|
9
|
+
if (!existsSync(CACHE_FILE))
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
mem = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
}
|
|
16
|
+
function persistCache() {
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(CACHE_FILE, JSON.stringify(mem));
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
}
|
|
22
|
+
/** Normalize a natural language query for cache lookup */
|
|
23
|
+
export function normalizeNl(nl) {
|
|
24
|
+
return nl
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/[^a-z0-9\s]/g, "") // strip punctuation
|
|
28
|
+
.replace(/\s+/g, " ");
|
|
29
|
+
}
|
|
30
|
+
export function cacheGet(nl) {
|
|
31
|
+
return mem[normalizeNl(nl)] ?? null;
|
|
32
|
+
}
|
|
33
|
+
export function cacheSet(nl, command) {
|
|
34
|
+
const key = normalizeNl(nl);
|
|
35
|
+
// evict oldest if full
|
|
36
|
+
const keys = Object.keys(mem);
|
|
37
|
+
if (keys.length >= MAX_ENTRIES)
|
|
38
|
+
delete mem[keys[0]];
|
|
39
|
+
mem[key] = command;
|
|
40
|
+
persistCache();
|
|
41
|
+
}
|
package/dist/history.js
CHANGED
|
@@ -13,6 +13,7 @@ export const DEFAULT_PERMISSIONS = {
|
|
|
13
13
|
};
|
|
14
14
|
export const DEFAULT_CONFIG = {
|
|
15
15
|
onboarded: false,
|
|
16
|
+
confirm: false,
|
|
16
17
|
permissions: DEFAULT_PERMISSIONS,
|
|
17
18
|
};
|
|
18
19
|
function ensureDir() {
|
|
@@ -47,6 +48,7 @@ export function loadConfig() {
|
|
|
47
48
|
return {
|
|
48
49
|
...DEFAULT_CONFIG,
|
|
49
50
|
...saved,
|
|
51
|
+
confirm: saved.confirm ?? false,
|
|
50
52
|
permissions: { ...DEFAULT_PERMISSIONS, ...(saved.permissions ?? {}) },
|
|
51
53
|
};
|
|
52
54
|
}
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -9,15 +9,19 @@ import {
|
|
|
9
9
|
saveConfig,
|
|
10
10
|
type Permissions,
|
|
11
11
|
} from "./history.js";
|
|
12
|
+
import { loadCache } from "./cache.js";
|
|
12
13
|
import Onboarding from "./Onboarding.js";
|
|
13
14
|
import StatusBar from "./StatusBar.js";
|
|
14
15
|
import Spinner from "./Spinner.js";
|
|
15
16
|
|
|
17
|
+
// warm cache on startup
|
|
18
|
+
loadCache();
|
|
19
|
+
|
|
16
20
|
// ── types ─────────────────────────────────────────────────────────────────────
|
|
17
21
|
|
|
18
22
|
type Phase =
|
|
19
23
|
| { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
|
|
20
|
-
| { type: "thinking"; nl: string; raw: boolean }
|
|
24
|
+
| { type: "thinking"; nl: string; raw: boolean; partial: string }
|
|
21
25
|
| { type: "confirm"; nl: string; command: string; raw: boolean; danger: boolean }
|
|
22
26
|
| { type: "explain"; nl: string; command: string; explanation: string }
|
|
23
27
|
| { type: "running"; nl: string; command: string }
|
|
@@ -80,7 +84,7 @@ export default function App() {
|
|
|
80
84
|
const allNl = [...nlHistory, ...sessionNl];
|
|
81
85
|
|
|
82
86
|
const finishOnboarding = (perms: Permissions) => {
|
|
83
|
-
const next = { onboarded: true, permissions: perms };
|
|
87
|
+
const next = { onboarded: true, confirm: false, permissions: perms };
|
|
84
88
|
setConfig(next);
|
|
85
89
|
saveConfig(next);
|
|
86
90
|
};
|
|
@@ -175,9 +179,11 @@ export default function App() {
|
|
|
175
179
|
return;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
182
|
+
setPhase({ type: "thinking", nl, raw: false, partial: "" });
|
|
179
183
|
try {
|
|
180
|
-
const command = await translateToCommand(nl, config.permissions, sessionCmds)
|
|
184
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds, (partial) => {
|
|
185
|
+
setPhase({ type: "thinking", nl, raw: false, partial });
|
|
186
|
+
});
|
|
181
187
|
const blocked = checkPermissions(command, config.permissions);
|
|
182
188
|
if (blocked) {
|
|
183
189
|
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
@@ -185,6 +191,11 @@ export default function App() {
|
|
|
185
191
|
return;
|
|
186
192
|
}
|
|
187
193
|
const danger = isIrreversible(command);
|
|
194
|
+
// skip confirm unless user opted in OR command is dangerous
|
|
195
|
+
if (!config.confirm && !danger) {
|
|
196
|
+
await runPhase(nl, command, false);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
188
199
|
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
189
200
|
} catch (e: any) {
|
|
190
201
|
setPhase({ type: "error", message: e.message });
|
|
@@ -210,7 +221,7 @@ export default function App() {
|
|
|
210
221
|
|
|
211
222
|
if (input === "?") {
|
|
212
223
|
const { nl, command } = phase;
|
|
213
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
224
|
+
setPhase({ type: "thinking", nl, raw: false, partial: "" });
|
|
214
225
|
try {
|
|
215
226
|
const explanation = await explainCommand(command);
|
|
216
227
|
setPhase({ type: "explain", nl, command, explanation });
|
|
@@ -243,10 +254,14 @@ export default function App() {
|
|
|
243
254
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
244
255
|
if (input === "y" || input === "Y" || key.return) {
|
|
245
256
|
const { nl, command, errorOutput } = phase;
|
|
246
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
257
|
+
setPhase({ type: "thinking", nl, raw: false, partial: "" });
|
|
247
258
|
try {
|
|
248
259
|
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
249
260
|
const danger = isIrreversible(fixed);
|
|
261
|
+
if (!config.confirm && !danger) {
|
|
262
|
+
await runPhase(nl, fixed, false);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
250
265
|
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
251
266
|
} catch (e: any) {
|
|
252
267
|
setPhase({ type: "error", message: e.message });
|
|
@@ -346,8 +361,23 @@ export default function App() {
|
|
|
346
361
|
</Box>
|
|
347
362
|
)}
|
|
348
363
|
|
|
349
|
-
{/*
|
|
350
|
-
{phase.type === "thinking" &&
|
|
364
|
+
{/* thinking — show streaming partial or spinner */}
|
|
365
|
+
{phase.type === "thinking" && (
|
|
366
|
+
phase.partial ? (
|
|
367
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
368
|
+
<Box gap={2}>
|
|
369
|
+
<Text dimColor>›</Text>
|
|
370
|
+
<Text dimColor>{phase.nl}</Text>
|
|
371
|
+
</Box>
|
|
372
|
+
<Box gap={2} paddingLeft={2}>
|
|
373
|
+
<Text dimColor>$</Text>
|
|
374
|
+
<Text dimColor>{phase.partial}</Text>
|
|
375
|
+
</Box>
|
|
376
|
+
</Box>
|
|
377
|
+
) : (
|
|
378
|
+
<Spinner label="translating" />
|
|
379
|
+
)
|
|
380
|
+
)}
|
|
351
381
|
|
|
352
382
|
{/* running — live stream */}
|
|
353
383
|
{phase.type === "running" && (
|
package/src/ai.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { Permissions } from "./history.js";
|
|
3
|
+
import { cacheGet, cacheSet } from "./cache.js";
|
|
3
4
|
|
|
4
5
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
5
6
|
|
|
7
|
+
// ── model routing ─────────────────────────────────────────────────────────────
|
|
8
|
+
// Simple queries → haiku (fast). Complex/ambiguous → sonnet.
|
|
9
|
+
|
|
10
|
+
const COMPLEX_SIGNALS = [
|
|
11
|
+
/\b(undo|revert|rollback|previous|last)\b/i,
|
|
12
|
+
/\b(all files?|recursively|bulk|batch)\b/i,
|
|
13
|
+
/\b(pipeline|chain|then|and then|after)\b/i,
|
|
14
|
+
/\b(if|when|unless|only if)\b/i,
|
|
15
|
+
/[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function pickModel(nl: string): string {
|
|
19
|
+
const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
|
|
20
|
+
return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
7
24
|
|
|
8
25
|
const IRREVERSIBLE_PATTERNS = [
|
|
9
26
|
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
10
|
-
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
27
|
+
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
11
28
|
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
12
29
|
];
|
|
13
30
|
|
|
@@ -17,11 +34,11 @@ export function isIrreversible(command: string): boolean {
|
|
|
17
34
|
|
|
18
35
|
// ── permissions ───────────────────────────────────────────────────────────────
|
|
19
36
|
|
|
20
|
-
const DESTRUCTIVE_PATTERNS
|
|
21
|
-
const NETWORK_PATTERNS
|
|
22
|
-
const SUDO_PATTERNS
|
|
23
|
-
const INSTALL_PATTERNS
|
|
24
|
-
const WRITE_OUTSIDE_PATTERNS
|
|
37
|
+
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
38
|
+
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
39
|
+
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
40
|
+
const INSTALL_PATTERNS = [/\bbrew\s+install\b/, /\bnpm\s+install\s+-g\b/, /\bpip\s+install\b/, /\bapt\s+install\b/, /\byum\s+install\b/];
|
|
41
|
+
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
25
42
|
|
|
26
43
|
export function checkPermissions(command: string, perms: Permissions): string | null {
|
|
27
44
|
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
@@ -42,32 +59,87 @@ export function checkPermissions(command: string, perms: Permissions): string |
|
|
|
42
59
|
function buildSystemPrompt(perms: Permissions, sessionCmds: string[]): string {
|
|
43
60
|
const restrictions: string[] = [];
|
|
44
61
|
if (!perms.destructive)
|
|
45
|
-
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data
|
|
62
|
+
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
|
|
46
63
|
if (!perms.network)
|
|
47
|
-
restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh,
|
|
64
|
+
restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
|
|
48
65
|
if (!perms.sudo)
|
|
49
|
-
restrictions.push("- NEVER generate commands
|
|
66
|
+
restrictions.push("- NEVER generate commands requiring sudo");
|
|
50
67
|
if (!perms.write_outside_cwd)
|
|
51
|
-
restrictions.push("- NEVER
|
|
68
|
+
restrictions.push("- NEVER write to paths outside the current working directory");
|
|
52
69
|
if (!perms.install)
|
|
53
|
-
restrictions.push("- NEVER
|
|
70
|
+
restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
|
|
54
71
|
|
|
55
72
|
const restrictionBlock = restrictions.length > 0
|
|
56
|
-
? `\n\
|
|
73
|
+
? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
|
|
57
74
|
: "";
|
|
58
75
|
|
|
59
76
|
const contextBlock = sessionCmds.length > 0
|
|
60
|
-
? `\n\
|
|
77
|
+
? `\n\nSESSION HISTORY:\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
|
|
61
78
|
: "";
|
|
62
79
|
|
|
63
|
-
|
|
80
|
+
return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
|
|
81
|
+
cwd: ${process.cwd()}
|
|
82
|
+
shell: zsh / macOS${restrictionBlock}${contextBlock}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── streaming translate ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export async function translateToCommand(
|
|
88
|
+
nl: string,
|
|
89
|
+
perms: Permissions,
|
|
90
|
+
sessionCmds: string[],
|
|
91
|
+
onToken?: (partial: string) => void
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
// cache hit — instant
|
|
94
|
+
const cached = cacheGet(nl);
|
|
95
|
+
if (cached) { onToken?.(cached); return cached; }
|
|
96
|
+
|
|
97
|
+
const model = pickModel(nl);
|
|
98
|
+
let result = "";
|
|
99
|
+
|
|
100
|
+
if (onToken) {
|
|
101
|
+
// streaming path
|
|
102
|
+
const stream = await client.messages.stream({
|
|
103
|
+
model,
|
|
104
|
+
max_tokens: 256,
|
|
105
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
106
|
+
messages: [{ role: "user", content: nl }],
|
|
107
|
+
});
|
|
108
|
+
for await (const chunk of stream) {
|
|
109
|
+
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
|
110
|
+
result += chunk.delta.text;
|
|
111
|
+
onToken(result.trim());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
const message = await client.messages.create({
|
|
116
|
+
model,
|
|
117
|
+
max_tokens: 256,
|
|
118
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
119
|
+
messages: [{ role: "user", content: nl }],
|
|
120
|
+
});
|
|
121
|
+
const block = message.content[0];
|
|
122
|
+
if (block.type !== "text") throw new Error("Unexpected response type");
|
|
123
|
+
result = block.text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = result.trim();
|
|
127
|
+
if (text.startsWith("BLOCKED:")) throw new Error(text);
|
|
128
|
+
cacheSet(nl, text);
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── prefetch ──────────────────────────────────────────────────────────────────
|
|
133
|
+
// Silently warm the cache after a command runs — no await, fire and forget
|
|
64
134
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
135
|
+
export function prefetchNext(
|
|
136
|
+
lastNl: string,
|
|
137
|
+
perms: Permissions,
|
|
138
|
+
sessionCmds: string[]
|
|
139
|
+
) {
|
|
140
|
+
// Only prefetch if we don't have it cached already
|
|
141
|
+
if (cacheGet(lastNl)) return;
|
|
142
|
+
translateToCommand(lastNl, perms, sessionCmds).catch(() => {});
|
|
71
143
|
}
|
|
72
144
|
|
|
73
145
|
// ── explain ───────────────────────────────────────────────────────────────────
|
|
@@ -76,7 +148,7 @@ export async function explainCommand(command: string): Promise<string> {
|
|
|
76
148
|
const message = await client.messages.create({
|
|
77
149
|
model: "claude-haiku-4-5-20251001",
|
|
78
150
|
max_tokens: 128,
|
|
79
|
-
system: "Explain what this shell command does in one plain English sentence. No markdown
|
|
151
|
+
system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
|
|
80
152
|
messages: [{ role: "user", content: command }],
|
|
81
153
|
});
|
|
82
154
|
const block = message.content[0];
|
|
@@ -94,35 +166,13 @@ export async function fixCommand(
|
|
|
94
166
|
sessionCmds: string[]
|
|
95
167
|
): Promise<string> {
|
|
96
168
|
const message = await client.messages.create({
|
|
97
|
-
model: "claude-
|
|
98
|
-
max_tokens: 256,
|
|
99
|
-
system: buildSystemPrompt(perms, sessionCmds),
|
|
100
|
-
messages: [
|
|
101
|
-
{
|
|
102
|
-
role: "user",
|
|
103
|
-
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nIt failed with:\n${errorOutput}\n\nGive me the corrected command.`,
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
});
|
|
107
|
-
const block = message.content[0];
|
|
108
|
-
if (block.type !== "text") throw new Error("Unexpected response type");
|
|
109
|
-
const text = block.text.trim();
|
|
110
|
-
if (text.startsWith("BLOCKED:")) throw new Error(text);
|
|
111
|
-
return text;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── translate ─────────────────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
export async function translateToCommand(
|
|
117
|
-
nl: string,
|
|
118
|
-
perms: Permissions,
|
|
119
|
-
sessionCmds: string[]
|
|
120
|
-
): Promise<string> {
|
|
121
|
-
const message = await client.messages.create({
|
|
122
|
-
model: "claude-opus-4-6",
|
|
169
|
+
model: "claude-sonnet-4-6",
|
|
123
170
|
max_tokens: 256,
|
|
124
171
|
system: buildSystemPrompt(perms, sessionCmds),
|
|
125
|
-
messages: [{
|
|
172
|
+
messages: [{
|
|
173
|
+
role: "user",
|
|
174
|
+
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
|
|
175
|
+
}],
|
|
126
176
|
});
|
|
127
177
|
const block = message.content[0];
|
|
128
178
|
if (block.type !== "text") throw new Error("Unexpected response type");
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// In-memory LRU cache + disk persistence for command translations
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
|
|
8
|
+
const MAX_ENTRIES = 500;
|
|
9
|
+
|
|
10
|
+
type CacheMap = Record<string, string>;
|
|
11
|
+
|
|
12
|
+
let mem: CacheMap = {};
|
|
13
|
+
|
|
14
|
+
export function loadCache() {
|
|
15
|
+
if (!existsSync(CACHE_FILE)) return;
|
|
16
|
+
try { mem = JSON.parse(readFileSync(CACHE_FILE, "utf8")); } catch {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function persistCache() {
|
|
20
|
+
try { writeFileSync(CACHE_FILE, JSON.stringify(mem)); } catch {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize a natural language query for cache lookup */
|
|
24
|
+
export function normalizeNl(nl: string): string {
|
|
25
|
+
return nl
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.trim()
|
|
28
|
+
.replace(/[^a-z0-9\s]/g, "") // strip punctuation
|
|
29
|
+
.replace(/\s+/g, " ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cacheGet(nl: string): string | null {
|
|
33
|
+
return mem[normalizeNl(nl)] ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function cacheSet(nl: string, command: string) {
|
|
37
|
+
const key = normalizeNl(nl);
|
|
38
|
+
// evict oldest if full
|
|
39
|
+
const keys = Object.keys(mem);
|
|
40
|
+
if (keys.length >= MAX_ENTRIES) delete mem[keys[0]];
|
|
41
|
+
mem[key] = command;
|
|
42
|
+
persistCache();
|
|
43
|
+
}
|
package/src/history.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface Permissions {
|
|
|
29
29
|
|
|
30
30
|
export interface Config {
|
|
31
31
|
onboarded: boolean;
|
|
32
|
+
confirm: boolean; // ask before running — false = run immediately
|
|
32
33
|
permissions: Permissions;
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -42,6 +43,7 @@ export const DEFAULT_PERMISSIONS: Permissions = {
|
|
|
42
43
|
|
|
43
44
|
export const DEFAULT_CONFIG: Config = {
|
|
44
45
|
onboarded: false,
|
|
46
|
+
confirm: false,
|
|
45
47
|
permissions: DEFAULT_PERMISSIONS,
|
|
46
48
|
};
|
|
47
49
|
|
|
@@ -77,6 +79,7 @@ export function loadConfig(): Config {
|
|
|
77
79
|
return {
|
|
78
80
|
...DEFAULT_CONFIG,
|
|
79
81
|
...saved,
|
|
82
|
+
confirm: saved.confirm ?? false,
|
|
80
83
|
permissions: { ...DEFAULT_PERMISSIONS, ...(saved.permissions ?? {}) },
|
|
81
84
|
};
|
|
82
85
|
} catch {
|