@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 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 v1.0.0 Smart terminal for AI agents and humans
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
- terminal <subcommand> Run a specific command
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
- mcp serve Start MCP server (stdio transport)
15
- mcp install --claude|--codex|--gemini|--all
16
- Install as MCP server for AI agents
17
- hook install --claude Install Claude Code PostToolUse hook
18
- hook uninstall Remove hooks
19
- recipe add <name> <cmd> Save a reusable command recipe
20
- recipe list List saved recipes
21
- recipe run <name> [--var=X] Run a recipe with variable substitution
22
- recipe delete <name> Delete a recipe
23
- collection create <name> Create a recipe collection
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);
@@ -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 terminal exec --offset=20 --limit=20 to paginate.`,
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 */
@@ -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 output summarizer for a terminal. Given command output, return a CONCISE structured summary.
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
- - Return ONLY the summary, no explanations
12
- - For test output: show pass count, fail count, and ONLY the failing test names + errors
13
- - For build output: show status (ok/fail), error count, warning count
14
- - For install output: show package count, time, vulnerabilities
15
- - For file listings: show directory count, file count, notable files
16
- - For git output: show branch, status, key info
17
- - For logs: show line count, error count, latest error
18
- - For search results: show match count, top files
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
  });
@@ -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
- // Must have a summary line with counts (not just words "pass"/"fail" in prose)
42
- const summaryLine = /(?:\d+\s+pass|\d+\s+fail|Tests?:\s+\d+|Ran\s+\d+\s+tests?)/i;
43
- const testMarkers = /(?:✓|✗|✔|✕|PASS\s+\S+\.test|FAIL\s+\S+\.test|bun test|jest|vitest|pytest)/;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "bin": {
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 v1.0.0 Smart terminal for AI agents and humans
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
- terminal <subcommand> Run a specific command
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
- mcp serve Start MCP server (stdio transport)
18
- mcp install --claude|--codex|--gemini|--all
19
- Install as MCP server for AI agents
20
- hook install --claude Install Claude Code PostToolUse hook
21
- hook uninstall Remove hooks
22
- recipe add <name> <cmd> Save a reusable command recipe
23
- recipe list List saved recipes
24
- recipe run <name> [--var=X] Run a recipe with variable substitution
25
- recipe delete <name> Delete a recipe
26
- collection create <name> Create a recipe collection
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);
@@ -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 terminal exec --offset=20 --limit=20 to paginate.`,
57
+ hint: `${lines.length} results. Showing first 20. Use a more specific query to narrow results.`,
58
58
  };
59
59
  }
60
60
 
@@ -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 output summarizer for a terminal. Given command output, return a CONCISE structured summary.
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
- - Return ONLY the summary, no explanations
36
- - For test output: show pass count, fail count, and ONLY the failing test names + errors
37
- - For build output: show status (ok/fail), error count, warning count
38
- - For install output: show package count, time, vulnerabilities
39
- - For file listings: show directory count, file count, notable files
40
- - For git output: show branch, status, key info
41
- - For logs: show line count, error count, latest error
42
- - For search results: show match count, top files
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,
@@ -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
- // Must have a summary line with counts (not just words "pass"/"fail" in prose)
68
- const summaryLine = /(?:\d+\s+pass|\d+\s+fail|Tests?:\s+\d+|Ran\s+\d+\s+tests?)/i;
69
- const testMarkers = /(?:✓|✗|✔|✕|PASS\s+\S+\.test|FAIL\s+\S+\.test|bun test|jest|vitest|pytest)/;
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