@hasna/terminal 0.1.0

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.
@@ -0,0 +1 @@
1
+ {"sessionId":"501c27b4-5243-40d6-8fb5-04ebdb3c8df8","pid":90841,"acquiredAt":1773556984409}
package/dist/App.js ADDED
@@ -0,0 +1,119 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, Text, useInput, useApp } from "ink";
4
+ import { exec } from "child_process";
5
+ import { promisify } from "util";
6
+ import { translateToCommand, checkPermissions } from "./ai.js";
7
+ import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
8
+ import Onboarding from "./Onboarding.js";
9
+ const execAsync = promisify(exec);
10
+ export default function App() {
11
+ const { exit } = useApp();
12
+ const [config, setConfig] = useState(() => loadConfig());
13
+ const [nlHistory] = useState(() => loadHistory().map((h) => h.nl).filter(Boolean));
14
+ const [sessionNl, setSessionNl] = useState([]);
15
+ const [scroll, setScroll] = useState([]);
16
+ const [phase, setPhase] = useState({ type: "input", value: "", histIdx: -1 });
17
+ const allNl = [...nlHistory, ...sessionNl];
18
+ const finishOnboarding = (perms) => {
19
+ const next = { onboarded: true, permissions: perms };
20
+ setConfig(next);
21
+ saveConfig(next);
22
+ };
23
+ const pushScroll = (entry) => setScroll((s) => [...s, entry]);
24
+ useInput(useCallback(async (input, key) => {
25
+ if (phase.type === "input") {
26
+ if (key.ctrl && input === "c") {
27
+ exit();
28
+ return;
29
+ }
30
+ if (key.upArrow) {
31
+ const nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
32
+ const val = allNl[allNl.length - 1 - nextIdx] ?? "";
33
+ setPhase({ type: "input", value: val, histIdx: nextIdx });
34
+ return;
35
+ }
36
+ if (key.downArrow) {
37
+ const nextIdx = Math.max(phase.histIdx - 1, -1);
38
+ const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
39
+ setPhase({ type: "input", value: val, histIdx: nextIdx });
40
+ return;
41
+ }
42
+ if (key.return) {
43
+ const nl = phase.value.trim();
44
+ if (!nl)
45
+ return;
46
+ setSessionNl((h) => [...h, nl]);
47
+ setPhase({ type: "thinking", nl });
48
+ try {
49
+ const command = await translateToCommand(nl, config.permissions);
50
+ // Local permission guard on the returned command
51
+ const blocked = checkPermissions(command, config.permissions);
52
+ if (blocked) {
53
+ pushScroll({ nl, cmd: command, output: `blocked: ${blocked}`, error: true });
54
+ setPhase({ type: "input", value: "", histIdx: -1 });
55
+ return;
56
+ }
57
+ setPhase({ type: "confirm", nl, command });
58
+ }
59
+ catch (e) {
60
+ setPhase({ type: "error", message: e.message });
61
+ }
62
+ return;
63
+ }
64
+ if (key.backspace || key.delete) {
65
+ setPhase({ ...phase, value: phase.value.slice(0, -1), histIdx: -1 });
66
+ return;
67
+ }
68
+ if (input && !key.ctrl && !key.meta) {
69
+ setPhase({ ...phase, value: phase.value + input, histIdx: -1 });
70
+ }
71
+ return;
72
+ }
73
+ if (phase.type === "confirm") {
74
+ if (key.ctrl && input === "c") {
75
+ exit();
76
+ return;
77
+ }
78
+ if (input === "y" || input === "Y" || key.return) {
79
+ const { nl, command } = phase;
80
+ setPhase({ type: "running", nl, command });
81
+ try {
82
+ const { stdout, stderr } = await execAsync(command, { shell: "/bin/zsh" });
83
+ const output = (stdout + stderr).trim();
84
+ pushScroll({ nl, cmd: command, output });
85
+ appendHistory({ nl, cmd: command, output, ts: Date.now() });
86
+ setPhase({ type: "input", value: "", histIdx: -1 });
87
+ }
88
+ catch (e) {
89
+ const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
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 });
93
+ }
94
+ return;
95
+ }
96
+ if (input === "n" || input === "N" || key.escape) {
97
+ setPhase({ type: "input", value: "", histIdx: -1 });
98
+ return;
99
+ }
100
+ if (input === "e" || input === "E") {
101
+ setPhase({ type: "input", value: phase.command, histIdx: -1 });
102
+ return;
103
+ }
104
+ return;
105
+ }
106
+ if (phase.type === "error") {
107
+ if (key.ctrl && input === "c") {
108
+ exit();
109
+ return;
110
+ }
111
+ setPhase({ type: "input", value: "", histIdx: -1 });
112
+ return;
113
+ }
114
+ }, [phase, allNl, config, exit]));
115
+ if (!config.onboarded) {
116
+ return _jsx(Onboarding, { onDone: finishOnboarding });
117
+ }
118
+ return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: entry.cmd })] }), entry.output && (_jsx(Text, { color: entry.error ? "red" : undefined, children: entry.output }))] }, i))), phase.type === "input" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { children: phase.value }), _jsx(Text, { inverse: true, children: " " })] })), phase.type === "thinking" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsx(Text, { dimColor: true, children: " translating\u2026" })] })), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Text, { dimColor: true, children: " [enter] run [n] cancel [e] edit" })] })), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Text, { dimColor: true, children: " running\u2026" })] })), phase.type === "error" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", children: [" ", phase.message] }), _jsx(Text, { dimColor: true, children: " press any key" })] }))] }));
119
+ }
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { DEFAULT_PERMISSIONS } from "./history.js";
5
+ const PERM_KEYS = [
6
+ { key: "destructive", label: "destructive", description: "rm, delete, drop table…" },
7
+ { key: "network", label: "network", description: "curl, wget, ssh, ping…" },
8
+ { key: "sudo", label: "sudo", description: "commands requiring root" },
9
+ { key: "install", label: "install", description: "brew, npm -g, pip…" },
10
+ { key: "write_outside_cwd", label: "write outside cwd", description: "files outside current directory" },
11
+ ];
12
+ export default function Onboarding({ onDone }) {
13
+ const [step, setStep] = useState({ type: "welcome" });
14
+ const [perms, setPerms] = useState({ ...DEFAULT_PERMISSIONS });
15
+ useInput((input, key) => {
16
+ if (key.ctrl && input === "c")
17
+ process.exit(0);
18
+ if (step.type === "welcome") {
19
+ setStep({ type: "permissions", cursor: 0 });
20
+ return;
21
+ }
22
+ if (step.type === "permissions") {
23
+ const { cursor } = step;
24
+ if (key.upArrow) {
25
+ setStep({ type: "permissions", cursor: Math.max(0, cursor - 1) });
26
+ return;
27
+ }
28
+ if (key.downArrow) {
29
+ setStep({ type: "permissions", cursor: Math.min(PERM_KEYS.length - 1, cursor + 1) });
30
+ return;
31
+ }
32
+ if (input === " ") {
33
+ const k = PERM_KEYS[cursor].key;
34
+ setPerms((p) => ({ ...p, [k]: !p[k] }));
35
+ return;
36
+ }
37
+ if (key.return) {
38
+ onDone(perms);
39
+ return;
40
+ }
41
+ return;
42
+ }
43
+ });
44
+ if (step.type === "welcome") {
45
+ return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, gap: 1, children: [_jsx(Text, { bold: true, children: "terminal" }), _jsx(Text, { children: "Type anything in plain English." }), _jsx(Text, { children: "The AI translates it to a shell command and runs it." }), _jsx(Text, { children: "Use \u2191 / \u2193 to browse history. Enter to run, n to cancel, e to edit." }), _jsx(Text, { dimColor: true, children: "press any key to set up permissions \u2192" })] }));
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" })] }));
53
+ }
54
+ return null;
55
+ }
package/dist/ai.js ADDED
@@ -0,0 +1,57 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3
+ function buildSystemPrompt(perms) {
4
+ const restrictions = [];
5
+ if (!perms.destructive)
6
+ restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
7
+ if (!perms.network)
8
+ restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, scp, ping, nc, etc.)");
9
+ if (!perms.sudo)
10
+ restrictions.push("- NEVER generate commands that use sudo or require root privileges");
11
+ if (!perms.write_outside_cwd)
12
+ restrictions.push("- NEVER generate commands that write to paths outside the current working directory");
13
+ if (!perms.install)
14
+ restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
15
+ 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>`
17
+ : "";
18
+ return `You are a terminal assistant. The user will describe what they want to do in plain English.
19
+ Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
20
+ No explanation. No markdown. No backticks. Just the raw command.
21
+ 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;
42
+ }
43
+ export async function translateToCommand(nl, perms) {
44
+ const message = await client.messages.create({
45
+ model: "claude-opus-4-6",
46
+ max_tokens: 256,
47
+ system: buildSystemPrompt(perms),
48
+ messages: [{ role: "user", content: nl }],
49
+ });
50
+ const block = message.content[0];
51
+ if (block.type !== "text")
52
+ throw new Error("Unexpected response type");
53
+ const text = block.text.trim();
54
+ if (text.startsWith("BLOCKED:"))
55
+ throw new Error(text);
56
+ return text;
57
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import App from "./App.js";
5
+ if (!process.env.ANTHROPIC_API_KEY) {
6
+ console.error("terminal: ANTHROPIC_API_KEY is not set.");
7
+ console.error("Add it to your shell: export ANTHROPIC_API_KEY=your_key");
8
+ process.exit(1);
9
+ }
10
+ render(_jsx(App, {}));
@@ -0,0 +1,60 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ const DIR = join(homedir(), ".terminal");
5
+ const HISTORY_FILE = join(DIR, "history.json");
6
+ const CONFIG_FILE = join(DIR, "config.json");
7
+ export const DEFAULT_PERMISSIONS = {
8
+ destructive: false,
9
+ network: true,
10
+ sudo: false,
11
+ write_outside_cwd: false,
12
+ install: false,
13
+ };
14
+ export const DEFAULT_CONFIG = {
15
+ onboarded: false,
16
+ permissions: DEFAULT_PERMISSIONS,
17
+ };
18
+ function ensureDir() {
19
+ if (!existsSync(DIR))
20
+ mkdirSync(DIR, { recursive: true });
21
+ }
22
+ export function loadHistory() {
23
+ ensureDir();
24
+ if (!existsSync(HISTORY_FILE))
25
+ return [];
26
+ try {
27
+ return JSON.parse(readFileSync(HISTORY_FILE, "utf8"));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ export function saveHistory(entries) {
34
+ ensureDir();
35
+ writeFileSync(HISTORY_FILE, JSON.stringify(entries.slice(-500), null, 2));
36
+ }
37
+ export function appendHistory(entry) {
38
+ const existing = loadHistory();
39
+ saveHistory([...existing, entry]);
40
+ }
41
+ export function loadConfig() {
42
+ ensureDir();
43
+ if (!existsSync(CONFIG_FILE))
44
+ return { ...DEFAULT_CONFIG };
45
+ try {
46
+ const saved = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
47
+ return {
48
+ ...DEFAULT_CONFIG,
49
+ ...saved,
50
+ permissions: { ...DEFAULT_PERMISSIONS, ...(saved.permissions ?? {}) },
51
+ };
52
+ }
53
+ catch {
54
+ return { ...DEFAULT_CONFIG };
55
+ }
56
+ }
57
+ export function saveConfig(config) {
58
+ ensureDir();
59
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
60
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@hasna/terminal",
3
+ "version": "0.1.0",
4
+ "description": "Natural language terminal — speak plain English, get shell commands",
5
+ "type": "module",
6
+ "bin": {
7
+ "t": "./dist/cli.js",
8
+ "terminal": "./dist/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsx src/cli.tsx",
13
+ "start": "node dist/cli.js"
14
+ },
15
+ "dependencies": {
16
+ "@anthropic-ai/sdk": "^0.39.0",
17
+ "ink": "^5.0.1",
18
+ "react": "^18.2.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/hasna/terminal.git"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.0.0",
30
+ "@types/react": "^18.2.0",
31
+ "tsx": "^4.0.0",
32
+ "typescript": "^5.0.0"
33
+ }
34
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,212 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { Box, Text, useInput, useApp } from "ink";
3
+ import { exec } from "child_process";
4
+ import { promisify } from "util";
5
+ import { translateToCommand, checkPermissions } from "./ai.js";
6
+ import {
7
+ loadHistory,
8
+ appendHistory,
9
+ loadConfig,
10
+ saveConfig,
11
+ type HistoryEntry,
12
+ type Permissions,
13
+ } from "./history.js";
14
+ import Onboarding from "./Onboarding.js";
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ type Phase =
19
+ | { type: "input"; value: string; histIdx: number }
20
+ | { type: "thinking"; nl: string }
21
+ | { type: "confirm"; nl: string; command: string }
22
+ | { type: "running"; nl: string; command: string }
23
+ | { type: "error"; message: string };
24
+
25
+ interface ScrollEntry {
26
+ nl: string;
27
+ cmd: string;
28
+ output: string;
29
+ error?: boolean;
30
+ }
31
+
32
+ export default function App() {
33
+ const { exit } = useApp();
34
+ const [config, setConfig] = useState(() => loadConfig());
35
+ const [nlHistory] = useState<string[]>(() =>
36
+ loadHistory().map((h) => h.nl).filter(Boolean)
37
+ );
38
+ const [sessionNl, setSessionNl] = useState<string[]>([]);
39
+ const [scroll, setScroll] = useState<ScrollEntry[]>([]);
40
+ const [phase, setPhase] = useState<Phase>({ type: "input", value: "", histIdx: -1 });
41
+
42
+ const allNl = [...nlHistory, ...sessionNl];
43
+
44
+ const finishOnboarding = (perms: Permissions) => {
45
+ const next = { onboarded: true, permissions: perms };
46
+ setConfig(next);
47
+ saveConfig(next);
48
+ };
49
+
50
+ const pushScroll = (entry: ScrollEntry) => setScroll((s) => [...s, entry]);
51
+
52
+ useInput(
53
+ useCallback(
54
+ async (input: string, key: any) => {
55
+ if (phase.type === "input") {
56
+ if (key.ctrl && input === "c") { exit(); return; }
57
+
58
+ if (key.upArrow) {
59
+ const nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
60
+ const val = allNl[allNl.length - 1 - nextIdx] ?? "";
61
+ setPhase({ type: "input", value: val, histIdx: nextIdx });
62
+ return;
63
+ }
64
+ if (key.downArrow) {
65
+ const nextIdx = Math.max(phase.histIdx - 1, -1);
66
+ const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
67
+ setPhase({ type: "input", value: val, histIdx: nextIdx });
68
+ return;
69
+ }
70
+ if (key.return) {
71
+ const nl = phase.value.trim();
72
+ if (!nl) return;
73
+ setSessionNl((h) => [...h, nl]);
74
+ setPhase({ type: "thinking", nl });
75
+ try {
76
+ const command = await translateToCommand(nl, config.permissions);
77
+ // Local permission guard on the returned command
78
+ const blocked = checkPermissions(command, config.permissions);
79
+ if (blocked) {
80
+ pushScroll({ nl, cmd: command, output: `blocked: ${blocked}`, error: true });
81
+ setPhase({ type: "input", value: "", histIdx: -1 });
82
+ return;
83
+ }
84
+ setPhase({ type: "confirm", nl, command });
85
+ } catch (e: any) {
86
+ setPhase({ type: "error", message: e.message });
87
+ }
88
+ return;
89
+ }
90
+ if (key.backspace || key.delete) {
91
+ setPhase({ ...phase, value: phase.value.slice(0, -1), histIdx: -1 });
92
+ return;
93
+ }
94
+ if (input && !key.ctrl && !key.meta) {
95
+ setPhase({ ...phase, value: phase.value + input, histIdx: -1 });
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (phase.type === "confirm") {
101
+ if (key.ctrl && input === "c") { exit(); return; }
102
+ if (input === "y" || input === "Y" || key.return) {
103
+ const { nl, command } = phase;
104
+ setPhase({ type: "running", nl, command });
105
+ try {
106
+ const { stdout, stderr } = await execAsync(command, { shell: "/bin/zsh" });
107
+ const output = (stdout + stderr).trim();
108
+ pushScroll({ nl, cmd: command, output });
109
+ appendHistory({ nl, cmd: command, output, ts: Date.now() });
110
+ setPhase({ type: "input", value: "", histIdx: -1 });
111
+ } catch (e: any) {
112
+ const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
113
+ pushScroll({ nl, cmd: command, output, error: true });
114
+ appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
115
+ setPhase({ type: "input", value: "", histIdx: -1 });
116
+ }
117
+ return;
118
+ }
119
+ if (input === "n" || input === "N" || key.escape) {
120
+ setPhase({ type: "input", value: "", histIdx: -1 });
121
+ return;
122
+ }
123
+ if (input === "e" || input === "E") {
124
+ setPhase({ type: "input", value: phase.command, histIdx: -1 });
125
+ return;
126
+ }
127
+ return;
128
+ }
129
+
130
+ if (phase.type === "error") {
131
+ if (key.ctrl && input === "c") { exit(); return; }
132
+ setPhase({ type: "input", value: "", histIdx: -1 });
133
+ return;
134
+ }
135
+ },
136
+ [phase, allNl, config, exit]
137
+ )
138
+ );
139
+
140
+ if (!config.onboarded) {
141
+ return <Onboarding onDone={finishOnboarding} />;
142
+ }
143
+
144
+ return (
145
+ <Box flexDirection="column">
146
+ {scroll.map((entry, i) => (
147
+ <Box key={i} flexDirection="column" marginBottom={1}>
148
+ <Box gap={1}>
149
+ <Text dimColor>›</Text>
150
+ <Text dimColor>{entry.nl}</Text>
151
+ </Box>
152
+ <Box gap={1}>
153
+ <Text dimColor>$</Text>
154
+ <Text>{entry.cmd}</Text>
155
+ </Box>
156
+ {entry.output && (
157
+ <Text color={entry.error ? "red" : undefined}>{entry.output}</Text>
158
+ )}
159
+ </Box>
160
+ ))}
161
+
162
+ {phase.type === "input" && (
163
+ <Box gap={1}>
164
+ <Text dimColor>›</Text>
165
+ <Text>{phase.value}</Text>
166
+ <Text inverse> </Text>
167
+ </Box>
168
+ )}
169
+
170
+ {phase.type === "thinking" && (
171
+ <Box flexDirection="column">
172
+ <Box gap={1}>
173
+ <Text dimColor>›</Text>
174
+ <Text dimColor>{phase.nl}</Text>
175
+ </Box>
176
+ <Text dimColor> translating…</Text>
177
+ </Box>
178
+ )}
179
+
180
+ {phase.type === "confirm" && (
181
+ <Box flexDirection="column">
182
+ <Box gap={1}>
183
+ <Text dimColor>›</Text>
184
+ <Text dimColor>{phase.nl}</Text>
185
+ </Box>
186
+ <Box gap={1}>
187
+ <Text dimColor>$</Text>
188
+ <Text>{phase.command}</Text>
189
+ </Box>
190
+ <Text dimColor> [enter] run [n] cancel [e] edit</Text>
191
+ </Box>
192
+ )}
193
+
194
+ {phase.type === "running" && (
195
+ <Box flexDirection="column">
196
+ <Box gap={1}>
197
+ <Text dimColor>$</Text>
198
+ <Text>{phase.command}</Text>
199
+ </Box>
200
+ <Text dimColor> running…</Text>
201
+ </Box>
202
+ )}
203
+
204
+ {phase.type === "error" && (
205
+ <Box flexDirection="column">
206
+ <Text color="red"> {phase.message}</Text>
207
+ <Text dimColor> press any key</Text>
208
+ </Box>
209
+ )}
210
+ </Box>
211
+ );
212
+ }
@@ -0,0 +1,94 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { type Permissions, DEFAULT_PERMISSIONS } from "./history.js";
4
+
5
+ interface Props {
6
+ onDone: (perms: Permissions) => void;
7
+ }
8
+
9
+ type Step =
10
+ | { type: "welcome" }
11
+ | { type: "permissions"; cursor: number }
12
+ | { type: "done" };
13
+
14
+ const PERM_KEYS: Array<{ key: keyof Permissions; label: string; description: string }> = [
15
+ { key: "destructive", label: "destructive", description: "rm, delete, drop table…" },
16
+ { key: "network", label: "network", description: "curl, wget, ssh, ping…" },
17
+ { key: "sudo", label: "sudo", description: "commands requiring root" },
18
+ { key: "install", label: "install", description: "brew, npm -g, pip…" },
19
+ { key: "write_outside_cwd", label: "write outside cwd", description: "files outside current directory" },
20
+ ];
21
+
22
+ export default function Onboarding({ onDone }: Props) {
23
+ const [step, setStep] = useState<Step>({ type: "welcome" });
24
+ const [perms, setPerms] = useState<Permissions>({ ...DEFAULT_PERMISSIONS });
25
+
26
+ useInput((input, key) => {
27
+ if (key.ctrl && input === "c") process.exit(0);
28
+
29
+ if (step.type === "welcome") {
30
+ setStep({ type: "permissions", cursor: 0 });
31
+ return;
32
+ }
33
+
34
+ if (step.type === "permissions") {
35
+ const { cursor } = step;
36
+ if (key.upArrow) {
37
+ setStep({ type: "permissions", cursor: Math.max(0, cursor - 1) });
38
+ return;
39
+ }
40
+ if (key.downArrow) {
41
+ setStep({ type: "permissions", cursor: Math.min(PERM_KEYS.length - 1, cursor + 1) });
42
+ return;
43
+ }
44
+ if (input === " ") {
45
+ const k = PERM_KEYS[cursor].key;
46
+ setPerms((p) => ({ ...p, [k]: !p[k] }));
47
+ return;
48
+ }
49
+ if (key.return) {
50
+ onDone(perms);
51
+ return;
52
+ }
53
+ return;
54
+ }
55
+ });
56
+
57
+ if (step.type === "welcome") {
58
+ return (
59
+ <Box flexDirection="column" paddingTop={1} gap={1}>
60
+ <Text bold>terminal</Text>
61
+ <Text>Type anything in plain English.</Text>
62
+ <Text>The AI translates it to a shell command and runs it.</Text>
63
+ <Text>Use ↑ / ↓ to browse history. Enter to run, n to cancel, e to edit.</Text>
64
+ <Text dimColor>press any key to set up permissions →</Text>
65
+ </Box>
66
+ );
67
+ }
68
+
69
+ if (step.type === "permissions") {
70
+ return (
71
+ <Box flexDirection="column" paddingTop={1} gap={1}>
72
+ <Text bold>what can the AI do?</Text>
73
+ <Text dimColor>space to toggle · enter to confirm</Text>
74
+ <Box flexDirection="column" marginTop={1}>
75
+ {PERM_KEYS.map((p, i) => {
76
+ const active = step.cursor === i;
77
+ const on = perms[p.key];
78
+ return (
79
+ <Box key={p.key} gap={2}>
80
+ <Text>{active ? "›" : " "}</Text>
81
+ <Text color={on ? undefined : "red"}>{on ? "✓" : "✗"}</Text>
82
+ <Text bold={active}>{p.label}</Text>
83
+ <Text dimColor>{p.description}</Text>
84
+ </Box>
85
+ );
86
+ })}
87
+ </Box>
88
+ <Text dimColor>you can change this later in ~/.terminal/config.json</Text>
89
+ </Box>
90
+ );
91
+ }
92
+
93
+ return null;
94
+ }
package/src/ai.ts ADDED
@@ -0,0 +1,65 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { Permissions } from "./history.js";
3
+
4
+ const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
5
+
6
+ function buildSystemPrompt(perms: Permissions): string {
7
+ const restrictions: string[] = [];
8
+ if (!perms.destructive)
9
+ restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
10
+ if (!perms.network)
11
+ restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, scp, ping, nc, etc.)");
12
+ if (!perms.sudo)
13
+ restrictions.push("- NEVER generate commands that use sudo or require root privileges");
14
+ if (!perms.write_outside_cwd)
15
+ restrictions.push("- NEVER generate commands that write to paths outside the current working directory");
16
+ if (!perms.install)
17
+ restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
18
+
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
+ : "";
23
+
24
+ return `You are a terminal assistant. The user will describe what they want to do in plain English.
25
+ Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
26
+ No explanation. No markdown. No backticks. Just the raw command.
27
+ If multiple commands are needed, join them with && or use a newline.
28
+ Assume macOS/Linux zsh environment.${restrictionBlock}`;
29
+ }
30
+
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
+ }
51
+
52
+ export async function translateToCommand(nl: string, perms: Permissions): Promise<string> {
53
+ const message = await client.messages.create({
54
+ model: "claude-opus-4-6",
55
+ max_tokens: 256,
56
+ system: buildSystemPrompt(perms),
57
+ messages: [{ role: "user", content: nl }],
58
+ });
59
+
60
+ const block = message.content[0];
61
+ if (block.type !== "text") throw new Error("Unexpected response type");
62
+ const text = block.text.trim();
63
+ if (text.startsWith("BLOCKED:")) throw new Error(text);
64
+ return text;
65
+ }
package/src/cli.tsx ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import React from "react";
3
+ import { render } from "ink";
4
+ import App from "./App.js";
5
+
6
+ if (!process.env.ANTHROPIC_API_KEY) {
7
+ console.error("terminal: ANTHROPIC_API_KEY is not set.");
8
+ console.error("Add it to your shell: export ANTHROPIC_API_KEY=your_key");
9
+ process.exit(1);
10
+ }
11
+
12
+ render(<App />);
package/src/history.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ const DIR = join(homedir(), ".terminal");
6
+ const HISTORY_FILE = join(DIR, "history.json");
7
+ const CONFIG_FILE = join(DIR, "config.json");
8
+
9
+ export interface HistoryEntry {
10
+ nl: string;
11
+ cmd: string;
12
+ output: string;
13
+ ts: number;
14
+ error?: boolean;
15
+ }
16
+
17
+ export interface Permissions {
18
+ /** Allow commands that delete files/data (rm, drop, truncate, etc.) */
19
+ destructive: boolean;
20
+ /** Allow commands that make network requests (curl, wget, ssh, etc.) */
21
+ network: boolean;
22
+ /** Allow commands that require sudo / root */
23
+ sudo: boolean;
24
+ /** Allow writing to files outside the current directory */
25
+ write_outside_cwd: boolean;
26
+ /** Allow installing packages (npm, brew, pip, apt, etc.) */
27
+ install: boolean;
28
+ }
29
+
30
+ export interface Config {
31
+ onboarded: boolean;
32
+ permissions: Permissions;
33
+ }
34
+
35
+ export const DEFAULT_PERMISSIONS: Permissions = {
36
+ destructive: false,
37
+ network: true,
38
+ sudo: false,
39
+ write_outside_cwd: false,
40
+ install: false,
41
+ };
42
+
43
+ export const DEFAULT_CONFIG: Config = {
44
+ onboarded: false,
45
+ permissions: DEFAULT_PERMISSIONS,
46
+ };
47
+
48
+ function ensureDir() {
49
+ if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
50
+ }
51
+
52
+ export function loadHistory(): HistoryEntry[] {
53
+ ensureDir();
54
+ if (!existsSync(HISTORY_FILE)) return [];
55
+ try {
56
+ return JSON.parse(readFileSync(HISTORY_FILE, "utf8"));
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ export function saveHistory(entries: HistoryEntry[]) {
63
+ ensureDir();
64
+ writeFileSync(HISTORY_FILE, JSON.stringify(entries.slice(-500), null, 2));
65
+ }
66
+
67
+ export function appendHistory(entry: HistoryEntry) {
68
+ const existing = loadHistory();
69
+ saveHistory([...existing, entry]);
70
+ }
71
+
72
+ export function loadConfig(): Config {
73
+ ensureDir();
74
+ if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
75
+ try {
76
+ const saved = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
77
+ return {
78
+ ...DEFAULT_CONFIG,
79
+ ...saved,
80
+ permissions: { ...DEFAULT_PERMISSIONS, ...(saved.permissions ?? {}) },
81
+ };
82
+ } catch {
83
+ return { ...DEFAULT_CONFIG };
84
+ }
85
+ }
86
+
87
+ export function saveConfig(config: Config) {
88
+ ensureDir();
89
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
90
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "jsx": "react-jsx",
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }