@hasna/terminal 2.0.5 → 2.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.
Files changed (64) hide show
  1. package/dist/cli.js +23 -9
  2. package/package.json +1 -1
  3. package/src/ai.ts +46 -113
  4. package/src/cli.tsx +22 -9
  5. package/src/command-validator.ts +11 -0
  6. package/src/context-hints.ts +202 -0
  7. package/src/output-processor.ts +7 -18
  8. package/src/providers/base.ts +3 -1
  9. package/src/providers/groq.ts +108 -0
  10. package/src/providers/index.ts +26 -2
  11. package/src/providers/providers.test.ts +4 -2
  12. package/src/providers/xai.ts +108 -0
  13. package/dist/App.js +0 -404
  14. package/dist/Browse.js +0 -79
  15. package/dist/FuzzyPicker.js +0 -47
  16. package/dist/Onboarding.js +0 -51
  17. package/dist/Spinner.js +0 -12
  18. package/dist/StatusBar.js +0 -49
  19. package/dist/ai.js +0 -368
  20. package/dist/cache.js +0 -41
  21. package/dist/command-rewriter.js +0 -64
  22. package/dist/command-validator.js +0 -77
  23. package/dist/compression.js +0 -107
  24. package/dist/diff-cache.js +0 -107
  25. package/dist/economy.js +0 -79
  26. package/dist/expand-store.js +0 -38
  27. package/dist/file-cache.js +0 -72
  28. package/dist/file-index.js +0 -62
  29. package/dist/history.js +0 -62
  30. package/dist/lazy-executor.js +0 -54
  31. package/dist/line-dedup.js +0 -59
  32. package/dist/loop-detector.js +0 -75
  33. package/dist/mcp/install.js +0 -98
  34. package/dist/mcp/server.js +0 -569
  35. package/dist/noise-filter.js +0 -86
  36. package/dist/output-processor.js +0 -136
  37. package/dist/output-router.js +0 -41
  38. package/dist/parsers/base.js +0 -2
  39. package/dist/parsers/build.js +0 -64
  40. package/dist/parsers/errors.js +0 -101
  41. package/dist/parsers/files.js +0 -78
  42. package/dist/parsers/git.js +0 -99
  43. package/dist/parsers/index.js +0 -48
  44. package/dist/parsers/tests.js +0 -89
  45. package/dist/providers/anthropic.js +0 -39
  46. package/dist/providers/base.js +0 -4
  47. package/dist/providers/cerebras.js +0 -95
  48. package/dist/providers/index.js +0 -49
  49. package/dist/recipes/model.js +0 -20
  50. package/dist/recipes/storage.js +0 -136
  51. package/dist/search/content-search.js +0 -68
  52. package/dist/search/file-search.js +0 -61
  53. package/dist/search/filters.js +0 -34
  54. package/dist/search/index.js +0 -5
  55. package/dist/search/semantic.js +0 -320
  56. package/dist/session-boot.js +0 -59
  57. package/dist/session-context.js +0 -55
  58. package/dist/sessions-db.js +0 -120
  59. package/dist/smart-display.js +0 -286
  60. package/dist/snapshots.js +0 -51
  61. package/dist/supervisor.js +0 -112
  62. package/dist/test-watchlist.js +0 -131
  63. package/dist/tree.js +0 -94
  64. package/dist/usage-cache.js +0 -65
package/dist/cli.js CHANGED
@@ -41,7 +41,9 @@ MCP TOOLS (20+):
41
41
  snapshot, token_stats, session_history
42
42
 
43
43
  ENVIRONMENT:
44
- CEREBRAS_API_KEY Cerebras API key (free, open-source default)
44
+ XAI_API_KEY xAI API key (Grok, code-optimized default)
45
+ CEREBRAS_API_KEY Cerebras API key (free, open-source)
46
+ GROQ_API_KEY Groq API key (free, ultra-fast inference)
45
47
  ANTHROPIC_API_KEY Anthropic API key (Claude models)
46
48
  `);
47
49
  process.exit(0);
@@ -397,7 +399,7 @@ else if (args[0] === "history") {
397
399
  // ── Explain command ─────────────────────────────────────────────────────────
398
400
  else if (args[0] === "explain" && args[1]) {
399
401
  const command = args.slice(1).join(" ");
400
- if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
402
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY && !process.env.GROQ_API_KEY && !process.env.XAI_API_KEY) {
401
403
  console.error("explain requires an API key");
402
404
  process.exit(1);
403
405
  }
@@ -420,7 +422,7 @@ else if (args[0] === "project" && args[1] === "init") {
420
422
  else if (args.length > 0) {
421
423
  // Everything that doesn't match a subcommand is treated as natural language
422
424
  const prompt = args.join(" ");
423
- const offlineMode = !process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY;
425
+ const offlineMode = !process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY && !process.env.GROQ_API_KEY && !process.env.XAI_API_KEY;
424
426
  const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
425
427
  const { execSync } = await import("child_process");
426
428
  const { compress, stripAnsi } = await import("./compression.js");
@@ -477,9 +479,10 @@ else if (args.length > 0) {
477
479
  catch { }
478
480
  }
479
481
  }
480
- // "I don't know" honesty — better than wrong answer
482
+ // Show the block reason clearly
481
483
  if (e.message?.startsWith("BLOCKED:")) {
482
- console.log(`I don't know how to do this with shell commands. Try running it directly.`);
484
+ console.log(`⚠ ${e.message}`);
485
+ console.log(` This is a READ-ONLY terminal. Run directly in your shell if you're sure.`);
483
486
  }
484
487
  else {
485
488
  console.error(e.message);
@@ -525,13 +528,22 @@ else if (args.length > 0) {
525
528
  // Auto-retry: re-translate with simpler constraints
526
529
  console.error(`[open-terminal] invalid command detected: ${validation.issues.join(", ")}`);
527
530
  try {
528
- const retryCommand = await translateToCommand(`${prompt} (IMPORTANT: keep it simple. Use basic grep/find/cat/ls/wc commands. No complex awk/sed pipelines. No GNU flags. Verify file paths from the project context.)`, perms, []);
531
+ const retryCommand = await translateToCommand(`${prompt} (Previous command had issues: ${validation.issues.join(", ")}. Fix those specific issues. Keep the approach but correct the errors.)`, perms, []);
529
532
  if (retryCommand && retryCommand !== command) {
530
533
  const retryValidation = validateCommand(retryCommand, process.cwd());
531
534
  if (retryValidation.valid || retryValidation.issues.length < validation.issues.length) {
532
535
  command = retryCommand;
533
536
  console.error(`[open-terminal] retried: $ ${command}`);
534
537
  }
538
+ else {
539
+ // Retry also invalid — use the simpler of the two
540
+ const retryPipes = (retryCommand.match(/\|/g) || []).length;
541
+ const origPipes = (command.match(/\|/g) || []).length;
542
+ if (retryPipes < origPipes) {
543
+ command = retryCommand;
544
+ console.error(`[open-terminal] retried (simpler): $ ${command}`);
545
+ }
546
+ }
535
547
  }
536
548
  }
537
549
  catch { }
@@ -652,11 +664,13 @@ else if (args.length > 0) {
652
664
  }
653
665
  // ── TUI mode (no args) ──────────────────────────────────────────────────────
654
666
  else {
655
- if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
667
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY && !process.env.GROQ_API_KEY && !process.env.XAI_API_KEY) {
656
668
  console.error("terminal: No API key found.");
657
669
  console.error("Set one of:");
658
- console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
659
- console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
670
+ console.error(" export XAI_API_KEY=your_key (Grok, code-optimized — default)");
671
+ console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
672
+ console.error(" export GROQ_API_KEY=your_key (free, ultra-fast)");
673
+ console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
660
674
  process.exit(1);
661
675
  }
662
676
  const App = (await import("./App.js")).default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "2.0.5",
3
+ "version": "2.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
@@ -3,6 +3,7 @@ import { cacheGet, cacheSet } from "./cache.js";
3
3
  import { getProvider } from "./providers/index.js";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
+ import { discoverProjectHints, discoverSafetyHints, formatHints } from "./context-hints.js";
6
7
 
7
8
  // ── model routing ─────────────────────────────────────────────────────────────
8
9
  // Simple queries → fast model. Complex/ambiguous → smart model.
@@ -30,6 +31,22 @@ function pickModel(nl: string): { fast: string; smart: string; pick: "fast" | "s
30
31
  };
31
32
  }
32
33
 
34
+ if (provider.name === "groq") {
35
+ return {
36
+ fast: "openai/gpt-oss-120b",
37
+ smart: "moonshotai/kimi-k2-instruct",
38
+ pick: isComplex ? "smart" : "fast",
39
+ };
40
+ }
41
+
42
+ if (provider.name === "xai") {
43
+ return {
44
+ fast: "grok-code-fast-1",
45
+ smart: "grok-4-fast-non-reasoning",
46
+ pick: isComplex ? "smart" : "fast",
47
+ };
48
+ }
49
+
33
50
  // Cerebras — qwen for everything (llama3.1-8b too unreliable)
34
51
  return {
35
52
  fast: "qwen-3-235b-a22b-instruct-2507",
@@ -107,118 +124,11 @@ export interface SessionEntry {
107
124
  error?: boolean;
108
125
  }
109
126
 
110
- // ── project context ──────────────────────────────────────────────────────────
127
+ // ── project context (powered by context-hints) ──────────────────────────────
111
128
 
112
129
  function detectProjectContext(): string {
113
- const cwd = process.cwd();
114
- const parts: string[] = [];
115
-
116
- // Node.js / TypeScript
117
- const pkgPath = join(cwd, "package.json");
118
- if (existsSync(pkgPath)) {
119
- try {
120
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
121
- parts.push(`Project: ${pkg.name}@${pkg.version} (Node.js/TypeScript)`);
122
- parts.push(`npm package: ${pkg.name} (use this name for npm view, npm info, etc.)`);
123
- if (pkg.scripts) {
124
- const scripts = Object.entries(pkg.scripts).map(([k, v]) => `${k}: ${v}`).slice(0, 8);
125
- parts.push(`Available scripts: ${scripts.join(", ")}`);
126
- }
127
- if (pkg.dependencies) parts.push(`Dependencies: ${Object.keys(pkg.dependencies).join(", ")}`);
128
- parts.push(`Use npm/bun/pnpm commands, NOT maven/gradle/cargo.`);
129
- } catch {}
130
- }
131
-
132
- // Python
133
- if (existsSync(join(cwd, "pyproject.toml"))) {
134
- try {
135
- const pyproject = readFileSync(join(cwd, "pyproject.toml"), "utf8");
136
- const nameMatch = pyproject.match(/name\s*=\s*"([^"]+)"/);
137
- const versionMatch = pyproject.match(/version\s*=\s*"([^"]+)"/);
138
- parts.push(`Project: ${nameMatch?.[1] ?? "Python"}${versionMatch ? `@${versionMatch[1]}` : ""} (Python)`);
139
- } catch { parts.push("Project: Python (pyproject.toml found)"); }
140
- parts.push("Use pip/python/pytest commands. Test: pytest. Build: python -m build.");
141
- } else if (existsSync(join(cwd, "requirements.txt"))) {
142
- parts.push("Project: Python (requirements.txt). Use pip/python/pytest commands.");
143
- }
144
-
145
- // Go
146
- if (existsSync(join(cwd, "go.mod"))) {
147
- try {
148
- const gomod = readFileSync(join(cwd, "go.mod"), "utf8");
149
- const moduleMatch = gomod.match(/module\s+(\S+)/);
150
- parts.push(`Project: ${moduleMatch?.[1] ?? "Go"} (Go module)`);
151
- } catch { parts.push("Project: Go (go.mod found)"); }
152
- parts.push("Use go build/test/run. Test: go test ./... Build: go build.");
153
- }
154
-
155
- // Rust
156
- if (existsSync(join(cwd, "Cargo.toml"))) {
157
- try {
158
- const cargo = readFileSync(join(cwd, "Cargo.toml"), "utf8");
159
- const nameMatch = cargo.match(/name\s*=\s*"([^"]+)"/);
160
- const versionMatch = cargo.match(/version\s*=\s*"([^"]+)"/);
161
- parts.push(`Project: ${nameMatch?.[1] ?? "Rust"}${versionMatch ? `@${versionMatch[1]}` : ""} (Rust/Cargo)`);
162
- } catch { parts.push("Project: Rust (Cargo.toml found)"); }
163
- parts.push("Use cargo build/test/run. Test: cargo test. Build: cargo build --release.");
164
- }
165
-
166
- // Java
167
- if (existsSync(join(cwd, "pom.xml"))) {
168
- parts.push("Project: Java/Maven. Use mvn commands. Test: mvn test. Build: mvn package.");
169
- }
170
- if (existsSync(join(cwd, "build.gradle")) || existsSync(join(cwd, "build.gradle.kts"))) {
171
- parts.push("Project: Java/Gradle. Use gradle commands. Test: gradle test. Build: gradle build.");
172
- }
173
-
174
- // Docker
175
- if (existsSync(join(cwd, "Dockerfile")) || existsSync(join(cwd, "docker-compose.yml")) || existsSync(join(cwd, "docker-compose.yaml"))) {
176
- parts.push("Docker: Dockerfile/docker-compose present. Container commands available.");
177
- }
178
-
179
- // Makefile
180
- if (existsSync(join(cwd, "Makefile"))) {
181
- try {
182
- const { execSync: execS } = require("child_process");
183
- const targets = execS("grep -E '^[a-zA-Z_-]+:' Makefile | head -10 | cut -d: -f1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
184
- if (targets) parts.push(`Makefile targets: ${targets.split("\n").join(", ")}`);
185
- } catch {}
186
- }
187
-
188
- // Directory structure — so AI knows actual paths (not guessed ones)
189
- try {
190
- const { execSync } = require("child_process");
191
- // Top-level dirs
192
- const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 2000 }).trim();
193
- parts.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
194
-
195
- // Detect monorepo (packages/ or workspaces in package.json)
196
- const isMonorepo = existsSync(join(cwd, "packages")) || existsSync(join(cwd, "apps"));
197
- if (isMonorepo) {
198
- const pkgDirs = execSync(
199
- `ls -d packages/*/src 2>/dev/null || ls -d apps/*/src 2>/dev/null || echo ""`,
200
- { cwd, encoding: "utf8", timeout: 2000 }
201
- ).trim();
202
- if (pkgDirs) {
203
- parts.push(`MONOREPO: Source is in packages/*/src/, NOT src/. Search packages/ not src/.`);
204
- parts.push(`Package sources:\n${pkgDirs}`);
205
- }
206
- }
207
-
208
- // src/ structure — include FILES so AI knows exact filenames + extensions
209
- for (const srcDir of isMonorepo ? ["packages"] : ["src", "lib", "app"]) {
210
- if (existsSync(join(cwd, srcDir))) {
211
- const tree = execSync(
212
- `find ${srcDir} -maxdepth ${isMonorepo ? 4 : 3} -not -path '*/node_modules/*' -not -path '*/dist/*' -not -name '*.test.*' -not -name '*.spec.*' 2>/dev/null | sort | head -80`,
213
- { cwd, encoding: "utf8", timeout: 3000 }
214
- ).trim();
215
- if (tree) parts.push(`Files in ${srcDir}/:\n${tree}`);
216
- break;
217
- }
218
- }
219
- } catch { /* timeout or no exec — skip */ }
220
-
221
- return parts.length > 0 ? `\n\nPROJECT CONTEXT:\n${parts.join("\n")}` : "";
130
+ const hints = discoverProjectHints(process.cwd());
131
+ return hints.length > 0 ? `\n\n${formatHints(hints)}` : "";
222
132
  }
223
133
 
224
134
  // ── system prompt ─────────────────────────────────────────────────────────────
@@ -254,10 +164,24 @@ function buildSystemPrompt(perms: Permissions, sessionEntries: SessionEntry[]):
254
164
 
255
165
  const projectContext = detectProjectContext();
256
166
 
167
+ // Inject safety hints for the command being generated (AI sees what's risky)
168
+ const safetyBlock = sessionEntries.length > 0
169
+ ? (() => {
170
+ const lastCmd = sessionEntries[sessionEntries.length - 1]?.cmd;
171
+ if (lastCmd) {
172
+ const safetyHints = discoverSafetyHints(lastCmd);
173
+ return safetyHints.length > 0 ? `\n\nLAST COMMAND SAFETY:\n${safetyHints.join("\n")}` : "";
174
+ }
175
+ return "";
176
+ })()
177
+ : "";
178
+
257
179
  return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
258
180
  The user describes what they want in plain English. You translate to the exact shell command.
259
181
 
260
182
  RULES:
183
+ - SIMPLICITY FIRST: Use the simplest command that works. Prefer grep | sort | head over 10-pipe chains. Complex pipelines are OK when needed, but NEVER pass file:line output to wc or xargs without cleaning it first.
184
+ - ALWAYS use grep -rn (with -r) when searching directories. NEVER use grep without -r on src/ or any directory.
261
185
  - When user refers to items from previous output, use the EXACT names shown (e.g., "feature/auth" not "auth", "open-skills" not "open_skills")
