@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.
- package/.claude/scheduled_tasks.lock +1 -1
- package/README.md +186 -0
- package/dist/ai.js +45 -50
- package/dist/cli.js +138 -6
- package/dist/compression.js +107 -0
- package/dist/compression.test.js +42 -0
- package/dist/diff-cache.js +87 -0
- package/dist/diff-cache.test.js +27 -0
- package/dist/economy.js +79 -0
- package/dist/economy.test.js +13 -0
- package/dist/mcp/install.js +98 -0
- package/dist/mcp/server.js +333 -0
- package/dist/output-router.js +41 -0
- package/dist/parsers/base.js +2 -0
- package/dist/parsers/build.js +64 -0
- package/dist/parsers/errors.js +101 -0
- package/dist/parsers/files.js +78 -0
- package/dist/parsers/git.js +86 -0
- package/dist/parsers/index.js +48 -0
- package/dist/parsers/parsers.test.js +136 -0
- package/dist/parsers/tests.js +89 -0
- package/dist/providers/anthropic.js +39 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +95 -0
- package/dist/providers/index.js +49 -0
- package/dist/providers/providers.test.js +14 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/recipes.test.js +36 -0
- package/dist/recipes/storage.js +118 -0
- package/dist/search/content-search.js +61 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +4 -0
- package/dist/search/search.test.js +22 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/tree.js +94 -0
- package/package.json +7 -4
- package/src/ai.ts +63 -51
- package/src/cli.tsx +132 -6
- package/src/compression.test.ts +50 -0
- package/src/compression.ts +140 -0
- package/src/diff-cache.test.ts +30 -0
- package/src/diff-cache.ts +125 -0
- package/src/economy.test.ts +16 -0
- package/src/economy.ts +99 -0
- package/src/mcp/install.ts +94 -0
- package/src/mcp/server.ts +476 -0
- package/src/output-router.ts +56 -0
- package/src/parsers/base.ts +72 -0
- package/src/parsers/build.ts +73 -0
- package/src/parsers/errors.ts +107 -0
- package/src/parsers/files.ts +91 -0
- package/src/parsers/git.ts +86 -0
- package/src/parsers/index.ts +66 -0
- package/src/parsers/parsers.test.ts +153 -0
- package/src/parsers/tests.ts +98 -0
- package/src/providers/anthropic.ts +44 -0
- package/src/providers/base.ts +34 -0
- package/src/providers/cerebras.ts +108 -0
- package/src/providers/index.ts +60 -0
- package/src/providers/providers.test.ts +16 -0
- package/src/recipes/model.ts +55 -0
- package/src/recipes/recipes.test.ts +44 -0
- package/src/recipes/storage.ts +142 -0
- package/src/search/content-search.ts +97 -0
- package/src/search/file-search.ts +86 -0
- package/src/search/filters.ts +36 -0
- package/src/search/index.ts +7 -0
- package/src/search/search.test.ts +25 -0
- package/src/snapshots.ts +67 -0
- package/src/supervisor.ts +129 -0
- package/src/tree.ts +101 -0
- package/tsconfig.json +2 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sessionId":"
|
|
1
|
+
{"sessionId":"c1e414c7-f1a5-4b9e-bcc4-64c451584cb8","pid":54679,"acquiredAt":1773566918526}
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# open-terminal
|
|
2
|
+
|
|
3
|
+
Smart terminal wrapper for AI agents and humans. Speak plain English or let agents execute commands with structured output, token compression, and massive context savings.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
AI agents waste tokens on terminal interaction. Every `npm test` dumps hundreds of lines into context. Every `find` returns noise. `open-terminal` sits between callers and the shell, making every interaction dramatically more efficient.
|
|
8
|
+
|
|
9
|
+
**For agents:** MCP server with structured output, token compression, diff-aware caching, smart search, process supervision. Cut token usage 50-90% on verbose commands.
|
|
10
|
+
|
|
11
|
+
**For humans:** Natural language terminal powered by Cerebras (free, open-source) or Anthropic. Type "count typescript files" instead of `find . -name '*.ts' | wc -l`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @hasna/terminal
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### For Humans (TUI Mode)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Set your API key (pick one)
|
|
25
|
+
export CEREBRAS_API_KEY=your_key # free, open-source (default)
|
|
26
|
+
export ANTHROPIC_API_KEY=your_key # Claude
|
|
27
|
+
|
|
28
|
+
# Launch
|
|
29
|
+
t
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Type in plain English. The terminal translates, shows you the command, and runs it.
|
|
33
|
+
|
|
34
|
+
### For AI Agents (MCP Server)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Install for your agent
|
|
38
|
+
t mcp install --claude # Claude Code
|
|
39
|
+
t mcp install --codex # OpenAI Codex
|
|
40
|
+
t mcp install --gemini # Gemini CLI
|
|
41
|
+
t mcp install --all # All agents
|
|
42
|
+
|
|
43
|
+
# Or start manually
|
|
44
|
+
t mcp serve
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## MCP Tools
|
|
48
|
+
|
|
49
|
+
| Tool | Description | Token Savings |
|
|
50
|
+
|------|-------------|---------------|
|
|
51
|
+
| `execute` | Run command with structured output, compression, or AI summary | 50-90% |
|
|
52
|
+
| `execute_diff` | Run command, return only what changed since last run | 80-95% |
|
|
53
|
+
| `browse` | List files as structured JSON, auto-filter node_modules | 60-80% |
|
|
54
|
+
| `search_files` | Find files by pattern, categorized (source/config/other) | 70-90% |
|
|
55
|
+
| `search_content` | Grep with grouping by file and relevance ranking | 60-80% |
|
|
56
|
+
| `explain_error` | Structured error diagnosis with fix suggestions | N/A |
|
|
57
|
+
| `bg_start` | Start background process with port auto-detection | N/A |
|
|
58
|
+
| `bg_status` | List managed processes with health info | N/A |
|
|
59
|
+
| `bg_wait_port` | Wait for a port to be ready | N/A |
|
|
60
|
+
| `bg_stop` / `bg_logs` | Stop process / get recent output | N/A |
|
|
61
|
+
| `list_recipes` / `run_recipe` / `save_recipe` | Reusable command templates | N/A |
|
|
62
|
+
| `snapshot` | Capture terminal state for agent handoff | N/A |
|
|
63
|
+
| `token_stats` | Token economy dashboard | N/A |
|
|
64
|
+
|
|
65
|
+
### Example: Structured Output
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
Agent: execute("npm test", {format: "json"})
|
|
69
|
+
|
|
70
|
+
→ {"passed": 142, "failed": 2, "failures": [{"test": "auth.test.ts:45", "error": "expected 200 got 401"}]}
|
|
71
|
+
(saved 847 tokens vs raw output)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Example: Diff Mode
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Agent: execute_diff("npm test") # first run → full output
|
|
78
|
+
Agent: execute_diff("npm test") # second run → only changes
|
|
79
|
+
|
|
80
|
+
→ {"diffSummary": "+1 new line, -1 removed", "added": ["PASS auth.test.ts:45"], "removed": ["FAIL auth.test.ts:45"], "tokensSaved": 892}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Example: Smart Search
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
Agent: search_files("*hooks*")
|
|
87
|
+
|
|
88
|
+
→ {"source": ["src/lib/webhooks.ts", "src/hooks/useAuth.ts"], "filtered": [{"count": 47, "reason": "node_modules"}], "tokensSaved": 312}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Recipes
|
|
92
|
+
|
|
93
|
+
Reusable command templates with variable substitution:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Save a recipe
|
|
97
|
+
t recipe add kill-port "lsof -i :{port} -t | xargs kill"
|
|
98
|
+
|
|
99
|
+
# Run it
|
|
100
|
+
t recipe run kill-port --port=3000
|
|
101
|
+
|
|
102
|
+
# List recipes
|
|
103
|
+
t recipe list
|
|
104
|
+
|
|
105
|
+
# Project-scoped recipes
|
|
106
|
+
t project init
|
|
107
|
+
t recipe add dev-start "npm run dev" --project
|
|
108
|
+
|
|
109
|
+
# Collections
|
|
110
|
+
t collection create docker "Docker commands"
|
|
111
|
+
t recipe add docker-build "docker build -t {tag} ." --collection=docker
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Token Economy
|
|
115
|
+
|
|
116
|
+
Track how many tokens you've saved:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
t stats
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Token Economy:
|
|
124
|
+
Total saved: 124.5K
|
|
125
|
+
By feature:
|
|
126
|
+
Structured: 45.2K
|
|
127
|
+
Compressed: 32.1K
|
|
128
|
+
Diff cache: 28.7K
|
|
129
|
+
Search: 18.5K
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## TUI Keyboard Shortcuts
|
|
133
|
+
|
|
134
|
+
| Key | Action |
|
|
135
|
+
|-----|--------|
|
|
136
|
+
| `ctrl+t` | New tab |
|
|
137
|
+
| `tab` | Switch tabs |
|
|
138
|
+
| `ctrl+w` | Close tab |
|
|
139
|
+
| `ctrl+b` | Browse mode (file navigator) |
|
|
140
|
+
| `ctrl+r` | Fuzzy history search |
|
|
141
|
+
| `ctrl+l` | Clear scrollback |
|
|
142
|
+
| `ctrl+c` | Cancel / exit |
|
|
143
|
+
| `?` | Explain command before running |
|
|
144
|
+
| `e` | Edit translated command |
|
|
145
|
+
| `→` | Accept ghost text suggestion |
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
Config stored at `~/.terminal/config.json`:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"provider": "cerebras",
|
|
154
|
+
"permissions": {
|
|
155
|
+
"destructive": true,
|
|
156
|
+
"network": true,
|
|
157
|
+
"sudo": false,
|
|
158
|
+
"install": true,
|
|
159
|
+
"write_outside_cwd": false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Architecture
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
┌──────────────────────────────────────────┐
|
|
168
|
+
│ open-terminal │
|
|
169
|
+
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
|
|
170
|
+
│ │ Human │ │ MCP │ │ CLI │ │
|
|
171
|
+
│ │ TUI │ │ Server │ │ Tools │ │
|
|
172
|
+
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
|
|
173
|
+
│ └──────────┬───┘────────────┘ │
|
|
174
|
+
│ ┌──────────────────────────────────┐ │
|
|
175
|
+
│ │ Output Intelligence Router │ │
|
|
176
|
+
│ │ Parsers → Compression → Diff │ │
|
|
177
|
+
│ └──────────────┬───────────────────┘ │
|
|
178
|
+
│ ┌──────────────────────────────────┐ │
|
|
179
|
+
│ │ Shell (zsh/bash) │ │
|
|
180
|
+
│ └──────────────────────────────────┘ │
|
|
181
|
+
└──────────────────────────────────────────┘
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
package/dist/ai.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
2
1
|
import { cacheGet, cacheSet } from "./cache.js";
|
|
3
|
-
|
|
2
|
+
import { getProvider } from "./providers/index.js";
|
|
4
3
|
// ── model routing ─────────────────────────────────────────────────────────────
|
|
5
|
-
// Simple queries →
|
|
4
|
+
// Simple queries → fast model. Complex/ambiguous → smart model.
|
|
6
5
|
const COMPLEX_SIGNALS = [
|
|
7
6
|
/\b(undo|revert|rollback|previous|last)\b/i,
|
|
8
7
|
/\b(all files?|recursively|bulk|batch)\b/i,
|
|
@@ -10,9 +9,23 @@ const COMPLEX_SIGNALS = [
|
|
|
10
9
|
/\b(if|when|unless|only if)\b/i,
|
|
11
10
|
/[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
|
|
12
11
|
];
|
|
12
|
+
/** Model routing per provider */
|
|
13
13
|
function pickModel(nl) {
|
|
14
14
|
const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
|
|
15
|
-
|
|
15
|
+
const provider = getProvider();
|
|
16
|
+
if (provider.name === "anthropic") {
|
|
17
|
+
return {
|
|
18
|
+
fast: "claude-haiku-4-5-20251001",
|
|
19
|
+
smart: "claude-sonnet-4-6",
|
|
20
|
+
pick: isComplex ? "smart" : "fast",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Cerebras — single fast model (Llama is already fast)
|
|
24
|
+
return {
|
|
25
|
+
fast: "llama-4-scout-17b-16e",
|
|
26
|
+
smart: "llama-4-scout-17b-16e",
|
|
27
|
+
pick: isComplex ? "smart" : "fast",
|
|
28
|
+
};
|
|
16
29
|
}
|
|
17
30
|
// ── irreversibility ───────────────────────────────────────────────────────────
|
|
18
31
|
const IRREVERSIBLE_PATTERNS = [
|
|
@@ -73,78 +86,60 @@ export async function translateToCommand(nl, perms, sessionCmds, onToken) {
|
|
|
73
86
|
onToken?.(cached);
|
|
74
87
|
return cached;
|
|
75
88
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
89
|
+
const provider = getProvider();
|
|
90
|
+
const routing = pickModel(nl);
|
|
91
|
+
const model = routing.pick === "smart" ? routing.smart : routing.fast;
|
|
92
|
+
const system = buildSystemPrompt(perms, sessionCmds);
|
|
93
|
+
let text;
|
|
78
94
|
if (onToken) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
model,
|
|
82
|
-
max_tokens: 256,
|
|
83
|
-
system: buildSystemPrompt(perms, sessionCmds),
|
|
84
|
-
messages: [{ role: "user", content: nl }],
|
|
95
|
+
text = await provider.stream(nl, { model, maxTokens: 256, system }, {
|
|
96
|
+
onToken: (partial) => onToken(partial),
|
|
85
97
|
});
|
|
86
|
-
for await (const chunk of stream) {
|
|
87
|
-
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
|
88
|
-
result += chunk.delta.text;
|
|
89
|
-
onToken(result.trim());
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
98
|
}
|
|
93
99
|
else {
|
|
94
|
-
|
|
95
|
-
model,
|
|
96
|
-
max_tokens: 256,
|
|
97
|
-
system: buildSystemPrompt(perms, sessionCmds),
|
|
98
|
-
messages: [{ role: "user", content: nl }],
|
|
99
|
-
});
|
|
100
|
-
const block = message.content[0];
|
|
101
|
-
if (block.type !== "text")
|
|
102
|
-
throw new Error("Unexpected response type");
|
|
103
|
-
result = block.text;
|
|
100
|
+
text = await provider.complete(nl, { model, maxTokens: 256, system });
|
|
104
101
|
}
|
|
105
|
-
const text = result.trim();
|
|
106
102
|
if (text.startsWith("BLOCKED:"))
|
|
107
103
|
throw new Error(text);
|
|
108
104
|
cacheSet(nl, text);
|
|
109
105
|
return text;
|
|
110
106
|
}
|
|
111
107
|
// ── prefetch ──────────────────────────────────────────────────────────────────
|
|
112
|
-
// Silently warm the cache after a command runs — no await, fire and forget
|
|
113
108
|
export function prefetchNext(lastNl, perms, sessionCmds) {
|
|
114
|
-
// Only prefetch if we don't have it cached already
|
|
115
109
|
if (cacheGet(lastNl))
|
|
116
110
|
return;
|
|
117
111
|
translateToCommand(lastNl, perms, sessionCmds).catch(() => { });
|
|
118
112
|
}
|
|
119
113
|
// ── explain ───────────────────────────────────────────────────────────────────
|
|
120
114
|
export async function explainCommand(command) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
const provider = getProvider();
|
|
116
|
+
const routing = pickModel("explain"); // simple = fast model
|
|
117
|
+
return provider.complete(command, {
|
|
118
|
+
model: routing.fast,
|
|
119
|
+
maxTokens: 128,
|
|
124
120
|
system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
|
|
125
|
-
messages: [{ role: "user", content: command }],
|
|
126
121
|
});
|
|
127
|
-
const block = message.content[0];
|
|
128
|
-
if (block.type !== "text")
|
|
129
|
-
return "";
|
|
130
|
-
return block.text.trim();
|
|
131
122
|
}
|
|
132
123
|
// ── auto-fix ──────────────────────────────────────────────────────────────────
|
|
133
124
|
export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
125
|
+
const provider = getProvider();
|
|
126
|
+
const routing = pickModel(originalNl);
|
|
127
|
+
const text = await provider.complete(`I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`, {
|
|
128
|
+
model: routing.smart, // always use smart model for fixes
|
|
129
|
+
maxTokens: 256,
|
|
137
130
|
system: buildSystemPrompt(perms, sessionCmds),
|
|
138
|
-
messages: [{
|
|
139
|
-
role: "user",
|
|
140
|
-
content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
|
|
141
|
-
}],
|
|
142
131
|
});
|
|
143
|
-
const block = message.content[0];
|
|
144
|
-
if (block.type !== "text")
|
|
145
|
-
throw new Error("Unexpected response type");
|
|
146
|
-
const text = block.text.trim();
|
|
147
132
|
if (text.startsWith("BLOCKED:"))
|
|
148
133
|
throw new Error(text);
|
|
149
134
|
return text;
|
|
150
135
|
}
|
|
136
|
+
// ── summarize output (for MCP/agent use) ──────────────────────────────────────
|
|
137
|
+
export async function summarizeOutput(command, output, maxTokens = 200) {
|
|
138
|
+
const provider = getProvider();
|
|
139
|
+
const routing = pickModel("summarize");
|
|
140
|
+
return provider.complete(`Command: ${command}\nOutput:\n${output}\n\nSummarize this output concisely for an AI agent. Focus on: status, key results, errors. Be terse.`, {
|
|
141
|
+
model: routing.fast,
|
|
142
|
+
maxTokens,
|
|
143
|
+
system: "You summarize command output for AI agents. Be extremely concise. Return structured info. No prose.",
|
|
144
|
+
});
|
|
145
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,142 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { render } from "ink";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
// ── MCP commands ─────────────────────────────────────────────────────────────
|
|
6
|
+
if (args[0] === "mcp") {
|
|
7
|
+
if (args[1] === "serve" || args.length === 1) {
|
|
8
|
+
const { startMcpServer } = await import("./mcp/server.js");
|
|
9
|
+
startMcpServer().catch((err) => {
|
|
10
|
+
console.error("MCP server error:", err);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
else if (args[1] === "install") {
|
|
15
|
+
const { handleMcpInstall } = await import("./mcp/install.js");
|
|
16
|
+
handleMcpInstall(args.slice(2));
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
console.log("Usage: t mcp [serve|install]");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ── Recipe commands ──────────────────────────────────────────────────────────
|
|
23
|
+
else if (args[0] === "recipe") {
|
|
24
|
+
const { listRecipes, getRecipe, createRecipe, deleteRecipe, listCollections, createCollection } = await import("./recipes/storage.js");
|
|
25
|
+
const { substituteVariables } = await import("./recipes/model.js");
|
|
26
|
+
const sub = args[1];
|
|
27
|
+
if (sub === "list") {
|
|
28
|
+
const collection = args.find(a => a.startsWith("--collection="))?.split("=")[1];
|
|
29
|
+
let recipes = listRecipes(process.cwd());
|
|
30
|
+
if (collection)
|
|
31
|
+
recipes = recipes.filter(r => r.collection === collection);
|
|
32
|
+
if (recipes.length === 0) {
|
|
33
|
+
console.log("No recipes found.");
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
for (const r of recipes) {
|
|
37
|
+
const scope = r.project ? "(project)" : "(global)";
|
|
38
|
+
const col = r.collection ? ` [${r.collection}]` : "";
|
|
39
|
+
console.log(` ${r.name}${col} ${scope} → ${r.command}`);
|
|
40
|
+
}
|
|
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
|
+
}
|
|
51
|
+
else if (sub === "run" && args[2]) {
|
|
52
|
+
const recipe = getRecipe(args[2], process.cwd());
|
|
53
|
+
if (!recipe) {
|
|
54
|
+
console.error(`Recipe '${args[2]}' not found.`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
// Parse --var=value arguments
|
|
58
|
+
const vars = {};
|
|
59
|
+
for (const arg of args.slice(3)) {
|
|
60
|
+
const match = arg.match(/^--(\w+)=(.+)$/);
|
|
61
|
+
if (match)
|
|
62
|
+
vars[match[1]] = match[2];
|
|
63
|
+
}
|
|
64
|
+
const cmd = substituteVariables(recipe.command, vars);
|
|
65
|
+
console.log(`$ ${cmd}`);
|
|
66
|
+
const { execSync } = await import("child_process");
|
|
67
|
+
try {
|
|
68
|
+
execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
process.exit(e.status ?? 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (sub === "delete" && args[2]) {
|
|
75
|
+
const ok = deleteRecipe(args[2], process.cwd());
|
|
76
|
+
console.log(ok ? `✓ Deleted recipe '${args[2]}'` : `Recipe '${args[2]}' not found.`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.log("Usage: t recipe [add|list|run|delete]");
|
|
80
|
+
console.log(" t recipe add <name> <command> [--collection=X] [--project]");
|
|
81
|
+
console.log(" t recipe list [--collection=X]");
|
|
82
|
+
console.log(" t recipe run <name> [--var=value]");
|
|
83
|
+
console.log(" t recipe delete <name>");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ── Collection commands ──────────────────────────────────────────────────────
|
|
87
|
+
else if (args[0] === "collection") {
|
|
88
|
+
const { listCollections, createCollection } = await import("./recipes/storage.js");
|
|
89
|
+
const sub = args[1];
|
|
90
|
+
if (sub === "create" && args[2]) {
|
|
91
|
+
const col = createCollection({ name: args[2], description: args[3], project: args.includes("--project") ? process.cwd() : undefined });
|
|
92
|
+
console.log(`✓ Created collection '${col.name}'`);
|
|
93
|
+
}
|
|
94
|
+
else if (sub === "list") {
|
|
95
|
+
const cols = listCollections(process.cwd());
|
|
96
|
+
if (cols.length === 0)
|
|
97
|
+
console.log("No collections.");
|
|
98
|
+
else
|
|
99
|
+
for (const c of cols)
|
|
100
|
+
console.log(` ${c.name}${c.description ? ` — ${c.description}` : ""}`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.log("Usage: t collection [create|list]");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ── Stats command ────────────────────────────────────────────────────────────
|
|
107
|
+
else if (args[0] === "stats") {
|
|
108
|
+
const { getEconomyStats, formatTokens } = await import("./economy.js");
|
|
109
|
+
const s = getEconomyStats();
|
|
110
|
+
console.log("Token Economy:");
|
|
111
|
+
console.log(` Total saved: ${formatTokens(s.totalTokensSaved)}`);
|
|
112
|
+
console.log(` Total used: ${formatTokens(s.totalTokensUsed)}`);
|
|
113
|
+
console.log(` By feature:`);
|
|
114
|
+
console.log(` Structured: ${formatTokens(s.savingsByFeature.structured)}`);
|
|
115
|
+
console.log(` Compressed: ${formatTokens(s.savingsByFeature.compressed)}`);
|
|
116
|
+
console.log(` Diff cache: ${formatTokens(s.savingsByFeature.diff)}`);
|
|
117
|
+
console.log(` NL cache: ${formatTokens(s.savingsByFeature.cache)}`);
|
|
118
|
+
console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
|
|
119
|
+
}
|
|
120
|
+
// ── Snapshot command ─────────────────────────────────────────────────────────
|
|
121
|
+
else if (args[0] === "snapshot") {
|
|
122
|
+
const { captureSnapshot } = await import("./snapshots.js");
|
|
123
|
+
console.log(JSON.stringify(captureSnapshot(), null, 2));
|
|
124
|
+
}
|
|
125
|
+
// ── Project init ─────────────────────────────────────────────────────────────
|
|
126
|
+
else if (args[0] === "project" && args[1] === "init") {
|
|
127
|
+
const { initProject } = await import("./recipes/storage.js");
|
|
128
|
+
initProject(process.cwd());
|
|
129
|
+
console.log("✓ Initialized .terminal/recipes.json");
|
|
130
|
+
}
|
|
131
|
+
// ── TUI mode (default) ──────────────────────────────────────────────────────
|
|
132
|
+
else {
|
|
133
|
+
if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
|
|
134
|
+
console.error("terminal: No API key found.");
|
|
135
|
+
console.error("Set one of:");
|
|
136
|
+
console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
|
|
137
|
+
console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const App = (await import("./App.js")).default;
|
|
141
|
+
render(_jsx(App, {}));
|
|
9
142
|
}
|
|
10
|
-
render(_jsx(App, {}));
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Token compression engine — reduces CLI output to fit within token budgets
|
|
2
|
+
import { parseOutput, estimateTokens, tokenSavings } from "./parsers/index.js";
|
|
3
|
+
/** Strip ANSI escape codes from text */
|
|
4
|
+
export function stripAnsi(text) {
|
|
5
|
+
// eslint-disable-next-line no-control-regex
|
|
6
|
+
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
|
|
7
|
+
}
|
|
8
|
+
/** Deduplicate consecutive similar lines (e.g., "Compiling X... Compiling Y...") */
|
|
9
|
+
function deduplicateLines(lines) {
|
|
10
|
+
if (lines.length <= 3)
|
|
11
|
+
return lines;
|
|
12
|
+
const result = [];
|
|
13
|
+
let repeatCount = 0;
|
|
14
|
+
let repeatPattern = "";
|
|
15
|
+
for (let i = 0; i < lines.length; i++) {
|
|
16
|
+
const line = lines[i];
|
|
17
|
+
// Extract a "pattern" — the line without numbers, paths, specific identifiers
|
|
18
|
+
const pattern = line.replace(/[0-9]+/g, "N").replace(/\/\S+/g, "/PATH").replace(/\s+/g, " ").trim();
|
|
19
|
+
if (pattern === repeatPattern) {
|
|
20
|
+
repeatCount++;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
if (repeatCount > 2) {
|
|
24
|
+
result.push(` ... (${repeatCount} similar lines)`);
|
|
25
|
+
}
|
|
26
|
+
else if (repeatCount > 0) {
|
|
27
|
+
// Push the skipped lines back
|
|
28
|
+
for (let j = i - repeatCount; j < i; j++) {
|
|
29
|
+
result.push(lines[j]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
result.push(line);
|
|
33
|
+
repeatPattern = pattern;
|
|
34
|
+
repeatCount = 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (repeatCount > 2) {
|
|
38
|
+
result.push(` ... (${repeatCount} similar lines)`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
for (let j = lines.length - repeatCount; j < lines.length; j++) {
|
|
42
|
+
result.push(lines[j]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/** Smart truncation: keep first N + last M lines */
|
|
48
|
+
function smartTruncate(text, maxTokens) {
|
|
49
|
+
const lines = text.split("\n");
|
|
50
|
+
const currentTokens = estimateTokens(text);
|
|
51
|
+
if (currentTokens <= maxTokens)
|
|
52
|
+
return text;
|
|
53
|
+
// Keep proportional first/last, with first getting more
|
|
54
|
+
const targetLines = Math.floor((maxTokens * lines.length) / currentTokens);
|
|
55
|
+
const firstCount = Math.ceil(targetLines * 0.6);
|
|
56
|
+
const lastCount = Math.floor(targetLines * 0.4);
|
|
57
|
+
if (firstCount + lastCount >= lines.length)
|
|
58
|
+
return text;
|
|
59
|
+
const first = lines.slice(0, firstCount);
|
|
60
|
+
const last = lines.slice(-lastCount);
|
|
61
|
+
const hiddenCount = lines.length - firstCount - lastCount;
|
|
62
|
+
return [...first, `\n--- ${hiddenCount} lines hidden ---\n`, ...last].join("\n");
|
|
63
|
+
}
|
|
64
|
+
/** Compress command output to fit within a token budget */
|
|
65
|
+
export function compress(command, output, options = {}) {
|
|
66
|
+
const { maxTokens, format = "text", stripAnsi: doStrip = true } = options;
|
|
67
|
+
const originalTokens = estimateTokens(output);
|
|
68
|
+
// Step 1: Strip ANSI codes
|
|
69
|
+
let text = doStrip ? stripAnsi(output) : output;
|
|
70
|
+
// Step 2: Try structured parsing (format=json or when it saves tokens)
|
|
71
|
+
if (format === "json" || format === "summary") {
|
|
72
|
+
const parsed = parseOutput(command, text);
|
|
73
|
+
if (parsed) {
|
|
74
|
+
const json = JSON.stringify(parsed.data, null, format === "summary" ? 0 : 2);
|
|
75
|
+
const savings = tokenSavings(output, parsed.data);
|
|
76
|
+
const compressedTokens = estimateTokens(json);
|
|
77
|
+
// If within budget or no budget, return structured
|
|
78
|
+
if (!maxTokens || compressedTokens <= maxTokens) {
|
|
79
|
+
return {
|
|
80
|
+
content: json,
|
|
81
|
+
format: "json",
|
|
82
|
+
originalTokens,
|
|
83
|
+
compressedTokens,
|
|
84
|
+
tokensSaved: savings.saved,
|
|
85
|
+
savingsPercent: savings.percent,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Step 3: Deduplicate similar lines
|
|
91
|
+
const lines = text.split("\n");
|
|
92
|
+
const deduped = deduplicateLines(lines);
|
|
93
|
+
text = deduped.join("\n");
|
|
94
|
+
// Step 4: Smart truncation if over budget
|
|
95
|
+
if (maxTokens) {
|
|
96
|
+
text = smartTruncate(text, maxTokens);
|
|
97
|
+
}
|
|
98
|
+
const compressedTokens = estimateTokens(text);
|
|
99
|
+
return {
|
|
100
|
+
content: text,
|
|
101
|
+
format: "text",
|
|
102
|
+
originalTokens,
|
|
103
|
+
compressedTokens,
|
|
104
|
+
tokensSaved: Math.max(0, originalTokens - compressedTokens),
|
|
105
|
+
savingsPercent: originalTokens > 0 ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 100) : 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { compress, stripAnsi } from "./compression.js";
|
|
3
|
+
describe("stripAnsi", () => {
|
|
4
|
+
it("removes ANSI escape codes", () => {
|
|
5
|
+
expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red");
|
|
6
|
+
expect(stripAnsi("\x1b[1;32mbold green\x1b[0m")).toBe("bold green");
|
|
7
|
+
});
|
|
8
|
+
it("leaves clean text unchanged", () => {
|
|
9
|
+
expect(stripAnsi("hello world")).toBe("hello world");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe("compress", () => {
|
|
13
|
+
it("strips ANSI by default", () => {
|
|
14
|
+
const result = compress("ls", "\x1b[32mfile.ts\x1b[0m");
|
|
15
|
+
expect(result.content).not.toContain("\x1b");
|
|
16
|
+
});
|
|
17
|
+
it("uses structured parser when format=json", () => {
|
|
18
|
+
const output = `total 16
|
|
19
|
+
-rw-r--r-- 1 user staff 450 Mar 10 09:00 package.json
|
|
20
|
+
drwxr-xr-x 5 user staff 160 Mar 10 09:00 src`;
|
|
21
|
+
const result = compress("ls -la", output, { format: "json" });
|
|
22
|
+
// Parser may or may not save tokens on small input, just check it parsed
|
|
23
|
+
expect(result.content).toBeTruthy();
|
|
24
|
+
const parsed = JSON.parse(result.content);
|
|
25
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it("respects maxTokens budget", () => {
|
|
28
|
+
const longOutput = Array.from({ length: 100 }, (_, i) => `Line ${i}: some output text here`).join("\n");
|
|
29
|
+
const result = compress("some-command", longOutput, { maxTokens: 50 });
|
|
30
|
+
expect(result.compressedTokens).toBeLessThanOrEqual(60); // allow some slack
|
|
31
|
+
});
|
|
32
|
+
it("deduplicates similar lines", () => {
|
|
33
|
+
const output = Array.from({ length: 20 }, (_, i) => `Compiling module ${i}...`).join("\n");
|
|
34
|
+
const result = compress("build", output);
|
|
35
|
+
expect(result.compressedTokens).toBeLessThan(result.originalTokens);
|
|
36
|
+
});
|
|
37
|
+
it("tracks savings on large output", () => {
|
|
38
|
+
const output = Array.from({ length: 100 }, (_, i) => `Line ${i}: some long output text here that takes tokens`).join("\n");
|
|
39
|
+
const result = compress("cmd", output, { maxTokens: 50 });
|
|
40
|
+
expect(result.compressedTokens).toBeLessThan(result.originalTokens);
|
|
41
|
+
});
|
|
42
|
+
});
|