@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.
- package/.claude/scheduled_tasks.lock +1 -1
- package/README.md +186 -0
- package/dist/App.js +217 -105
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- 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/App.tsx +371 -245
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
- 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
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
|
|
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", {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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",
|
|
36
|
-
["sudo",
|
|
37
|
-
["install",
|
|
33
|
+
["network", "net"],
|
|
34
|
+
["sudo", "sudo"],
|
|
35
|
+
["install", "pkg"],
|
|
38
36
|
["write_outside_cwd", "write"],
|
|
39
37
|
];
|
|
40
|
-
|
|
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 =
|
|
49
|
-
const branch = getGitBranch();
|
|
50
|
-
const dirty = branch ? getGitDirty() : false;
|
|
51
|
-
const
|
|
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
|
-
|
|
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 →
|
|
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
|
+
});
|