262
186
  - When user says "the largest/smallest/first/second", look at the previous output to identify the correct item
263
187
  - When user says "them all" or "combine them", refer to items from the most recent command output
@@ -265,6 +189,7 @@ RULES:
265
189
  - For text search in code, use grep -rn, NOT nm or objdump (those are for compiled binaries)
266
190
  - On macOS: for memory use vm_stat or top -l 1, for disk use df -h, for processes use ps aux
267
191
  - macOS uses BSD tools, NOT GNU. Use: du -d 1 (not --max-depth), ls (not ls --color), sort -r (not sort --reverse), ps aux (not ps --sort)
192
+ - NEVER use grep -P (PCRE). macOS grep has NO -P flag. Use grep -E for extended regex, or sed/awk for complex extraction.
268
193
  - NEVER invent commands that don't exist. Stick to standard Unix/macOS commands.
269
194
  - NEVER install packages (npx, npm install, pip install, brew install). This is a READ-ONLY terminal.
270
195
  - NEVER modify source code (sed -i, codemod, awk with redirect). Only observe, never change.
@@ -272,8 +197,10 @@ RULES:
272
197
  - Use exact file paths from the project context below. Do NOT guess paths.
273
198
  - For "what would break if I deleted X": use grep -rn "from.*X\\|import.*X\\|require.*X" src/ to find all importers.
274
199
  - For "find where X is defined": use grep -rn "export.*function X\\|export.*class X\\|export.*const X" src/
275
- - For "show me the code of function X": use grep -A 20 "function X" src/ to show the function body.
200
+ - For "show me the code of function X": if you know the file, use grep -A 30 "function X" src/file.ts. If not, use grep -rn -A 30 "function X" src/ --include="*.ts"
201
+ - ALWAYS use grep -rn (recursive) when searching directories. NEVER use grep without -r on a directory — it will fail.
276
202
  - For conceptual questions about what code does: use cat on the relevant file, the AI summary will explain it.
203
+ - For DESTRUCTIVE requests (delete, remove, install, push): output BLOCKED: <reason>. NEVER try to execute destructive commands.
277
204
 
278
205
  COMPOUND QUESTIONS: For questions asking multiple things, prefer ONE command that captures all info. Extract multiple answers from a single output.
279
206
  - "how many tests and do they pass" → bun test (extract count AND pass/fail from output)
@@ -287,7 +214,13 @@ BLOCKED ALTERNATIVES: If your preferred command would require installing package
287
214
  - Security scan → grep -rn "eval\\|exec\\|spawn\\|password\\|secret" src/
