@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 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 { exec } from "child_process";
5
- import { promisify } from "util";
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 execAsync = promisify(exec);
12
- // ── helpers ──────────────────────────────────────────────────────────────────
13
- function insertAt(str, pos, ch) {
14
- return str.slice(0, pos) + ch + str.slice(pos);
15
- }
16
- function deleteAt(str, pos) {
17
- if (pos <= 0)
18
- return str;
19
- return str.slice(0, pos - 1) + str.slice(pos);
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
- .map((h) => h.nl)
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 pushScroll = (entry) => setScroll((s) => [...s, entry]);
44
- const inputPhase = (overrides = {}) => setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
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
- // toggle raw mode
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 nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
60
- const val = allNl[allNl.length - 1 - nextIdx] ?? "";
61
- setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
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 nextIdx = Math.max(phase.histIdx - 1, -1);
66
- const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
67
- setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
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
- // raw mode — run directly
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, output: `blocked: ${blocked}`, error: true });
139
+ pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
108
140
  inputPhase();
109
141
  return;
110
142
  }
111
- setPhase({ type: "confirm", nl, command, raw: false });
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
- const { nl, command } = phase;
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
- // any key back to confirm
183
- setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false });
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
- 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.output && (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { color: entry.error ? "red" : undefined, children: entry.output }) }))] }, 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 })] }), _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 === "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 })] }), _jsx(Spinner, { label: "running" })] })), 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 })] }));
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
- function buildSystemPrompt(perms) {
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 these absolutely):\n${restrictions.join("\n")}\nIf the user asks for something restricted, output exactly: BLOCKED: <reason>`
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.${restrictionBlock}`;
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
- export async function translateToCommand(nl, perms) {
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: false,
8
+ destructive: true,
9
9
  network: true,
10
- sudo: false,
11
- write_outside_cwd: false,
12
- install: false,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Natural language terminal — speak plain English, get shell commands",
5
5
  "type": "module",
6
6
  "bin": {
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 { exec } from "child_process";
4
- import { promisify } from "util";
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
- const execAsync = promisify(exec);
18
-
19
- // ── types ────────────────────────────────────────────────────────────────────
16
+ // ── types ─────────────────────────────────────────────────────────────────────
20
17
 
21
18
  type Phase =
22
- | { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
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: "error"; message: string };
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
- output: string;
30
+ lines: string[];
31
+ truncated: boolean;
32
+ expanded: boolean;
33
33
  error?: boolean;
34
34
  }
35
35
 
36
- // ── helpers ──────────────────────────────────────────────────────────────────
36
+ const MAX_LINES = 20;
37
37
 
38
- function insertAt(str: string, pos: number, ch: string) {
39
- return str.slice(0, pos) + ch + str.slice(pos);
40
- }
41
- function deleteAt(str: string, pos: number) {
42
- if (pos <= 0) return str;
43
- return str.slice(0, pos - 1) + str.slice(pos);
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
- loadHistory()
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 pushScroll = (entry: ScrollEntry) => setScroll((s) => [...s, entry]);
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 nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
95
- const val = allNl[allNl.length - 1 - nextIdx] ?? "";
96
- setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
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 nextIdx = Math.max(phase.histIdx - 1, -1);
101
- const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
102
- setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
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
- // raw mode — run directly
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, output: `blocked: ${blocked}`, error: true });
183
+ pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
145
184
  inputPhase();
146
185
  return;
147
186
  }
148
- setPhase({ type: "confirm", nl, command, raw: false });
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
- const { nl, command } = phase;
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
- // any key back to confirm
214
- setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false });
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.output && (
254
- <Box paddingLeft={4}>
255
- <Text color={entry.error ? "red" : undefined}>{entry.output}</Text>
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
- {/* spinner states */}
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
- <Spinner label="running" />
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 line */}
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
- function buildSystemPrompt(perms: Permissions): string {
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.length > 0
21
- ? `\n\nCURRENT RESTRICTIONS (respect these absolutely):\n${restrictions.join("\n")}\nIf the user asks for something restricted, output exactly: BLOCKED: <reason>`
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.${restrictionBlock}`;
69
+ Assume macOS/Linux zsh environment.
70
+ Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
29
71
  }
30
72
 
31
- /** Regex patterns for permission checks — fast local guard before even calling AI */
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
- export async function translateToCommand(nl: string, perms: Permissions): Promise<string> {
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: [{ role: "user", content: nl }],
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: false,
37
+ destructive: true,
37
38
  network: true,
38
- sudo: false,
39
- write_outside_cwd: false,
40
- install: false,
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 {