@hasna/terminal 0.1.5 → 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 (74) hide show
  1. package/.claude/scheduled_tasks.lock +1 -1
  2. package/README.md +186 -0
  3. package/dist/ai.js +45 -50
  4. package/dist/cli.js +138 -6
  5. package/dist/compression.js +107 -0
  6. package/dist/compression.test.js +42 -0
  7. package/dist/diff-cache.js +87 -0
  8. package/dist/diff-cache.test.js +27 -0
  9. package/dist/economy.js +79 -0
  10. package/dist/economy.test.js +13 -0
  11. package/dist/mcp/install.js +98 -0
  12. package/dist/mcp/server.js +333 -0
  13. package/dist/output-router.js +41 -0
  14. package/dist/parsers/base.js +2 -0
  15. package/dist/parsers/build.js +64 -0
  16. package/dist/parsers/errors.js +101 -0
  17. package/dist/parsers/files.js +78 -0
  18. package/dist/parsers/git.js +86 -0
  19. package/dist/parsers/index.js +48 -0
  20. package/dist/parsers/parsers.test.js +136 -0
  21. package/dist/parsers/tests.js +89 -0
  22. package/dist/providers/anthropic.js +39 -0
  23. package/dist/providers/base.js +4 -0
  24. package/dist/providers/cerebras.js +95 -0
  25. package/dist/providers/index.js +49 -0
  26. package/dist/providers/providers.test.js +14 -0
  27. package/dist/recipes/model.js +20 -0
  28. package/dist/recipes/recipes.test.js +36 -0
  29. package/dist/recipes/storage.js +118 -0
  30. package/dist/search/content-search.js +61 -0
  31. package/dist/search/file-search.js +61 -0
  32. package/dist/search/filters.js +34 -0
  33. package/dist/search/index.js +4 -0
  34. package/dist/search/search.test.js +22 -0
  35. package/dist/snapshots.js +51 -0
  36. package/dist/supervisor.js +112 -0
  37. package/dist/tree.js +94 -0
  38. package/package.json +7 -4
  39. package/src/ai.ts +63 -51
  40. package/src/cli.tsx +132 -6
  41. package/src/compression.test.ts +50 -0
  42. package/src/compression.ts +140 -0
  43. package/src/diff-cache.test.ts +30 -0
  44. package/src/diff-cache.ts +125 -0
  45. package/src/economy.test.ts +16 -0
  46. package/src/economy.ts +99 -0
  47. package/src/mcp/install.ts +94 -0
  48. package/src/mcp/server.ts +476 -0
  49. package/src/output-router.ts +56 -0
  50. package/src/parsers/base.ts +72 -0
  51. package/src/parsers/build.ts +73 -0
  52. package/src/parsers/errors.ts +107 -0
  53. package/src/parsers/files.ts +91 -0
  54. package/src/parsers/git.ts +86 -0
  55. package/src/parsers/index.ts +66 -0
  56. package/src/parsers/parsers.test.ts +153 -0
  57. package/src/parsers/tests.ts +98 -0
  58. package/src/providers/anthropic.ts +44 -0
  59. package/src/providers/base.ts +34 -0
  60. package/src/providers/cerebras.ts +108 -0
  61. package/src/providers/index.ts +60 -0
  62. package/src/providers/providers.test.ts +16 -0
  63. package/src/recipes/model.ts +55 -0
  64. package/src/recipes/recipes.test.ts +44 -0
  65. package/src/recipes/storage.ts +142 -0
  66. package/src/search/content-search.ts +97 -0
  67. package/src/search/file-search.ts +86 -0
  68. package/src/search/filters.ts +36 -0
  69. package/src/search/index.ts +7 -0
  70. package/src/search/search.test.ts +25 -0
  71. package/src/snapshots.ts +67 -0
  72. package/src/supervisor.ts +129 -0
  73. package/src/tree.ts +101 -0
  74. package/tsconfig.json +2 -1
