@hasna/terminal 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -545,7 +545,107 @@ else if (args[0] === "project" && args[1] === "init") {
545
545
  initProject(process.cwd());
546
546
  console.log("✓ Initialized .terminal/recipes.json");
547
547
  }
548
- // ── TUI mode (default) ──────────────────────────────────────────────────────
548
+ // ── NL mode: terminal "natural language prompt" ─────────────────────────────
549
+ else if (args.length > 0) {
550
+ // Everything that doesn't match a subcommand is treated as natural language
551
+ const prompt = args.join(" ");
552
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
553
+ console.error("terminal: No API key found.");
554
+ console.error("Set one of:");
555
+ console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
556
+ console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
557
+ process.exit(1);
558
+ }
559
+ const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
560
+ const { execSync } = await import("child_process");
561
+ const { compress, stripAnsi } = await import("./compression.js");
562
+ const { stripNoise } = await import("./noise-filter.js");
563
+ const { processOutput, shouldProcess } = await import("./output-processor.js");
564
+ const { rewriteCommand } = await import("./command-rewriter.js");
565
+ const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
566
+ const { parseOutput, estimateTokens } = await import("./parsers/index.js");
567
+ const { recordSaving, recordUsage } = await import("./economy.js");
568
+ const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
569
+ const { detectLoop } = await import("./loop-detector.js");
570
+ const { loadConfig } = await import("./history.js");
571
+ const config = loadConfig();
572
+ const perms = config.permissions;
573
+ // Step 1: AI translates NL → shell command
574
+ let command;
575
+ try {
576
+ command = await translateToCommand(prompt, perms, []);
577
+ }
578
+ catch (e) {
579
+ console.error(e.message);
580
+ process.exit(1);
581
+ }
582
+ // Check permissions
583
+ const blocked = checkPermissions(command, perms);
584
+ if (blocked) {
585
+ console.error(`blocked: ${blocked}`);
586
+ process.exit(1);
587
+ }
588
+ // Show what we're running
589
+ console.error(`$ ${command}`);
590
+ // Step 2: Rewrite for optimization
591
+ const rw = rewriteCommand(command);
592
+ const actualCmd = rw.changed ? rw.rewritten : command;
593
+ if (rw.changed)
594
+ console.error(`[open-terminal] optimized: ${actualCmd}`);
595
+ // Loop detection
596
+ const loop = detectLoop(actualCmd);
597
+ if (loop.detected)
598
+ console.error(`[open-terminal] loop #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : ""}`);
599
+ // Step 3: Execute
600
+ try {
601
+ const start = Date.now();
602
+ const raw = execSync(actualCmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
603
+ const duration = Date.now() - start;
604
+ const clean = stripNoise(stripAnsi(raw)).cleaned;
605
+ const rawTokens = estimateTokens(raw);
606
+ recordUsage(rawTokens);
607
+ // Test output detection
608
+ if (isTestOutput(clean)) {
609
+ const result = trackTests(process.cwd(), clean);
610
+ console.log(formatWatchResult(result));
611
+ process.exit(0);
612
+ }
613
+ // Lazy mode
614
+ if (shouldBeLazy(clean, actualCmd)) {
615
+ const lazy = toLazy(clean, actualCmd);
616
+ const saved = rawTokens - estimateTokens(JSON.stringify(lazy));
617
+ if (saved > 0)
618
+ recordSaving("compressed", saved);
619
+ console.log(JSON.stringify(lazy, null, 2));
620
+ process.exit(0);
621
+ }
622
+ // AI summary for medium-large output
623
+ if (shouldProcess(clean)) {
624
+ const processed = await processOutput(actualCmd, clean);
625
+ if (processed.aiProcessed && processed.tokensSaved > 30) {
626
+ recordSaving("compressed", processed.tokensSaved);
627
+ console.log(processed.summary);
628
+ console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved})`);
629
+ process.exit(0);
630
+ }
631
+ }
632
+ // Small output — pass through clean
633
+ console.log(clean);
634
+ const saved = rawTokens - estimateTokens(clean);
635
+ if (saved > 10) {
636
+ recordSaving("compressed", saved);
637
+ console.error(`[open-terminal] saved ${saved} tokens`);
638
+ }
639
+ }
640
+ catch (e) {
641
+ const stderr = e.stderr?.toString() ?? "";
642
+ const stdout = e.stdout?.toString() ?? "";
643
+ const combined = stderr && stdout.includes(stderr.trim()) ? stdout : stdout + stderr;
644
+ console.log(stripNoise(stripAnsi(combined)).cleaned);
645
+ process.exit(e.status ?? 1);
646
+ }
647
+ }
648
+ // ── TUI mode (no args) ──────────────────────────────────────────────────────
549
649
  else {
550
650
  if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
551
651
  console.error("terminal: No API key found.");
@@ -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
- const markers = /(?:Tests?:|PASS|FAIL|✓|✗|passed|failed|\d+\s+pass|\d+\s+fail)/i;
42
- const lines = output.split("\n");
43
- return lines.filter(l => markers.test(l)).length >= 2;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "1.0.1",
3
+ "version": "1.1.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/cli.tsx CHANGED
@@ -518,7 +518,111 @@ else if (args[0] === "project" && args[1] === "init") {
518
518
  console.log("✓ Initialized .terminal/recipes.json");
519
519
  }
520
520
 
521
- // ── TUI mode (default) ──────────────────────────────────────────────────────
521
+ // ── NL mode: terminal "natural language prompt" ─────────────────────────────
522
+
523
+ else if (args.length > 0) {
524
+ // Everything that doesn't match a subcommand is treated as natural language
525
+ const prompt = args.join(" ");
526
+
527
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
528
+ console.error("terminal: No API key found.");
529
+ console.error("Set one of:");
530
+ console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
531
+ console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
532
+ process.exit(1);
533
+ }
534
+
535
+ const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
536
+ const { execSync } = await import("child_process");
537
+ const { compress, stripAnsi } = await import("./compression.js");
538
+ const { stripNoise } = await import("./noise-filter.js");
539
+ const { processOutput, shouldProcess } = await import("./output-processor.js");
540
+ const { rewriteCommand } = await import("./command-rewriter.js");
541
+ const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
542
+ const { parseOutput, estimateTokens } = await import("./parsers/index.js");
543
+ const { recordSaving, recordUsage } = await import("./economy.js");
544
+ const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
545
+ const { detectLoop } = await import("./loop-detector.js");
546
+ const { loadConfig } = await import("./history.js");
547
+
548
+ const config = loadConfig();
549
+ const perms = config.permissions;
550
+
551
+ // Step 1: AI translates NL → shell command
552
+ let command: string;
553
+ try {
554
+ command = await translateToCommand(prompt, perms, []);
555
+ } catch (e: any) {
556
+ console.error(e.message);
557
+ process.exit(1);
558
+ }
559
+
560
+ // Check permissions
561
+ const blocked = checkPermissions(command, perms);
562
+ if (blocked) { console.error(`blocked: ${blocked}`); process.exit(1); }
563
+
564
+ // Show what we're running
565
+ console.error(`$ ${command}`);
566
+
567
+ // Step 2: Rewrite for optimization
568
+ const rw = rewriteCommand(command);
569
+ const actualCmd = rw.changed ? rw.rewritten : command;
570
+ if (rw.changed) console.error(`[open-terminal] optimized: ${actualCmd}`);
571
+
572
+ // Loop detection
573
+ const loop = detectLoop(actualCmd);
574
+ if (loop.detected) console.error(`[open-terminal] loop #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : ""}`);
575
+
576
+ // Step 3: Execute
577
+ try {
578
+ const start = Date.now();
579
+ const raw = execSync(actualCmd, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
580
+ const duration = Date.now() - start;
581
+ const clean = stripNoise(stripAnsi(raw)).cleaned;
582
+ const rawTokens = estimateTokens(raw);
583
+ recordUsage(rawTokens);
584
+
585
+ // Test output detection
586
+ if (isTestOutput(clean)) {
587
+ const result = trackTests(process.cwd(), clean);
588
+ console.log(formatWatchResult(result));
589
+ process.exit(0);
590
+ }
591
+
592
+ // Lazy mode
593
+ if (shouldBeLazy(clean, actualCmd)) {
594
+ const lazy = toLazy(clean, actualCmd);
595
+ const saved = rawTokens - estimateTokens(JSON.stringify(lazy));
596
+ if (saved > 0) recordSaving("compressed", saved);
597
+ console.log(JSON.stringify(lazy, null, 2));
598
+ process.exit(0);
599
+ }
600
+
601
+ // AI summary for medium-large output
602
+ if (shouldProcess(clean)) {
603
+ const processed = await processOutput(actualCmd, clean);
604
+ if (processed.aiProcessed && processed.tokensSaved > 30) {
605
+ recordSaving("compressed", processed.tokensSaved);
606
+ console.log(processed.summary);
607
+ console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved})`);
608
+ process.exit(0);
609
+ }
610
+ }
611
+
612
+ // Small output — pass through clean
613
+ console.log(clean);
614
+ const saved = rawTokens - estimateTokens(clean);
615
+ if (saved > 10) { recordSaving("compressed", saved); console.error(`[open-terminal] saved ${saved} tokens`); }
616
+ } catch (e: any) {
617
+ const stderr = e.stderr?.toString() ?? "";
618
+ const stdout = e.stdout?.toString() ?? "";
619
+ const combined = stderr && stdout.includes(stderr.trim()) ? stdout : stdout + stderr;
620
+ console.log(stripNoise(stripAnsi(combined)).cleaned);
621
+ process.exit(e.status ?? 1);
622
+ }
623
+ }
624
+
625
+ // ── TUI mode (no args) ──────────────────────────────────────────────────────
522
626
 
523
627
  else {
524
628
  if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
@@ -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
- const markers = /(?:Tests?:|PASS|FAIL|✓|✗|passed|failed|\d+\s+pass|\d+\s+fail)/i;
68
- const lines = output.split("\n");
69
- return lines.filter(l => markers.test(l)).length >= 2;
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 */