@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,140 @@
|
|
|
1
|
+
// Token compression engine — reduces CLI output to fit within token budgets
|
|
2
|
+
|
|
3
|
+
import { parseOutput, estimateTokens, tokenSavings } from "./parsers/index.js";
|
|
4
|
+
|
|
5
|
+
export interface CompressOptions {
|
|
6
|
+
/** Max tokens for the output (default: unlimited) */
|
|
7
|
+
maxTokens?: number;
|
|
8
|
+
/** Output format */
|
|
9
|
+
format?: "text" | "json" | "summary";
|
|
10
|
+
/** Strip ANSI escape codes (default: true) */
|
|
11
|
+
stripAnsi?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CompressedOutput {
|
|
15
|
+
content: string;
|
|
16
|
+
format: "text" | "json" | "summary";
|
|
17
|
+
originalTokens: number;
|
|
18
|
+
compressedTokens: number;
|
|
19
|
+
tokensSaved: number;
|
|
20
|
+
savingsPercent: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Strip ANSI escape codes from text */
|
|
24
|
+
export function stripAnsi(text: string): string {
|
|
25
|
+
// eslint-disable-next-line no-control-regex
|
|
26
|
+
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deduplicate consecutive similar lines (e.g., "Compiling X... Compiling Y...") */
|
|
30
|
+
function deduplicateLines(lines: string[]): string[] {
|
|
31
|
+
if (lines.length <= 3) return lines;
|
|
32
|
+
|
|
33
|
+
const result: string[] = [];
|
|
34
|
+
let repeatCount = 0;
|
|
35
|
+
let repeatPattern = "";
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
// Extract a "pattern" — the line without numbers, paths, specific identifiers
|
|
40
|
+
const pattern = line.replace(/[0-9]+/g, "N").replace(/\/\S+/g, "/PATH").replace(/\s+/g, " ").trim();
|
|
41
|
+
|
|
42
|
+
if (pattern === repeatPattern) {
|
|
43
|
+
repeatCount++;
|
|
44
|
+
} else {
|
|
45
|
+
if (repeatCount > 2) {
|
|
46
|
+
result.push(` ... (${repeatCount} similar lines)`);
|
|
47
|
+
} else if (repeatCount > 0) {
|
|
48
|
+
// Push the skipped lines back
|
|
49
|
+
for (let j = i - repeatCount; j < i; j++) {
|
|
50
|
+
result.push(lines[j]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
result.push(line);
|
|
54
|
+
repeatPattern = pattern;
|
|
55
|
+
repeatCount = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (repeatCount > 2) {
|
|
60
|
+
result.push(` ... (${repeatCount} similar lines)`);
|
|
61
|
+
} else {
|
|
62
|
+
for (let j = lines.length - repeatCount; j < lines.length; j++) {
|
|
63
|
+
result.push(lines[j]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Smart truncation: keep first N + last M lines */
|
|
71
|
+
function smartTruncate(text: string, maxTokens: number): string {
|
|
72
|
+
const lines = text.split("\n");
|
|
73
|
+
const currentTokens = estimateTokens(text);
|
|
74
|
+
|
|
75
|
+
if (currentTokens <= maxTokens) return text;
|
|
76
|
+
|
|
77
|
+
// Keep proportional first/last, with first getting more
|
|
78
|
+
const targetLines = Math.floor((maxTokens * lines.length) / currentTokens);
|
|
79
|
+
const firstCount = Math.ceil(targetLines * 0.6);
|
|
80
|
+
const lastCount = Math.floor(targetLines * 0.4);
|
|
81
|
+
|
|
82
|
+
if (firstCount + lastCount >= lines.length) return text;
|
|
83
|
+
|
|
84
|
+
const first = lines.slice(0, firstCount);
|
|
85
|
+
const last = lines.slice(-lastCount);
|
|
86
|
+
const hiddenCount = lines.length - firstCount - lastCount;
|
|
87
|
+
|
|
88
|
+
return [...first, `\n--- ${hiddenCount} lines hidden ---\n`, ...last].join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Compress command output to fit within a token budget */
|
|
92
|
+
export function compress(command: string, output: string, options: CompressOptions = {}): CompressedOutput {
|
|
93
|
+
const { maxTokens, format = "text", stripAnsi: doStrip = true } = options;
|
|
94
|
+
const originalTokens = estimateTokens(output);
|
|
95
|
+
|
|
96
|
+
// Step 1: Strip ANSI codes
|
|
97
|
+
let text = doStrip ? stripAnsi(output) : output;
|
|
98
|
+
|
|
99
|
+
// Step 2: Try structured parsing (format=json or when it saves tokens)
|
|
100
|
+
if (format === "json" || format === "summary") {
|
|
101
|
+
const parsed = parseOutput(command, text);
|
|
102
|
+
if (parsed) {
|
|
103
|
+
const json = JSON.stringify(parsed.data, null, format === "summary" ? 0 : 2);
|
|
104
|
+
const savings = tokenSavings(output, parsed.data);
|
|
105
|
+
const compressedTokens = estimateTokens(json);
|
|
106
|
+
|
|
107
|
+
// If within budget or no budget, return structured
|
|
108
|
+
if (!maxTokens || compressedTokens <= maxTokens) {
|
|
109
|
+
return {
|
|
110
|
+
content: json,
|
|
111
|
+
format: "json",
|
|
112
|
+
originalTokens,
|
|
113
|
+
compressedTokens,
|
|
114
|
+
tokensSaved: savings.saved,
|
|
115
|
+
savingsPercent: savings.percent,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Step 3: Deduplicate similar lines
|
|
122
|
+
const lines = text.split("\n");
|
|
123
|
+
const deduped = deduplicateLines(lines);
|
|
124
|
+
text = deduped.join("\n");
|
|
125
|
+
|
|
126
|
+
// Step 4: Smart truncation if over budget
|
|
127
|
+
if (maxTokens) {
|
|
128
|
+
text = smartTruncate(text, maxTokens);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const compressedTokens = estimateTokens(text);
|
|
132
|
+
return {
|
|
133
|
+
content: text,
|
|
134
|
+
format: "text",
|
|
135
|
+
originalTokens,
|
|
136
|
+
compressedTokens,
|
|
137
|
+
tokensSaved: Math.max(0, originalTokens - compressedTokens),
|
|
138
|
+
savingsPercent: originalTokens > 0 ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 100) : 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { diffOutput, clearDiffCache } from "./diff-cache.js";
|
|
3
|
+
|
|
4
|
+
describe("diffOutput", () => {
|
|
5
|
+
it("returns first run with no previous", () => {
|
|
6
|
+
clearDiffCache();
|
|
7
|
+
const result = diffOutput("npm test", "/tmp", "PASS\n5 passed");
|
|
8
|
+
expect(result.hasPrevious).toBe(false);
|
|
9
|
+
expect(result.diffSummary).toBe("first run");
|
|
10
|
+
expect(result.tokensSaved).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("detects identical output", () => {
|
|
14
|
+
clearDiffCache();
|
|
15
|
+
diffOutput("npm test", "/tmp/id", "PASS\n5 passed");
|
|
16
|
+
const result = diffOutput("npm test", "/tmp/id", "PASS\n5 passed");
|
|
17
|
+
expect(result.unchanged).toBe(true);
|
|
18
|
+
expect(result.diffSummary).toBe("identical to previous run");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("computes diff for changed output", () => {
|
|
22
|
+
clearDiffCache();
|
|
23
|
+
diffOutput("npm test", "/tmp/diff", "PASS test1\nPASS test2\nFAIL test3");
|
|
24
|
+
const result = diffOutput("npm test", "/tmp/diff", "PASS test1\nPASS test2\nPASS test3");
|
|
25
|
+
expect(result.hasPrevious).toBe(true);
|
|
26
|
+
expect(result.unchanged).toBe(false);
|
|
27
|
+
expect(result.added).toContain("PASS test3");
|
|
28
|
+
expect(result.removed).toContain("FAIL test3");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Diff-aware output caching — when same command runs again, return only what changed
|
|
2
|
+
|
|
3
|
+
import { estimateTokens } from "./parsers/index.js";
|
|
4
|
+
|
|
5
|
+
interface CachedOutput {
|
|
6
|
+
command: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
output: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const cache = new Map<string, CachedOutput>();
|
|
13
|
+
|
|
14
|
+
function cacheKey(command: string, cwd: string): string {
|
|
15
|
+
return `${cwd}:${command}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compute a simple line diff between two outputs */
|
|
19
|
+
function lineDiff(prev: string, curr: string): { added: string[]; removed: string[]; unchanged: number } {
|
|
20
|
+
const prevLines = new Set(prev.split("\n"));
|
|
21
|
+
const currLines = curr.split("\n");
|
|
22
|
+
|
|
23
|
+
const added: string[] = [];
|
|
24
|
+
const removed: string[] = [];
|
|
25
|
+
let unchanged = 0;
|
|
26
|
+
|
|
27
|
+
for (const line of currLines) {
|
|
28
|
+
if (prevLines.has(line)) {
|
|
29
|
+
unchanged++;
|
|
30
|
+
prevLines.delete(line);
|
|
31
|
+
} else {
|
|
32
|
+
added.push(line);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const line of prevLines) {
|
|
37
|
+
removed.push(line);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { added, removed, unchanged };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Generate a human-readable diff summary */
|
|
44
|
+
function summarizeDiff(diff: { added: string[]; removed: string[]; unchanged: number }): string {
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
if (diff.added.length > 0) parts.push(`+${diff.added.length} new lines`);
|
|
47
|
+
if (diff.removed.length > 0) parts.push(`-${diff.removed.length} removed lines`);
|
|
48
|
+
parts.push(`${diff.unchanged} unchanged`);
|
|
49
|
+
return parts.join(", ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DiffResult {
|
|
53
|
+
/** Full current output */
|
|
54
|
+
full: string;
|
|
55
|
+
/** Was there a previous run to diff against? */
|
|
56
|
+
hasPrevious: boolean;
|
|
57
|
+
/** Lines added since last run */
|
|
58
|
+
added: string[];
|
|
59
|
+
/** Lines removed since last run */
|
|
60
|
+
removed: string[];
|
|
61
|
+
/** Summary of changes */
|
|
62
|
+
diffSummary: string;
|
|
63
|
+
/** Whether output is identical to last run */
|
|
64
|
+
unchanged: boolean;
|
|
65
|
+
/** Tokens saved by returning diff instead of full output */
|
|
66
|
+
tokensSaved: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run diffing on command output. Caches the output for next comparison. */
|
|
70
|
+
export function diffOutput(command: string, cwd: string, output: string): DiffResult {
|
|
71
|
+
const key = cacheKey(command, cwd);
|
|
72
|
+
const prev = cache.get(key);
|
|
73
|
+
|
|
74
|
+
// Store current for next time
|
|
75
|
+
cache.set(key, { command, cwd, output, timestamp: Date.now() });
|
|
76
|
+
|
|
77
|
+
if (!prev) {
|
|
78
|
+
return {
|
|
79
|
+
full: output,
|
|
80
|
+
hasPrevious: false,
|
|
81
|
+
added: [],
|
|
82
|
+
removed: [],
|
|
83
|
+
diffSummary: "first run",
|
|
84
|
+
unchanged: false,
|
|
85
|
+
tokensSaved: 0,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (prev.output === output) {
|
|
90
|
+
const fullTokens = estimateTokens(output);
|
|
91
|
+
return {
|
|
92
|
+
full: output,
|
|
93
|
+
hasPrevious: true,
|
|
94
|
+
added: [],
|
|
95
|
+
removed: [],
|
|
96
|
+
diffSummary: "identical to previous run",
|
|
97
|
+
unchanged: true,
|
|
98
|
+
tokensSaved: fullTokens - 10, // ~10 tokens for the "unchanged" message
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const diff = lineDiff(prev.output, output);
|
|
103
|
+
const diffContent = [
|
|
104
|
+
...diff.added.map(l => `+ ${l}`),
|
|
105
|
+
...diff.removed.map(l => `- ${l}`),
|
|
106
|
+
].join("\n");
|
|
107
|
+
|
|
108
|
+
const fullTokens = estimateTokens(output);
|
|
109
|
+
const diffTokens = estimateTokens(diffContent);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
full: output,
|
|
113
|
+
hasPrevious: true,
|
|
114
|
+
added: diff.added,
|
|
115
|
+
removed: diff.removed,
|
|
116
|
+
diffSummary: summarizeDiff(diff),
|
|
117
|
+
unchanged: false,
|
|
118
|
+
tokensSaved: Math.max(0, fullTokens - diffTokens),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Clear the diff cache */
|
|
123
|
+
export function clearDiffCache(): void {
|
|
124
|
+
cache.clear();
|
|
125
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { formatTokens } from "./economy.js";
|
|
3
|
+
|
|
4
|
+
describe("formatTokens", () => {
|
|
5
|
+
it("formats small numbers", () => {
|
|
6
|
+
expect(formatTokens(42)).toBe("42");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("formats thousands", () => {
|
|
10
|
+
expect(formatTokens(1500)).toBe("1.5K");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("formats millions", () => {
|
|
14
|
+
expect(formatTokens(2500000)).toBe("2.5M");
|
|
15
|
+
});
|
|
16
|
+
});
|
package/src/economy.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Token economy tracker — tracks token savings across all interactions
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
const DIR = join(homedir(), ".terminal");
|
|
8
|
+
const ECONOMY_FILE = join(DIR, "economy.json");
|
|
9
|
+
|
|
10
|
+
export interface EconomyStats {
|
|
11
|
+
totalTokensSaved: number;
|
|
12
|
+
totalTokensUsed: number;
|
|
13
|
+
savingsByFeature: {
|
|
14
|
+
structured: number;
|
|
15
|
+
compressed: number;
|
|
16
|
+
diff: number;
|
|
17
|
+
cache: number;
|
|
18
|
+
search: number;
|
|
19
|
+
};
|
|
20
|
+
sessionStart: number;
|
|
21
|
+
sessionSaved: number;
|
|
22
|
+
sessionUsed: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let stats: EconomyStats | null = null;
|
|
26
|
+
|
|
27
|
+
function ensureDir() {
|
|
28
|
+
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadStats(): EconomyStats {
|
|
32
|
+
if (stats) return stats;
|
|
33
|
+
ensureDir();
|
|
34
|
+
if (existsSync(ECONOMY_FILE)) {
|
|
35
|
+
try {
|
|
36
|
+
const saved = JSON.parse(readFileSync(ECONOMY_FILE, "utf8"));
|
|
37
|
+
stats = {
|
|
38
|
+
totalTokensSaved: saved.totalTokensSaved ?? 0,
|
|
39
|
+
totalTokensUsed: saved.totalTokensUsed ?? 0,
|
|
40
|
+
savingsByFeature: {
|
|
41
|
+
structured: saved.savingsByFeature?.structured ?? 0,
|
|
42
|
+
compressed: saved.savingsByFeature?.compressed ?? 0,
|
|
43
|
+
diff: saved.savingsByFeature?.diff ?? 0,
|
|
44
|
+
cache: saved.savingsByFeature?.cache ?? 0,
|
|
45
|
+
search: saved.savingsByFeature?.search ?? 0,
|
|
46
|
+
},
|
|
47
|
+
sessionStart: Date.now(),
|
|
48
|
+
sessionSaved: 0,
|
|
49
|
+
sessionUsed: 0,
|
|
50
|
+
};
|
|
51
|
+
return stats;
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
stats = {
|
|
55
|
+
totalTokensSaved: 0,
|
|
56
|
+
totalTokensUsed: 0,
|
|
57
|
+
savingsByFeature: { structured: 0, compressed: 0, diff: 0, cache: 0, search: 0 },
|
|
58
|
+
sessionStart: Date.now(),
|
|
59
|
+
sessionSaved: 0,
|
|
60
|
+
sessionUsed: 0,
|
|
61
|
+
};
|
|
62
|
+
return stats;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveStats() {
|
|
66
|
+
ensureDir();
|
|
67
|
+
if (stats) {
|
|
68
|
+
writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Record token savings from a feature */
|
|
73
|
+
export function recordSaving(feature: keyof EconomyStats["savingsByFeature"], tokensSaved: number) {
|
|
74
|
+
const s = loadStats();
|
|
75
|
+
s.totalTokensSaved += tokensSaved;
|
|
76
|
+
s.sessionSaved += tokensSaved;
|
|
77
|
+
s.savingsByFeature[feature] += tokensSaved;
|
|
78
|
+
saveStats();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Record tokens used (for AI calls) */
|
|
82
|
+
export function recordUsage(tokens: number) {
|
|
83
|
+
const s = loadStats();
|
|
84
|
+
s.totalTokensUsed += tokens;
|
|
85
|
+
s.sessionUsed += tokens;
|
|
86
|
+
saveStats();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get current economy stats */
|
|
90
|
+
export function getEconomyStats(): EconomyStats {
|
|
91
|
+
return { ...loadStats() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Format token count for display */
|
|
95
|
+
export function formatTokens(n: number): string {
|
|
96
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
97
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
98
|
+
return `${n}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// MCP installation helper — register open-terminal as MCP server for various agents
|
|
2
|
+
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
|
|
8
|
+
const TERMINAL_BIN = "terminal"; // the CLI binary name
|
|
9
|
+
|
|
10
|
+
function which(cmd: string): string | null {
|
|
11
|
+
try {
|
|
12
|
+
return execSync(`which ${cmd}`, { encoding: "utf8" }).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function installClaude(): boolean {
|
|
19
|
+
try {
|
|
20
|
+
execSync(
|
|
21
|
+
`claude mcp add --transport stdio --scope user open-terminal -- ${which(TERMINAL_BIN) ?? "npx"} ${which(TERMINAL_BIN) ? "mcp serve" : "@hasna/terminal mcp serve"}`,
|
|
22
|
+
{ stdio: "inherit" }
|
|
23
|
+
);
|
|
24
|
+
console.log("✓ Installed open-terminal MCP server for Claude Code");
|
|
25
|
+
return true;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error("Failed to install for Claude Code:", e);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installCodex(): boolean {
|
|
33
|
+
const configPath = join(homedir(), ".codex", "config.toml");
|
|
34
|
+
try {
|
|
35
|
+
let content = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
36
|
+
if (content.includes("[mcp_servers.open-terminal]")) {
|
|
37
|
+
console.log("✓ open-terminal already configured for Codex");
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const bin = which(TERMINAL_BIN) ?? "npx @hasna/terminal";
|
|
41
|
+
content += `\n[mcp_servers.open-terminal]\ncommand = "${bin}"\nargs = ["mcp", "serve"]\n`;
|
|
42
|
+
writeFileSync(configPath, content);
|
|
43
|
+
console.log("✓ Installed open-terminal MCP server for Codex");
|
|
44
|
+
return true;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error("Failed to install for Codex:", e);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function installGemini(): boolean {
|
|
52
|
+
const configPath = join(homedir(), ".gemini", "settings.json");
|
|
53
|
+
try {
|
|
54
|
+
let config: any = {};
|
|
55
|
+
if (existsSync(configPath)) {
|
|
56
|
+
config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
59
|
+
const bin = which(TERMINAL_BIN) ?? "npx";
|
|
60
|
+
const args = which(TERMINAL_BIN) ? ["mcp", "serve"] : ["@hasna/terminal", "mcp", "serve"];
|
|
61
|
+
config.mcpServers["open-terminal"] = { command: bin, args };
|
|
62
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
63
|
+
console.log("✓ Installed open-terminal MCP server for Gemini");
|
|
64
|
+
return true;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error("Failed to install for Gemini:", e);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function installAll(): void {
|
|
72
|
+
installClaude();
|
|
73
|
+
installCodex();
|
|
74
|
+
installGemini();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function handleMcpInstall(args: string[]): void {
|
|
78
|
+
const flags = new Set(args);
|
|
79
|
+
|
|
80
|
+
if (flags.has("--all")) { installAll(); return; }
|
|
81
|
+
if (flags.has("--claude")) { installClaude(); return; }
|
|
82
|
+
if (flags.has("--codex")) { installCodex(); return; }
|
|
83
|
+
if (flags.has("--gemini")) { installGemini(); return; }
|
|
84
|
+
|
|
85
|
+
console.log("Usage: t mcp install [--claude|--codex|--gemini|--all]");
|
|
86
|
+
console.log("");
|
|
87
|
+
console.log("Install open-terminal as an MCP server for AI coding agents.");
|
|
88
|
+
console.log("");
|
|
89
|
+
console.log("Options:");
|
|
90
|
+
console.log(" --claude Install for Claude Code");
|
|
91
|
+
console.log(" --codex Install for OpenAI Codex");
|
|
92
|
+
console.log(" --gemini Install for Gemini CLI");
|
|
93
|
+
console.log(" --all Install for all agents");
|
|
94
|
+
}
|