@hasna/terminal 0.1.0 → 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 +163 -38
- package/dist/Onboarding.js +19 -23
- package/dist/Spinner.js +12 -0
- package/dist/StatusBar.js +45 -0
- package/dist/ai.js +73 -23
- package/dist/history.js +4 -4
- package/package.json +4 -4
- package/src/App.tsx +262 -82
- package/src/Onboarding.tsx +47 -59
- package/src/Spinner.tsx +24 -0
- package/src/StatusBar.tsx +67 -0
- package/src/ai.ts +94 -27
- package/src/history.ts +4 -4
package/dist/App.js
CHANGED
|
@@ -1,42 +1,125 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback } from "react";
|
|
2
|
+
import { useState, useCallback, useRef } from "react";
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { translateToCommand, 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";
|
|
9
|
+
import Spinner from "./Spinner.js";
|
|
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;
|
|
29
|
+
}
|
|
30
|
+
// ── component ─────────────────────────────────────────────────────────────────
|
|
10
31
|
export default function App() {
|
|
11
32
|
const { exit } = useApp();
|
|
12
33
|
const [config, setConfig] = useState(() => loadConfig());
|
|
13
34
|
const [nlHistory] = useState(() => loadHistory().map((h) => h.nl).filter(Boolean));
|
|
35
|
+
const [sessionCmds, setSessionCmds] = useState([]);
|
|
14
36
|
const [sessionNl, setSessionNl] = useState([]);
|
|
15
37
|
const [scroll, setScroll] = useState([]);
|
|
16
|
-
const [
|
|
38
|
+
const [streamLines, setStreamLines] = useState([]);
|
|
39
|
+
const abortRef = useRef(null);
|
|
40
|
+
const [phase, setPhase] = useState({
|
|
41
|
+
type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
|
|
42
|
+
});
|
|
17
43
|
const allNl = [...nlHistory, ...sessionNl];
|
|
18
44
|
const finishOnboarding = (perms) => {
|
|
19
45
|
const next = { onboarded: true, permissions: perms };
|
|
20
46
|
setConfig(next);
|
|
21
47
|
saveConfig(next);
|
|
22
48
|
};
|
|
23
|
-
const
|
|
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
|
+
};
|
|
24
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
|
+
}
|
|
91
|
+
// ── input ─────────────────────────────────────────────────────────
|
|
25
92
|
if (phase.type === "input") {
|
|
26
93
|
if (key.ctrl && input === "c") {
|
|
27
94
|
exit();
|
|
28
95
|
return;
|
|
29
96
|
}
|
|
97
|
+
if (key.ctrl && input === "l") {
|
|
98
|
+
setScroll([]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (key.ctrl && input === "r") {
|
|
102
|
+
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
30
105
|
if (key.upArrow) {
|
|
31
|
-
const
|
|
32
|
-
const val = allNl[allNl.length - 1 -
|
|
33
|
-
setPhase({
|
|
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 });
|
|
34
109
|
return;
|
|
35
110
|
}
|
|
36
111
|
if (key.downArrow) {
|
|
37
|
-
const
|
|
38
|
-
const val =
|
|
39
|
-
setPhase({
|
|
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 });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (key.leftArrow) {
|
|
118
|
+
setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.rightArrow) {
|
|
122
|
+
setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
|
|
40
123
|
return;
|
|
41
124
|
}
|
|
42
125
|
if (key.return) {
|
|
@@ -44,17 +127,21 @@ export default function App() {
|
|
|
44
127
|
if (!nl)
|
|
45
128
|
return;
|
|
46
129
|
setSessionNl((h) => [...h, nl]);
|
|
47
|
-
|
|
130
|
+
if (phase.raw) {
|
|
131
|
+
await runPhase(nl, nl, true);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
48
135
|
try {
|
|
49
|
-
const command = await translateToCommand(nl, config.permissions);
|
|
50
|
-
// Local permission guard on the returned command
|
|
136
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds);
|
|
51
137
|
const blocked = checkPermissions(command, config.permissions);
|
|
52
138
|
if (blocked) {
|
|
53
|
-
pushScroll({ nl, cmd: command,
|
|
54
|
-
|
|
139
|
+
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
140
|
+
inputPhase();
|
|
55
141
|
return;
|
|
56
142
|
}
|
|
57
|
-
|
|
143
|
+
const danger = isIrreversible(command);
|
|
144
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
58
145
|
}
|
|
59
146
|
catch (e) {
|
|
60
147
|
setPhase({ type: "error", message: e.message });
|
|
@@ -62,58 +149,96 @@ export default function App() {
|
|
|
62
149
|
return;
|
|
63
150
|
}
|
|
64
151
|
if (key.backspace || key.delete) {
|
|
65
|
-
|
|
152
|
+
const val = deleteAt(phase.value, phase.cursor);
|
|
153
|
+
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
66
154
|
return;
|
|
67
155
|
}
|
|
68
156
|
if (input && !key.ctrl && !key.meta) {
|
|
69
|
-
|
|
157
|
+
const val = insertAt(phase.value, phase.cursor, input);
|
|
158
|
+
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
70
159
|
}
|
|
71
160
|
return;
|
|
72
161
|
}
|
|
162
|
+
// ── confirm ───────────────────────────────────────────────────────
|
|
73
163
|
if (phase.type === "confirm") {
|
|
74
164
|
if (key.ctrl && input === "c") {
|
|
75
165
|
exit();
|
|
76
166
|
return;
|
|
77
167
|
}
|
|
78
|
-
if (input === "
|
|
168
|
+
if (input === "?") {
|
|
79
169
|
const { nl, command } = phase;
|
|
80
|
-
setPhase({ type: "
|
|
170
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
81
171
|
try {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
pushScroll({ nl, cmd: command, output });
|
|
85
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now() });
|
|
86
|
-
setPhase({ type: "input", value: "", histIdx: -1 });
|
|
172
|
+
const explanation = await explainCommand(command);
|
|
173
|
+
setPhase({ type: "explain", nl, command, explanation });
|
|
87
174
|
}
|
|
88
|
-
catch
|
|
89
|
-
|
|
90
|
-
pushScroll({ nl, cmd: command, output, error: true });
|
|
91
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
|
|
92
|
-
setPhase({ type: "input", value: "", histIdx: -1 });
|
|
175
|
+
catch {
|
|
176
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
93
177
|
}
|
|
94
178
|
return;
|
|
95
179
|
}
|
|
180
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
181
|
+
await runPhase(phase.nl, phase.command, false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
96
184
|
if (input === "n" || input === "N" || key.escape) {
|
|
97
|
-
|
|
185
|
+
inputPhase();
|
|
98
186
|
return;
|
|
99
187
|
}
|
|
100
188
|
if (input === "e" || input === "E") {
|
|
101
|
-
setPhase({ type: "input", value: phase.command, histIdx: -1 });
|
|
189
|
+
setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// ── explain → back to confirm ─────────────────────────────────────
|
|
195
|
+
if (phase.type === "explain") {
|
|
196
|
+
if (key.ctrl && input === "c") {
|
|
197
|
+
exit();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
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
|
+
}
|
|
102
220
|
return;
|
|
103
221
|
}
|
|
222
|
+
inputPhase();
|
|
104
223
|
return;
|
|
105
224
|
}
|
|
225
|
+
// ── error ─────────────────────────────────────────────────────────
|
|
106
226
|
if (phase.type === "error") {
|
|
107
227
|
if (key.ctrl && input === "c") {
|
|
108
228
|
exit();
|
|
109
229
|
return;
|
|
110
230
|
}
|
|
111
|
-
|
|
231
|
+
inputPhase();
|
|
112
232
|
return;
|
|
113
233
|
}
|
|
114
|
-
}, [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));
|
|
237
|
+
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
115
238
|
if (!config.onboarded) {
|
|
116
239
|
return _jsx(Onboarding, { onDone: finishOnboarding });
|
|
117
240
|
}
|
|
118
|
-
|
|
241
|
+
const isRaw = phase.type === "input" && phase.raw;
|
|
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 })] }));
|
|
119
244
|
}
|
package/dist/Onboarding.js
CHANGED
|
@@ -3,30 +3,30 @@ import { useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { DEFAULT_PERMISSIONS } from "./history.js";
|
|
5
5
|
const PERM_KEYS = [
|
|
6
|
-
{ key: "destructive", label: "destructive",
|
|
7
|
-
{ key: "network", label: "network",
|
|
8
|
-
{ key: "sudo", label: "sudo",
|
|
9
|
-
{ key: "install", label: "install",
|
|
10
|
-
{ key: "write_outside_cwd", label: "write outside
|
|
6
|
+
{ key: "destructive", label: "destructive", hint: "rm, delete, drop…" },
|
|
7
|
+
{ key: "network", label: "network", hint: "curl, wget, ssh…" },
|
|
8
|
+
{ key: "sudo", label: "sudo", hint: "root-level commands" },
|
|
9
|
+
{ key: "install", label: "install", hint: "brew, npm -g, pip…" },
|
|
10
|
+
{ key: "write_outside_cwd", label: "write outside", hint: "files outside current dir" },
|
|
11
11
|
];
|
|
12
12
|
export default function Onboarding({ onDone }) {
|
|
13
|
-
const [step, setStep] = useState(
|
|
13
|
+
const [step, setStep] = useState("welcome");
|
|
14
|
+
const [cursor, setCursor] = useState(0);
|
|
14
15
|
const [perms, setPerms] = useState({ ...DEFAULT_PERMISSIONS });
|
|
15
16
|
useInput((input, key) => {
|
|
16
17
|
if (key.ctrl && input === "c")
|
|
17
18
|
process.exit(0);
|
|
18
|
-
if (step
|
|
19
|
-
setStep(
|
|
19
|
+
if (step === "welcome") {
|
|
20
|
+
setStep("permissions");
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
|
-
if (step
|
|
23
|
-
const { cursor } = step;
|
|
23
|
+
if (step === "permissions") {
|
|
24
24
|
if (key.upArrow) {
|
|
25
|
-
|
|
25
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
if (key.downArrow) {
|
|
29
|
-
|
|
29
|
+
setCursor((c) => Math.min(PERM_KEYS.length - 1, c + 1));
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
if (input === " ") {
|
|
@@ -38,18 +38,14 @@ export default function Onboarding({ onDone }) {
|
|
|
38
38
|
onDone(perms);
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
-
return;
|
|
42
41
|
}
|
|
43
42
|
});
|
|
44
|
-
if (step
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
46
|
-
}
|
|
47
|
-
if (step.type === "permissions") {
|
|
48
|
-
return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, gap: 1, children: [_jsx(Text, { bold: true, children: "what can the AI do?" }), _jsx(Text, { dimColor: true, children: "space to toggle \u00B7 enter to confirm" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: PERM_KEYS.map((p, i) => {
|
|
49
|
-
const active = step.cursor === i;
|
|
50
|
-
const on = perms[p.key];
|
|
51
|
-
return (_jsxs(Box, { gap: 2, children: [_jsx(Text, { children: active ? "›" : " " }), _jsx(Text, { color: on ? undefined : "red", children: on ? "✓" : "✗" }), _jsx(Text, { bold: active, children: p.label }), _jsx(Text, { dimColor: true, children: p.description })] }, p.key));
|
|
52
|
-
}) }), _jsx(Text, { dimColor: true, children: "you can change this later in ~/.terminal/config.json" })] }));
|
|
43
|
+
if (step === "welcome") {
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, gap: 1, paddingTop: 1, children: [_jsx(Text, { children: "terminal" }), _jsx(Text, { dimColor: true, children: "speak plain english, run commands" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, gap: 0, children: [_jsx(Text, { dimColor: true, children: "\u203A type what you want" }), _jsx(Text, { dimColor: true, children: "$ see the command before it runs" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 browse history" }), _jsx(Text, { dimColor: true, children: "? explain a command before running" }), _jsx(Text, { dimColor: true, children: "ctrl+r toggle raw shell mode" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "any key to set permissions \u2192" }) })] }));
|
|
53
45
|
}
|
|
54
|
-
return
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, gap: 1, paddingTop: 1, children: [_jsx(Text, { dimColor: true, children: "what can the AI run?" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: PERM_KEYS.map((p, i) => {
|
|
47
|
+
const active = cursor === i;
|
|
48
|
+
const on = perms[p.key];
|
|
49
|
+
return (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: active ? "›" : " " }), _jsx(Text, { color: on ? undefined : "red", children: on ? "✓" : "✗" }), _jsx(Text, { bold: active, children: p.label }), _jsx(Text, { dimColor: true, children: p.hint })] }, p.key));
|
|
50
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "space toggle \u00B7 enter confirm" }) }), _jsx(Text, { dimColor: true, children: "edit later: ~/.terminal/config.json" })] }));
|
|
55
51
|
}
|
package/dist/Spinner.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
export default function Spinner({ label }) {
|
|
6
|
+
const [frame, setFrame] = useState(0);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
9
|
+
return () => clearInterval(t);
|
|
10
|
+
}, []);
|
|
11
|
+
return (_jsxs(Text, { dimColor: true, children: [" ", FRAMES[frame], " ", label] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
function getCwd() {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
const home = homedir();
|
|
8
|
+
return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
9
|
+
}
|
|
10
|
+
function getGitBranch() {
|
|
11
|
+
try {
|
|
12
|
+
return execSync("git branch --show-current 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] })
|
|
13
|
+
.toString()
|
|
14
|
+
.trim() || null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function getGitDirty() {
|
|
21
|
+
try {
|
|
22
|
+
const out = execSync("git status --porcelain 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
23
|
+
return out.trim().length > 0;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function activePerms(perms) {
|
|
30
|
+
const labels = [
|
|
31
|
+
["destructive", "del"],
|
|
32
|
+
["network", "net"],
|
|
33
|
+
["sudo", "sudo"],
|
|
34
|
+
["install", "pkg"],
|
|
35
|
+
["write_outside_cwd", "write"],
|
|
36
|
+
];
|
|
37
|
+
return labels.filter(([k]) => perms[k]).map(([, l]) => l);
|
|
38
|
+
}
|
|
39
|
+
export default function StatusBar({ permissions }) {
|
|
40
|
+
const cwd = getCwd();
|
|
41
|
+
const branch = getGitBranch();
|
|
42
|
+
const dirty = branch ? getGitDirty() : false;
|
|
43
|
+
const perms = activePerms(permissions);
|
|
44
|
+
return (_jsxs(Box, { gap: 2, paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: cwd }), branch && (_jsxs(Text, { dimColor: true, children: [branch, dirty ? " ●" : ""] })), perms.length > 0 && (_jsx(Text, { dimColor: true, children: perms.join(" · ") }))] }));
|
|
45
|
+
}
|
package/dist/ai.js
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
3
|
-
|
|
3
|
+
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
4
|
+
const IRREVERSIBLE_PATTERNS = [
|
|
5
|
+
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
6
|
+
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/, // overwrite redirect
|
|
7
|
+
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
8
|
+
];
|
|
9
|
+
export function isIrreversible(command) {
|
|
10
|
+
return IRREVERSIBLE_PATTERNS.some((r) => r.test(command));
|
|
11
|
+
}
|
|
12
|
+
// ── permissions ───────────────────────────────────────────────────────────────
|
|
13
|
+
const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
|
|
14
|
+
const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
|
|
15
|
+
const SUDO_PATTERNS = [/\bsudo\b/];
|
|
16
|
+
const INSTALL_PATTERNS = [/\bbrew\s+install\b/, /\bnpm\s+install\s+-g\b/, /\bpip\s+install\b/, /\bapt\s+install\b/, /\byum\s+install\b/];
|
|
17
|
+
const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
|
|
18
|
+
export function checkPermissions(command, perms) {
|
|
19
|
+
if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
|
|
20
|
+
return "destructive commands are disabled";
|
|
21
|
+
if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
|
|
22
|
+
return "network commands are disabled";
|
|
23
|
+
if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
|
|
24
|
+
return "sudo is disabled";
|
|
25
|
+
if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
|
|
26
|
+
return "package installation is disabled";
|
|
27
|
+
if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
|
|
28
|
+
return "writing outside cwd is disabled";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// ── system prompt ─────────────────────────────────────────────────────────────
|
|
32
|
+
function buildSystemPrompt(perms, sessionCmds) {
|
|
4
33
|
const restrictions = [];
|
|
5
34
|
if (!perms.destructive)
|
|
6
35
|
restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
|
|
@@ -13,38 +42,59 @@ function buildSystemPrompt(perms) {
|
|
|
13
42
|
if (!perms.install)
|
|
14
43
|
restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
|
|
15
44
|
const restrictionBlock = restrictions.length > 0
|
|
16
|
-
? `\n\nCURRENT RESTRICTIONS (respect
|
|
45
|
+
? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
|
|
17
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")}`
|
|
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
|
|
55
|
+
Assume macOS/Linux zsh environment.
|
|
56
|
+
Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
|
|
23
57
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
return "
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
// ── explain ───────────────────────────────────────────────────────────────────
|
|
59
|
+
export async function explainCommand(command) {
|
|
60
|
+
const message = await client.messages.create({
|
|
61
|
+
model: "claude-haiku-4-5-20251001",
|
|
62
|
+
max_tokens: 128,
|
|
63
|
+
system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
|
|
64
|
+
messages: [{ role: "user", content: command }],
|
|
65
|
+
});
|
|
66
|
+
const block = message.content[0];
|
|
67
|
+
if (block.type !== "text")
|
|
68
|
+
return "";
|
|
69
|
+
return block.text.trim();
|
|
70
|
+
}
|
|
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;
|
|
42
91
|
}
|
|
43
|
-
|
|
92
|
+
// ── translate ─────────────────────────────────────────────────────────────────
|
|
93
|
+
export async function translateToCommand(nl, perms, sessionCmds) {
|
|
44
94
|
const message = await client.messages.create({
|
|
45
95
|
model: "claude-opus-4-6",
|
|
46
96
|
max_tokens: 256,
|
|
47
|
-
system: buildSystemPrompt(perms),
|
|
97
|
+
system: buildSystemPrompt(perms, sessionCmds),
|
|
48
98
|
messages: [{ role: "user", content: nl }],
|
|
49
99
|
});
|
|
50
100
|
const block = message.content[0];
|
package/dist/history.js
CHANGED
|
@@ -5,11 +5,11 @@ const DIR = join(homedir(), ".terminal");
|
|
|
5
5
|
const HISTORY_FILE = join(DIR, "history.json");
|
|
6
6
|
const CONFIG_FILE = join(DIR, "config.json");
|
|
7
7
|
export const DEFAULT_PERMISSIONS = {
|
|
8
|
-
destructive:
|
|
8
|
+
destructive: true,
|
|
9
9
|
network: true,
|
|
10
|
-
sudo:
|
|
11
|
-
write_outside_cwd:
|
|
12
|
-
install:
|
|
10
|
+
sudo: true,
|
|
11
|
+
write_outside_cwd: true,
|
|
12
|
+
install: true,
|
|
13
13
|
};
|
|
14
14
|
export const DEFAULT_CONFIG = {
|
|
15
15
|
onboarded: false,
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/terminal",
|
|
3
|
-
"version": "0.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": {
|
|
7
|
-
"t": "
|
|
8
|
-
"terminal": "
|
|
7
|
+
"t": "dist/cli.js",
|
|
8
|
+
"terminal": "dist/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "https://github.com/hasna/terminal.git"
|
|
26
|
+
"url": "git+https://github.com/hasna/terminal.git"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^20.0.0",
|