288
215
  - Dependency audit → cat package.json | grep -A 50 dependencies
289
216
  - Test coverage → bun test --coverage (or npm run test:coverage if available)
290
- NEVER give up. Always try a grep/find/cat read-only alternative.
217
+ NEVER give up. NEVER output BLOCKED for analysis questions. Always try a grep/find/cat/wc/awk read-only alternative.
218
+ - Cyclomatic complexity → grep -rn "if\\|else\\|for\\|while\\|switch\\|case\\|catch\\|&&\\|||" src/ --include="*.ts" | wc -l
219
+ - Unused exports → grep -rn "export function\|export const\|export class" src/ --include="*.ts" | sed 's/.*export [a-z]* //' | sed 's/[(<:].*//' | sort -u
220
+ - Dead code → for each exported name, grep -rn "name" src/ --include="*.ts" | wc -l (if only 1 match = unused)
221
+ - Dependency graph → grep -rn "from " src/ --include="*.ts" | sed 's/:.*from "/→/' | sed 's/".*//' | sort -u
222
+ - Most parameters → grep -rn "function " src/ --include="*.ts" | awk -F'[()]' '{print gsub(/,/,",",$2)+1, $0}' | sort -nr | head -10
223
+ ALWAYS try a heuristic shell approach before giving up. NEVER say BLOCKED for analysis questions.
291
224
 
292
225
  SEMANTIC MAPPING: When the user references a concept, search the file tree for RELATED terms:
293
226
  - Look at directory names: src/agent/ likely contains "agentic" code
@@ -300,7 +233,7 @@ EXISTENCE CHECKS: If the prompt starts with "is there", "does this have", "do we
300
233
 
301
234
  MONOREPO: If the project context says "MONOREPO", search packages/ or apps/ NOT src/. Use: grep -rn "pattern" packages/ --include="*.ts". For specific packages, use packages/PKGNAME/src/.
302
235
  cwd: ${process.cwd()}
303
- shell: zsh / macOS${projectContext}${restrictionBlock}${contextBlock}`;
236
+ shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}`;
304
237
  }
305
238
 
306
239
  // ── streaming translate ───────────────────────────────────────────────────────
package/src/cli.tsx CHANGED
@@ -44,7 +44,9 @@ MCP TOOLS (20+):
44
44
  snapshot, token_stats, session_history
45
45
 
46
46
  ENVIRONMENT:
47
- CEREBRAS_API_KEY Cerebras API key (free, open-source default)
47
+ XAI_API_KEY xAI API key (Grok, code-optimized default)
48
+ CEREBRAS_API_KEY Cerebras API key (free, open-source)
49
+ GROQ_API_KEY Groq API key (free, ultra-fast inference)
48
50
  ANTHROPIC_API_KEY Anthropic API key (Claude models)
49
51
  `);
50
52
  process.exit(0);
@@ -371,7 +373,7 @@ else if (args[0] === "history") {
371
373
 
372
374
  else if (args[0] === "explain" && args[1]) {
373
375
  const command = args.slice(1).join(" ");
374
- if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
376
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY && !process.env.GROQ_API_KEY && !process.env.XAI_API_KEY) {
375
377
  console.error("explain requires an API key"); process.exit(1);
376
378
  }
377
379
  const { explainCommand } = await import("./ai.js");
@@ -400,7 +402,7 @@ else if (args.length > 0) {
400
402
  // Everything that doesn't match a subcommand is treated as natural language
401
403
  const prompt = args.join(" ");
402
404
 
403
- const offlineMode = !process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY;
405
+ const offlineMode = !process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY && !process.env.GROQ_API_KEY && !process.env.XAI_API_KEY;
404
406
 
405
407
  const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
406
408
  const { execSync } = await import("child_process");
@@ -458,9 +460,10 @@ else if (args.length > 0) {
458
460
  } catch {}
459
461
  }
460
462
  }
461
- // "I don't know" honesty — better than wrong answer
463
+ // Show the block reason clearly
462
464
  if (e.message?.startsWith("BLOCKED:")) {
463
- console.log(`I don't know how to do this with shell commands. Try running it directly.`);
465
+ console.log(`⚠ ${e.message}`);
466
+ console.log(` This is a READ-ONLY terminal. Run directly in your shell if you're sure.`);
464
467
  } else {
465
468
  console.error(e.message);
466
469
  }
