@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
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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 →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
+
});
|