@@ -0,0 +1,112 @@
1
+ // Process supervisor — manages background processes for agents and humans
2
+ import { spawn } from "child_process";
3
+ import { createConnection } from "net";
4
+ const processes = new Map();
5
+ /** Auto-detect port from common commands */
6
+ function detectPort(command) {
7
+ // "next dev -p 3001", "vite --port 4000", etc.
8
+ const portMatch = command.match(/-p\s+(\d+)|--port\s+(\d+)|PORT=(\d+)/);
9
+ if (portMatch)
10
+ return parseInt(portMatch[1] ?? portMatch[2] ?? portMatch[3]);
11
+ // Common defaults
12
+ if (/\bnext\s+dev\b/.test(command))
13
+ return 3000;
14
+ if (/\bvite\b/.test(command))
15
+ return 5173;
16
+ if (/\bnuxt\s+dev\b/.test(command))
17
+ return 3000;
18
+ if (/\bremix\s+dev\b/.test(command))
19
+ return 5173;
20
+ return undefined;
21
+ }
22
+ /** Start a background process */
23
+ export function bgStart(command, cwd) {
24
+ const workDir = cwd ?? process.cwd();
25
+ const proc = spawn("/bin/zsh", ["-c", command], {
26
+ cwd: workDir,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ detached: false,
29
+ });
30
+ const meta = {
31
+ pid: proc.pid,
32
+ command,
33
+ cwd: workDir,
34
+ port: detectPort(command),
35
+ startedAt: Date.now(),
36
+ lastOutput: [],
37
+ };
38
+ const pushOutput = (d) => {
39
+ const lines = d.toString().split("\n").filter(l => l.trim());
40
+ meta.lastOutput.push(...lines);
41
+ // Keep last 50 lines
42
+ if (meta.lastOutput.length > 50) {
43
+ meta.lastOutput = meta.lastOutput.slice(-50);
44
+ }
45
+ };
46
+ proc.stdout?.on("data", pushOutput);
47
+ proc.stderr?.on("data", pushOutput);
48
+ proc.on("close", (code) => {
49
+ meta.exitCode = code ?? 0;
50
+ });
51
+ processes.set(proc.pid, { proc, meta });
52
+ return meta;
53
+ }
54
+ /** List all managed processes */
55
+ export function bgStatus() {
56
+ const result = [];
57
+ for (const [pid, { proc, meta }] of processes) {
58
+ // Check if still alive
59
+ try {
60
+ process.kill(pid, 0);
61
+ result.push({ ...meta, lastOutput: meta.lastOutput.slice(-5) });
62
+ }
63
+ catch {
64
+ // Process is dead
65
+ result.push({ ...meta, exitCode: meta.exitCode ?? -1, lastOutput: meta.lastOutput.slice(-5) });
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+ /** Stop a background process */
71
+ export function bgStop(pid) {
72
+ const entry = processes.get(pid);
73
+ if (!entry)
74
+ return false;
75
+ try {
76
+ entry.proc.kill("SIGTERM");
77
+ processes.delete(pid);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** Get logs for a background process */
85
+ export function bgLogs(pid, tail = 20) {
86
+ const entry = processes.get(pid);
87
+ if (!entry)
88
+ return [];
89
+ return entry.meta.lastOutput.slice(-tail);
90
+ }
91
+ /** Wait for a port to be ready */
92
+ export function bgWaitPort(port, timeoutMs = 30000) {
93
+ return new Promise((resolve) => {
94
+ const start = Date.now();
95
+ const check = () => {
96
+ if (Date.now() - start > timeoutMs) {
97
+ resolve(false);
98
+ return;
99
+ }
100
+ const sock = createConnection({ port, host: "127.0.0.1" });
101
+ sock.on("connect", () => {
102
+ sock.destroy();
103
+ resolve(true);
104
+ });
105
+ sock.on("error", () => {
106
+ sock.destroy();
107
+ setTimeout(check, 500);
108
+ });
109
+ };
110
+ check();
111
+ });
112
+ }
package/dist/tree.js ADDED
@@ -0,0 +1,94 @@
1
+ // Tree compression — convert flat file paths to compact tree representation
2
+ import { readdirSync, statSync } from "fs";
3
+ import { join, basename } from "path";
4
+ import { DEFAULT_EXCLUDE_DIRS } from "./search/filters.js";
5
+ /** Build a tree from a directory */
6
+ export function buildTree(dirPath, options = {}) {
7
+ const { maxDepth = 2, includeHidden = false, depth = 0 } = options;
8
+ const name = basename(dirPath) || dirPath;
9
+ const node = { name, type: "dir", children: [], fileCount: 0 };
10
+ if (depth >= maxDepth) {
11
+ // Count files without listing them
12
+ try {
13
+ const entries = readdirSync(dirPath);
14
+ node.fileCount = entries.length;
15
+ node.children = undefined; // don't expand
16
+ }
17
+ catch {
18
+ node.fileCount = 0;
19
+ }
20
+ return node;
21
+ }
22
+ try {
23
+ const entries = readdirSync(dirPath);
24
+ for (const entry of entries) {
25
+ if (!includeHidden && entry.startsWith("."))
26
+ continue;
27
+ if (DEFAULT_EXCLUDE_DIRS.includes(entry)) {
28
+ // Show as collapsed with count
29
+ try {
30
+ const subPath = join(dirPath, entry);
31
+ const subStat = statSync(subPath);
32
+ if (subStat.isDirectory()) {
33
+ node.children.push({ name: entry, type: "dir", fileCount: -1 }); // -1 = hidden
34
+ continue;
35
+ }
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ const fullPath = join(dirPath, entry);
42
+ try {
43
+ const stat = statSync(fullPath);
44
+ if (stat.isDirectory()) {
45
+ node.children.push(buildTree(fullPath, { maxDepth, includeHidden, depth: depth + 1 }));
46
+ }
47
+ else {
48
+ node.children.push({ name: entry, type: "file", size: stat.size });
49
+ node.fileCount++;
50
+ }
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ }
56
+ }
57
+ catch { }
58
+ return node;
59
+ }
60
+ /** Render tree as compact string (for agents — minimum tokens) */
61
+ export function compactTree(node, indent = 0) {
62
+ const pad = " ".repeat(indent);
63
+ if (node.type === "file")
64
+ return `${pad}${node.name}`;
65
+ if (node.fileCount === -1)
66
+ return `${pad}${node.name}/ (hidden)`;
67
+ if (!node.children || node.children.length === 0)
68
+ return `${pad}${node.name}/ (empty)`;
69
+ if (!node.children.some(c => c.children)) {
70
+ // Leaf directory — compact single line
71
+ const files = node.children.filter(c => c.type === "file").map(c => c.name);
72
+ const dirs = node.children.filter(c => c.type === "dir");
73
+ const parts = [];
74
+ if (files.length <= 5) {
75
+ parts.push(...files);
76
+ }
77
+ else {
78
+ parts.push(`${files.length} files`);
79
+ }
80
+ for (const d of dirs) {
81
+ parts.push(`${d.name}/${d.fileCount != null ? ` (${d.fileCount === -1 ? "hidden" : d.fileCount + " files"})` : ""}`);
82
+ }
83
+ return `${pad}${node.name}/ [${parts.join(", ")}]`;
84
+ }
85
+ const lines = [`${pad}${node.name}/`];
86
+ for (const child of node.children) {
87
+ lines.push(compactTree(child, indent + 1));
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+ /** Render tree as JSON (for MCP) */
92
+ export function treeToJson(node) {
93
+ return node;
94
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.1.5",
4
- "description": "Natural language terminal — speak plain English, get shell commands",
3
+ "version": "0.2.0",
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": {
7
7
  "t": "dist/cli.js",
@@ -10,12 +10,15 @@
10
10
  "scripts": {
11
11
  "build": "tsc",
12
12
  "dev": "tsx src/cli.tsx",
13
- "start": "node dist/cli.js"
13
+ "start": "node dist/cli.js",
14
+ "test": "bun test"
14
15
  },
15
16
  "dependencies": {
16
17
  "@anthropic-ai/sdk": "^0.39.0",
18
+ "@modelcontextprotocol/sdk": "^1.27.1",
17
19
  "ink": "^5.0.1",
18
- "react": "^18.2.0"
20
+ "react": "^18.2.0",
21
+ "zod": "^4.3.6"
19
22
  },
20
23
  "publishConfig": {
21
24
  "access": "public",
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
+ });