@hasna/terminal 1.0.1 → 1.1.1
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 +17 -0
- package/dist/cli.js +121 -176
- package/dist/test-watchlist.js +4 -3
- package/package.json +1 -1
- package/src/ai.ts +20 -0
- package/src/cli.tsx +125 -172
- package/src/test-watchlist.ts +4 -3
package/dist/ai.js
CHANGED
|
@@ -96,6 +96,23 @@ function detectProjectContext() {
|
|
|
96
96
|
if (existsSync(join(cwd, "build.gradle")) || existsSync(join(cwd, "build.gradle.kts"))) {
|
|
97
97
|
parts.push("Project: Java/Gradle. Use gradle commands.");
|
|
98
98
|
}
|
|
99
|
+
// Directory structure — so AI knows actual paths (not guessed ones)
|
|
100
|
+
try {
|
|
101
|
+
const { execSync } = require("child_process");
|
|
102
|
+
// Top-level dirs
|
|
103
|
+
const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 2000 }).trim();
|
|
104
|
+
parts.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
|
|
105
|
+
// src/ structure (2 levels deep, most important for path resolution)
|
|
106
|
+
for (const srcDir of ["src", "lib", "app"]) {
|
|
107
|
+
if (existsSync(join(cwd, srcDir))) {
|
|
108
|
+
const tree = execSync(`find ${srcDir} -maxdepth 2 -type d -not -path '*/node_modules/*' 2>/dev/null | head -30`, { cwd, encoding: "utf8", timeout: 2000 }).trim();
|
|
109
|
+
if (tree)
|
|
110
|
+
parts.push(`Directories in ${srcDir}/:\n${tree}`);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch { /* timeout or no exec — skip */ }
|
|
99
116
|
return parts.length > 0 ? `\n\nPROJECT CONTEXT:\n${parts.join("\n")}` : "";
|
|
100
117
|
}
|
|
101
118
|
// ── 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) {
|
|
@@ -545,7 +390,107 @@ else if (args[0] === "project" && args[1] === "init") {
|
|
|
545
390
|
initProject(process.cwd());
|
|
546
391
|
console.log("✓ Initialized .terminal/recipes.json");
|
|
547
392
|
}
|
|
548
|
-
// ──
|
|
393
|
+
// ── NL mode: terminal "natural language prompt" ─────────────────────────────
|
|
394
|
+
else if (args.length > 0) {
|
|
395
|
+
// Everything that doesn't match a subcommand is treated as natural language
|
|
396
|
+
const prompt = args.join(" ");
|
|
397
|
+
if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
|
|
398
|
+
console.error("terminal: No API key found.");
|
|
399
|
+
console.error("Set one of:");
|
|
400
|
+
console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
|
|
401
|
+
console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
|
|
405
|
+
const { execSync } = await import("child_process");
|
|
406
|
+
const { compress, stripAnsi } = await import("./compression.js");
|
|
407
|
+
const { stripNoise } = await import("./noise-filter.js");
|
|
408
|
+
const { processOutput, shouldProcess } = await import("./output-processor.js");
|
|
409
|
+
const { rewriteCommand } = await import("./command-rewriter.js");
|
|
410
|
+
const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
|
|
411
|
+
const { parseOutput, estimateTokens } = await import("./parsers/index.js");
|
|
412
|
+
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
413
|
+
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
414
|
+
const { detectLoop } = await import("./loop-detector.js");
|
|
415
|
+
const { loadConfig } = await import("./history.js");
|
|
416
|
+
const config = loadConfig();
|
|
417
|
+
const perms = config.permissions;
|
|
418
|
+
// Step 1: AI translates NL → shell command
|
|
419
|
+
let command;
|
|
420
|
+
try {
|
|
421
|
+
command = await translateToCommand(prompt, perms, []);
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
console.error(e.message);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
// Check permissions
|
|
428
|
+
const blocked = checkPermissions(command, perms);
|
|
429
|
+
if (blocked) {
|
|
430
|
+
console.error(`blocked: ${blocked}`);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
// Show what we're running
|
|
434
|
+
console.error(`$ ${command}`);
|
|
435
|
+
// Step 2: Rewrite for optimization
|
|
436
|
+
const rw = rewriteCommand(command);
|
|
437
|
+
const actualCmd = rw.changed ? rw.rewritten : command;
|
|
438
|
+
if (rw.changed)
|
|
439
|
+
console.error(`[open-terminal] optimized: ${actualCmd}`);
|
|
440
|
+
// Loop detection
|
|
441
|
+
const loop = detectLoop(actualCmd);
|
|
442
|
+
if (loop.detected)
|
|
443
|
+
console.error(`[open-terminal] loop #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : ""}`);
|
|
444
|
+
// Step 3: Execute
|
|
445
|
+
try {
|
|
446
|
+
const start = Date.now();
|
|
447
|
+
const raw = execSync(actualCmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
|
|
448
|
+
const duration = Date.now() - start;
|
|
449
|
+
const clean = stripNoise(stripAnsi(raw)).cleaned;
|
|
450
|
+
const rawTokens = estimateTokens(raw);
|
|
451
|
+
recordUsage(rawTokens);
|
|
452
|
+
// Test output detection
|
|
453
|
+
if (isTestOutput(clean)) {
|
|
454
|
+
const result = trackTests(process.cwd(), clean);
|
|
455
|
+
console.log(formatWatchResult(result));
|
|
456
|
+
process.exit(0);
|
|
457
|
+
}
|
|
458
|
+
// Lazy mode
|
|
459
|
+
if (shouldBeLazy(clean, actualCmd)) {
|
|
460
|
+
const lazy = toLazy(clean, actualCmd);
|
|
461
|
+
const saved = rawTokens - estimateTokens(JSON.stringify(lazy));
|
|
462
|
+
if (saved > 0)
|
|
463
|
+
recordSaving("compressed", saved);
|
|
464
|
+
console.log(JSON.stringify(lazy, null, 2));
|
|
465
|
+
process.exit(0);
|
|
466
|
+
}
|
|
467
|
+
// AI summary for medium-large output
|
|
468
|
+
if (shouldProcess(clean)) {
|
|
469
|
+
const processed = await processOutput(actualCmd, clean);
|
|
470
|
+
if (processed.aiProcessed && processed.tokensSaved > 30) {
|
|
471
|
+
recordSaving("compressed", processed.tokensSaved);
|
|
472
|
+
console.log(processed.summary);
|
|
473
|
+
console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved})`);
|
|
474
|
+
process.exit(0);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Small output — pass through clean
|
|
478
|
+
console.log(clean);
|
|
479
|
+
const saved = rawTokens - estimateTokens(clean);
|
|
480
|
+
if (saved > 10) {
|
|
481
|
+
recordSaving("compressed", saved);
|
|
482
|
+
console.error(`[open-terminal] saved ${saved} tokens`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
const stderr = e.stderr?.toString() ?? "";
|
|
487
|
+
const stdout = e.stdout?.toString() ?? "";
|
|
488
|
+
const combined = stderr && stdout.includes(stderr.trim()) ? stdout : stdout + stderr;
|
|
489
|
+
console.log(stripNoise(stripAnsi(combined)).cleaned);
|
|
490
|
+
process.exit(e.status ?? 1);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// ── TUI mode (no args) ──────────────────────────────────────────────────────
|
|
549
494
|
else {
|
|
550
495
|
if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
|
|
551
496
|
console.error("terminal: No API key found.");
|
package/dist/test-watchlist.js
CHANGED
|
@@ -38,9 +38,10 @@ function extractTests(output) {
|
|
|
38
38
|
}
|
|
39
39
|
/** Detect if output looks like test runner output */
|
|
40
40
|
export function isTestOutput(output) {
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
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)/;
|
|
44
|
+
return summaryLine.test(output) && testMarkers.test(output);
|
|
44
45
|
}
|
|
45
46
|
/** Track test results and return only changes */
|
|
46
47
|
export function trackTests(cwd, output) {
|
package/package.json
CHANGED
package/src/ai.ts
CHANGED
|
@@ -124,6 +124,26 @@ function detectProjectContext(): string {
|
|
|
124
124
|
parts.push("Project: Java/Gradle. Use gradle commands.");
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Directory structure — so AI knows actual paths (not guessed ones)
|
|
128
|
+
try {
|
|
129
|
+
const { execSync } = require("child_process");
|
|
130
|
+
// Top-level dirs
|
|
131
|
+
const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 2000 }).trim();
|
|
132
|
+
parts.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
|
|
133
|
+
|
|
134
|
+
// src/ structure (2 levels deep, most important for path resolution)
|
|
135
|
+
for (const srcDir of ["src", "lib", "app"]) {
|
|
136
|
+
if (existsSync(join(cwd, srcDir))) {
|
|
137
|
+
const tree = execSync(
|
|
138
|
+
`find ${srcDir} -maxdepth 2 -type d -not -path '*/node_modules/*' 2>/dev/null | head -30`,
|
|
139
|
+
{ cwd, encoding: "utf8", timeout: 2000 }
|
|
140
|
+
).trim();
|
|
141
|
+
if (tree) parts.push(`Directories in ${srcDir}/:\n${tree}`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch { /* timeout or no exec — skip */ }
|
|
146
|
+
|
|
127
147
|
return parts.length > 0 ? `\n\nPROJECT CONTEXT:\n${parts.join("\n")}` : "";
|
|
128
148
|
}
|
|
129
149
|
|
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") {
|
|
@@ -518,7 +367,111 @@ else if (args[0] === "project" && args[1] === "init") {
|
|
|
518
367
|
console.log("✓ Initialized .terminal/recipes.json");
|
|
519
368
|
}
|
|
520
369
|
|
|
521
|
-
// ──
|
|
370
|
+
// ── NL mode: terminal "natural language prompt" ─────────────────────────────
|
|
371
|
+
|
|
372
|
+
else if (args.length > 0) {
|
|
373
|
+
// Everything that doesn't match a subcommand is treated as natural language
|
|
374
|
+
const prompt = args.join(" ");
|
|
375
|
+
|
|
376
|
+
if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
|
|
377
|
+
console.error("terminal: No API key found.");
|
|
378
|
+
console.error("Set one of:");
|
|
379
|
+
console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
|
|
380
|
+
console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
|
|
385
|
+
const { execSync } = await import("child_process");
|
|
386
|
+
const { compress, stripAnsi } = await import("./compression.js");
|
|
387
|
+
const { stripNoise } = await import("./noise-filter.js");
|
|
388
|
+
const { processOutput, shouldProcess } = await import("./output-processor.js");
|
|
389
|
+
const { rewriteCommand } = await import("./command-rewriter.js");
|
|
390
|
+
const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
|
|
391
|
+
const { parseOutput, estimateTokens } = await import("./parsers/index.js");
|
|
392
|
+
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
393
|
+
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
394
|
+
const { detectLoop } = await import("./loop-detector.js");
|
|
395
|
+
const { loadConfig } = await import("./history.js");
|
|
396
|
+
|
|
397
|
+
const config = loadConfig();
|
|
398
|
+
const perms = config.permissions;
|
|
399
|
+
|
|
400
|
+
// Step 1: AI translates NL → shell command
|
|
401
|
+
let command: string;
|
|
402
|
+
try {
|
|
403
|
+
command = await translateToCommand(prompt, perms, []);
|
|
404
|
+
} catch (e: any) {
|
|
405
|
+
console.error(e.message);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check permissions
|
|
410
|
+
const blocked = checkPermissions(command, perms);
|
|
411
|
+
if (blocked) { console.error(`blocked: ${blocked}`); process.exit(1); }
|
|
412
|
+
|
|
413
|
+
// Show what we're running
|
|
414
|
+
console.error(`$ ${command}`);
|
|
415
|
+
|
|
416
|
+
// Step 2: Rewrite for optimization
|
|
417
|
+
const rw = rewriteCommand(command);
|
|
418
|
+
const actualCmd = rw.changed ? rw.rewritten : command;
|
|
419
|
+
if (rw.changed) console.error(`[open-terminal] optimized: ${actualCmd}`);
|
|
420
|
+
|
|
421
|
+
// Loop detection
|
|
422
|
+
const loop = detectLoop(actualCmd);
|
|
423
|
+
if (loop.detected) console.error(`[open-terminal] loop #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : ""}`);
|
|
424
|
+
|
|
425
|
+
// Step 3: Execute
|
|
426
|
+
try {
|
|
427
|
+
const start = Date.now();
|
|
428
|
+
const raw = execSync(actualCmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
|
|
429
|
+
const duration = Date.now() - start;
|
|
430
|
+
const clean = stripNoise(stripAnsi(raw)).cleaned;
|
|
431
|
+
const rawTokens = estimateTokens(raw);
|
|
432
|
+
recordUsage(rawTokens);
|
|
433
|
+
|
|
434
|
+
// Test output detection
|
|
435
|
+
if (isTestOutput(clean)) {
|
|
436
|
+
const result = trackTests(process.cwd(), clean);
|
|
437
|
+
console.log(formatWatchResult(result));
|
|
438
|
+
process.exit(0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Lazy mode
|
|
442
|
+
if (shouldBeLazy(clean, actualCmd)) {
|
|
443
|
+
const lazy = toLazy(clean, actualCmd);
|
|
444
|
+
const saved = rawTokens - estimateTokens(JSON.stringify(lazy));
|
|
445
|
+
if (saved > 0) recordSaving("compressed", saved);
|
|
446
|
+
console.log(JSON.stringify(lazy, null, 2));
|
|
447
|
+
process.exit(0);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// AI summary for medium-large output
|
|
451
|
+
if (shouldProcess(clean)) {
|
|
452
|
+
const processed = await processOutput(actualCmd, clean);
|
|
453
|
+
if (processed.aiProcessed && processed.tokensSaved > 30) {
|
|
454
|
+
recordSaving("compressed", processed.tokensSaved);
|
|
455
|
+
console.log(processed.summary);
|
|
456
|
+
console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved})`);
|
|
457
|
+
process.exit(0);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Small output — pass through clean
|
|
462
|
+
console.log(clean);
|
|
463
|
+
const saved = rawTokens - estimateTokens(clean);
|
|
464
|
+
if (saved > 10) { recordSaving("compressed", saved); console.error(`[open-terminal] saved ${saved} tokens`); }
|
|
465
|
+
} catch (e: any) {
|
|
466
|
+
const stderr = e.stderr?.toString() ?? "";
|
|
467
|
+
const stdout = e.stdout?.toString() ?? "";
|
|
468
|
+
const combined = stderr && stdout.includes(stderr.trim()) ? stdout : stdout + stderr;
|
|
469
|
+
console.log(stripNoise(stripAnsi(combined)).cleaned);
|
|
470
|
+
process.exit(e.status ?? 1);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── TUI mode (no args) ──────────────────────────────────────────────────────
|
|
522
475
|
|
|
523
476
|
else {
|
|
524
477
|
if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
|
package/src/test-watchlist.ts
CHANGED
|
@@ -64,9 +64,10 @@ function extractTests(output: string): TestStatus[] {
|
|
|
64
64
|
|
|
65
65
|
/** Detect if output looks like test runner output */
|
|
66
66
|
export function isTestOutput(output: string): boolean {
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
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)/;
|
|
70
|
+
return summaryLine.test(output) && testMarkers.test(output);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/** Track test results and return only changes */
|