@hasna/terminal 0.1.1 → 0.1.2
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 +115 -74
- package/dist/ai.js +62 -24
- package/dist/history.js +4 -4
- package/package.json +1 -1
- package/src/App.tsx +165 -99
- package/src/ai.ts +84 -29
- package/src/history.ts +4 -4
package/dist/App.js
CHANGED
|
@@ -1,38 +1,44 @@
|
|
|
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) => {
|
|
@@ -40,34 +46,74 @@ export default function App() {
|
|
|
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,26 @@ 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
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
112
145
|
}
|
|
113
146
|
catch (e) {
|
|
114
147
|
setPhase({ type: "error", message: e.message });
|
|
@@ -132,7 +165,6 @@ export default function App() {
|
|
|
132
165
|
exit();
|
|
133
166
|
return;
|
|
134
167
|
}
|
|
135
|
-
// explain
|
|
136
168
|
if (input === "?") {
|
|
137
169
|
const { nl, command } = phase;
|
|
138
170
|
setPhase({ type: "thinking", nl, raw: false });
|
|
@@ -141,26 +173,12 @@ export default function App() {
|
|
|
141
173
|
setPhase({ type: "explain", nl, command, explanation });
|
|
142
174
|
}
|
|
143
175
|
catch {
|
|
144
|
-
setPhase({ type: "confirm", nl, command, raw: false });
|
|
176
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
145
177
|
}
|
|
146
178
|
return;
|
|
147
179
|
}
|
|
148
180
|
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
|
-
}
|
|
181
|
+
await runPhase(phase.nl, phase.command, false);
|
|
164
182
|
return;
|
|
165
183
|
}
|
|
166
184
|
if (input === "n" || input === "N" || key.escape) {
|
|
@@ -173,14 +191,35 @@ export default function App() {
|
|
|
173
191
|
}
|
|
174
192
|
return;
|
|
175
193
|
}
|
|
176
|
-
// ── explain
|
|
194
|
+
// ── explain → back to confirm ─────────────────────────────────────
|
|
177
195
|
if (phase.type === "explain") {
|
|
178
196
|
if (key.ctrl && input === "c") {
|
|
179
197
|
exit();
|
|
180
198
|
return;
|
|
181
199
|
}
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// ── autofix ───────────────────────────────────────────────────────
|
|
204
|
+
if (phase.type === "autofix") {
|
|
205
|
+
if (key.ctrl && input === "c") {
|
|
206
|
+
exit();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
210
|
+
const { nl, command, errorOutput } = phase;
|
|
211
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
212
|
+
try {
|
|
213
|
+
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
214
|
+
const danger = isIrreversible(fixed);
|
|
215
|
+
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
setPhase({ type: "error", message: e.message });
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
inputPhase();
|
|
184
223
|
return;
|
|
185
224
|
}
|
|
186
225
|
// ── error ─────────────────────────────────────────────────────────
|
|
@@ -192,12 +231,14 @@ export default function App() {
|
|
|
192
231
|
inputPhase();
|
|
193
232
|
return;
|
|
194
233
|
}
|
|
195
|
-
}, [phase, allNl, config, exit]));
|
|
234
|
+
}, [phase, allNl, config, sessionCmds, exit]));
|
|
235
|
+
// ── expand toggle ──────────────────────────────────────────────────────────
|
|
236
|
+
const toggleExpand = (i) => setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
|
|
196
237
|
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
197
238
|
if (!config.onboarded) {
|
|
198
239
|
return _jsx(Onboarding, { onDone: finishOnboarding });
|
|
199
240
|
}
|
|
200
|
-
// ── render ─────────────────────────────────────────────────────────────────
|
|
201
241
|
const isRaw = phase.type === "input" && phase.raw;
|
|
202
|
-
|
|
242
|
+
// ── 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 })] }));
|
|
203
244
|
}
|
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,11 +5,11 @@ 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,
|
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,53 +13,68 @@ 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];
|
|
@@ -71,81 +85,107 @@ export default function App() {
|
|
|
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
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
149
189
|
} catch (e: any) {
|
|
150
190
|
setPhase({ type: "error", message: e.message });
|
|
151
191
|
}
|
|
@@ -157,7 +197,6 @@ export default function App() {
|
|
|
157
197
|
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
158
198
|
return;
|
|
159
199
|
}
|
|
160
|
-
|
|
161
200
|
if (input && !key.ctrl && !key.meta) {
|
|
162
201
|
const val = insertAt(phase.value, phase.cursor, input);
|
|
163
202
|
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
@@ -169,7 +208,6 @@ export default function App() {
|
|
|
169
208
|
if (phase.type === "confirm") {
|
|
170
209
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
171
210
|
|
|
172
|
-
// explain
|
|
173
211
|
if (input === "?") {
|
|
174
212
|
const { nl, command } = phase;
|
|
175
213
|
setPhase({ type: "thinking", nl, raw: false });
|
|
@@ -177,26 +215,12 @@ export default function App() {
|
|
|
177
215
|
const explanation = await explainCommand(command);
|
|
178
216
|
setPhase({ type: "explain", nl, command, explanation });
|
|
179
217
|
} catch {
|
|
180
|
-
setPhase({ type: "confirm", nl, command, raw: false });
|
|
218
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
181
219
|
}
|
|
182
220
|
return;
|
|
183
221
|
}
|
|
184
|
-
|
|
185
222
|
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
|
-
}
|
|
223
|
+
await runPhase(phase.nl, phase.command, false);
|
|
200
224
|
return;
|
|
201
225
|
}
|
|
202
226
|
if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
|
|
@@ -207,11 +231,29 @@ export default function App() {
|
|
|
207
231
|
return;
|
|
208
232
|
}
|
|
209
233
|
|
|
210
|
-
// ── explain
|
|
234
|
+
// ── explain → back to confirm ─────────────────────────────────────
|
|
211
235
|
if (phase.type === "explain") {
|
|
212
236
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
213
|
-
|
|
214
|
-
|
|
237
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── autofix ───────────────────────────────────────────────────────
|
|
242
|
+
if (phase.type === "autofix") {
|
|
243
|
+
if (key.ctrl && input === "c") { exit(); return; }
|
|
244
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
245
|
+
const { nl, command, errorOutput } = phase;
|
|
246
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
247
|
+
try {
|
|
248
|
+
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
249
|
+
const danger = isIrreversible(fixed);
|
|
250
|
+
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
251
|
+
} catch (e: any) {
|
|
252
|
+
setPhase({ type: "error", message: e.message });
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
inputPhase();
|
|
215
257
|
return;
|
|
216
258
|
}
|
|
217
259
|
|
|
@@ -222,18 +264,22 @@ export default function App() {
|
|
|
222
264
|
return;
|
|
223
265
|
}
|
|
224
266
|
},
|
|
225
|
-
[phase, allNl, config, exit]
|
|
267
|
+
[phase, allNl, config, sessionCmds, exit]
|
|
226
268
|
)
|
|
227
269
|
);
|
|
228
270
|
|
|
271
|
+
// ── expand toggle ──────────────────────────────────────────────────────────
|
|
272
|
+
const toggleExpand = (i: number) =>
|
|
273
|
+
setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
|
|
274
|
+
|
|
229
275
|
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
230
276
|
if (!config.onboarded) {
|
|
231
277
|
return <Onboarding onDone={finishOnboarding} />;
|
|
232
278
|
}
|
|
233
279
|
|
|
234
|
-
// ── render ─────────────────────────────────────────────────────────────────
|
|
235
280
|
const isRaw = phase.type === "input" && phase.raw;
|
|
236
281
|
|
|
282
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
237
283
|
return (
|
|
238
284
|
<Box flexDirection="column">
|
|
239
285
|
|
|
@@ -250,9 +296,14 @@ export default function App() {
|
|
|
250
296
|
<Text dimColor>{entry.cmd}</Text>
|
|
251
297
|
</Box>
|
|
252
298
|
)}
|
|
253
|
-
{entry.
|
|
254
|
-
<Box paddingLeft={4}>
|
|
255
|
-
|
|
299
|
+
{entry.lines.length > 0 && (
|
|
300
|
+
<Box flexDirection="column" paddingLeft={4}>
|
|
301
|
+
{entry.lines.map((line, j) => (
|
|
302
|
+
<Text key={j} color={entry.error ? "red" : undefined}>{line}</Text>
|
|
303
|
+
))}
|
|
304
|
+
{entry.truncated && !entry.expanded && (
|
|
305
|
+
<Text dimColor>… (space to expand)</Text>
|
|
306
|
+
)}
|
|
256
307
|
</Box>
|
|
257
308
|
)}
|
|
258
309
|
</Box>
|
|
@@ -268,6 +319,7 @@ export default function App() {
|
|
|
268
319
|
<Box gap={2} paddingLeft={2}>
|
|
269
320
|
<Text dimColor>$</Text>
|
|
270
321
|
<Text>{phase.command}</Text>
|
|
322
|
+
{phase.danger && <Text color="red"> ⚠ irreversible</Text>}
|
|
271
323
|
</Box>
|
|
272
324
|
<Box paddingLeft={4}><Text dimColor>enter n e ?</Text></Box>
|
|
273
325
|
</Box>
|
|
@@ -287,15 +339,31 @@ export default function App() {
|
|
|
287
339
|
</Box>
|
|
288
340
|
)}
|
|
289
341
|
|
|
290
|
-
{/*
|
|
342
|
+
{/* autofix */}
|
|
343
|
+
{phase.type === "autofix" && (
|
|
344
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
345
|
+
<Text dimColor> command failed — retry with fix? [enter / n]</Text>
|
|
346
|
+
</Box>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
{/* spinners */}
|
|
291
350
|
{phase.type === "thinking" && <Spinner label="translating" />}
|
|
351
|
+
|
|
352
|
+
{/* running — live stream */}
|
|
292
353
|
{phase.type === "running" && (
|
|
293
354
|
<Box flexDirection="column" paddingLeft={2}>
|
|
294
355
|
<Box gap={2}>
|
|
295
356
|
<Text dimColor>$</Text>
|
|
296
357
|
<Text dimColor>{phase.command}</Text>
|
|
297
358
|
</Box>
|
|
298
|
-
|
|
359
|
+
{streamLines.length > 0 && (
|
|
360
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
361
|
+
{streamLines.slice(-MAX_LINES).map((line, i) => (
|
|
362
|
+
<Text key={i}>{line}</Text>
|
|
363
|
+
))}
|
|
364
|
+
</Box>
|
|
365
|
+
)}
|
|
366
|
+
<Spinner label="ctrl+c to cancel" />
|
|
299
367
|
</Box>
|
|
300
368
|
)}
|
|
301
369
|
|
|
@@ -306,7 +374,7 @@ export default function App() {
|
|
|
306
374
|
</Box>
|
|
307
375
|
)}
|
|
308
376
|
|
|
309
|
-
{/* input
|
|
377
|
+
{/* input */}
|
|
310
378
|
{phase.type === "input" && (
|
|
311
379
|
<Box gap={2} paddingLeft={2}>
|
|
312
380
|
<Text dimColor>{isRaw ? "$" : "›"}</Text>
|
|
@@ -318,9 +386,7 @@ export default function App() {
|
|
|
318
386
|
</Box>
|
|
319
387
|
)}
|
|
320
388
|
|
|
321
|
-
{/* status bar */}
|
|
322
389
|
<StatusBar permissions={config.permissions} />
|
|
323
|
-
|
|
324
390
|
</Box>
|
|
325
391
|
);
|
|
326
392
|
}
|
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
|
@@ -33,11 +33,11 @@ export interface Config {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export const DEFAULT_PERMISSIONS: Permissions = {
|
|
36
|
-
destructive:
|
|
36
|
+
destructive: true,
|
|
37
37
|
network: true,
|
|
38
|
-
sudo:
|
|
39
|
-
write_outside_cwd:
|
|
40
|
-
install:
|
|
38
|
+
sudo: true,
|
|
39
|
+
write_outside_cwd: true,
|
|
40
|
+
install: true,
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export const DEFAULT_CONFIG: Config = {
|