@hasna/terminal 0.1.1 → 0.1.3
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 +125 -75
- package/dist/ai.js +62 -24
- package/dist/history.js +6 -4
- package/package.json +1 -1
- package/src/App.tsx +175 -100
- package/src/ai.ts +84 -29
- package/src/history.ts +7 -4
package/dist/App.js
CHANGED
|
@@ -1,73 +1,119 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback } from "react";
|
|
2
|
+
import { useState, useCallback, useRef } from "react";
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { translateToCommand, explainCommand, checkPermissions } from "./ai.js";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
|
|
7
6
|
import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
|
|
8
7
|
import Onboarding from "./Onboarding.js";
|
|
9
8
|
import StatusBar from "./StatusBar.js";
|
|
10
9
|
import Spinner from "./Spinner.js";
|
|
11
|
-
const
|
|
12
|
-
// ── helpers
|
|
13
|
-
function insertAt(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
const MAX_LINES = 20;
|
|
11
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
12
|
+
function insertAt(s, pos, ch) { return s.slice(0, pos) + ch + s.slice(pos); }
|
|
13
|
+
function deleteAt(s, pos) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
|
|
14
|
+
function runCommand(command, onLine, onDone, signal) {
|
|
15
|
+
const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
16
|
+
const handleData = (data) => {
|
|
17
|
+
const text = data.toString();
|
|
18
|
+
text.split("\n").forEach((line) => { if (line)
|
|
19
|
+
onLine(line); });
|
|
20
|
+
};
|
|
21
|
+
proc.stdout?.on("data", handleData);
|
|
22
|
+
proc.stderr?.on("data", handleData);
|
|
23
|
+
proc.on("close", (code) => onDone(code ?? 0));
|
|
24
|
+
signal.addEventListener("abort", () => { try {
|
|
25
|
+
proc.kill("SIGTERM");
|
|
26
|
+
}
|
|
27
|
+
catch { } });
|
|
28
|
+
return proc;
|
|
20
29
|
}
|
|
21
|
-
// ── component
|
|
30
|
+
// ── component ─────────────────────────────────────────────────────────────────
|
|
22
31
|
export default function App() {
|
|
23
32
|
const { exit } = useApp();
|
|
24
33
|
const [config, setConfig] = useState(() => loadConfig());
|
|
25
|
-
const [nlHistory] = useState(() => loadHistory()
|
|
26
|
-
|
|
27
|
-
.filter(Boolean));
|
|
34
|
+
const [nlHistory] = useState(() => loadHistory().map((h) => h.nl).filter(Boolean));
|
|
35
|
+
const [sessionCmds, setSessionCmds] = useState([]);
|
|
28
36
|
const [sessionNl, setSessionNl] = useState([]);
|
|
29
37
|
const [scroll, setScroll] = useState([]);
|
|
38
|
+
const [streamLines, setStreamLines] = useState([]);
|
|
39
|
+
const abortRef = useRef(null);
|
|
30
40
|
const [phase, setPhase] = useState({
|
|
31
|
-
type: "input",
|
|
32
|
-
value: "",
|
|
33
|
-
cursor: 0,
|
|
34
|
-
histIdx: -1,
|
|
35
|
-
raw: false,
|
|
41
|
+
type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
|
|
36
42
|
});
|
|
37
43
|
const allNl = [...nlHistory, ...sessionNl];
|
|
38
44
|
const finishOnboarding = (perms) => {
|
|
39
|
-
const next = { onboarded: true, permissions: perms };
|
|
45
|
+
const next = { onboarded: true, confirm: false, permissions: perms };
|
|
40
46
|
setConfig(next);
|
|
41
47
|
saveConfig(next);
|
|
42
48
|
};
|
|
43
|
-
const
|
|
44
|
-
|
|
49
|
+
const inputPhase = (overrides = {}) => {
|
|
50
|
+
setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
|
|
51
|
+
setStreamLines([]);
|
|
52
|
+
};
|
|
53
|
+
const pushScroll = (entry) => setScroll((s) => [...s, { ...entry, expanded: false }]);
|
|
54
|
+
const commitStream = (nl, cmd, lines, error) => {
|
|
55
|
+
const truncated = lines.length > MAX_LINES;
|
|
56
|
+
pushScroll({ nl, cmd, lines: truncated ? lines.slice(0, MAX_LINES) : lines, truncated, error });
|
|
57
|
+
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
58
|
+
setSessionCmds((c) => [...c.slice(-9), cmd]);
|
|
59
|
+
setStreamLines([]);
|
|
60
|
+
};
|
|
61
|
+
const runPhase = async (nl, command, raw) => {
|
|
62
|
+
setPhase({ type: "running", nl, command });
|
|
63
|
+
setStreamLines([]);
|
|
64
|
+
const abort = new AbortController();
|
|
65
|
+
abortRef.current = abort;
|
|
66
|
+
const lines = [];
|
|
67
|
+
await new Promise((resolve) => {
|
|
68
|
+
runCommand(command, (line) => { lines.push(line); setStreamLines([...lines]); }, (code) => {
|
|
69
|
+
commitStream(nl, command, lines, code !== 0);
|
|
70
|
+
abortRef.current = null;
|
|
71
|
+
if (code !== 0 && !raw) {
|
|
72
|
+
// offer auto-fix
|
|
73
|
+
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
inputPhase({ raw });
|
|
77
|
+
}
|
|
78
|
+
resolve();
|
|
79
|
+
}, abort.signal);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
45
82
|
useInput(useCallback(async (input, key) => {
|
|
83
|
+
// ── running: Ctrl+C kills process ─────────────────────────────────
|
|
84
|
+
if (phase.type === "running") {
|
|
85
|
+
if (key.ctrl && input === "c") {
|
|
86
|
+
abortRef.current?.abort();
|
|
87
|
+
inputPhase();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
46
91
|
// ── input ─────────────────────────────────────────────────────────
|
|
47
92
|
if (phase.type === "input") {
|
|
48
93
|
if (key.ctrl && input === "c") {
|
|
49
94
|
exit();
|
|
50
95
|
return;
|
|
51
96
|
}
|
|
52
|
-
|
|
97
|
+
if (key.ctrl && input === "l") {
|
|
98
|
+
setScroll([]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
53
101
|
if (key.ctrl && input === "r") {
|
|
54
102
|
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
55
103
|
return;
|
|
56
104
|
}
|
|
57
|
-
// history navigation
|
|
58
105
|
if (key.upArrow) {
|
|
59
|
-
const
|
|
60
|
-
const val = allNl[allNl.length - 1 -
|
|
61
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx:
|
|
106
|
+
const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
107
|
+
const val = allNl[allNl.length - 1 - idx] ?? "";
|
|
108
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
62
109
|
return;
|
|
63
110
|
}
|
|
64
111
|
if (key.downArrow) {
|
|
65
|
-
const
|
|
66
|
-
const val =
|
|
67
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx:
|
|
112
|
+
const idx = Math.max(phase.histIdx - 1, -1);
|
|
113
|
+
const val = idx === -1 ? "" : allNl[allNl.length - 1 - idx] ?? "";
|
|
114
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
68
115
|
return;
|
|
69
116
|
}
|
|
70
|
-
// cursor movement
|
|
71
117
|
if (key.leftArrow) {
|
|
72
118
|
setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
|
|
73
119
|
return;
|
|
@@ -76,39 +122,31 @@ export default function App() {
|
|
|
76
122
|
setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
|
|
77
123
|
return;
|
|
78
124
|
}
|
|
79
|
-
// submit
|
|
80
125
|
if (key.return) {
|
|
81
126
|
const nl = phase.value.trim();
|
|
82
127
|
if (!nl)
|
|
83
128
|
return;
|
|
84
129
|
setSessionNl((h) => [...h, nl]);
|
|
85
130
|
if (phase.raw) {
|
|
86
|
-
|
|
87
|
-
setPhase({ type: "running", nl, command: nl });
|
|
88
|
-
try {
|
|
89
|
-
const { stdout, stderr } = await execAsync(nl, { shell: "/bin/zsh" });
|
|
90
|
-
const output = (stdout + stderr).trim();
|
|
91
|
-
pushScroll({ nl, cmd: nl, output });
|
|
92
|
-
appendHistory({ nl, cmd: nl, output, ts: Date.now() });
|
|
93
|
-
}
|
|
94
|
-
catch (e) {
|
|
95
|
-
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
96
|
-
pushScroll({ nl, cmd: nl, output, error: true });
|
|
97
|
-
appendHistory({ nl, cmd: nl, output, ts: Date.now(), error: true });
|
|
98
|
-
}
|
|
99
|
-
inputPhase({ raw: true });
|
|
131
|
+
await runPhase(nl, nl, true);
|
|
100
132
|
return;
|
|
101
133
|
}
|
|
102
134
|
setPhase({ type: "thinking", nl, raw: false });
|
|
103
135
|
try {
|
|
104
|
-
const command = await translateToCommand(nl, config.permissions);
|
|
136
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds);
|
|
105
137
|
const blocked = checkPermissions(command, config.permissions);
|
|
106
138
|
if (blocked) {
|
|
107
|
-
pushScroll({ nl, cmd: command,
|
|
139
|
+
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
108
140
|
inputPhase();
|
|
109
141
|
return;
|
|
110
142
|
}
|
|
111
|
-
|
|
143
|
+
const danger = isIrreversible(command);
|
|
144
|
+
// skip confirm unless user opted in OR command is dangerous
|
|
145
|
+
if (!config.confirm && !danger) {
|
|
146
|
+
await runPhase(nl, command, false);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
112
150
|
}
|
|
113
151
|
catch (e) {
|
|
114
152
|
setPhase({ type: "error", message: e.message });
|
|
@@ -132,7 +170,6 @@ export default function App() {
|
|
|
132
170
|
exit();
|
|
133
171
|
return;
|
|
134
172
|
}
|
|
135
|
-
// explain
|
|
136
173
|
if (input === "?") {
|
|
137
174
|
const { nl, command } = phase;
|
|
138
175
|
setPhase({ type: "thinking", nl, raw: false });
|
|
@@ -141,26 +178,12 @@ export default function App() {
|
|
|
141
178
|
setPhase({ type: "explain", nl, command, explanation });
|
|
142
179
|
}
|
|
143
180
|
catch {
|
|
144
|
-
setPhase({ type: "confirm", nl, command, raw: false });
|
|
181
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
145
182
|
}
|
|
146
183
|
return;
|
|
147
184
|
}
|
|
148
185
|
if (input === "y" || input === "Y" || key.return) {
|
|
149
|
-
|
|
150
|
-
setPhase({ type: "running", nl, command });
|
|
151
|
-
try {
|
|
152
|
-
const { stdout, stderr } = await execAsync(command, { shell: "/bin/zsh" });
|
|
153
|
-
const output = (stdout + stderr).trim();
|
|
154
|
-
pushScroll({ nl, cmd: command, output });
|
|
155
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now() });
|
|
156
|
-
inputPhase();
|
|
157
|
-
}
|
|
158
|
-
catch (e) {
|
|
159
|
-
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
160
|
-
pushScroll({ nl, cmd: command, output, error: true });
|
|
161
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
|
|
162
|
-
inputPhase();
|
|
163
|
-
}
|
|
186
|
+
await runPhase(phase.nl, phase.command, false);
|
|
164
187
|
return;
|
|
165
188
|
}
|
|
166
189
|
if (input === "n" || input === "N" || key.escape) {
|
|
@@ -173,14 +196,39 @@ export default function App() {
|
|
|
173
196
|
}
|
|
174
197
|
return;
|
|
175
198
|
}
|
|
176
|
-
// ── explain
|
|
199
|
+
// ── explain → back to confirm ─────────────────────────────────────
|
|
177
200
|
if (phase.type === "explain") {
|
|
178
201
|
if (key.ctrl && input === "c") {
|
|
179
202
|
exit();
|
|
180
203
|
return;
|
|
181
204
|
}
|
|
182
|
-
|
|
183
|
-
|
|
205
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// ── autofix ───────────────────────────────────────────────────────
|
|
209
|
+
if (phase.type === "autofix") {
|
|
210
|
+
if (key.ctrl && input === "c") {
|
|
211
|
+
exit();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
215
|
+
const { nl, command, errorOutput } = phase;
|
|
216
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
217
|
+
try {
|
|
218
|
+
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
219
|
+
const danger = isIrreversible(fixed);
|
|
220
|
+
if (!config.confirm && !danger) {
|
|
221
|
+
await runPhase(nl, fixed, false);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
setPhase({ type: "error", message: e.message });
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
inputPhase();
|
|
184
232
|
return;
|
|
185
233
|
}
|
|
186
234
|
// ── error ─────────────────────────────────────────────────────────
|
|
@@ -192,12 +240,14 @@ export default function App() {
|
|
|
192
240
|
inputPhase();
|
|
193
241
|
return;
|
|
194
242
|
}
|
|
195
|
-
}, [phase, allNl, config, exit]));
|
|
243
|
+
}, [phase, allNl, config, sessionCmds, exit]));
|
|
244
|
+
// ── expand toggle ──────────────────────────────────────────────────────────
|
|
245
|
+
const toggleExpand = (i) => setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
|
|
196
246
|
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
197
247
|
if (!config.onboarded) {
|
|
198
248
|
return _jsx(Onboarding, { onDone: finishOnboarding });
|
|
199
249
|
}
|
|
200
|
-
// ── render ─────────────────────────────────────────────────────────────────
|
|
201
250
|
const isRaw = phase.type === "input" && phase.raw;
|
|
202
|
-
|
|
251
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
252
|
+
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 })] }));
|
|
203
253
|
}
|
package/dist/ai.js
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
3
|
-
|
|
3
|
+
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
4
|
+
const IRREVERSIBLE_PATTERNS = [
|
|
5
|
+
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
6
|
+
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/, // overwrite redirect
|
|
7
|
+
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
8
|
+
];
|
|
9
|
+
export function isIrreversible(command) {
|
|
10
|
+
return IRREVERSIBLE_PATTERNS.some((r) => r.test(command));
|
|
11
|
+
}
|
|
12
|
+
// ── permissions ───────────────────────────────────────────────────────────────
|
|
13
|
+
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
14
|
+
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
15
|
+
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
16
|
+
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/];
|
|
17
|
+
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
18
|
+
export function checkPermissions(command, perms) {
|
|
19
|
+
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
20
|
+
return "destructive commands are disabled";
|
|
21
|
+
if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
|
|
22
|
+
return "network commands are disabled";
|
|
23
|
+
if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
|
|
24
|
+
return "sudo is disabled";
|
|
25
|
+
if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
|
|
26
|
+
return "package installation is disabled";
|
|
27
|
+
if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
|
|
28
|
+
return "writing outside cwd is disabled";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// ── system prompt ─────────────────────────────────────────────────────────────
|
|
32
|
+
function buildSystemPrompt(perms, sessionCmds) {
|
|
4
33
|
const restrictions = [];
|
|
5
34
|
if (!perms.destructive)
|
|
6
35
|
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
|
|
@@ -13,33 +42,20 @@ function buildSystemPrompt(perms) {
|
|
|
13
42
|
if (!perms.install)
|
|
14
43
|
restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
|
|
15
44
|
const restrictionBlock = restrictions.length > 0
|
|
16
|
-
? `\n\nCURRENT RESTRICTIONS (respect
|
|
45
|
+
? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
|
|
46
|
+
: "";
|
|
47
|
+
const contextBlock = sessionCmds.length > 0
|
|
48
|
+
? `\n\nRECENT COMMANDS THIS SESSION (for context — e.g. "undo that", "do the same for X"):\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
|
|
17
49
|
: "";
|
|
50
|
+
const cwd = process.cwd();
|
|
18
51
|
return `You are a terminal assistant. The user will describe what they want to do in plain English.
|
|
19
52
|
Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
|
|
20
53
|
No explanation. No markdown. No backticks. Just the raw command.
|
|
21
54
|
If multiple commands are needed, join them with && or use a newline.
|
|
22
|
-
Assume macOS/Linux zsh environment
|
|
23
|
-
}
|
|
24
|
-
/** Regex patterns for permission checks — fast local guard before even calling AI */
|
|
25
|
-
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
26
|
-
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
27
|
-
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
28
|
-
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/];
|
|
29
|
-
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
30
|
-
export function checkPermissions(command, perms) {
|
|
31
|
-
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
32
|
-
return "destructive commands are disabled in your permissions";
|
|
33
|
-
if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
|
|
34
|
-
return "network commands are disabled in your permissions";
|
|
35
|
-
if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
|
|
36
|
-
return "sudo is disabled in your permissions";
|
|
37
|
-
if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
|
|
38
|
-
return "package installation is disabled in your permissions";
|
|
39
|
-
if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
|
|
40
|
-
return "writing outside cwd is disabled in your permissions";
|
|
41
|
-
return null;
|
|
55
|
+
Assume macOS/Linux zsh environment.
|
|
56
|
+
Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
|
|
42
57
|
}
|
|
58
|
+
// ── explain ───────────────────────────────────────────────────────────────────
|
|
43
59
|
export async function explainCommand(command) {
|
|
44
60
|
const message = await client.messages.create({
|
|
45
61
|
model: "claude-haiku-4-5-20251001",
|
|
@@ -52,11 +68,33 @@ export async function explainCommand(command) {
|
|
|
52
68
|
return "";
|
|
53
69
|
return block.text.trim();
|
|
54
70
|
}
|
|
55
|
-
|
|
71
|
+
// ── auto-fix ──────────────────────────────────────────────────────────────────
|
|
72
|
+
export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
|
|
73
|
+
const message = await client.messages.create({
|
|
74
|
+
model: "claude-opus-4-6",
|
|
75
|
+
max_tokens: 256,
|
|
76
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
77
|
+
messages: [
|
|
78
|
+
{
|
|
79
|
+
role: "user",
|
|
80
|
+
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nIt failed with:\n${errorOutput}\n\nGive me the corrected command.`,
|
|
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) {
|
|
56
94
|
const message = await client.messages.create({
|
|
57
95
|
model: "claude-opus-4-6",
|
|
58
96
|
max_tokens: 256,
|
|
59
|
-
system: buildSystemPrompt(perms),
|
|
97
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
60
98
|
messages: [{ role: "user", content: nl }],
|
|
61
99
|
});
|
|
62
100
|
const block = message.content[0];
|
package/dist/history.js
CHANGED
|
@@ -5,14 +5,15 @@ const DIR = join(homedir(), ".terminal");
|
|
|
5
5
|
const HISTORY_FILE = join(DIR, "history.json");
|
|
6
6
|
const CONFIG_FILE = join(DIR, "config.json");
|
|
7
7
|
export const DEFAULT_PERMISSIONS = {
|
|
8
|
-
destructive:
|
|
8
|
+
destructive: true,
|
|
9
9
|
network: true,
|
|
10
|
-
sudo:
|
|
11
|
-
write_outside_cwd:
|
|
12
|
-
install:
|
|
10
|
+
sudo: true,
|
|
11
|
+
write_outside_cwd: true,
|
|
12
|
+
install: true,
|
|
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
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import React, { useState, useCallback } from "react";
|
|
1
|
+
import React, { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { translateToCommand, explainCommand, checkPermissions } from "./ai.js";
|
|
3
|
+
import { spawn, type ChildProcess } from "child_process";
|
|
4
|
+
import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
|
|
6
5
|
import {
|
|
7
6
|
loadHistory,
|
|
8
7
|
appendHistory,
|
|
@@ -14,138 +13,184 @@ import Onboarding from "./Onboarding.js";
|
|
|
14
13
|
import StatusBar from "./StatusBar.js";
|
|
15
14
|
import Spinner from "./Spinner.js";
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// ── types ────────────────────────────────────────────────────────────────────
|
|
16
|
+
// ── types ─────────────────────────────────────────────────────────────────────
|
|
20
17
|
|
|
21
18
|
type Phase =
|
|
22
|
-
| { type: "input";
|
|
19
|
+
| { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
|
|
23
20
|
| { type: "thinking"; nl: string; raw: boolean }
|
|
24
|
-
| { type: "confirm"; nl: string; command: string; raw: boolean }
|
|
21
|
+
| { type: "confirm"; nl: string; command: string; raw: boolean; danger: boolean }
|
|
25
22
|
| { type: "explain"; nl: string; command: string; explanation: string }
|
|
26
23
|
| { type: "running"; nl: string; command: string }
|
|
27
|
-
| { type: "
|
|
24
|
+
| { type: "autofix"; nl: string; command: string; errorOutput: string }
|
|
25
|
+
| { type: "error"; message: string };
|
|
28
26
|
|
|
29
27
|
interface ScrollEntry {
|
|
30
28
|
nl: string;
|
|
31
29
|
cmd: string;
|
|
32
|
-
|
|
30
|
+
lines: string[];
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
expanded: boolean;
|
|
33
33
|
error?: boolean;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const MAX_LINES = 20;
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
function deleteAt(
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function insertAt(s: string, pos: number, ch: string) { return s.slice(0, pos) + ch + s.slice(pos); }
|
|
41
|
+
function deleteAt(s: string, pos: number) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
|
|
42
|
+
|
|
43
|
+
function runCommand(
|
|
44
|
+
command: string,
|
|
45
|
+
onLine: (line: string) => void,
|
|
46
|
+
onDone: (code: number) => void,
|
|
47
|
+
signal: AbortSignal
|
|
48
|
+
): ChildProcess {
|
|
49
|
+
const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
50
|
+
|
|
51
|
+
const handleData = (data: Buffer) => {
|
|
52
|
+
const text = data.toString();
|
|
53
|
+
text.split("\n").forEach((line) => { if (line) onLine(line); });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
proc.stdout?.on("data", handleData);
|
|
57
|
+
proc.stderr?.on("data", handleData);
|
|
58
|
+
proc.on("close", (code) => onDone(code ?? 0));
|
|
59
|
+
|
|
60
|
+
signal.addEventListener("abort", () => { try { proc.kill("SIGTERM"); } catch {} });
|
|
61
|
+
return proc;
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
// ── component
|
|
64
|
+
// ── component ─────────────────────────────────────────────────────────────────
|
|
47
65
|
|
|
48
66
|
export default function App() {
|
|
49
67
|
const { exit } = useApp();
|
|
50
68
|
const [config, setConfig] = useState(() => loadConfig());
|
|
51
|
-
const [nlHistory] = useState<string[]>(() =>
|
|
52
|
-
|
|
53
|
-
.map((h) => h.nl)
|
|
54
|
-
.filter(Boolean)
|
|
55
|
-
);
|
|
69
|
+
const [nlHistory] = useState<string[]>(() => loadHistory().map((h) => h.nl).filter(Boolean));
|
|
70
|
+
const [sessionCmds, setSessionCmds] = useState<string[]>([]);
|
|
56
71
|
const [sessionNl, setSessionNl] = useState<string[]>([]);
|
|
57
72
|
const [scroll, setScroll] = useState<ScrollEntry[]>([]);
|
|
73
|
+
const [streamLines, setStreamLines] = useState<string[]>([]);
|
|
74
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
75
|
+
|
|
58
76
|
const [phase, setPhase] = useState<Phase>({
|
|
59
|
-
type: "input",
|
|
60
|
-
value: "",
|
|
61
|
-
cursor: 0,
|
|
62
|
-
histIdx: -1,
|
|
63
|
-
raw: false,
|
|
77
|
+
type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
|
|
64
78
|
});
|
|
65
79
|
|
|
66
80
|
const allNl = [...nlHistory, ...sessionNl];
|
|
67
81
|
|
|
68
82
|
const finishOnboarding = (perms: Permissions) => {
|
|
69
|
-
const next = { onboarded: true, permissions: perms };
|
|
83
|
+
const next = { onboarded: true, confirm: false, permissions: perms };
|
|
70
84
|
setConfig(next);
|
|
71
85
|
saveConfig(next);
|
|
72
86
|
};
|
|
73
87
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const inputPhase = (overrides: Partial<Extract<Phase, { type: "input" }>> = {}) =>
|
|
88
|
+
const inputPhase = (overrides: Partial<Extract<Phase, { type: "input" }>> = {}) => {
|
|
77
89
|
setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
|
|
90
|
+
setStreamLines([]);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const pushScroll = (entry: Omit<ScrollEntry, "expanded">) =>
|
|
94
|
+
setScroll((s) => [...s, { ...entry, expanded: false }]);
|
|
95
|
+
|
|
96
|
+
const commitStream = (nl: string, cmd: string, lines: string[], error: boolean) => {
|
|
97
|
+
const truncated = lines.length > MAX_LINES;
|
|
98
|
+
pushScroll({ nl, cmd, lines: truncated ? lines.slice(0, MAX_LINES) : lines, truncated, error });
|
|
99
|
+
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
100
|
+
setSessionCmds((c) => [...c.slice(-9), cmd]);
|
|
101
|
+
setStreamLines([]);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const runPhase = async (nl: string, command: string, raw: boolean) => {
|
|
105
|
+
setPhase({ type: "running", nl, command });
|
|
106
|
+
setStreamLines([]);
|
|
107
|
+
const abort = new AbortController();
|
|
108
|
+
abortRef.current = abort;
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
await new Promise<void>((resolve) => {
|
|
112
|
+
runCommand(
|
|
113
|
+
command,
|
|
114
|
+
(line) => { lines.push(line); setStreamLines([...lines]); },
|
|
115
|
+
(code) => {
|
|
116
|
+
commitStream(nl, command, lines, code !== 0);
|
|
117
|
+
abortRef.current = null;
|
|
118
|
+
if (code !== 0 && !raw) {
|
|
119
|
+
// offer auto-fix
|
|
120
|
+
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
121
|
+
} else {
|
|
122
|
+
inputPhase({ raw });
|
|
123
|
+
}
|
|
124
|
+
resolve();
|
|
125
|
+
},
|
|
126
|
+
abort.signal
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
78
130
|
|
|
79
131
|
useInput(
|
|
80
132
|
useCallback(
|
|
81
133
|
async (input: string, key: any) => {
|
|
134
|
+
|
|
135
|
+
// ── running: Ctrl+C kills process ─────────────────────────────────
|
|
136
|
+
if (phase.type === "running") {
|
|
137
|
+
if (key.ctrl && input === "c") {
|
|
138
|
+
abortRef.current?.abort();
|
|
139
|
+
inputPhase();
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
82
144
|
// ── input ─────────────────────────────────────────────────────────
|
|
83
145
|
if (phase.type === "input") {
|
|
84
146
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
85
|
-
|
|
86
|
-
// toggle raw mode
|
|
147
|
+
if (key.ctrl && input === "l") { setScroll([]); return; }
|
|
87
148
|
if (key.ctrl && input === "r") {
|
|
88
149
|
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
89
150
|
return;
|
|
90
151
|
}
|
|
91
152
|
|
|
92
|
-
// history navigation
|
|
93
153
|
if (key.upArrow) {
|
|
94
|
-
const
|
|
95
|
-
const val = allNl[allNl.length - 1 -
|
|
96
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx:
|
|
154
|
+
const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
155
|
+
const val = allNl[allNl.length - 1 - idx] ?? "";
|
|
156
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
97
157
|
return;
|
|
98
158
|
}
|
|
99
159
|
if (key.downArrow) {
|
|
100
|
-
const
|
|
101
|
-
const val =
|
|
102
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx:
|
|
160
|
+
const idx = Math.max(phase.histIdx - 1, -1);
|
|
161
|
+
const val = idx === -1 ? "" : allNl[allNl.length - 1 - idx] ?? "";
|
|
162
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
103
163
|
return;
|
|
104
164
|
}
|
|
165
|
+
if (key.leftArrow) { setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) }); return; }
|
|
166
|
+
if (key.rightArrow) { setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) }); return; }
|
|
105
167
|
|
|
106
|
-
// cursor movement
|
|
107
|
-
if (key.leftArrow) {
|
|
108
|
-
setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (key.rightArrow) {
|
|
112
|
-
setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// submit
|
|
117
168
|
if (key.return) {
|
|
118
169
|
const nl = phase.value.trim();
|
|
119
170
|
if (!nl) return;
|
|
120
171
|
setSessionNl((h) => [...h, nl]);
|
|
121
172
|
|
|
122
173
|
if (phase.raw) {
|
|
123
|
-
|
|
124
|
-
setPhase({ type: "running", nl, command: nl });
|
|
125
|
-
try {
|
|
126
|
-
const { stdout, stderr } = await execAsync(nl, { shell: "/bin/zsh" });
|
|
127
|
-
const output = (stdout + stderr).trim();
|
|
128
|
-
pushScroll({ nl, cmd: nl, output });
|
|
129
|
-
appendHistory({ nl, cmd: nl, output, ts: Date.now() });
|
|
130
|
-
} catch (e: any) {
|
|
131
|
-
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
132
|
-
pushScroll({ nl, cmd: nl, output, error: true });
|
|
133
|
-
appendHistory({ nl, cmd: nl, output, ts: Date.now(), error: true });
|
|
134
|
-
}
|
|
135
|
-
inputPhase({ raw: true });
|
|
174
|
+
await runPhase(nl, nl, true);
|
|
136
175
|
return;
|
|
137
176
|
}
|
|
138
177
|
|
|
139
178
|
setPhase({ type: "thinking", nl, raw: false });
|
|
140
179
|
try {
|
|
141
|
-
const command = await translateToCommand(nl, config.permissions);
|
|
180
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds);
|
|
142
181
|
const blocked = checkPermissions(command, config.permissions);
|
|
143
182
|
if (blocked) {
|
|
144
|
-
pushScroll({ nl, cmd: command,
|
|
183
|
+
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
145
184
|
inputPhase();
|
|
146
185
|
return;
|
|
147
186
|
}
|
|
148
|
-
|
|
187
|
+
const danger = isIrreversible(command);
|
|
188
|
+
// skip confirm unless user opted in OR command is dangerous
|
|
189
|
+
if (!config.confirm && !danger) {
|
|
190
|
+
await runPhase(nl, command, false);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
149
194
|
} catch (e: any) {
|
|
150
195
|
setPhase({ type: "error", message: e.message });
|
|
151
196
|
}
|
|
@@ -157,7 +202,6 @@ export default function App() {
|
|
|
157
202
|
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
158
203
|
return;
|
|
159
204
|
}
|
|
160
|
-
|
|
161
205
|
if (input && !key.ctrl && !key.meta) {
|
|
162
206
|
const val = insertAt(phase.value, phase.cursor, input);
|
|
163
207
|
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
@@ -169,7 +213,6 @@ export default function App() {
|
|
|
169
213
|
if (phase.type === "confirm") {
|
|
170
214
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
171
215
|
|
|
172
|
-
// explain
|
|
173
216
|
if (input === "?") {
|
|
174
217
|
const { nl, command } = phase;
|
|
175
218
|
setPhase({ type: "thinking", nl, raw: false });
|
|
@@ -177,26 +220,12 @@ export default function App() {
|
|
|
177
220
|
const explanation = await explainCommand(command);
|
|
178
221
|
setPhase({ type: "explain", nl, command, explanation });
|
|
179
222
|
} catch {
|
|
180
|
-
setPhase({ type: "confirm", nl, command, raw: false });
|
|
223
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
181
224
|
}
|
|
182
225
|
return;
|
|
183
226
|
}
|
|
184
|
-
|
|
185
227
|
if (input === "y" || input === "Y" || key.return) {
|
|
186
|
-
|
|
187
|
-
setPhase({ type: "running", nl, command });
|
|
188
|
-
try {
|
|
189
|
-
const { stdout, stderr } = await execAsync(command, { shell: "/bin/zsh" });
|
|
190
|
-
const output = (stdout + stderr).trim();
|
|
191
|
-
pushScroll({ nl, cmd: command, output });
|
|
192
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now() });
|
|
193
|
-
inputPhase();
|
|
194
|
-
} catch (e: any) {
|
|
195
|
-
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
196
|
-
pushScroll({ nl, cmd: command, output, error: true });
|
|
197
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
|
|
198
|
-
inputPhase();
|
|
199
|
-
}
|
|
228
|
+
await runPhase(phase.nl, phase.command, false);
|
|
200
229
|
return;
|
|
201
230
|
}
|
|
202
231
|
if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
|
|
@@ -207,11 +236,33 @@ export default function App() {
|
|
|
207
236
|
return;
|
|
208
237
|
}
|
|
209
238
|
|
|
210
|
-
// ── explain
|
|
239
|
+
// ── explain → back to confirm ─────────────────────────────────────
|
|
211
240
|
if (phase.type === "explain") {
|
|
212
241
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
213
|
-
|
|
214
|
-
|
|
242
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── autofix ───────────────────────────────────────────────────────
|
|
247
|
+
if (phase.type === "autofix") {
|
|
248
|
+
if (key.ctrl && input === "c") { exit(); return; }
|
|
249
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
250
|
+
const { nl, command, errorOutput } = phase;
|
|
251
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
252
|
+
try {
|
|
253
|
+
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
254
|
+
const danger = isIrreversible(fixed);
|
|
255
|
+
if (!config.confirm && !danger) {
|
|
256
|
+
await runPhase(nl, fixed, false);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
260
|
+
} catch (e: any) {
|
|
261
|
+
setPhase({ type: "error", message: e.message });
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
inputPhase();
|
|
215
266
|
return;
|
|
216
267
|
}
|
|
217
268
|
|
|
@@ -222,18 +273,22 @@ export default function App() {
|
|
|
222
273
|
return;
|
|
223
274
|
}
|
|
224
275
|
},
|
|
225
|
-
[phase, allNl, config, exit]
|
|
276
|
+
[phase, allNl, config, sessionCmds, exit]
|
|
226
277
|
)
|
|
227
278
|
);
|
|
228
279
|
|
|
280
|
+
// ── expand toggle ──────────────────────────────────────────────────────────
|
|
281
|
+
const toggleExpand = (i: number) =>
|
|
282
|
+
setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
|
|
283
|
+
|
|
229
284
|
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
230
285
|
if (!config.onboarded) {
|
|
231
286
|
return <Onboarding onDone={finishOnboarding} />;
|
|
232
287
|
}
|
|
233
288
|
|
|
234
|
-
// ── render ─────────────────────────────────────────────────────────────────
|
|
235
289
|
const isRaw = phase.type === "input" && phase.raw;
|
|
236
290
|
|
|
291
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
237
292
|
return (
|
|
238
293
|
<Box flexDirection="column">
|
|
239
294
|
|
|
@@ -250,9 +305,14 @@ export default function App() {
|
|
|
250
305
|
<Text dimColor>{entry.cmd}</Text>
|
|
251
306
|
</Box>
|
|
252
307
|
)}
|
|
253
|
-
{entry.
|
|
254
|
-
<Box paddingLeft={4}>
|
|
255
|
-
|
|
308
|
+
{entry.lines.length > 0 && (
|
|
309
|
+
<Box flexDirection="column" paddingLeft={4}>
|
|
310
|
+
{entry.lines.map((line, j) => (
|
|
311
|
+
<Text key={j} color={entry.error ? "red" : undefined}>{line}</Text>
|
|
312
|
+
))}
|
|
313
|
+
{entry.truncated && !entry.expanded && (
|
|
314
|
+
<Text dimColor>… (space to expand)</Text>
|
|
315
|
+
)}
|
|
256
316
|
</Box>
|
|
257
317
|
)}
|
|
258
318
|
</Box>
|
|
@@ -268,6 +328,7 @@ export default function App() {
|
|
|
268
328
|
<Box gap={2} paddingLeft={2}>
|
|
269
329
|
<Text dimColor>$</Text>
|
|
270
330
|
<Text>{phase.command}</Text>
|
|
331
|
+
{phase.danger && <Text color="red"> ⚠ irreversible</Text>}
|
|
271
332
|
</Box>
|
|
272
333
|
<Box paddingLeft={4}><Text dimColor>enter n e ?</Text></Box>
|
|
273
334
|
</Box>
|
|
@@ -287,15 +348,31 @@ export default function App() {
|
|
|
287
348
|
</Box>
|
|
288
349
|
)}
|
|
289
350
|
|
|
290
|
-
{/*
|
|
351
|
+
{/* autofix */}
|
|
352
|
+
{phase.type === "autofix" && (
|
|
353
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
354
|
+
<Text dimColor> command failed — retry with fix? [enter / n]</Text>
|
|
355
|
+
</Box>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{/* spinners */}
|
|
291
359
|
{phase.type === "thinking" && <Spinner label="translating" />}
|
|
360
|
+
|
|
361
|
+
{/* running — live stream */}
|
|
292
362
|
{phase.type === "running" && (
|
|
293
363
|
<Box flexDirection="column" paddingLeft={2}>
|
|
294
364
|
<Box gap={2}>
|
|
295
365
|
<Text dimColor>$</Text>
|
|
296
366
|
<Text dimColor>{phase.command}</Text>
|
|
297
367
|
</Box>
|
|
298
|
-
|
|
368
|
+
{streamLines.length > 0 && (
|
|
369
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
370
|
+
{streamLines.slice(-MAX_LINES).map((line, i) => (
|
|
371
|
+
<Text key={i}>{line}</Text>
|
|
372
|
+
))}
|
|
373
|
+
</Box>
|
|
374
|
+
)}
|
|
375
|
+
<Spinner label="ctrl+c to cancel" />
|
|
299
376
|
</Box>
|
|
300
377
|
)}
|
|
301
378
|
|
|
@@ -306,7 +383,7 @@ export default function App() {
|
|
|
306
383
|
</Box>
|
|
307
384
|
)}
|
|
308
385
|
|
|
309
|
-
{/* input
|
|
386
|
+
{/* input */}
|
|
310
387
|
{phase.type === "input" && (
|
|
311
388
|
<Box gap={2} paddingLeft={2}>
|
|
312
389
|
<Text dimColor>{isRaw ? "$" : "›"}</Text>
|
|
@@ -318,9 +395,7 @@ export default function App() {
|
|
|
318
395
|
</Box>
|
|
319
396
|
)}
|
|
320
397
|
|
|
321
|
-
{/* status bar */}
|
|
322
398
|
<StatusBar permissions={config.permissions} />
|
|
323
|
-
|
|
324
399
|
</Box>
|
|
325
400
|
);
|
|
326
401
|
}
|
package/src/ai.ts
CHANGED
|
@@ -3,7 +3,43 @@ import type { Permissions } from "./history.js";
|
|
|
3
3
|
|
|
4
4
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const IRREVERSIBLE_PATTERNS = [
|
|
9
|
+
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
10
|
+
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/, // overwrite redirect
|
|
11
|
+
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function isIrreversible(command: string): boolean {
|
|
15
|
+
return IRREVERSIBLE_PATTERNS.some((r) => r.test(command));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── permissions ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
21
|
+
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
22
|
+
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
23
|
+
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/];
|
|
24
|
+
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
25
|
+
|
|
26
|
+
export function checkPermissions(command: string, perms: Permissions): string | null {
|
|
27
|
+
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
28
|
+
return "destructive commands are disabled";
|
|
29
|
+
if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
|
|
30
|
+
return "network commands are disabled";
|
|
31
|
+
if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
|
|
32
|
+
return "sudo is disabled";
|
|
33
|
+
if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
|
|
34
|
+
return "package installation is disabled";
|
|
35
|
+
if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
|
|
36
|
+
return "writing outside cwd is disabled";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── system prompt ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function buildSystemPrompt(perms: Permissions, sessionCmds: string[]): string {
|
|
7
43
|
const restrictions: string[] = [];
|
|
8
44
|
if (!perms.destructive)
|
|
9
45
|
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
|
|
@@ -16,38 +52,25 @@ function buildSystemPrompt(perms: Permissions): string {
|
|
|
16
52
|
if (!perms.install)
|
|
17
53
|
restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
|
|
18
54
|
|
|
19
|
-
const restrictionBlock =
|
|
20
|
-
restrictions.
|
|
21
|
-
|
|
22
|
-
|
|
55
|
+
const restrictionBlock = restrictions.length > 0
|
|
56
|
+
? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
|
|
57
|
+
: "";
|
|
58
|
+
|
|
59
|
+
const contextBlock = sessionCmds.length > 0
|
|
60
|
+
? `\n\nRECENT COMMANDS THIS SESSION (for context — e.g. "undo that", "do the same for X"):\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
|
|
61
|
+
: "";
|
|
62
|
+
|
|
63
|
+
const cwd = process.cwd();
|
|
23
64
|
|
|
24
65
|
return `You are a terminal assistant. The user will describe what they want to do in plain English.
|
|
25
66
|
Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
|
|
26
67
|
No explanation. No markdown. No backticks. Just the raw command.
|
|
27
68
|
If multiple commands are needed, join them with && or use a newline.
|
|
28
|
-
Assume macOS/Linux zsh environment
|
|
69
|
+
Assume macOS/Linux zsh environment.
|
|
70
|
+
Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
|
|
29
71
|
}
|
|
30
72
|
|
|
31
|
-
|
|
32
|
-
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
33
|
-
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
34
|
-
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
35
|
-
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/];
|
|
36
|
-
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
37
|
-
|
|
38
|
-
export function checkPermissions(command: string, perms: Permissions): string | null {
|
|
39
|
-
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
40
|
-
return "destructive commands are disabled in your permissions";
|
|
41
|
-
if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
|
|
42
|
-
return "network commands are disabled in your permissions";
|
|
43
|
-
if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
|
|
44
|
-
return "sudo is disabled in your permissions";
|
|
45
|
-
if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
|
|
46
|
-
return "package installation is disabled in your permissions";
|
|
47
|
-
if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
|
|
48
|
-
return "writing outside cwd is disabled in your permissions";
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
73
|
+
// ── explain ───────────────────────────────────────────────────────────────────
|
|
51
74
|
|
|
52
75
|
export async function explainCommand(command: string): Promise<string> {
|
|
53
76
|
const message = await client.messages.create({
|
|
@@ -61,14 +84,46 @@ export async function explainCommand(command: string): Promise<string> {
|
|
|
61
84
|
return block.text.trim();
|
|
62
85
|
}
|
|
63
86
|
|
|
64
|
-
|
|
87
|
+
// ── auto-fix ──────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export async function fixCommand(
|
|
90
|
+
originalNl: string,
|
|
91
|
+
failedCommand: string,
|
|
92
|
+
errorOutput: string,
|
|
93
|
+
perms: Permissions,
|
|
94
|
+
sessionCmds: string[]
|
|
95
|
+
): Promise<string> {
|
|
65
96
|
const message = await client.messages.create({
|
|
66
97
|
model: "claude-opus-4-6",
|
|
67
98
|
max_tokens: 256,
|
|
68
|
-
system: buildSystemPrompt(perms),
|
|
69
|
-
messages: [
|
|
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
|
+
],
|
|
70
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 ─────────────────────────────────────────────────────────────────
|
|
71
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",
|
|
123
|
+
max_tokens: 256,
|
|
124
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
125
|
+
messages: [{ role: "user", content: nl }],
|
|
126
|
+
});
|
|
72
127
|
const block = message.content[0];
|
|
73
128
|
if (block.type !== "text") throw new Error("Unexpected response type");
|
|
74
129
|
const text = block.text.trim();
|
package/src/history.ts
CHANGED
|
@@ -29,19 +29,21 @@ 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
|
|
|
35
36
|
export const DEFAULT_PERMISSIONS: Permissions = {
|
|
36
|
-
destructive:
|
|
37
|
+
destructive: true,
|
|
37
38
|
network: true,
|
|
38
|
-
sudo:
|
|
39
|
-
write_outside_cwd:
|
|
40
|
-
install:
|
|
39
|
+
sudo: true,
|
|
40
|
+
write_outside_cwd: true,
|
|
41
|
+
install: true,
|
|
41
42
|
};
|
|
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 {
|