@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,107 @@
|
|
|
1
|
+
// Parser for common error patterns
|
|
2
|
+
|
|
3
|
+
import type { Parser, ErrorInfo } from "./base.js";
|
|
4
|
+
|
|
5
|
+
const ERROR_PATTERNS: { type: string; pattern: RegExp; extract: (m: RegExpMatchArray, output: string) => ErrorInfo }[] = [
|
|
6
|
+
{
|
|
7
|
+
type: "port_in_use",
|
|
8
|
+
pattern: /EADDRINUSE.*?(?::(\d+))|port\s+(\d+)\s+(?:is\s+)?(?:already\s+)?in\s+use/i,
|
|
9
|
+
extract: (m) => ({
|
|
10
|
+
type: "port_in_use",
|
|
11
|
+
message: m[0],
|
|
12
|
+
suggestion: `Kill the process: lsof -i :${m[1] ?? m[2]} -t | xargs kill`,
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
type: "file_not_found",
|
|
17
|
+
pattern: /ENOENT.*?'([^']+)'|No such file or directory:\s*(.+)/,
|
|
18
|
+
extract: (m) => ({
|
|
19
|
+
type: "file_not_found",
|
|
20
|
+
message: m[0],
|
|
21
|
+
file: m[1] ?? m[2]?.trim(),
|
|
22
|
+
suggestion: "Check the file path exists",
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: "permission_denied",
|
|
27
|
+
pattern: /EACCES.*?'([^']+)'|Permission denied:\s*(.+)/,
|
|
28
|
+
extract: (m) => ({
|
|
29
|
+
type: "permission_denied",
|
|
30
|
+
message: m[0],
|
|
31
|
+
file: m[1] ?? m[2]?.trim(),
|
|
32
|
+
suggestion: "Check file permissions or run with sudo",
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: "command_not_found",
|
|
37
|
+
pattern: /command not found:\s*(\S+)|(\S+):\s*not found/,
|
|
38
|
+
extract: (m) => ({
|
|
39
|
+
type: "command_not_found",
|
|
40
|
+
message: m[0],
|
|
41
|
+
suggestion: `Install ${m[1] ?? m[2]} or check your PATH`,
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "dependency_missing",
|
|
46
|
+
pattern: /Cannot find module\s+'([^']+)'|Module not found.*?'([^']+)'/,
|
|
47
|
+
extract: (m) => ({
|
|
48
|
+
type: "dependency_missing",
|
|
49
|
+
message: m[0],
|
|
50
|
+
suggestion: `Install: npm install ${m[1] ?? m[2]}`,
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: "syntax_error",
|
|
55
|
+
pattern: /SyntaxError:\s*(.+)|error TS\d+:\s*(.+)/,
|
|
56
|
+
extract: (m, output) => {
|
|
57
|
+
const fileMatch = output.match(/(\S+\.\w+):(\d+)/);
|
|
58
|
+
return {
|
|
59
|
+
type: "syntax_error",
|
|
60
|
+
message: m[1] ?? m[2] ?? m[0],
|
|
61
|
+
file: fileMatch?.[1],
|
|
62
|
+
line: fileMatch ? parseInt(fileMatch[2]) : undefined,
|
|
63
|
+
suggestion: "Fix the syntax error in the referenced file",
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: "out_of_memory",
|
|
69
|
+
pattern: /ENOMEM|JavaScript heap out of memory|Killed/,
|
|
70
|
+
extract: (m) => ({
|
|
71
|
+
type: "out_of_memory",
|
|
72
|
+
message: m[0],
|
|
73
|
+
suggestion: "Increase memory: NODE_OPTIONS=--max-old-space-size=4096",
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "network_error",
|
|
78
|
+
pattern: /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|fetch failed/,
|
|
79
|
+
extract: (m) => ({
|
|
80
|
+
type: "network_error",
|
|
81
|
+
message: m[0],
|
|
82
|
+
suggestion: "Check network connection and target URL/host",
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export const errorParser: Parser<ErrorInfo> = {
|
|
88
|
+
name: "error",
|
|
89
|
+
|
|
90
|
+
detect(_command: string, output: string): boolean {
|
|
91
|
+
return ERROR_PATTERNS.some(({ pattern }) => pattern.test(output));
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
parse(_command: string, output: string): ErrorInfo {
|
|
95
|
+
for (const { pattern, extract } of ERROR_PATTERNS) {
|
|
96
|
+
const match = output.match(pattern);
|
|
97
|
+
if (match) return extract(match, output);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Generic error fallback
|
|
101
|
+
const errorLine = output.split("\n").find(l => /error/i.test(l));
|
|
102
|
+
return {
|
|
103
|
+
type: "unknown",
|
|
104
|
+
message: errorLine?.trim() ?? "Unknown error",
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Parser for file listing output (ls -la, find, etc.)
|
|
2
|
+
|
|
3
|
+
import type { Parser, FileEntry, SearchResult } from "./base.js";
|
|
4
|
+
|
|
5
|
+
const NODE_MODULES_RE = /node_modules/;
|
|
6
|
+
const DIST_RE = /\b(dist|build|\.next|__pycache__|coverage|\.git)\b/;
|
|
7
|
+
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|go|rs|java|rb|sh|c|cpp|h|css|scss|html|vue|svelte|md|json|yaml|yml|toml)$/;
|
|
8
|
+
|
|
9
|
+
export const lsParser: Parser<FileEntry[]> = {
|
|
10
|
+
name: "ls",
|
|
11
|
+
|
|
12
|
+
detect(command: string, output: string): boolean {
|
|
13
|
+
return /^\s*(ls|ll|la)\b/.test(command) && output.includes(" ");
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
parse(_command: string, output: string): FileEntry[] {
|
|
17
|
+
const lines = output.split("\n").filter(l => l.trim());
|
|
18
|
+
const entries: FileEntry[] = [];
|
|
19
|
+
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
// ls -la format: drwxr-xr-x 5 user group 160 Mar 10 09:00 dirname
|
|
22
|
+
const match = line.match(/^([dlcbps-])([rwxsStT-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
|
23
|
+
if (match) {
|
|
24
|
+
const typeChar = match[1];
|
|
25
|
+
entries.push({
|
|
26
|
+
name: match[5],
|
|
27
|
+
type: typeChar === "d" ? "dir" : typeChar === "l" ? "symlink" : "file",
|
|
28
|
+
size: parseInt(match[3]),
|
|
29
|
+
modified: match[4],
|
|
30
|
+
permissions: match[1] + match[2],
|
|
31
|
+
});
|
|
32
|
+
} else if (line.trim() && !line.startsWith("total ")) {
|
|
33
|
+
// Simple ls output — just filenames
|
|
34
|
+
entries.push({ name: line.trim(), type: "file" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return entries;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const findParser: Parser<SearchResult> = {
|
|
43
|
+
name: "find",
|
|
44
|
+
|
|
45
|
+
detect(command: string, _output: string): boolean {
|
|
46
|
+
return /^\s*(find|fd)\b/.test(command);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
parse(_command: string, output: string): SearchResult {
|
|
50
|
+
const lines = output.split("\n").filter(l => l.trim());
|
|
51
|
+
const source: FileEntry[] = [];
|
|
52
|
+
const other: FileEntry[] = [];
|
|
53
|
+
let nodeModulesCount = 0;
|
|
54
|
+
let distCount = 0;
|
|
55
|
+
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const path = line.trim();
|
|
58
|
+
if (!path) continue;
|
|
59
|
+
|
|
60
|
+
if (NODE_MODULES_RE.test(path)) {
|
|
61
|
+
nodeModulesCount++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (DIST_RE.test(path)) {
|
|
66
|
+
distCount++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const name = path.split("/").pop() ?? path;
|
|
71
|
+
const entry: FileEntry = { name: path, type: SOURCE_EXTS.test(name) ? "file" : "other" };
|
|
72
|
+
|
|
73
|
+
if (SOURCE_EXTS.test(name)) {
|
|
74
|
+
source.push(entry);
|
|
75
|
+
} else {
|
|
76
|
+
other.push(entry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const filtered: { count: number; reason: string }[] = [];
|
|
81
|
+
if (nodeModulesCount > 0) filtered.push({ count: nodeModulesCount, reason: "node_modules" });
|
|
82
|
+
if (distCount > 0) filtered.push({ count: distCount, reason: "dist/build" });
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
total: lines.length,
|
|
86
|
+
source,
|
|
87
|
+
other,
|
|
88
|
+
filtered,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Parsers for git output (log, status, diff)
|
|
2
|
+
|
|
3
|
+
import type { Parser, GitLogEntry, GitStatus } from "./base.js";
|
|
4
|
+
|
|
5
|
+
export const gitLogParser: Parser<GitLogEntry[]> = {
|
|
6
|
+
name: "git-log",
|
|
7
|
+
|
|
8
|
+
detect(command: string, _output: string): boolean {
|
|
9
|
+
return /\bgit\s+log\b/.test(command);
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
parse(_command: string, output: string): GitLogEntry[] {
|
|
13
|
+
const entries: GitLogEntry[] = [];
|
|
14
|
+
const lines = output.split("\n");
|
|
15
|
+
|
|
16
|
+
let hash = "", author = "", date = "", message: string[] = [];
|
|
17
|
+
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const commitMatch = line.match(/^commit\s+([a-f0-9]+)/);
|
|
20
|
+
if (commitMatch) {
|
|
21
|
+
if (hash) {
|
|
22
|
+
entries.push({ hash: hash.slice(0, 8), author, date, message: message.join(" ").trim() });
|
|
23
|
+
}
|
|
24
|
+
hash = commitMatch[1];
|
|
25
|
+
author = ""; date = ""; message = [];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const authorMatch = line.match(/^Author:\s+(.+)/);
|
|
30
|
+
if (authorMatch) { author = authorMatch[1]; continue; }
|
|
31
|
+
|
|
32
|
+
const dateMatch = line.match(/^Date:\s+(.+)/);
|
|
33
|
+
if (dateMatch) { date = dateMatch[1].trim(); continue; }
|
|
34
|
+
|
|
35
|
+
if (line.startsWith(" ")) {
|
|
36
|
+
message.push(line.trim());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (hash) {
|
|
41
|
+
entries.push({ hash: hash.slice(0, 8), author, date, message: message.join(" ").trim() });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entries;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const gitStatusParser: Parser<GitStatus> = {
|
|
49
|
+
name: "git-status",
|
|
50
|
+
|
|
51
|
+
detect(command: string, _output: string): boolean {
|
|
52
|
+
return /\bgit\s+status\b/.test(command);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
parse(_command: string, output: string): GitStatus {
|
|
56
|
+
const lines = output.split("\n");
|
|
57
|
+
let branch = "";
|
|
58
|
+
const staged: string[] = [];
|
|
59
|
+
const unstaged: string[] = [];
|
|
60
|
+
const untracked: string[] = [];
|
|
61
|
+
|
|
62
|
+
const branchMatch = output.match(/On branch\s+(\S+)/);
|
|
63
|
+
if (branchMatch) branch = branchMatch[1];
|
|
64
|
+
|
|
65
|
+
let section = "";
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (line.includes("Changes to be committed")) { section = "staged"; continue; }
|
|
68
|
+
if (line.includes("Changes not staged")) { section = "unstaged"; continue; }
|
|
69
|
+
if (line.includes("Untracked files")) { section = "untracked"; continue; }
|
|
70
|
+
|
|
71
|
+
const fileMatch = line.match(/^\s+(?:new file|modified|deleted|renamed):\s+(.+)/);
|
|
72
|
+
if (fileMatch) {
|
|
73
|
+
if (section === "staged") staged.push(fileMatch[1].trim());
|
|
74
|
+
else if (section === "unstaged") unstaged.push(fileMatch[1].trim());
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Untracked files are just indented filenames
|
|
79
|
+
if (section === "untracked" && line.match(/^\s+\S/) && !line.includes("(use ")) {
|
|
80
|
+
untracked.push(line.trim());
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { branch, staged, unstaged, untracked };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Output parser registry — auto-detect command output type and parse to structured JSON
|
|
2
|
+
|
|
3
|
+
import type { Parser } from "./base.js";
|
|
4
|
+
import { lsParser, findParser } from "./files.js";
|
|
5
|
+
import { testParser } from "./tests.js";
|
|
6
|
+
import { gitLogParser, gitStatusParser } from "./git.js";
|
|
7
|
+
import { buildParser, npmInstallParser } from "./build.js";
|
|
8
|
+
import { errorParser } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
export type { Parser } from "./base.js";
|
|
11
|
+
export type {
|
|
12
|
+
FileEntry, TestResult, GitLogEntry, GitStatus,
|
|
13
|
+
BuildResult, NpmInstallResult, ErrorInfo, SearchResult,
|
|
14
|
+
} from "./base.js";
|
|
15
|
+
|
|
16
|
+
// Ordered by specificity — more specific parsers first
|
|
17
|
+
const parsers: Parser[] = [
|
|
18
|
+
npmInstallParser,
|
|
19
|
+
testParser,
|
|
20
|
+
gitLogParser,
|
|
21
|
+
gitStatusParser,
|
|
22
|
+
buildParser,
|
|
23
|
+
findParser,
|
|
24
|
+
lsParser,
|
|
25
|
+
errorParser, // fallback for error detection
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export interface ParseResult {
|
|
29
|
+
parser: string;
|
|
30
|
+
data: unknown;
|
|
31
|
+
raw: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Try to parse command output with the best matching parser */
|
|
35
|
+
export function parseOutput(command: string, output: string): ParseResult | null {
|
|
36
|
+
for (const parser of parsers) {
|
|
37
|
+
if (parser.detect(command, output)) {
|
|
38
|
+
try {
|
|
39
|
+
const data = parser.parse(command, output);
|
|
40
|
+
return { parser: parser.name, data, raw: output };
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get all parsers that match (for debugging/info) */
|
|
50
|
+
export function detectParsers(command: string, output: string): string[] {
|
|
51
|
+
return parsers.filter(p => p.detect(command, output)).map(p => p.name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Estimate token count for a string (rough: ~4 chars per token) */
|
|
55
|
+
export function estimateTokens(text: string): number {
|
|
56
|
+
return Math.ceil(text.length / 4);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Calculate token savings between raw output and parsed JSON */
|
|
60
|
+
export function tokenSavings(raw: string, parsed: unknown): { rawTokens: number; parsedTokens: number; saved: number; percent: number } {
|
|
61
|
+
const rawTokens = estimateTokens(raw);
|
|
62
|
+
const parsedTokens = estimateTokens(JSON.stringify(parsed));
|
|
63
|
+
const saved = Math.max(0, rawTokens - parsedTokens);
|
|
64
|
+
const percent = rawTokens > 0 ? Math.round((saved / rawTokens) * 100) : 0;
|
|
65
|
+
return { rawTokens, parsedTokens, saved, percent };
|
|
66
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { parseOutput, tokenSavings, estimateTokens } from "./index.js";
|
|
3
|
+
|
|
4
|
+
describe("parseOutput", () => {
|
|
5
|
+
it("parses ls -la output", () => {
|
|
6
|
+
const output = `total 32
|
|
7
|
+
drwxr-xr-x 5 user staff 160 Mar 10 09:00 src
|
|
8
|
+
-rw-r--r-- 1 user staff 450 Mar 10 09:00 package.json
|
|
9
|
+
lrwxr-xr-x 1 user staff 20 Mar 10 09:00 link -> target`;
|
|
10
|
+
|
|
11
|
+
const result = parseOutput("ls -la", output);
|
|
12
|
+
expect(result).not.toBeNull();
|
|
13
|
+
expect(result!.parser).toBe("ls");
|
|
14
|
+
const data = result!.data as any[];
|
|
15
|
+
expect(data.length).toBe(3);
|
|
16
|
+
expect(data[0].name).toBe("src");
|
|
17
|
+
expect(data[0].type).toBe("dir");
|
|
18
|
+
expect(data[1].name).toBe("package.json");
|
|
19
|
+
expect(data[1].type).toBe("file");
|
|
20
|
+
expect(data[2].type).toBe("symlink");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses find output and filters node_modules", () => {
|
|
24
|
+
const output = `./src/lib/webhooks.ts
|
|
25
|
+
./node_modules/@types/node/async_hooks.d.ts
|
|
26
|
+
./node_modules/@types/node/perf_hooks.d.ts
|
|
27
|
+
./dist/lib/webhooks.d.ts
|
|
28
|
+
./src/routes/api.ts`;
|
|
29
|
+
|
|
30
|
+
const result = parseOutput("find . -name '*hooks*' -type f", output);
|
|
31
|
+
expect(result).not.toBeNull();
|
|
32
|
+
expect(result!.parser).toBe("find");
|
|
33
|
+
const data = result!.data as any;
|
|
34
|
+
expect(data.source.length).toBe(2); // webhooks.ts and api.ts
|
|
35
|
+
expect(data.filtered.length).toBeGreaterThan(0);
|
|
36
|
+
expect(data.filtered.find((f: any) => f.reason === "node_modules")?.count).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses test output (jest style)", () => {
|
|
40
|
+
const output = `PASS src/auth.test.ts
|
|
41
|
+
FAIL src/db.test.ts
|
|
42
|
+
✗ should connect to database
|
|
43
|
+
Error: Connection refused
|
|
44
|
+
Tests: 5 passed, 1 failed, 1 skipped, 7 total
|
|
45
|
+
Time: 3.2s`;
|
|
46
|
+
|
|
47
|
+
const result = parseOutput("npm test", output);
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result!.parser).toBe("test");
|
|
50
|
+
const data = result!.data as any;
|
|
51
|
+
expect(data.passed).toBe(5);
|
|
52
|
+
expect(data.failed).toBe(1);
|
|
53
|
+
expect(data.skipped).toBe(1);
|
|
54
|
+
expect(data.total).toBe(7);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parses git status", () => {
|
|
58
|
+
const output = `On branch main
|
|
59
|
+
Changes to be committed:
|
|
60
|
+
new file: src/mcp/server.ts
|
|
61
|
+
modified: src/ai.ts
|
|
62
|
+
|
|
63
|
+
Changes not staged for commit:
|
|
64
|
+
modified: package.json
|
|
65
|
+
|
|
66
|
+
Untracked files:
|
|
67
|
+
src/tree.ts`;
|
|
68
|
+
|
|
69
|
+
const result = parseOutput("git status", output);
|
|
70
|
+
expect(result).not.toBeNull();
|
|
71
|
+
expect(result!.parser).toBe("git-status");
|
|
72
|
+
const data = result!.data as any;
|
|
73
|
+
expect(data.branch).toBe("main");
|
|
74
|
+
expect(data.staged.length).toBe(2);
|
|
75
|
+
expect(data.unstaged.length).toBe(1);
|
|
76
|
+
expect(data.untracked.length).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("parses git log", () => {
|
|
80
|
+
const output = `commit af19ce3456789
|
|
81
|
+
Author: Andrei Hasna <andrei@hasna.com>
|
|
82
|
+
Date: Sat Mar 15 10:00:00 2026
|
|
83
|
+
|
|
84
|
+
feat: add MCP server
|
|
85
|
+
|
|
86
|
+
commit 3963db5123456
|
|
87
|
+
Author: Andrei Hasna <andrei@hasna.com>
|
|
88
|
+
Date: Fri Mar 14 09:00:00 2026
|
|
89
|
+
|
|
90
|
+
feat: tabs and browse mode`;
|
|
91
|
+
|
|
92
|
+
const result = parseOutput("git log", output);
|
|
93
|
+
expect(result).not.toBeNull();
|
|
94
|
+
expect(result!.parser).toBe("git-log");
|
|
95
|
+
const data = result!.data as any[];
|
|
96
|
+
expect(data.length).toBe(2);
|
|
97
|
+
expect(data[0].hash).toBe("af19ce34");
|
|
98
|
+
expect(data[0].message).toBe("feat: add MCP server");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("parses npm install output", () => {
|
|
102
|
+
const output = `added 47 packages in 3.2s
|
|
103
|
+
2 vulnerabilities found`;
|
|
104
|
+
|
|
105
|
+
const result = parseOutput("npm install", output);
|
|
106
|
+
expect(result).not.toBeNull();
|
|
107
|
+
expect(result!.parser).toBe("npm-install");
|
|
108
|
+
const data = result!.data as any;
|
|
109
|
+
expect(data.installed).toBe(47);
|
|
110
|
+
expect(data.duration).toBe("3.2s");
|
|
111
|
+
expect(data.vulnerabilities).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("parses build output", () => {
|
|
115
|
+
const output = `Compiling...
|
|
116
|
+
1 warning
|
|
117
|
+
Found 0 errors
|
|
118
|
+
Done in 2.5s`;
|
|
119
|
+
|
|
120
|
+
const result = parseOutput("npm run build", output);
|
|
121
|
+
expect(result).not.toBeNull();
|
|
122
|
+
expect(result!.parser).toBe("build");
|
|
123
|
+
const data = result!.data as any;
|
|
124
|
+
expect(data.status).toBe("success");
|
|
125
|
+
expect(data.warnings).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("detects errors", () => {
|
|
129
|
+
const output = `Error: EADDRINUSE: address already in use :3000`;
|
|
130
|
+
const result = parseOutput("node server.js", output);
|
|
131
|
+
expect(result).not.toBeNull();
|
|
132
|
+
expect(result!.parser).toBe("error");
|
|
133
|
+
const data = result!.data as any;
|
|
134
|
+
expect(data.type).toBe("port_in_use");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("estimateTokens", () => {
|
|
139
|
+
it("estimates roughly 4 chars per token", () => {
|
|
140
|
+
expect(estimateTokens("hello world")).toBe(3); // 11 chars / 4 = 2.75 → 3
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("tokenSavings", () => {
|
|
145
|
+
it("calculates savings correctly", () => {
|
|
146
|
+
const raw = "a".repeat(400); // 100 tokens
|
|
147
|
+
const parsed = { status: "ok" };
|
|
148
|
+
const result = tokenSavings(raw, parsed);
|
|
149
|
+
expect(result.rawTokens).toBe(100);
|
|
150
|
+
expect(result.saved).toBeGreaterThan(0);
|
|
151
|
+
expect(result.percent).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Parser for test runner output (jest, vitest, bun test, pytest, go test)
|
|
2
|
+
|
|
3
|
+
import type { Parser, TestResult } from "./base.js";
|
|
4
|
+
|
|
5
|
+
export const testParser: Parser<TestResult> = {
|
|
6
|
+
name: "test",
|
|
7
|
+
|
|
8
|
+
detect(command: string, output: string): boolean {
|
|
9
|
+
if (/\b(jest|vitest|bun\s+test|pytest|go\s+test|mocha|ava|tap)\b/.test(command)) return true;
|
|
10
|
+
if (/\b(npm|bun|pnpm|yarn)\s+(run\s+)?test\b/.test(command)) return true;
|
|
11
|
+
// Detect by output patterns
|
|
12
|
+
return /Tests:\s+\d+/.test(output) || /\d+\s+(passing|passed|failed)/.test(output) || /PASS|FAIL/.test(output);
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
parse(_command: string, output: string): TestResult {
|
|
16
|
+
const failures: { test: string; error: string }[] = [];
|
|
17
|
+
let passed = 0, failed = 0, skipped = 0, duration: string | undefined;
|
|
18
|
+
|
|
19
|
+
// Jest/Vitest style: Tests: 5 passed, 2 failed, 7 total
|
|
20
|
+
const jestMatch = output.match(/Tests:\s+(?:(\d+)\s+passed)?[,\s]*(?:(\d+)\s+failed)?[,\s]*(?:(\d+)\s+skipped)?[,\s]*(\d+)\s+total/);
|
|
21
|
+
if (jestMatch) {
|
|
22
|
+
passed = parseInt(jestMatch[1] ?? "0");
|
|
23
|
+
failed = parseInt(jestMatch[2] ?? "0");
|
|
24
|
+
skipped = parseInt(jestMatch[3] ?? "0");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Bun test style: 5 pass, 2 fail
|
|
28
|
+
const bunMatch = output.match(/(\d+)\s+pass.*?(\d+)\s+fail/);
|
|
29
|
+
if (!jestMatch && bunMatch) {
|
|
30
|
+
passed = parseInt(bunMatch[1]);
|
|
31
|
+
failed = parseInt(bunMatch[2]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pytest style: 5 passed, 2 failed
|
|
35
|
+
const pytestMatch = output.match(/(\d+)\s+passed(?:.*?(\d+)\s+failed)?/);
|
|
36
|
+
if (!jestMatch && !bunMatch && pytestMatch) {
|
|
37
|
+
passed = parseInt(pytestMatch[1]);
|
|
38
|
+
failed = parseInt(pytestMatch[2] ?? "0");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Go test: ok/FAIL + count
|
|
42
|
+
const goPassMatch = output.match(/ok\s+\S+\s+([\d.]+s)/);
|
|
43
|
+
const goFailMatch = output.match(/FAIL\s+\S+/);
|
|
44
|
+
if (!jestMatch && !bunMatch && !pytestMatch && (goPassMatch || goFailMatch)) {
|
|
45
|
+
const passLines = (output.match(/--- PASS/g) || []).length;
|
|
46
|
+
const failLines = (output.match(/--- FAIL/g) || []).length;
|
|
47
|
+
passed = passLines;
|
|
48
|
+
failed = failLines;
|
|
49
|
+
if (goPassMatch) duration = goPassMatch[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Duration
|
|
53
|
+
const timeMatch = output.match(/Time:\s+([\d.]+\s*(?:s|ms|m))/i) || output.match(/in\s+([\d.]+\s*(?:s|ms|m))/i);
|
|
54
|
+
if (timeMatch) duration = timeMatch[1];
|
|
55
|
+
|
|
56
|
+
// Extract failure details: lines starting with FAIL or ✗ or ×
|
|
57
|
+
const lines = output.split("\n");
|
|
58
|
+
let capturingFailure = false;
|
|
59
|
+
let currentTest = "";
|
|
60
|
+
let currentError: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const failMatch = line.match(/(?:FAIL|✗|×|✕)\s+(.+)/);
|
|
64
|
+
if (failMatch) {
|
|
65
|
+
if (capturingFailure && currentTest) {
|
|
66
|
+
failures.push({ test: currentTest, error: currentError.join("\n").trim() });
|
|
67
|
+
}
|
|
68
|
+
currentTest = failMatch[1].trim();
|
|
69
|
+
currentError = [];
|
|
70
|
+
capturingFailure = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (capturingFailure) {
|
|
75
|
+
if (line.match(/^(PASS|✓|✔|FAIL|✗|×|✕)\s/) || line.match(/^Tests:|^\d+ pass/)) {
|
|
76
|
+
failures.push({ test: currentTest, error: currentError.join("\n").trim() });
|
|
77
|
+
capturingFailure = false;
|
|
78
|
+
currentTest = "";
|
|
79
|
+
currentError = [];
|
|
80
|
+
} else {
|
|
81
|
+
currentError.push(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (capturingFailure && currentTest) {
|
|
86
|
+
failures.push({ test: currentTest, error: currentError.join("\n").trim() });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
passed,
|
|
91
|
+
failed,
|
|
92
|
+
skipped,
|
|
93
|
+
total: passed + failed + skipped,
|
|
94
|
+
duration,
|
|
95
|
+
failures,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { LLMProvider, ProviderOptions, StreamCallbacks } from "./base.js";
|
|
3
|
+
|
|
4
|
+
export class AnthropicProvider implements LLMProvider {
|
|
5
|
+
readonly name = "anthropic";
|
|
6
|
+
private client: Anthropic;
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
this.client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
isAvailable(): boolean {
|
|
13
|
+
return !!process.env.ANTHROPIC_API_KEY;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async complete(prompt: string, options: ProviderOptions): Promise<string> {
|
|
17
|
+
const message = await this.client.messages.create({
|
|
18
|
+
model: options.model ?? "claude-haiku-4-5-20251001",
|
|
19
|
+
max_tokens: options.maxTokens ?? 256,
|
|
20
|
+
system: options.system,
|
|
21
|
+
messages: [{ role: "user", content: prompt }],
|
|
22
|
+
});
|
|
23
|
+
const block = message.content[0];
|
|
24
|
+
if (block.type !== "text") throw new Error("Unexpected response type");
|
|
25
|
+
return block.text.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async stream(prompt: string, options: ProviderOptions, callbacks: StreamCallbacks): Promise<string> {
|
|
29
|
+
let result = "";
|
|
30
|
+
const stream = await this.client.messages.stream({
|
|
31
|
+
model: options.model ?? "claude-haiku-4-5-20251001",
|
|
32
|
+
max_tokens: options.maxTokens ?? 256,
|
|
33
|
+
system: options.system,
|
|
34
|
+
messages: [{ role: "user", content: prompt }],
|
|
35
|
+
});
|
|
36
|
+
for await (const chunk of stream) {
|
|
37
|
+
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
|
38
|
+
result += chunk.delta.text;
|
|
39
|
+
callbacks.onToken(result.trim());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result.trim();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Provider interface for LLM backends (Anthropic, Cerebras, etc.)
|
|
2
|
+
|
|
3
|
+
export interface ProviderOptions {
|
|
4
|
+
model?: string;
|
|
5
|
+
maxTokens?: number;
|
|
6
|
+
system: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StreamCallbacks {
|
|
10
|
+
onToken: (partial: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LLMProvider {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
|
|
16
|
+
/** Generate a completion (non-streaming) */
|
|
17
|
+
complete(prompt: string, options: ProviderOptions): Promise<string>;
|
|
18
|
+
|
|
19
|
+
/** Generate a completion with streaming */
|
|
20
|
+
stream(prompt: string, options: ProviderOptions, callbacks: StreamCallbacks): Promise<string>;
|
|
21
|
+
|
|
22
|
+
/** Check if the provider is available (has API key, etc.) */
|
|
23
|
+
isAvailable(): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProviderConfig {
|
|
27
|
+
provider: "cerebras" | "anthropic" | "auto";
|
|
28
|
+
cerebrasModel?: string;
|
|
29
|
+
anthropicModel?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_PROVIDER_CONFIG: ProviderConfig = {
|
|
33
|
+
provider: "auto",
|
|
34
|
+
};
|