@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 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 { 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) => {
@@ -40,34 +46,74 @@ export default function App() {
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,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
- // 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
+ 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
- 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
- }
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
- // any key back to confirm
183
- setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false });
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
- 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 })] }));
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
- 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,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: 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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,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
- 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];
@@ -71,81 +85,107 @@ export default function App() {
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
+ 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
- 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
- }
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
- // any key back to confirm
214
- setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false });
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.output && (
254
- <Box paddingLeft={4}>
255
- <Text color={entry.error ? "red" : undefined}>{entry.output}</Text>
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
- {/* spinner states */}
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
- <Spinner label="running" />
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 line */}
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
- 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
@@ -33,11 +33,11 @@ export interface Config {
33
33
  }
34
34
 
35
35
  export const DEFAULT_PERMISSIONS: Permissions = {
36
- destructive: false,
36
+ destructive: true,
37
37
  network: true,
38
- sudo: false,
39
- write_outside_cwd: false,
40
- install: false,
38
+ sudo: true,
39
+ write_outside_cwd: true,
40
+ install: true,
41
41
  };
42
42
 
43
43
  export const DEFAULT_CONFIG: Config = {