@hasna/terminal 0.1.4 → 0.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 (82) hide show
  1. package/.claude/scheduled_tasks.lock +1 -1
  2. package/README.md +186 -0
  3. package/dist/App.js +217 -105
  4. package/dist/Browse.js +79 -0
  5. package/dist/FuzzyPicker.js +47 -0
  6. package/dist/StatusBar.js +20 -16
  7. package/dist/ai.js +45 -50
  8. package/dist/cli.js +138 -6
  9. package/dist/compression.js +107 -0
  10. package/dist/compression.test.js +42 -0
  11. package/dist/diff-cache.js +87 -0
  12. package/dist/diff-cache.test.js +27 -0
  13. package/dist/economy.js +79 -0
  14. package/dist/economy.test.js +13 -0
  15. package/dist/mcp/install.js +98 -0
  16. package/dist/mcp/server.js +333 -0
  17. package/dist/output-router.js +41 -0
  18. package/dist/parsers/base.js +2 -0
  19. package/dist/parsers/build.js +64 -0
  20. package/dist/parsers/errors.js +101 -0
  21. package/dist/parsers/files.js +78 -0
  22. package/dist/parsers/git.js +86 -0
  23. package/dist/parsers/index.js +48 -0
  24. package/dist/parsers/parsers.test.js +136 -0
  25. package/dist/parsers/tests.js +89 -0
  26. package/dist/providers/anthropic.js +39 -0
  27. package/dist/providers/base.js +4 -0
  28. package/dist/providers/cerebras.js +95 -0
  29. package/dist/providers/index.js +49 -0
  30. package/dist/providers/providers.test.js +14 -0
  31. package/dist/recipes/model.js +20 -0
  32. package/dist/recipes/recipes.test.js +36 -0
  33. package/dist/recipes/storage.js +118 -0
  34. package/dist/search/content-search.js +61 -0
  35. package/dist/search/file-search.js +61 -0
  36. package/dist/search/filters.js +34 -0
  37. package/dist/search/index.js +4 -0
  38. package/dist/search/search.test.js +22 -0
  39. package/dist/snapshots.js +51 -0
  40. package/dist/supervisor.js +112 -0
  41. package/dist/tree.js +94 -0
  42. package/package.json +7 -4
  43. package/src/App.tsx +371 -245
  44. package/src/Browse.tsx +103 -0
  45. package/src/FuzzyPicker.tsx +69 -0
  46. package/src/StatusBar.tsx +28 -34
  47. package/src/ai.ts +63 -51
  48. package/src/cli.tsx +132 -6
  49. package/src/compression.test.ts +50 -0
  50. package/src/compression.ts +140 -0
  51. package/src/diff-cache.test.ts +30 -0
  52. package/src/diff-cache.ts +125 -0
  53. package/src/economy.test.ts +16 -0
  54. package/src/economy.ts +99 -0
  55. package/src/mcp/install.ts +94 -0
  56. package/src/mcp/server.ts +476 -0
  57. package/src/output-router.ts +56 -0
  58. package/src/parsers/base.ts +72 -0
  59. package/src/parsers/build.ts +73 -0
  60. package/src/parsers/errors.ts +107 -0
  61. package/src/parsers/files.ts +91 -0
  62. package/src/parsers/git.ts +86 -0
  63. package/src/parsers/index.ts +66 -0
  64. package/src/parsers/parsers.test.ts +153 -0
  65. package/src/parsers/tests.ts +98 -0
  66. package/src/providers/anthropic.ts +44 -0
  67. package/src/providers/base.ts +34 -0
  68. package/src/providers/cerebras.ts +108 -0
  69. package/src/providers/index.ts +60 -0
  70. package/src/providers/providers.test.ts +16 -0
  71. package/src/recipes/model.ts +55 -0
  72. package/src/recipes/recipes.test.ts +44 -0
  73. package/src/recipes/storage.ts +142 -0
  74. package/src/search/content-search.ts +97 -0
  75. package/src/search/file-search.ts +86 -0
  76. package/src/search/filters.ts +36 -0
  77. package/src/search/index.ts +7 -0
  78. package/src/search/search.test.ts +25 -0
  79. package/src/snapshots.ts +67 -0
  80. package/src/supervisor.ts +129 -0
  81. package/src/tree.ts +101 -0
  82. package/tsconfig.json +2 -1
