@hasna/terminal 1.1.0 → 1.2.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.
- package/dist/ai.js +21 -0
- package/dist/cli.js +29 -177
- package/dist/lazy-executor.js +1 -1
- package/dist/output-processor.js +11 -15
- package/dist/test-watchlist.js +7 -4
- package/package.json +1 -1
- package/src/ai.ts +24 -0
- package/src/cli.tsx +30 -173
- package/src/lazy-executor.ts +1 -1
- package/src/output-processor.ts +11 -14
- package/src/test-watchlist.ts +6 -4
package/dist/ai.js
CHANGED
|
@@ -36,6 +36,10 @@ const IRREVERSIBLE_PATTERNS = [
|
|
|
36
36
|
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
37
37
|
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
38
38
|
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
39
|
+
// Process/service killing
|
|
40
|
+
/\bkill\b/, /\bkillall\b/, /\bpkill\b/,
|
|
41
|
+
// Git push/force operations
|
|
42
|
+
/\bgit\s+push\b/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+force\b/,
|
|
39
43
|
];
|
|
40
44
|
export function isIrreversible(command) {
|
|
41
45
|
return IRREVERSIBLE_PATTERNS.some((r) => r.test(command));
|
|
@@ -96,6 +100,23 @@ function detectProjectContext() {
|
|
|
96
100
|
if (existsSync(join(cwd, "build.gradle")) || existsSync(join(cwd, "build.gradle.kts"))) {
|
|
97
101
|
parts.push("Project: Java/Gradle. Use gradle commands.");
|
|
98
102
|
}
|
|
103
|
+
// Directory structure — so AI knows actual paths (not guessed ones)
|
|
104
|
+
try {
|
|
105
|
+
const { execSync } = require("child_process");
|
|
106
|
+
// Top-level dirs
|
|
107
|
+
const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 2000 }).trim();
|
|
108
|
+
parts.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
|
|
109
|
+
// src/ structure (2 levels deep, most important for path resolution)
|
|
110
|
+
for (const srcDir of ["src", "lib", "app"]) {
|
|
111
|
+
if (existsSync(join(cwd, srcDir))) {
|
|
112
|
+
const tree = execSync(`find ${srcDir} -maxdepth 2 -type d -not -path '*/node_modules/*' 2>/dev/null | head -30`, { cwd, encoding: "utf8", timeout: 2000 }).trim();
|
|
113
|
+
if (tree)
|
|
114
|
+
parts.push(`Directories in ${srcDir}/:\n${tree}`);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch { /* timeout or no exec — skip */ }
|
|
99
120
|
return parts.length > 0 ? `\n\nPROJECT CONTEXT:\n${parts.join("\n")}` : "";
|
|
100
121
|
}
|
|
101
122
|
// ── system prompt ─────────────────────────────────────────────────────────────
|
package/dist/cli.js
CHANGED
|
@@ -4,32 +4,31 @@ import { render } from "ink";
|
|
|
4
4
|
const args = process.argv.slice(2);
|
|
5
5
|
// ── Help / Version ───────────────────────────────────────────────────────────
|
|
6
6
|
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
7
|
-
console.log(`open-terminal
|
|
7
|
+
console.log(`open-terminal — Natural language shell for AI agents and humans
|
|
8
8
|
|
|
9
9
|
USAGE:
|
|
10
|
+
terminal "your request" NL → AI picks command → runs → smart output
|
|
10
11
|
terminal Launch interactive NL terminal (TUI)
|
|
11
|
-
|
|
12
|
+
|
|
13
|
+
EXAMPLES:
|
|
14
|
+
terminal "list all typescript files"
|
|
15
|
+
terminal "run tests"
|
|
16
|
+
terminal "what changed in git"
|
|
17
|
+
terminal "show me the auth functions"
|
|
18
|
+
terminal "kill port 3000"
|
|
19
|
+
terminal "how many lines of code"
|
|
12
20
|
|
|
13
21
|
SUBCOMMANDS:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
recipe add
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
collection list List collections
|
|
25
|
-
project init Initialize project-scoped recipes
|
|
26
|
-
repo Show git repo state (branch + status + log)
|
|
27
|
-
symbols <file> Show file outline (functions, classes, exports)
|
|
28
|
-
stats Show token economy dashboard
|
|
29
|
-
sessions List recent terminal sessions
|
|
30
|
-
sessions stats Show session analytics
|
|
31
|
-
sessions <id> Show session details
|
|
32
|
-
snapshot Capture terminal state as JSON
|
|
22
|
+
repo Git repo state (branch + status + log)
|
|
23
|
+
symbols <file> File outline (functions, classes, exports)
|
|
24
|
+
overview Project overview (deps, scripts, structure)
|
|
25
|
+
stats Token economy dashboard
|
|
26
|
+
sessions [stats|<id>] Session history and analytics
|
|
27
|
+
recipe add|list|run|delete Reusable command recipes
|
|
28
|
+
collection create|list Recipe collections
|
|
29
|
+
mcp serve Start MCP server for AI agents
|
|
30
|
+
mcp install --claude|--codex Install MCP server
|
|
31
|
+
snapshot Terminal state as JSON
|
|
33
32
|
--help Show this help
|
|
34
33
|
--version Show version
|
|
35
34
|
|
|
@@ -59,160 +58,6 @@ if (args[0] === "--version" || args[0] === "-v") {
|
|
|
59
58
|
}
|
|
60
59
|
process.exit(0);
|
|
61
60
|
}
|
|
62
|
-
// ── Exec command — smart execution for agents ────────────────────────────────
|
|
63
|
-
if (args[0] === "exec") {
|
|
64
|
-
// Parse flags: --json, --offset=N, --limit=N, --raw
|
|
65
|
-
const flags = {};
|
|
66
|
-
const cmdParts = [];
|
|
67
|
-
for (const arg of args.slice(1)) {
|
|
68
|
-
const flagMatch = arg.match(/^--(\w+)(?:=(.+))?$/);
|
|
69
|
-
if (flagMatch) {
|
|
70
|
-
flags[flagMatch[1]] = flagMatch[2] ?? "true";
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
cmdParts.push(arg);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const command = cmdParts.join(" ");
|
|
77
|
-
const jsonMode = flags.json === "true";
|
|
78
|
-
const rawMode = flags.raw === "true";
|
|
79
|
-
const offset = flags.offset ? parseInt(flags.offset) : undefined;
|
|
80
|
-
const limit = flags.limit ? parseInt(flags.limit) : undefined;
|
|
81
|
-
if (!command) {
|
|
82
|
-
console.error("Usage: terminal exec <command> [--json] [--raw] [--offset=N] [--limit=N]");
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
const { execSync } = await import("child_process");
|
|
86
|
-
const { compress, stripAnsi } = await import("./compression.js");
|
|
87
|
-
const { stripNoise } = await import("./noise-filter.js");
|
|
88
|
-
const { processOutput, shouldProcess } = await import("./output-processor.js");
|
|
89
|
-
const { rewriteCommand } = await import("./command-rewriter.js");
|
|
90
|
-
const { shouldBeLazy, toLazy, getSlice } = await import("./lazy-executor.js");
|
|
91
|
-
const { parseOutput, estimateTokens } = await import("./parsers/index.js");
|
|
92
|
-
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
93
|
-
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
94
|
-
const { detectLoop } = await import("./loop-detector.js");
|
|
95
|
-
// Loop detection — suggest narrowing if running full test suite repeatedly
|
|
96
|
-
const loop = detectLoop(command);
|
|
97
|
-
if (loop.detected) {
|
|
98
|
-
console.error(`[open-terminal] loop detected: test run #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : " — consider narrowing to specific test file"}`);
|
|
99
|
-
}
|
|
100
|
-
// Rewrite command if possible
|
|
101
|
-
const rw = rewriteCommand(command);
|
|
102
|
-
const actualCmd = rw.changed ? rw.rewritten : command;
|
|
103
|
-
if (rw.changed)
|
|
104
|
-
console.error(`[open-terminal] rewritten: ${actualCmd} (${rw.reason})`);
|
|
105
|
-
try {
|
|
106
|
-
const start = Date.now();
|
|
107
|
-
const raw = execSync(actualCmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
|
|
108
|
-
const duration = Date.now() - start;
|
|
109
|
-
const clean = stripNoise(stripAnsi(raw)).cleaned;
|
|
110
|
-
const rawTokens = estimateTokens(raw);
|
|
111
|
-
// Track usage
|
|
112
|
-
recordUsage(rawTokens);
|
|
113
|
-
// --raw flag: skip all processing
|
|
114
|
-
if (rawMode) {
|
|
115
|
-
console.log(clean);
|
|
116
|
-
process.exit(0);
|
|
117
|
-
}
|
|
118
|
-
// --json flag: always return structured JSON
|
|
119
|
-
if (jsonMode) {
|
|
120
|
-
const parsed = parseOutput(actualCmd, clean);
|
|
121
|
-
if (parsed) {
|
|
122
|
-
const saved = rawTokens - estimateTokens(JSON.stringify(parsed.data));
|
|
123
|
-
if (saved > 0)
|
|
124
|
-
recordSaving("structured", saved);
|
|
125
|
-
console.log(JSON.stringify({ exitCode: 0, parser: parsed.parser, data: parsed.data, duration, tokensSaved: Math.max(0, saved) }));
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
const compressed = compress(actualCmd, clean, { format: "json" });
|
|
129
|
-
console.log(JSON.stringify({ exitCode: 0, output: compressed.content, duration, tokensSaved: compressed.tokensSaved }));
|
|
130
|
-
}
|
|
131
|
-
process.exit(0);
|
|
132
|
-
}
|
|
133
|
-
// Pagination: --offset + --limit on a previous large result
|
|
134
|
-
if (offset !== undefined || limit !== undefined) {
|
|
135
|
-
const slice = getSlice(clean, offset ?? 0, limit ?? 50);
|
|
136
|
-
console.log(slice.lines.join("\n"));
|
|
137
|
-
if (slice.hasMore)
|
|
138
|
-
console.error(`[open-terminal] showing ${slice.lines.length}/${slice.total}, ${slice.total - (offset ?? 0) - slice.lines.length} remaining`);
|
|
139
|
-
process.exit(0);
|
|
140
|
-
}
|
|
141
|
-
// Test output detection — use watchlist for structured test tracking
|
|
142
|
-
if (isTestOutput(clean)) {
|
|
143
|
-
const result = trackTests(process.cwd(), clean);
|
|
144
|
-
const formatted = formatWatchResult(result);
|
|
145
|
-
const savedTokens = rawTokens - estimateTokens(formatted);
|
|
146
|
-
if (savedTokens > 20)
|
|
147
|
-
recordSaving("structured", savedTokens);
|
|
148
|
-
if (jsonMode) {
|
|
149
|
-
console.log(JSON.stringify({ exitCode: 0, type: "test-results", ...result, duration: Date.now() - start }));
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
console.log(formatted);
|
|
153
|
-
}
|
|
154
|
-
if (savedTokens > 10)
|
|
155
|
-
console.error(`[open-terminal] test watchlist: saved ${savedTokens} tokens`);
|
|
156
|
-
process.exit(0);
|
|
157
|
-
}
|
|
158
|
-
// Lazy mode for huge output (threshold 200, skip cat/summary commands)
|
|
159
|
-
if (shouldBeLazy(clean, actualCmd)) {
|
|
160
|
-
const lazy = toLazy(clean, actualCmd);
|
|
161
|
-
const savedTokens = rawTokens - estimateTokens(JSON.stringify(lazy));
|
|
162
|
-
if (savedTokens > 0)
|
|
163
|
-
recordSaving("compressed", savedTokens);
|
|
164
|
-
console.log(JSON.stringify({ ...lazy, duration, tokensSaved: savedTokens }));
|
|
165
|
-
process.exit(0);
|
|
166
|
-
}
|
|
167
|
-
// AI summary for medium-large output (>15 lines)
|
|
168
|
-
if (shouldProcess(clean)) {
|
|
169
|
-
const processed = await processOutput(actualCmd, clean);
|
|
170
|
-
if (processed.aiProcessed && processed.tokensSaved > 30) {
|
|
171
|
-
recordSaving("compressed", processed.tokensSaved);
|
|
172
|
-
console.log(processed.summary);
|
|
173
|
-
console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved}, ${Math.round(processed.tokensSaved / rawTokens * 100)}%)`);
|
|
174
|
-
process.exit(0);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// Small/medium output — just noise-strip and return
|
|
178
|
-
console.log(clean);
|
|
179
|
-
const savedTokens = rawTokens - estimateTokens(clean);
|
|
180
|
-
if (savedTokens > 10) {
|
|
181
|
-
recordSaving("compressed", savedTokens);
|
|
182
|
-
console.error(`[open-terminal] saved ${savedTokens} tokens (noise filter)`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
catch (e) {
|
|
186
|
-
// Command failed — parse error output for structured diagnosis
|
|
187
|
-
const stderr = e.stderr?.toString() ?? "";
|
|
188
|
-
const stdout = e.stdout?.toString() ?? "";
|
|
189
|
-
// Deduplicate: if stderr content appears in stdout, skip it
|
|
190
|
-
const combined = stderr && stdout.includes(stderr.trim()) ? stdout : stdout + stderr;
|
|
191
|
-
const errorOutput = stripNoise(stripAnsi(combined)).cleaned;
|
|
192
|
-
// Try structured error parsing
|
|
193
|
-
const { errorParser } = await import("./parsers/errors.js");
|
|
194
|
-
if (errorOutput.length > 200 && errorParser.detect(actualCmd, errorOutput)) {
|
|
195
|
-
const info = errorParser.parse(actualCmd, errorOutput);
|
|
196
|
-
if (jsonMode) {
|
|
197
|
-
console.log(JSON.stringify({ exitCode: e.status ?? 1, error: info }));
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
console.log(`Error: ${info.type}`);
|
|
201
|
-
console.log(` ${info.message}`);
|
|
202
|
-
if (info.file)
|
|
203
|
-
console.log(` File: ${info.file}${info.line ? `:${info.line}` : ""}`);
|
|
204
|
-
if (info.suggestion)
|
|
205
|
-
console.log(` Fix: ${info.suggestion}`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
// Short error or no parser match — pass through cleaned
|
|
210
|
-
console.log(errorOutput);
|
|
211
|
-
}
|
|
212
|
-
process.exit(e.status ?? 1);
|
|
213
|
-
}
|
|
214
|
-
process.exit(0);
|
|
215
|
-
}
|
|
216
61
|
// ── MCP commands ─────────────────────────────────────────────────────────────
|
|
217
62
|
if (args[0] === "mcp") {
|
|
218
63
|
if (args[1] === "serve" || args.length === 1) {
|
|
@@ -585,6 +430,13 @@ else if (args.length > 0) {
|
|
|
585
430
|
console.error(`blocked: ${blocked}`);
|
|
586
431
|
process.exit(1);
|
|
587
432
|
}
|
|
433
|
+
// Safety: warn about irreversible commands (kill, push, rm, etc.)
|
|
434
|
+
if (isIrreversible(command)) {
|
|
435
|
+
console.error(`⚠ IRREVERSIBLE: $ ${command}`);
|
|
436
|
+
console.error(` This command may kill processes, push code, or delete data.`);
|
|
437
|
+
console.error(` Run with terminal exec "${command}" to bypass, or use the TUI for confirmation.`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
588
440
|
// Show what we're running
|
|
589
441
|
console.error(`$ ${command}`);
|
|
590
442
|
// Step 2: Rewrite for optimization
|
|
@@ -605,7 +457,7 @@ else if (args.length > 0) {
|
|
|
605
457
|
const rawTokens = estimateTokens(raw);
|
|
606
458
|
recordUsage(rawTokens);
|
|
607
459
|
// Test output detection
|
|
608
|
-
if (isTestOutput(clean)) {
|
|
460
|
+
if (isTestOutput(clean, actualCmd)) {
|
|
609
461
|
const result = trackTests(process.cwd(), clean);
|
|
610
462
|
console.log(formatWatchResult(result));
|
|
611
463
|
process.exit(0);
|
|
@@ -621,7 +473,7 @@ else if (args.length > 0) {
|
|
|
621
473
|
}
|
|
622
474
|
// AI summary for medium-large output
|
|
623
475
|
if (shouldProcess(clean)) {
|
|
624
|
-
const processed = await processOutput(actualCmd, clean);
|
|
476
|
+
const processed = await processOutput(actualCmd, clean, prompt);
|
|
625
477
|
if (processed.aiProcessed && processed.tokensSaved > 30) {
|
|
626
478
|
recordSaving("compressed", processed.tokensSaved);
|
|
627
479
|
console.log(processed.summary);
|
package/dist/lazy-executor.js
CHANGED
|
@@ -39,7 +39,7 @@ export function toLazy(output, command) {
|
|
|
39
39
|
count: lines.length,
|
|
40
40
|
sample,
|
|
41
41
|
categories: Object.keys(categories).length > 1 ? categories : undefined,
|
|
42
|
-
hint: `${lines.length} results. Showing first 20. Use
|
|
42
|
+
hint: `${lines.length} results. Showing first 20. Use a more specific query to narrow results.`,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
/** Get a slice of output */
|
package/dist/output-processor.js
CHANGED
|
@@ -5,26 +5,22 @@ import { estimateTokens } from "./parsers/index.js";
|
|
|
5
5
|
import { recordSaving } from "./economy.js";
|
|
6
6
|
const MIN_LINES_TO_PROCESS = 15;
|
|
7
7
|
const MAX_OUTPUT_FOR_AI = 8000; // chars to send to AI (truncate if longer)
|
|
8
|
-
const SUMMARIZE_PROMPT = `You are an
|
|
8
|
+
const SUMMARIZE_PROMPT = `You are an intelligent terminal assistant. Given a user's original question and the command output, ANSWER THE QUESTION directly.
|
|
9
9
|
|
|
10
10
|
RULES:
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- For ANY output: keep errors/failures/warnings, drop verbose/repetitive/progress lines
|
|
20
|
-
- Use symbols: ✓ for success, ✗ for failure, ⚠ for warnings
|
|
21
|
-
- Maximum 8 lines in your summary
|
|
22
|
-
- If there are errors, ALWAYS include them verbatim`;
|
|
11
|
+
- If the user asked a YES/NO question, start with Yes or No, then explain briefly
|
|
12
|
+
- If the user asked "how many", give the number first, then context
|
|
13
|
+
- If the user asked "show me X", show only X, not everything
|
|
14
|
+
- ANSWER the question using the data — don't just summarize the raw output
|
|
15
|
+
- Use symbols: ✓ for success/yes, ✗ for failure/no, ⚠ for warnings
|
|
16
|
+
- Maximum 8 lines
|
|
17
|
+
- Keep errors/failures verbatim
|
|
18
|
+
- Be direct and concise — the user wants an ANSWER, not a data dump`;
|
|
23
19
|
/**
|
|
24
20
|
* Process command output through AI summarization.
|
|
25
21
|
* Cheap AI call (~100 tokens) saves 1000+ tokens downstream.
|
|
26
22
|
*/
|
|
27
|
-
export async function processOutput(command, output) {
|
|
23
|
+
export async function processOutput(command, output, originalPrompt) {
|
|
28
24
|
const lines = output.split("\n");
|
|
29
25
|
// Short output — pass through, no AI needed
|
|
30
26
|
if (lines.length <= MIN_LINES_TO_PROCESS) {
|
|
@@ -50,7 +46,7 @@ export async function processOutput(command, output) {
|
|
|
50
46
|
}
|
|
51
47
|
try {
|
|
52
48
|
const provider = getProvider();
|
|
53
|
-
const summary = await provider.complete(`Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`, {
|
|
49
|
+
const summary = await provider.complete(`${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`, {
|
|
54
50
|
system: SUMMARIZE_PROMPT,
|
|
55
51
|
maxTokens: 300,
|
|
56
52
|
});
|
package/dist/test-watchlist.js
CHANGED
|
@@ -37,10 +37,13 @@ function extractTests(output) {
|
|
|
37
37
|
return tests;
|
|
38
38
|
}
|
|
39
39
|
/** Detect if output looks like test runner output */
|
|
40
|
-
export function isTestOutput(output) {
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
export function isTestOutput(output, command) {
|
|
41
|
+
// If the command is explicitly a test command, trust it
|
|
42
|
+
if (command && /\b(bun\s+test|npm\s+test|jest|vitest|pytest|cargo\s+test|go\s+test)\b/.test(command))
|
|
43
|
+
return true;
|
|
44
|
+
// Otherwise require BOTH a summary line AND a test runner marker in the output
|
|
45
|
+
const summaryLine = /(?:\d+\s+pass|\d+\s+fail|Tests?:\s+\d+|Ran\s+\d+\s+tests?)\s*$/im;
|
|
46
|
+
const testMarkers = /(?:✓|✗|✔|✕|PASS\s+\S+\.test|FAIL\s+\S+\.test|bun test v|jest|vitest|pytest)/;
|
|
44
47
|
return summaryLine.test(output) && testMarkers.test(output);
|
|
45
48
|
}
|
|
46
49
|
/** Track test results and return only changes */
|
package/package.json
CHANGED
package/src/ai.ts
CHANGED
|
@@ -44,6 +44,10 @@ const IRREVERSIBLE_PATTERNS = [
|
|
|
44
44
|
/\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
|
|
45
45
|
/\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
|
|
46
46
|
/\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
|
|
47
|
+
// Process/service killing
|
|
48
|
+
/\bkill\b/, /\bkillall\b/, /\bpkill\b/,
|
|
49
|
+
// Git push/force operations
|
|
50
|
+
/\bgit\s+push\b/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+force\b/,
|
|
47
51
|
];
|
|
48
52
|
|
|
49
53
|
export function isIrreversible(command: string): boolean {
|
|
@@ -124,6 +128,26 @@ function detectProjectContext(): string {
|
|
|
124
128
|
parts.push("Project: Java/Gradle. Use gradle commands.");
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
// Directory structure — so AI knows actual paths (not guessed ones)
|
|
132
|
+
try {
|
|
133
|
+
const { execSync } = require("child_process");
|
|
134
|
+
// Top-level dirs
|
|
135
|
+
const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 2000 }).trim();
|
|
136
|
+
parts.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
|
|
137
|
+
|
|
138
|
+
// src/ structure (2 levels deep, most important for path resolution)
|
|
139
|
+
for (const srcDir of ["src", "lib", "app"]) {
|
|
140
|
+
if (existsSync(join(cwd, srcDir))) {
|
|
141
|
+
const tree = execSync(
|
|
142
|
+
`find ${srcDir} -maxdepth 2 -type d -not -path '*/node_modules/*' 2>/dev/null | head -30`,
|
|
143
|
+
{ cwd, encoding: "utf8", timeout: 2000 }
|
|
144
|
+
).trim();
|
|
145
|
+
if (tree) parts.push(`Directories in ${srcDir}/:\n${tree}`);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch { /* timeout or no exec — skip */ }
|
|
150
|
+
|
|
127
151
|
return parts.length > 0 ? `\n\nPROJECT CONTEXT:\n${parts.join("\n")}` : "";
|
|
128
152
|
}
|
|
129
153
|
|
package/src/cli.tsx
CHANGED
|
@@ -7,32 +7,31 @@ const args = process.argv.slice(2);
|
|
|
7
7
|
// ── Help / Version ───────────────────────────────────────────────────────────
|
|
8
8
|
|
|
9
9
|
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
10
|
-
console.log(`open-terminal
|
|
10
|
+
console.log(`open-terminal — Natural language shell for AI agents and humans
|
|
11
11
|
|
|
12
12
|
USAGE:
|
|
13
|
+
terminal "your request" NL → AI picks command → runs → smart output
|
|
13
14
|
terminal Launch interactive NL terminal (TUI)
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
EXAMPLES:
|
|
17
|
+
terminal "list all typescript files"
|
|
18
|
+
terminal "run tests"
|
|
19
|
+
terminal "what changed in git"
|
|
20
|
+
terminal "show me the auth functions"
|
|
21
|
+
terminal "kill port 3000"
|
|
22
|
+
terminal "how many lines of code"
|
|
15
23
|
|
|
16
24
|
SUBCOMMANDS:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
recipe add
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
collection list List collections
|
|
28
|
-
project init Initialize project-scoped recipes
|
|
29
|
-
repo Show git repo state (branch + status + log)
|
|
30
|
-
symbols <file> Show file outline (functions, classes, exports)
|
|
31
|
-
stats Show token economy dashboard
|
|
32
|
-
sessions List recent terminal sessions
|
|
33
|
-
sessions stats Show session analytics
|
|
34
|
-
sessions <id> Show session details
|
|
35
|
-
snapshot Capture terminal state as JSON
|
|
25
|
+
repo Git repo state (branch + status + log)
|
|
26
|
+
symbols <file> File outline (functions, classes, exports)
|
|
27
|
+
overview Project overview (deps, scripts, structure)
|
|
28
|
+
stats Token economy dashboard
|
|
29
|
+
sessions [stats|<id>] Session history and analytics
|
|
30
|
+
recipe add|list|run|delete Reusable command recipes
|
|
31
|
+
collection create|list Recipe collections
|
|
32
|
+
mcp serve Start MCP server for AI agents
|
|
33
|
+
mcp install --claude|--codex Install MCP server
|
|
34
|
+
snapshot Terminal state as JSON
|
|
36
35
|
--help Show this help
|
|
37
36
|
--version Show version
|
|
38
37
|
|
|
@@ -61,156 +60,6 @@ if (args[0] === "--version" || args[0] === "-v") {
|
|
|
61
60
|
process.exit(0);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
// ── Exec command — smart execution for agents ────────────────────────────────
|
|
65
|
-
|
|
66
|
-
if (args[0] === "exec") {
|
|
67
|
-
// Parse flags: --json, --offset=N, --limit=N, --raw
|
|
68
|
-
const flags: Record<string, string> = {};
|
|
69
|
-
const cmdParts: string[] = [];
|
|
70
|
-
for (const arg of args.slice(1)) {
|
|
71
|
-
const flagMatch = arg.match(/^--(\w+)(?:=(.+))?$/);
|
|
72
|
-
if (flagMatch) { flags[flagMatch[1]] = flagMatch[2] ?? "true"; }
|
|
73
|
-
else { cmdParts.push(arg); }
|
|
74
|
-
}
|
|
75
|
-
const command = cmdParts.join(" ");
|
|
76
|
-
const jsonMode = flags.json === "true";
|
|
77
|
-
const rawMode = flags.raw === "true";
|
|
78
|
-
const offset = flags.offset ? parseInt(flags.offset) : undefined;
|
|
79
|
-
const limit = flags.limit ? parseInt(flags.limit) : undefined;
|
|
80
|
-
|
|
81
|
-
if (!command) {
|
|
82
|
-
console.error("Usage: terminal exec <command> [--json] [--raw] [--offset=N] [--limit=N]");
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const { execSync } = await import("child_process");
|
|
87
|
-
const { compress, stripAnsi } = await import("./compression.js");
|
|
88
|
-
const { stripNoise } = await import("./noise-filter.js");
|
|
89
|
-
const { processOutput, shouldProcess } = await import("./output-processor.js");
|
|
90
|
-
const { rewriteCommand } = await import("./command-rewriter.js");
|
|
91
|
-
const { shouldBeLazy, toLazy, getSlice } = await import("./lazy-executor.js");
|
|
92
|
-
const { parseOutput, estimateTokens } = await import("./parsers/index.js");
|
|
93
|
-
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
94
|
-
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
95
|
-
const { detectLoop } = await import("./loop-detector.js");
|
|
96
|
-
|
|
97
|
-
// Loop detection — suggest narrowing if running full test suite repeatedly
|
|
98
|
-
const loop = detectLoop(command);
|
|
99
|
-
if (loop.detected) {
|
|
100
|
-
console.error(`[open-terminal] loop detected: test run #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : " — consider narrowing to specific test file"}`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Rewrite command if possible
|
|
104
|
-
const rw = rewriteCommand(command);
|
|
105
|
-
const actualCmd = rw.changed ? rw.rewritten : command;
|
|
106
|
-
if (rw.changed) console.error(`[open-terminal] rewritten: ${actualCmd} (${rw.reason})`);
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
const start = Date.now();
|
|
110
|
-
const raw = execSync(actualCmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
|
|
111
|
-
const duration = Date.now() - start;
|
|
112
|
-
const clean = stripNoise(stripAnsi(raw)).cleaned;
|
|
113
|
-
const rawTokens = estimateTokens(raw);
|
|
114
|
-
|
|
115
|
-
// Track usage
|
|
116
|
-
recordUsage(rawTokens);
|
|
117
|
-
|
|
118
|
-
// --raw flag: skip all processing
|
|
119
|
-
if (rawMode) { console.log(clean); process.exit(0); }
|
|
120
|
-
|
|
121
|
-
// --json flag: always return structured JSON
|
|
122
|
-
if (jsonMode) {
|
|
123
|
-
const parsed = parseOutput(actualCmd, clean);
|
|
124
|
-
if (parsed) {
|
|
125
|
-
const saved = rawTokens - estimateTokens(JSON.stringify(parsed.data));
|
|
126
|
-
if (saved > 0) recordSaving("structured", saved);
|
|
127
|
-
console.log(JSON.stringify({ exitCode: 0, parser: parsed.parser, data: parsed.data, duration, tokensSaved: Math.max(0, saved) }));
|
|
128
|
-
} else {
|
|
129
|
-
const compressed = compress(actualCmd, clean, { format: "json" });
|
|
130
|
-
console.log(JSON.stringify({ exitCode: 0, output: compressed.content, duration, tokensSaved: compressed.tokensSaved }));
|
|
131
|
-
}
|
|
132
|
-
process.exit(0);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Pagination: --offset + --limit on a previous large result
|
|
136
|
-
if (offset !== undefined || limit !== undefined) {
|
|
137
|
-
const slice = getSlice(clean, offset ?? 0, limit ?? 50);
|
|
138
|
-
console.log(slice.lines.join("\n"));
|
|
139
|
-
if (slice.hasMore) console.error(`[open-terminal] showing ${slice.lines.length}/${slice.total}, ${slice.total - (offset ?? 0) - slice.lines.length} remaining`);
|
|
140
|
-
process.exit(0);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Test output detection — use watchlist for structured test tracking
|
|
144
|
-
if (isTestOutput(clean)) {
|
|
145
|
-
const result = trackTests(process.cwd(), clean);
|
|
146
|
-
const formatted = formatWatchResult(result);
|
|
147
|
-
const savedTokens = rawTokens - estimateTokens(formatted);
|
|
148
|
-
if (savedTokens > 20) recordSaving("structured", savedTokens);
|
|
149
|
-
if (jsonMode) {
|
|
150
|
-
console.log(JSON.stringify({ exitCode: 0, type: "test-results", ...result, duration: Date.now() - start }));
|
|
151
|
-
} else {
|
|
152
|
-
console.log(formatted);
|
|
153
|
-
}
|
|
154
|
-
if (savedTokens > 10) console.error(`[open-terminal] test watchlist: saved ${savedTokens} tokens`);
|
|
155
|
-
process.exit(0);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Lazy mode for huge output (threshold 200, skip cat/summary commands)
|
|
159
|
-
if (shouldBeLazy(clean, actualCmd)) {
|
|
160
|
-
const lazy = toLazy(clean, actualCmd);
|
|
161
|
-
const savedTokens = rawTokens - estimateTokens(JSON.stringify(lazy));
|
|
162
|
-
if (savedTokens > 0) recordSaving("compressed", savedTokens);
|
|
163
|
-
console.log(JSON.stringify({ ...lazy, duration, tokensSaved: savedTokens }));
|
|
164
|
-
process.exit(0);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// AI summary for medium-large output (>15 lines)
|
|
168
|
-
if (shouldProcess(clean)) {
|
|
169
|
-
const processed = await processOutput(actualCmd, clean);
|
|
170
|
-
if (processed.aiProcessed && processed.tokensSaved > 30) {
|
|
171
|
-
recordSaving("compressed", processed.tokensSaved);
|
|
172
|
-
console.log(processed.summary);
|
|
173
|
-
console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved}, ${Math.round(processed.tokensSaved/rawTokens*100)}%)`);
|
|
174
|
-
process.exit(0);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Small/medium output — just noise-strip and return
|
|
179
|
-
console.log(clean);
|
|
180
|
-
const savedTokens = rawTokens - estimateTokens(clean);
|
|
181
|
-
if (savedTokens > 10) {
|
|
182
|
-
recordSaving("compressed", savedTokens);
|
|
183
|
-
console.error(`[open-terminal] saved ${savedTokens} tokens (noise filter)`);
|
|
184
|
-
}
|
|
185
|
-
} catch (e: any) {
|
|
186
|
-
// Command failed — parse error output for structured diagnosis
|
|
187
|
-
const stderr = e.stderr?.toString() ?? "";
|
|
188
|
-
const stdout = e.stdout?.toString() ?? "";
|
|
189
|
-
// Deduplicate: if stderr content appears in stdout, skip it
|
|
190
|
-
const combined = stderr && stdout.includes(stderr.trim()) ? stdout : stdout + stderr;
|
|
191
|
-
const errorOutput = stripNoise(stripAnsi(combined)).cleaned;
|
|
192
|
-
|
|
193
|
-
// Try structured error parsing
|
|
194
|
-
const { errorParser } = await import("./parsers/errors.js");
|
|
195
|
-
if (errorOutput.length > 200 && errorParser.detect(actualCmd, errorOutput)) {
|
|
196
|
-
const info = errorParser.parse(actualCmd, errorOutput);
|
|
197
|
-
if (jsonMode) {
|
|
198
|
-
console.log(JSON.stringify({ exitCode: e.status ?? 1, error: info }));
|
|
199
|
-
} else {
|
|
200
|
-
console.log(`Error: ${info.type}`);
|
|
201
|
-
console.log(` ${info.message}`);
|
|
202
|
-
if (info.file) console.log(` File: ${info.file}${info.line ? `:${info.line}` : ""}`);
|
|
203
|
-
if (info.suggestion) console.log(` Fix: ${info.suggestion}`);
|
|
204
|
-
}
|
|
205
|
-
} else {
|
|
206
|
-
// Short error or no parser match — pass through cleaned
|
|
207
|
-
console.log(errorOutput);
|
|
208
|
-
}
|
|
209
|
-
process.exit(e.status ?? 1);
|
|
210
|
-
}
|
|
211
|
-
process.exit(0);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
63
|
// ── MCP commands ─────────────────────────────────────────────────────────────
|
|
215
64
|
|
|
216
65
|
if (args[0] === "mcp") {
|
|
@@ -561,6 +410,14 @@ else if (args.length > 0) {
|
|
|
561
410
|
const blocked = checkPermissions(command, perms);
|
|
562
411
|
if (blocked) { console.error(`blocked: ${blocked}`); process.exit(1); }
|
|
563
412
|
|
|
413
|
+
// Safety: warn about irreversible commands (kill, push, rm, etc.)
|
|
414
|
+
if (isIrreversible(command)) {
|
|
415
|
+
console.error(`⚠ IRREVERSIBLE: $ ${command}`);
|
|
416
|
+
console.error(` This command may kill processes, push code, or delete data.`);
|
|
417
|
+
console.error(` Run with terminal exec "${command}" to bypass, or use the TUI for confirmation.`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
|
|
564
421
|
// Show what we're running
|
|
565
422
|
console.error(`$ ${command}`);
|
|
566
423
|
|
|
@@ -583,7 +440,7 @@ else if (args.length > 0) {
|
|
|
583
440
|
recordUsage(rawTokens);
|
|
584
441
|
|
|
585
442
|
// Test output detection
|
|
586
|
-
if (isTestOutput(clean)) {
|
|
443
|
+
if (isTestOutput(clean, actualCmd)) {
|
|
587
444
|
const result = trackTests(process.cwd(), clean);
|
|
588
445
|
console.log(formatWatchResult(result));
|
|
589
446
|
process.exit(0);
|
|
@@ -600,7 +457,7 @@ else if (args.length > 0) {
|
|
|
600
457
|
|
|
601
458
|
// AI summary for medium-large output
|
|
602
459
|
if (shouldProcess(clean)) {
|
|
603
|
-
const processed = await processOutput(actualCmd, clean);
|
|
460
|
+
const processed = await processOutput(actualCmd, clean, prompt);
|
|
604
461
|
if (processed.aiProcessed && processed.tokensSaved > 30) {
|
|
605
462
|
recordSaving("compressed", processed.tokensSaved);
|
|
606
463
|
console.log(processed.summary);
|
package/src/lazy-executor.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function toLazy(output: string, command: string): LazyResult {
|
|
|
54
54
|
count: lines.length,
|
|
55
55
|
sample,
|
|
56
56
|
categories: Object.keys(categories).length > 1 ? categories : undefined,
|
|
57
|
-
hint: `${lines.length} results. Showing first 20. Use
|
|
57
|
+
hint: `${lines.length} results. Showing first 20. Use a more specific query to narrow results.`,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
package/src/output-processor.ts
CHANGED
|
@@ -29,21 +29,17 @@ export interface ProcessedOutput {
|
|
|
29
29
|
const MIN_LINES_TO_PROCESS = 15;
|
|
30
30
|
const MAX_OUTPUT_FOR_AI = 8000; // chars to send to AI (truncate if longer)
|
|
31
31
|
|
|
32
|
-
const SUMMARIZE_PROMPT = `You are an
|
|
32
|
+
const SUMMARIZE_PROMPT = `You are an intelligent terminal assistant. Given a user's original question and the command output, ANSWER THE QUESTION directly.
|
|
33
33
|
|
|
34
34
|
RULES:
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
- For ANY output: keep errors/failures/warnings, drop verbose/repetitive/progress lines
|
|
44
|
-
- Use symbols: ✓ for success, ✗ for failure, ⚠ for warnings
|
|
45
|
-
- Maximum 8 lines in your summary
|
|
46
|
-
- If there are errors, ALWAYS include them verbatim`;
|
|
35
|
+
- If the user asked a YES/NO question, start with Yes or No, then explain briefly
|
|
36
|
+
- If the user asked "how many", give the number first, then context
|
|
37
|
+
- If the user asked "show me X", show only X, not everything
|
|
38
|
+
- ANSWER the question using the data — don't just summarize the raw output
|
|
39
|
+
- Use symbols: ✓ for success/yes, ✗ for failure/no, ⚠ for warnings
|
|
40
|
+
- Maximum 8 lines
|
|
41
|
+
- Keep errors/failures verbatim
|
|
42
|
+
- Be direct and concise — the user wants an ANSWER, not a data dump`;
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
45
|
* Process command output through AI summarization.
|
|
@@ -52,6 +48,7 @@ RULES:
|
|
|
52
48
|
export async function processOutput(
|
|
53
49
|
command: string,
|
|
54
50
|
output: string,
|
|
51
|
+
originalPrompt?: string,
|
|
55
52
|
): Promise<ProcessedOutput> {
|
|
56
53
|
const lines = output.split("\n");
|
|
57
54
|
|
|
@@ -82,7 +79,7 @@ export async function processOutput(
|
|
|
82
79
|
try {
|
|
83
80
|
const provider = getProvider();
|
|
84
81
|
const summary = await provider.complete(
|
|
85
|
-
`Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`,
|
|
82
|
+
`${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`,
|
|
86
83
|
{
|
|
87
84
|
system: SUMMARIZE_PROMPT,
|
|
88
85
|
maxTokens: 300,
|
package/src/test-watchlist.ts
CHANGED
|
@@ -63,10 +63,12 @@ function extractTests(output: string): TestStatus[] {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Detect if output looks like test runner output */
|
|
66
|
-
export function isTestOutput(output: string): boolean {
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
export function isTestOutput(output: string, command?: string): boolean {
|
|
67
|
+
// If the command is explicitly a test command, trust it
|
|
68
|
+
if (command && /\b(bun\s+test|npm\s+test|jest|vitest|pytest|cargo\s+test|go\s+test)\b/.test(command)) return true;
|
|
69
|
+
// Otherwise require BOTH a summary line AND a test runner marker in the output
|
|
70
|
+
const summaryLine = /(?:\d+\s+pass|\d+\s+fail|Tests?:\s+\d+|Ran\s+\d+\s+tests?)\s*$/im;
|
|
71
|
+
const testMarkers = /(?:✓|✗|✔|✕|PASS\s+\S+\.test|FAIL\s+\S+\.test|bun test v|jest|vitest|pytest)/;
|
|
70
72
|
return summaryLine.test(output) && testMarkers.test(output);
|
|
71
73
|
}
|
|
72
74
|
|