@@ -507,7 +510,7 @@ else if (args.length > 0) {
507
510
  console.error(`[open-terminal] invalid command detected: ${validation.issues.join(", ")}`);
508
511
  try {
509
512
  const retryCommand = await translateToCommand(
510
- `${prompt} (IMPORTANT: keep it simple. Use basic grep/find/cat/ls/wc commands. No complex awk/sed pipelines. No GNU flags. Verify file paths from the project context.)`,
513
+ `${prompt} (Previous command had issues: ${validation.issues.join(", ")}. Fix those specific issues. Keep the approach but correct the errors.)`,
511
514
  perms, []
512
515
  );
513
516
  if (retryCommand && retryCommand !== command) {
@@ -515,6 +518,14 @@ else if (args.length > 0) {
515
518
  if (retryValidation.valid || retryValidation.issues.length < validation.issues.length) {
516
519
  command = retryCommand;
517
520
  console.error(`[open-terminal] retried: $ ${command}`);
521
+ } else {
522
+ // Retry also invalid — use the simpler of the two
523
+ const retryPipes = (retryCommand.match(/\|/g) || []).length;
524
+ const origPipes = (command.match(/\|/g) || []).length;
525
+ if (retryPipes < origPipes) {
526
+ command = retryCommand;
527
+ console.error(`[open-terminal] retried (simpler): $ ${command}`);
528
+ }
518
529
  }
519
530
  }
520
531
  } catch {}
@@ -642,11 +653,13 @@ else if (args.length > 0) {
642
653
  // ── TUI mode (no args) ──────────────────────────────────────────────────────
643
654
 
644
655
  else {
645
- if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
656
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY && !process.env.GROQ_API_KEY && !process.env.XAI_API_KEY) {
646
657
  console.error("terminal: No API key found.");
647
658
  console.error("Set one of:");
648
- console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
649
- console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
659
+ console.error(" export XAI_API_KEY=your_key (Grok, code-optimized — default)");
660
+ console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
661
+ console.error(" export GROQ_API_KEY=your_key (free, ultra-fast)");
662
+ console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
650
663
  process.exit(1);
651
664
  }
652
665
 
@@ -78,6 +78,17 @@ export function validateCommand(command: string, cwd: string): ValidationResult
78
78
  issues.push(`GNU flag on macOS: ${gnuFlags.join(", ")}`);
79
79
  }
80
80
 
81
+ // Complexity guard — extreme pipe chains are fragile
82
+ const pipeCount = (command.match(/\|/g) || []).length;
83
+ if (pipeCount > 7) {
84
+ issues.push(`too complex: ${pipeCount} pipes — simplify`);
85
+ }
86
+
87
+ // grep -P (PCRE) doesn't exist on macOS
88
+ if (/grep\s+.*-[a-zA-Z]*P/.test(command)) {
89
+ issues.push("grep -P (PCRE) not available on macOS — use grep -E");
90
+ }
91
+
81
92
  return {
82
93
  valid: issues.length === 0,
83
94
  issues,
@@ -0,0 +1,202 @@
1
+ // Context hints — discover context via lightweight checks, inject into AI prompt
2
+ // Regex DISCOVERS, AI DECIDES. No hardcoded logic that makes decisions.
3
+
4
+ import { existsSync, readFileSync, readdirSync } from "fs";
5
+ import { join } from "path";
6
+
7
+ export interface ContextHints {
8
+ project: string[]; // project metadata
9
+ output: string[]; // observations about command output
10
+ safety: string[]; // safety-relevant observations
11
+ environment: string[]; // system/env observations
12
+ }
13
+
14
+ /** Discover project context from the filesystem */
15
+ export function discoverProjectHints(cwd: string): string[] {
16
+ const hints: string[] = [];
17
+
18
+ // Package managers and project files
19
+ const projectFiles: [string, string][] = [
20
+ ["package.json", "Node.js/TypeScript"],
21
+ ["pyproject.toml", "Python"],
22
+ ["requirements.txt", "Python"],
23
+ ["go.mod", "Go"],
24
+ ["Cargo.toml", "Rust"],
25
+ ["pom.xml", "Java/Maven"],
26
+ ["build.gradle", "Java/Gradle"],
27
+ ["build.gradle.kts", "Java/Gradle (Kotlin DSL)"],
28
+ ["Makefile", "Has Makefile"],
29
+ ["Dockerfile", "Has Docker"],
30
+ ["docker-compose.yml", "Has Docker Compose"],
31
+ ["docker-compose.yaml", "Has Docker Compose"],
32
+ [".github/workflows", "Has GitHub Actions CI"],
33
+ ["Gemfile", "Ruby"],
34
+ ["composer.json", "PHP"],
35
+ ["mix.exs", "Elixir"],
36
+ ["build.zig", "Zig"],
37
+ ["CMakeLists.txt", "C/C++ (CMake)"],
38
+ ];
39
+
40
+ for (const [file, lang] of projectFiles) {
41
+ if (existsSync(join(cwd, file))) {
42
+ hints.push(`Project type: ${lang} (${file} found)`);
43
+ }
44
+ }
45
+
46
+ // Extract rich metadata from package.json
47
+ const pkgPath = join(cwd, "package.json");
48
+ if (existsSync(pkgPath)) {
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
51
+ if (pkg.name) hints.push(`Package name: ${pkg.name}@${pkg.version ?? "unknown"}`);
52
+ if (pkg.scripts) {
53
+ hints.push(`Available scripts: ${Object.entries(pkg.scripts).map(([k, v]) => `${k}: ${v}`).slice(0, 10).join(", ")}`);
54
+ }
55
+ if (pkg.dependencies) hints.push(`Dependencies: ${Object.keys(pkg.dependencies).join(", ")}`);
56
+ } catch {}
57
+ }
58
+
59
+ // Extract from pyproject.toml
60
+ const pyPath = join(cwd, "pyproject.toml");
61
+ if (existsSync(pyPath)) {
62
+ try {
63
+ const py = readFileSync(pyPath, "utf8");
64
+ const name = py.match(/name\s*=\s*"([^"]+)"/)?.[1];
65
+ if (name) hints.push(`Python package: ${name}`);
66
+ } catch {}
67
+ }
68
+
69
+ // Extract from go.mod
70
+ const goPath = join(cwd, "go.mod");
71
+ if (existsSync(goPath)) {
72
+ try {
73
+ const go = readFileSync(goPath, "utf8");
74
+ const mod = go.match(/module\s+(\S+)/)?.[1];
75
+ if (mod) hints.push(`Go module: ${mod}`);
76
+ } catch {}
77
+ }
78
+
79
+ // Extract from Cargo.toml
80
+ const cargoPath = join(cwd, "Cargo.toml");
81
+ if (existsSync(cargoPath)) {
82
+ try {
83
+ const cargo = readFileSync(cargoPath, "utf8");
84
+ const name = cargo.match(/name\s*=\s*"([^"]+)"/)?.[1];
85
+ if (name) hints.push(`Rust crate: ${name}`);
86
+ } catch {}
87
+ }
88
+
89
+ // Monorepo detection
90
+ if (existsSync(join(cwd, "packages"))) {
91
+ try {
92
+ const pkgs = readdirSync(join(cwd, "packages")).filter(d => !d.startsWith("."));
93
+ hints.push(`MONOREPO: ${pkgs.length} packages in packages/ — search packages/ not src/`);
94
+ hints.push(`Packages: ${pkgs.slice(0, 10).join(", ")}`);
95
+ } catch {}
96
+ }
97
+ if (existsSync(join(cwd, "apps"))) {
98
+ hints.push("MONOREPO: apps/ directory detected");
99
+ }
100
+
101
+ // Makefile targets
102
+ if (existsSync(join(cwd, "Makefile"))) {
103
+ try {
104
+ const { execSync } = require("child_process");
105
+ const targets = execSync("grep -E '^[a-zA-Z_-]+:' Makefile | head -10 | cut -d: -f1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
106
+ if (targets) hints.push(`Makefile targets: ${targets.split("\n").join(", ")}`);
107
+ } catch {}
108
+ }
109
+
110
+ // Source directory structure
111
+ try {
112
+ const { execSync } = require("child_process");
113
+ const srcDirs = ["src", "lib", "app", "packages"];
114
+ for (const dir of srcDirs) {
115
+ if (existsSync(join(cwd, dir))) {
116
+ const tree = execSync(
117
+ `find ${dir} -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/dist/*' -not -name '*.test.*' 2>/dev/null | sort | head -60`,
118
+ { cwd, encoding: "utf8", timeout: 3000 }
119
+ ).trim();
120
+ if (tree) hints.push(`Files in ${dir}/:\n${tree}`);
121
+ break;
122
+ }
123
+ }
124
+ // Top-level files
125
+ const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
126
+ hints.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
127
+ } catch {}
128
+
129
+ return hints;
130
+ }
131
+
132
+ /** Discover output-specific hints (observations about command output) */
133
+ export function discoverOutputHints(output: string, command: string): string[] {
134
+ const hints: string[] = [];
135
+
136
+ const lines = output.split("\n");
137
+ hints.push(`Output: ${lines.length} lines, ${output.length} chars`);
138
+
139
+ // Only detect test results from actual test runners (not grep output containing "pass"/"fail" in code)
140
+ const isGrepOutput = /^\s*(src\/|\.\/|packages\/).*:\d+:/.test(output);
141
+ if (!isGrepOutput) {
142
+ const passMatch = output.match(/(\d+)\s+pass(?:ed|ing)?\b/i);
143
+ const failMatch = output.match(/(\d+)\s+fail(?:ed|ing|ure)?\b/i);
144
+ if (passMatch) hints.push(`Test results detected: ${passMatch[0]}`);
145
+ if (failMatch) hints.push(`Test results detected: ${failMatch[0]}`);
146
+
147
+ // Error patterns (only from actual command output, not code search)
148
+ if (output.match(/error\s*TS\d+/i)) hints.push("TypeScript errors detected in output");
149
+ if (output.match(/ENOENT|EACCES|EADDRINUSE/)) hints.push("System error code detected in output");
150
+ }
151
+
152
+ // Coverage patterns
153
+ if (output.match(/%\s*Funcs|%\s*Lines|coverage/i)) hints.push("Code coverage data detected in output");
154
+
155
+ // Large/repetitive output
156
+ if (lines.length > 100) hints.push(`Large output (${lines.length} lines) — consider summarizing`);
157
+ const uniqueLines = new Set(lines.map(l => l.trim())).size;
158
+ if (uniqueLines < lines.length * 0.5) hints.push("Output has many duplicate/similar lines");
159
+
160
+ // Sensitive data (only env var assignments, not code containing the word KEY/TOKEN)
161
+ if (output.match(/^[A-Z_]+(KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S+/m)) hints.push("Output may contain sensitive data — redact credentials");
162
+
163
+ return hints;
164
+ }
165
+
166
+ /** Discover safety hints about a command */
167
+ export function discoverSafetyHints(command: string): string[] {
168
+ const hints: string[] = [];
169
+
170
+ // Observations about the command (AI decides if it's safe)
171
+ if (command.match(/\brm\b|\brmdir\b|\btruncate\b/)) hints.push("SAFETY: command contains file deletion (rm/rmdir/truncate)");
172
+ if (command.match(/\bkill\b|\bkillall\b|\bpkill\b/)) hints.push("SAFETY: command kills processes");
173
+ if (command.match(/\bgit\s+push\b|\bgit\s+reset\s+--hard\b/)) hints.push("SAFETY: command pushes/resets git");
174
+ if (command.match(/\bnpx\b|\bnpm\s+install\b|\bpip\s+install\b/)) hints.push("SAFETY: command installs packages");
175
+ if (command.match(/\bsed\s+-i\b|\bcodemod\b/)) hints.push("SAFETY: command modifies files in-place");
176
+ if (command.match(/\btouch\b|\bmkdir\b/)) hints.push("SAFETY: command creates files/directories");
177
+ if (command.match(/>\s*\S+\.\w+/)) hints.push("SAFETY: command writes to a file via redirect");
178
+ if (command.match(/\b(bun|npm|pnpm)\s+run\s+dev\b|\bstart\b/)) hints.push("SAFETY: command starts a server/process");
179
+
180
+ // Read-only observations
181
+ if (command.match(/^\s*git\s+(log|show|diff|status|branch|blame|tag)\b/)) hints.push("This is a read-only git command");
182
+ if (command.match(/^\s*(ls|cat|head|tail|grep|find|wc|du|df|uptime|whoami|pwd)\b/)) hints.push("This is a read-only command");
183
+
184
+ return hints;
185
+ }
186
+
187
+ /** Format all hints for system prompt injection */
188
+ export function formatHints(project: string[], output?: string[], safety?: string[]): string {
189
+ const sections: string[] = [];
190
+
191
+ if (project.length > 0) {
192
+ sections.push("PROJECT CONTEXT:\n" + project.join("\n"));
193
+ }
194
+ if (output && output.length > 0) {
195
+ sections.push("OUTPUT OBSERVATIONS:\n" + output.join("\n"));
196
+ }
197
+ if (safety && safety.length > 0) {
198
+ sections.push("SAFETY OBSERVATIONS:\n" + safety.join("\n"));
199
+ }
200
+
201
+ return sections.join("\n\n");
202
+ }
@@ -4,6 +4,7 @@
4
4
  import { getProvider } from "./providers/index.js";
5
5
  import { estimateTokens } from "./parsers/index.js";
6
6
  import { recordSaving } from "./economy.js";
7
+ import { discoverOutputHints } from "./context-hints.js";
7
8
 
8
9
  export interface ProcessedOutput {
9
10
  /** AI-generated summary (concise, structured) */
@@ -79,27 +80,15 @@ export async function processOutput(
79
80
  }
80
81
 
81
82
  try {
82
- // Pre-parse: if output contains clear pass/fail counts, extract and return directly
83
- // No hardcoded test runner list — works for ANY tool that outputs "X pass, Y fail"
84
- const passMatch = output.match(/(\d+)\s+pass/i);
85
- const failMatch = output.match(/(\d+)\s+fail/i);
86
- // Pre-parse fires when output has BOTH pass+fail counts AND the user asked about tests
87
- if (passMatch && failMatch && originalPrompt && /test|pass|fail/i.test(originalPrompt)) {
88
- const passed = parseInt(passMatch[1]);
89
- const failed = parseInt(failMatch[1]);
90
- const answer = failed === 0
91
- ? `✓ Yes, all ${passed} tests pass.`
92
- : `✗ ${failed} of ${passed + failed} tests failed.`;
93
- const savedTokens = estimateTokens(output) - estimateTokens(answer);
94
- return {
95
- summary: answer, full: output, tokensSaved: Math.max(0, savedTokens),
96
- aiTokensUsed: 0, aiProcessed: true, aiCostUsd: 0, savingsValueUsd: 0, netSavingsUsd: 0,
97
- };
98
- }
83
+ // Discover output hints regex discovers patterns, AI decides what matters
84
+ const outputHints = discoverOutputHints(output, command);
85
+ const hintsBlock = outputHints.length > 0
86
+ ? `\n\nOUTPUT OBSERVATIONS:\n${outputHints.join("\n")}`
87
+ : "";
99
88
 
100
89
  const provider = getProvider();
101
90
  const summary = await provider.complete(
102
- `${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`,
91
+ `${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}${hintsBlock}`,
103
92
  {
104
93
  system: SUMMARIZE_PROMPT,
105
94
  maxTokens: 300,