package/src/Browse.tsx ADDED
@@ -0,0 +1,103 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { readdirSync, statSync } from "fs";
4
+ import { join, dirname } from "path";
5
+
6
+ interface BrowseProps {
7
+ cwd: string;
8
+ onCd: (path: string) => void;
9
+ onSelect: (path: string) => void;
10
+ onExit: () => void;
11
+ }
12
+
13
+ interface Entry {
14
+ name: string;
15
+ isDir: boolean;
16
+ }
17
+
18
+ function readDir(dir: string): Entry[] {
19
+ try {
20
+ const names = readdirSync(dir);
21
+ const entries: Entry[] = [];
22
+ for (const name of names) {
23
+ try {
24
+ const stat = statSync(join(dir, name));
25
+ entries.push({ name, isDir: stat.isDirectory() });
26
+ } catch {
27
+ entries.push({ name, isDir: false });
28
+ }
29
+ }
30
+ entries.sort((a, b) => {
31
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
32
+ return a.name.localeCompare(b.name);
33
+ });
34
+ return entries;
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ const PAGE = 20;
41
+
42
+ export default function Browse({ cwd, onCd, onSelect, onExit }: BrowseProps) {
43
+ const [cursor, setCursor] = useState(0);
44
+
45
+ const entries = readDir(cwd);
46
+ const total = entries.length;
47
+
48
+ const safeIndex = Math.min(cursor, Math.max(0, total - 1));
49
+
50
+ const start = Math.max(0, Math.min(safeIndex - Math.floor(PAGE / 2), total - PAGE));
51
+ const slice = entries.slice(Math.max(0, start), Math.max(0, start) + PAGE);
52
+
53
+ useInput(useCallback((_input: string, key: any) => {
54
+ if (key.upArrow) {
55
+ setCursor(c => (c <= 0 ? Math.max(0, total - 1) : c - 1));
56
+ return;
57
+ }
58
+ if (key.downArrow) {
59
+ setCursor(c => (total === 0 ? 0 : (c >= total - 1 ? 0 : c + 1)));
60
+ return;
61
+ }
62
+ if (key.return) {
63
+ if (total === 0) return;
64
+ const entry = entries[safeIndex];
65
+ if (!entry) return;
66
+ const full = join(cwd, entry.name);
67
+ if (entry.isDir) {
68
+ setCursor(0);
69
+ onCd(full);
70
+ } else {
71
+ onSelect(full);
72
+ }
73
+ return;
74
+ }
75
+ if (key.backspace || key.delete || key.leftArrow) {
76
+ setCursor(0);
77
+ onCd(dirname(cwd));
78
+ return;
79
+ }
80
+ if (key.escape) {
81
+ onExit();
82
+ return;
83
+ }
84
+ }, [cwd, entries, safeIndex, total, onCd, onSelect, onExit]));
85
+
86
+ return (
87
+ <Box flexDirection="column">
88
+ <Text dimColor>{cwd}</Text>
89
+ {total === 0 && <Text dimColor> (empty)</Text>}
90
+ {slice.map((entry, i) => {
91
+ const absIdx = Math.max(0, start) + i;
92
+ const selected = absIdx === safeIndex;
93
+ const icon = entry.isDir ? "▸" : "·";
94
+ return (
95
+ <Box key={entry.name}>
96
+ <Text inverse={selected}>{` ${icon} ${entry.name}${entry.isDir ? "/" : ""} `}</Text>
97
+ </Box>
98
+ );
99
+ })}
100
+ <Text dimColor>{" enter backspace esc"}</Text>
101
+ </Box>
102
+ );
103
+ }
@@ -0,0 +1,69 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+
4
+ interface FuzzyPickerProps {
5
+ history: string[];
6
+ onSelect: (nl: string) => void;
7
+ onExit: () => void;
8
+ }
9
+
10
+ const MAX_MATCHES = 10;
11
+
12
+ export default function FuzzyPicker({ history, onSelect, onExit }: FuzzyPickerProps) {
13
+ const [query, setQuery] = useState("");
14
+ const [cursor, setCursor] = useState(0);
15
+
16
+ const matches = query === ""
17
+ ? history.slice().reverse().slice(0, MAX_MATCHES)
18
+ : history.slice().reverse().filter(h => h.toLowerCase().includes(query.toLowerCase())).slice(0, MAX_MATCHES);
19
+
20
+ const safeCursor = Math.min(cursor, Math.max(0, matches.length - 1));
21
+
22
+ useInput(useCallback((_input: string, key: any) => {
23
+ if (key.escape || key.ctrl && _input === "c") {
24
+ onExit();
25
+ return;
26
+ }
27
+ if (key.return) {
28
+ if (matches.length > 0) {
29
+ onSelect(matches[safeCursor]);
30
+ }
31
+ return;
32
+ }
33
+ if (key.upArrow) {
34
+ setCursor(c => Math.max(0, c - 1));
35
+ return;
36
+ }
37
+ if (key.downArrow) {
38
+ setCursor(c => Math.min(matches.length - 1, c + 1));
39
+ return;
40
+ }
41
+ if (key.backspace || key.delete) {
42
+ setQuery(q => q.slice(0, -1));
43
+ setCursor(0);
44
+ return;
45
+ }
46
+ if (!key.ctrl && !key.meta && _input && _input.length === 1) {
47
+ setQuery(q => q + _input);
48
+ setCursor(0);
49
+ }
50
+ }, [matches, safeCursor, onSelect, onExit]));
51
+
52
+ return (
53
+ <Box flexDirection="column">
54
+ <Text>{` / ${query}_`}</Text>
55
+ {matches.length === 0
56
+ ? <Text dimColor>{" no matches"}</Text>
57
+ : matches.map((m, i) => {
58
+ const selected = i === safeCursor;
59
+ return (
60
+ <Box key={i}>
61
+ <Text inverse={selected} dimColor={!selected}>{` ${m} `}</Text>
62
+ </Box>
63
+ );
64
+ })
65
+ }
66
+ <Text dimColor>{" enter esc"}</Text>
67
+ </Box>
68
+ );
69
+ }
package/src/StatusBar.tsx CHANGED
@@ -4,64 +4,58 @@ import { execSync } from "child_process";
4
4
  import { homedir } from "os";
5
5
  import { type Permissions } from "./history.js";
6
6
 
7
- function getCwd(): string {
8
- const cwd = process.cwd();
7
+ function formatCwd(cwd: string): string {
9
8
  const home = homedir();
10
9
  return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
11
10
  }
12
11
 
13
- function getGitBranch(): string | null {
12
+ function getGitBranch(cwd: string): string | null {
14
13
  try {
15
- return execSync("git branch --show-current 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] })
16
- .toString()
17
- .trim() || null;
18
- } catch {
19
- return null;
20
- }
14
+ return execSync("git branch --show-current 2>/dev/null", {
15
+ cwd,
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ }).toString().trim() || null;
18
+ } catch { return null; }
21
19
  }
22
20
 
23
- function getGitDirty(): boolean {
21
+ function getGitDirty(cwd: string): boolean {
24
22
  try {
25
- const out = execSync("git status --porcelain 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] }).toString();
26
- return out.trim().length > 0;
27
- } catch {
28
- return false;
29
- }
23
+ return execSync("git status --porcelain 2>/dev/null", {
24
+ cwd,
25
+ stdio: ["ignore", "pipe", "ignore"],
26
+ }).toString().trim().length > 0;
27
+ } catch { return false; }
30
28
  }
31
29
 
32
30
  function activePerms(perms: Permissions): string[] {
33
31
  const labels: Array<[keyof Permissions, string]> = [
34
32
  ["destructive", "del"],
35
- ["network", "net"],
36
- ["sudo", "sudo"],
37
- ["install", "pkg"],
33
+ ["network", "net"],
34
+ ["sudo", "sudo"],
35
+ ["install", "pkg"],
38
36
  ["write_outside_cwd", "write"],
39
37
  ];
40
- return labels.filter(([k]) => perms[k]).map(([, l]) => l);
38
+ // only show disabled ones full access is the default so no need to clutter
39
+ const disabled = labels.filter(([k]) => !perms[k]).map(([, l]) => `no-${l}`);
40
+ return disabled;
41
41
  }
42
42
 
43
43
  interface Props {
44
44
  permissions: Permissions;
45
+ cwd?: string;
45
46
  }
46
47
 
47
- export default function StatusBar({ permissions }: Props) {
48
- const cwd = getCwd();
49
- const branch = getGitBranch();
50
- const dirty = branch ? getGitDirty() : false;
51
- const perms = activePerms(permissions);
48
+ export default function StatusBar({ permissions, cwd: cwdProp }: Props) {
49
+ const cwd = cwdProp ?? process.cwd();
50
+ const branch = getGitBranch(cwd);
51
+ const dirty = branch ? getGitDirty(cwd) : false;
52
+ const restricted = activePerms(permissions);
52
53
 
53
54
  return (
54
55
  <Box gap={2} paddingLeft={2} marginTop={1}>
55
- <Text dimColor>{cwd}</Text>
56
- {branch && (
57
- <Text dimColor>
58
- {branch}
59
- {dirty ? " ●" : ""}
60
- </Text>
61
- )}
62
- {perms.length > 0 && (
63
- <Text dimColor>{perms.join(" · ")}</Text>
64
- )}
56
+ <Text dimColor>{formatCwd(cwd)}</Text>
57
+ {branch && <Text dimColor>{branch}{dirty ? " ●" : ""}</Text>}
58
+ {restricted.length > 0 && <Text dimColor>{restricted.join(" · ")}</Text>}
65
59
  </Box>
66
60
  );
67
61
  }
package/src/ai.ts CHANGED
@@ -1,11 +1,9 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
1
  import type { Permissions } from "./history.js";
3
2
  import { cacheGet, cacheSet } from "./cache.js";
4
-
5
- const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3
+ import { getProvider } from "./providers/index.js";
6
4
 
7
5
  // ── model routing ─────────────────────────────────────────────────────────────
8
- // Simple queries → haiku (fast). Complex/ambiguous → sonnet.
6
+ // Simple queries → fast model. Complex/ambiguous → smart model.
9
7
 
10
8
  const COMPLEX_SIGNALS = [
11
9
  /\b(undo|revert|rollback|previous|last)\b/i,
@@ -15,9 +13,25 @@ const COMPLEX_SIGNALS = [
15
13
  /[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
16
14
  ];
17
15
 
18
- function pickModel(nl: string): string {
16
+ /** Model routing per provider */
17
+ function pickModel(nl: string): { fast: string; smart: string; pick: "fast" | "smart" } {
19
18
  const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
20
- return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
19
+ const provider = getProvider();
20
+
21
+ if (provider.name === "anthropic") {
22
+ return {
23
+ fast: "claude-haiku-4-5-20251001",
24
+ smart: "claude-sonnet-4-6",
25
+ pick: isComplex ? "smart" : "fast",
26
+ };
27
+ }
28
+
29
+ // Cerebras — single fast model (Llama is already fast)
30
+ return {
31
+ fast: "llama-4-scout-17b-16e",
32
+ smart: "llama-4-scout-17b-16e",
33
+ pick: isComplex ? "smart" : "fast",
34
+ };
21
35
  }
22
36
 
23
37
  // ── irreversibility ───────────────────────────────────────────────────────────
@@ -94,50 +108,33 @@ export async function translateToCommand(
94
108
  const cached = cacheGet(nl);
95
109
  if (cached) { onToken?.(cached); return cached; }
96
110
 
97
- const model = pickModel(nl);
98
- let result = "";
111
+ const provider = getProvider();
112
+ const routing = pickModel(nl);
113
+ const model = routing.pick === "smart" ? routing.smart : routing.fast;
114
+ const system = buildSystemPrompt(perms, sessionCmds);
115
+
116
+ let text: string;
99
117
 
100
118
  if (onToken) {
101
- // streaming path
102
- const stream = await client.messages.stream({
103
- model,
104
- max_tokens: 256,
105
- system: buildSystemPrompt(perms, sessionCmds),
106
- messages: [{ role: "user", content: nl }],
119
+ text = await provider.stream(nl, { model, maxTokens: 256, system }, {
120
+ onToken: (partial) => onToken(partial),
107
121
  });
108
- for await (const chunk of stream) {
109
- if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
110
- result += chunk.delta.text;
111
- onToken(result.trim());
112
- }
113
- }
114
122
  } else {
115
- const message = await client.messages.create({
116
- model,
117
- max_tokens: 256,
118
- system: buildSystemPrompt(perms, sessionCmds),
119
- messages: [{ role: "user", content: nl }],
120
- });
121
- const block = message.content[0];
122
- if (block.type !== "text") throw new Error("Unexpected response type");
123
- result = block.text;
123
+ text = await provider.complete(nl, { model, maxTokens: 256, system });
124
124
  }
125
125
 
126
- const text = result.trim();
127
126
  if (text.startsWith("BLOCKED:")) throw new Error(text);
128
127
  cacheSet(nl, text);
129
128
  return text;
130
129
  }
131
130
 
132
131
  // ── prefetch ──────────────────────────────────────────────────────────────────
133
- // Silently warm the cache after a command runs — no await, fire and forget
134
132
 
135
133
  export function prefetchNext(
136
134
  lastNl: string,
137
135
  perms: Permissions,
138
136
  sessionCmds: string[]
139
137
  ) {
140
- // Only prefetch if we don't have it cached already
141
138
  if (cacheGet(lastNl)) return;
142
139
  translateToCommand(lastNl, perms, sessionCmds).catch(() => {});
143
140
  }
@@ -145,15 +142,13 @@ export function prefetchNext(
145
142
  // ── explain ───────────────────────────────────────────────────────────────────
146
143
 
147
144
  export async function explainCommand(command: string): Promise<string> {
148
- const message = await client.messages.create({
149
- model: "claude-haiku-4-5-20251001",
150
- max_tokens: 128,
145
+ const provider = getProvider();
146
+ const routing = pickModel("explain"); // simple = fast model
147
+ return provider.complete(command, {
148
+ model: routing.fast,
149
+ maxTokens: 128,
151
150
  system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
152
- messages: [{ role: "user", content: command }],
153
151
  });
154
- const block = message.content[0];
155
- if (block.type !== "text") return "";
156
- return block.text.trim();
157
152
  }
158
153
 
159
154
  // ── auto-fix ──────────────────────────────────────────────────────────────────
@@ -165,18 +160,35 @@ export async function fixCommand(
165
160
  perms: Permissions,
166
161
  sessionCmds: string[]
167
162
  ): Promise<string> {
168
- const message = await client.messages.create({
169
- model: "claude-sonnet-4-6",
170
- max_tokens: 256,
171
- system: buildSystemPrompt(perms, sessionCmds),
172
- messages: [{
173
- role: "user",
174
- content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
175
- }],
176
- });
177
- const block = message.content[0];
178
- if (block.type !== "text") throw new Error("Unexpected response type");
179
- const text = block.text.trim();
163
+ const provider = getProvider();
164
+ const routing = pickModel(originalNl);
165
+ const text = await provider.complete(
166
+ `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
167
+ {
168
+ model: routing.smart, // always use smart model for fixes
169
+ maxTokens: 256,
170
+ system: buildSystemPrompt(perms, sessionCmds),
171
+ }
172
+ );
180
173
  if (text.startsWith("BLOCKED:")) throw new Error(text);
181
174
  return text;
182
175
  }
176
+
177
+ // ── summarize output (for MCP/agent use) ──────────────────────────────────────
178
+
179
+ export async function summarizeOutput(
180
+ command: string,
181
+ output: string,
182
+ maxTokens: number = 200
183
+ ): Promise<string> {
184
+ const provider = getProvider();
185
+ const routing = pickModel("summarize");
186
+ return provider.complete(
187
+ `Command: ${command}\nOutput:\n${output}\n\nSummarize this output concisely for an AI agent. Focus on: status, key results, errors. Be terse.`,
188
+ {
189
+ model: routing.fast,
190
+ maxTokens,
191
+ system: "You summarize command output for AI agents. Be extremely concise. Return structured info. No prose.",
192
+ }
193
+ );
194
+ }
package/src/cli.tsx CHANGED
@@ -1,12 +1,138 @@
1
1
  #!/usr/bin/env node
2
2
  import React from "react";
3
3
  import { render } from "ink";
4
- import App from "./App.js";
5
4
 
6
- if (!process.env.ANTHROPIC_API_KEY) {
7
- console.error("terminal: ANTHROPIC_API_KEY is not set.");
8
- console.error("Add it to your shell: export ANTHROPIC_API_KEY=your_key");
9
- process.exit(1);
5
+ const args = process.argv.slice(2);
6
+
7
+ // ── MCP commands ─────────────────────────────────────────────────────────────
8
+
9
+ if (args[0] === "mcp") {
10
+ if (args[1] === "serve" || args.length === 1) {
11
+ const { startMcpServer } = await import("./mcp/server.js");
12
+ startMcpServer().catch((err) => {
13
+ console.error("MCP server error:", err);
14
+ process.exit(1);
15
+ });
16
+ } else if (args[1] === "install") {
17
+ const { handleMcpInstall } = await import("./mcp/install.js");
18
+ handleMcpInstall(args.slice(2));
19
+ } else {
20
+ console.log("Usage: t mcp [serve|install]");
21
+ }
22
+ }
23
+
24
+ // ── Recipe commands ──────────────────────────────────────────────────────────
25
+
26
+ else if (args[0] === "recipe") {
27
+ const { listRecipes, getRecipe, createRecipe, deleteRecipe, listCollections, createCollection } = await import("./recipes/storage.js");
28
+ const { substituteVariables } = await import("./recipes/model.js");
29
+ const sub = args[1];
30
+
31
+ if (sub === "list") {
32
+ const collection = args.find(a => a.startsWith("--collection="))?.split("=")[1];
33
+ let recipes = listRecipes(process.cwd());
34
+ if (collection) recipes = recipes.filter(r => r.collection === collection);
35
+ if (recipes.length === 0) { console.log("No recipes found."); }
36
+ else {
37
+ for (const r of recipes) {
38
+ const scope = r.project ? "(project)" : "(global)";
39
+ const col = r.collection ? ` [${r.collection}]` : "";
40
+ console.log(` ${r.name}${col} ${scope} → ${r.command}`);
41
+ }
42
+ }
43
+ } else if (sub === "add" && args[2] && args[3]) {
44
+ const name = args[2];
45
+ const command = args[3];
46
+ const collection = args.find(a => a.startsWith("--collection="))?.split("=")[1];
47
+ const project = args.includes("--project") ? process.cwd() : undefined;
48
+ const recipe = createRecipe({ name, command, collection, project });
49
+ console.log(`✓ Saved recipe '${recipe.name}' → ${recipe.command}`);
50
+ } else if (sub === "run" && args[2]) {
51
+ const recipe = getRecipe(args[2], process.cwd());
52
+ if (!recipe) { console.error(`Recipe '${args[2]}' not found.`); process.exit(1); }
53
+ // Parse --var=value arguments
54
+ const vars: Record<string, string> = {};
55
+ for (const arg of args.slice(3)) {
56
+ const match = arg.match(/^--(\w+)=(.+)$/);
57
+ if (match) vars[match[1]] = match[2];
58
+ }
59
+ const cmd = substituteVariables(recipe.command, vars);
60
+ console.log(`$ ${cmd}`);
61
+ const { execSync } = await import("child_process");
62
+ try { execSync(cmd, { stdio: "inherit", cwd: process.cwd() }); }
63
+ catch (e: any) { process.exit(e.status ?? 1); }
64
+ } else if (sub === "delete" && args[2]) {
65
+ const ok = deleteRecipe(args[2], process.cwd());
66
+ console.log(ok ? `✓ Deleted recipe '${args[2]}'` : `Recipe '${args[2]}' not found.`);
67
+ } else {
68
+ console.log("Usage: t recipe [add|list|run|delete]");
69
+ console.log(" t recipe add <name> <command> [--collection=X] [--project]");
70
+ console.log(" t recipe list [--collection=X]");
71
+ console.log(" t recipe run <name> [--var=value]");
72
+ console.log(" t recipe delete <name>");
73
+ }
74
+ }
75
+
76
+ // ── Collection commands ──────────────────────────────────────────────────────
77
+
78
+ else if (args[0] === "collection") {
79
+ const { listCollections, createCollection } = await import("./recipes/storage.js");
80
+ const sub = args[1];
81
+
82
+ if (sub === "create" && args[2]) {
83
+ const col = createCollection({ name: args[2], description: args[3], project: args.includes("--project") ? process.cwd() : undefined });
84
+ console.log(`✓ Created collection '${col.name}'`);
85
+ } else if (sub === "list") {
86
+ const cols = listCollections(process.cwd());
87
+ if (cols.length === 0) console.log("No collections.");
88
+ else for (const c of cols) console.log(` ${c.name}${c.description ? ` — ${c.description}` : ""}`);
89
+ } else {
90
+ console.log("Usage: t collection [create|list]");
91
+ }
92
+ }
93
+
94
+ // ── Stats command ────────────────────────────────────────────────────────────
95
+
96
+ else if (args[0] === "stats") {
97
+ const { getEconomyStats, formatTokens } = await import("./economy.js");
98
+ const s = getEconomyStats();
99
+ console.log("Token Economy:");
100
+ console.log(` Total saved: ${formatTokens(s.totalTokensSaved)}`);
101
+ console.log(` Total used: ${formatTokens(s.totalTokensUsed)}`);
102
+ console.log(` By feature:`);
103
+ console.log(` Structured: ${formatTokens(s.savingsByFeature.structured)}`);
104
+ console.log(` Compressed: ${formatTokens(s.savingsByFeature.compressed)}`);
105
+ console.log(` Diff cache: ${formatTokens(s.savingsByFeature.diff)}`);
106
+ console.log(` NL cache: ${formatTokens(s.savingsByFeature.cache)}`);
107
+ console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
108
+ }
109
+
110
+ // ── Snapshot command ─────────────────────────────────────────────────────────
111
+
112
+ else if (args[0] === "snapshot") {
113
+ const { captureSnapshot } = await import("./snapshots.js");
114
+ console.log(JSON.stringify(captureSnapshot(), null, 2));
10
115
  }
11
116
 
12
- render(<App />);
117
+ // ── Project init ─────────────────────────────────────────────────────────────
118
+
119
+ else if (args[0] === "project" && args[1] === "init") {
120
+ const { initProject } = await import("./recipes/storage.js");
121
+ initProject(process.cwd());
122
+ console.log("✓ Initialized .terminal/recipes.json");
123
+ }
124
+
125
+ // ── TUI mode (default) ──────────────────────────────────────────────────────
126
+
127
+ else {
128
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
129
+ console.error("terminal: No API key found.");
130
+ console.error("Set one of:");
131
+ console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
132
+ console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
133
+ process.exit(1);
134
+ }
135
+
136
+ const App = (await import("./App.js")).default;
137
+ render(<App />);
138
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { compress, stripAnsi } from "./compression.js";
3
+
4
+ describe("stripAnsi", () => {
5
+ it("removes ANSI escape codes", () => {
6
+ expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red");
7
+ expect(stripAnsi("\x1b[1;32mbold green\x1b[0m")).toBe("bold green");
8
+ });
9
+
10
+ it("leaves clean text unchanged", () => {
11
+ expect(stripAnsi("hello world")).toBe("hello world");
12
+ });
13
+ });
14
+
15
+ describe("compress", () => {
16
+ it("strips ANSI by default", () => {
17
+ const result = compress("ls", "\x1b[32mfile.ts\x1b[0m");
18
+ expect(result.content).not.toContain("\x1b");
19
+ });
20
+
21
+ it("uses structured parser when format=json", () => {
22
+ const output = `total 16
23
+ -rw-r--r-- 1 user staff 450 Mar 10 09:00 package.json
24
+ drwxr-xr-x 5 user staff 160 Mar 10 09:00 src`;
25
+
26
+ const result = compress("ls -la", output, { format: "json" });
27
+ // Parser may or may not save tokens on small input, just check it parsed
28
+ expect(result.content).toBeTruthy();
29
+ const parsed = JSON.parse(result.content);
30
+ expect(Array.isArray(parsed)).toBe(true);
31
+ });
32
+
33
+ it("respects maxTokens budget", () => {
34
+ const longOutput = Array.from({ length: 100 }, (_, i) => `Line ${i}: some output text here`).join("\n");
35
+ const result = compress("some-command", longOutput, { maxTokens: 50 });
36
+ expect(result.compressedTokens).toBeLessThanOrEqual(60); // allow some slack
37
+ });
38
+
39
+ it("deduplicates similar lines", () => {
40
+ const output = Array.from({ length: 20 }, (_, i) => `Compiling module ${i}...`).join("\n");
41
+ const result = compress("build", output);
42
+ expect(result.compressedTokens).toBeLessThan(result.originalTokens);
43
+ });
44
+
45
+ it("tracks savings on large output", () => {
46
+ const output = Array.from({ length: 100 }, (_, i) => `Line ${i}: some long output text here that takes tokens`).join("\n");
47
+ const result = compress("cmd", output, { maxTokens: 50 });
48
+ expect(result.compressedTokens).toBeLessThan(result.originalTokens);
49
+ });
50
+ });