@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.
Files changed (82) hide show
  1. package/.claude/scheduled_tasks.lock +1 -1
  2. package/README.md +186 -0
  3. package/dist/App.js +217 -105
  4. package/dist/Browse.js +79 -0
  5. package/dist/FuzzyPicker.js +47 -0
  6. package/dist/StatusBar.js +20 -16
  7. package/dist/ai.js +45 -50
  8. package/dist/cli.js +138 -6
  9. package/dist/compression.js +107 -0
  10. package/dist/compression.test.js +42 -0
  11. package/dist/diff-cache.js +87 -0
  12. package/dist/diff-cache.test.js +27 -0
  13. package/dist/economy.js +79 -0
  14. package/dist/economy.test.js +13 -0
  15. package/dist/mcp/install.js +98 -0
  16. package/dist/mcp/server.js +333 -0
  17. package/dist/output-router.js +41 -0
  18. package/dist/parsers/base.js +2 -0
  19. package/dist/parsers/build.js +64 -0
  20. package/dist/parsers/errors.js +101 -0
  21. package/dist/parsers/files.js +78 -0
  22. package/dist/parsers/git.js +86 -0
  23. package/dist/parsers/index.js +48 -0
  24. package/dist/parsers/parsers.test.js +136 -0
  25. package/dist/parsers/tests.js +89 -0
  26. package/dist/providers/anthropic.js +39 -0
  27. package/dist/providers/base.js +4 -0
  28. package/dist/providers/cerebras.js +95 -0
  29. package/dist/providers/index.js +49 -0
  30. package/dist/providers/providers.test.js +14 -0
  31. package/dist/recipes/model.js +20 -0
  32. package/dist/recipes/recipes.test.js +36 -0
  33. package/dist/recipes/storage.js +118 -0
  34. package/dist/search/content-search.js +61 -0
  35. package/dist/search/file-search.js +61 -0
  36. package/dist/search/filters.js +34 -0
  37. package/dist/search/index.js +4 -0
  38. package/dist/search/search.test.js +22 -0
  39. package/dist/snapshots.js +51 -0
  40. package/dist/supervisor.js +112 -0
  41. package/dist/tree.js +94 -0
  42. package/package.json +7 -4
  43. package/src/App.tsx +371 -245
  44. package/src/Browse.tsx +103 -0
  45. package/src/FuzzyPicker.tsx +69 -0
  46. package/src/StatusBar.tsx +28 -34
  47. package/src/ai.ts +63 -51
  48. package/src/cli.tsx +132 -6
  49. package/src/compression.test.ts +50 -0
  50. package/src/compression.ts +140 -0
  51. package/src/diff-cache.test.ts +30 -0
  52. package/src/diff-cache.ts +125 -0
  53. package/src/economy.test.ts +16 -0
  54. package/src/economy.ts +99 -0
  55. package/src/mcp/install.ts +94 -0
  56. package/src/mcp/server.ts +476 -0
  57. package/src/output-router.ts +56 -0
  58. package/src/parsers/base.ts +72 -0
  59. package/src/parsers/build.ts +73 -0
  60. package/src/parsers/errors.ts +107 -0
  61. package/src/parsers/files.ts +91 -0
  62. package/src/parsers/git.ts +86 -0
  63. package/src/parsers/index.ts +66 -0
  64. package/src/parsers/parsers.test.ts +153 -0
  65. package/src/parsers/tests.ts +98 -0
  66. package/src/providers/anthropic.ts +44 -0
  67. package/src/providers/base.ts +34 -0
  68. package/src/providers/cerebras.ts +108 -0
  69. package/src/providers/index.ts +60 -0
  70. package/src/providers/providers.test.ts +16 -0
  71. package/src/recipes/model.ts +55 -0
  72. package/src/recipes/recipes.test.ts +44 -0
  73. package/src/recipes/storage.ts +142 -0
  74. package/src/search/content-search.ts +97 -0
  75. package/src/search/file-search.ts +86 -0
  76. package/src/search/filters.ts +36 -0
  77. package/src/search/index.ts +7 -0
  78. package/src/search/search.test.ts +25 -0
  79. package/src/snapshots.ts +67 -0
  80. package/src/supervisor.ts +129 -0
  81. package/src/tree.ts +101 -0
  82. package/tsconfig.json +2 -1
@@ -0,0 +1,108 @@
1
+ // Cerebras provider — uses OpenAI-compatible API
2
+ // Default for open-source users. Fast inference on Llama models.
3
+
4
+ import type { LLMProvider, ProviderOptions, StreamCallbacks } from "./base.js";
5
+
6
+ const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1";
7
+ const DEFAULT_MODEL = "llama-4-scout-17b-16e";
8
+
9
+ export class CerebrasProvider implements LLMProvider {
10
+ readonly name = "cerebras";
11
+ private apiKey: string;
12
+
13
+ constructor() {
14
+ this.apiKey = process.env.CEREBRAS_API_KEY ?? "";
15
+ }
16
+
17
+ isAvailable(): boolean {
18
+ return !!process.env.CEREBRAS_API_KEY;
19
+ }
20
+
21
+ async complete(prompt: string, options: ProviderOptions): Promise<string> {
22
+ const model = options.model ?? DEFAULT_MODEL;
23
+ const res = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, {
24
+ method: "POST",
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ Authorization: `Bearer ${this.apiKey}`,
28
+ },
29
+ body: JSON.stringify({
30
+ model,
31
+ max_tokens: options.maxTokens ?? 256,
32
+ messages: [
33
+ { role: "system", content: options.system },
34
+ { role: "user", content: prompt },
35
+ ],
36
+ }),
37
+ });
38
+
39
+ if (!res.ok) {
40
+ const text = await res.text();
41
+ throw new Error(`Cerebras API error ${res.status}: ${text}`);
42
+ }
43
+
44
+ const json = (await res.json()) as any;
45
+ return (json.choices?.[0]?.message?.content ?? "").trim();
46
+ }
47
+
48
+ async stream(prompt: string, options: ProviderOptions, callbacks: StreamCallbacks): Promise<string> {
49
+ const model = options.model ?? DEFAULT_MODEL;
50
+ const res = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, {
51
+ method: "POST",
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ Authorization: `Bearer ${this.apiKey}`,
55
+ },
56
+ body: JSON.stringify({
57
+ model,
58
+ max_tokens: options.maxTokens ?? 256,
59
+ stream: true,
60
+ messages: [
61
+ { role: "system", content: options.system },
62
+ { role: "user", content: prompt },
63
+ ],
64
+ }),
65
+ });
66
+
67
+ if (!res.ok) {
68
+ const text = await res.text();
69
+ throw new Error(`Cerebras API error ${res.status}: ${text}`);
70
+ }
71
+
72
+ let result = "";
73
+ const reader = res.body?.getReader();
74
+ if (!reader) throw new Error("No response body");
75
+
76
+ const decoder = new TextDecoder();
77
+ let buffer = "";
78
+
79
+ while (true) {
80
+ const { done, value } = await reader.read();
81
+ if (done) break;
82
+
83
+ buffer += decoder.decode(value, { stream: true });
84
+ const lines = buffer.split("\n");
85
+ buffer = lines.pop() ?? "";
86
+
87
+ for (const line of lines) {
88
+ const trimmed = line.trim();
89
+ if (!trimmed.startsWith("data: ")) continue;
90
+ const data = trimmed.slice(6);
91
+ if (data === "[DONE]") break;
92
+
93
+ try {
94
+ const parsed = JSON.parse(data) as any;
95
+ const delta = parsed.choices?.[0]?.delta?.content;
96
+ if (delta) {
97
+ result += delta;
98
+ callbacks.onToken(result.trim());
99
+ }
100
+ } catch {
101
+ // skip malformed chunks
102
+ }
103
+ }
104
+ }
105
+
106
+ return result.trim();
107
+ }
108
+ }
@@ -0,0 +1,60 @@
1
+ // Provider auto-detection and management
2
+
3
+ import type { LLMProvider, ProviderConfig } from "./base.js";
4
+ import { DEFAULT_PROVIDER_CONFIG } from "./base.js";
5
+ import { AnthropicProvider } from "./anthropic.js";
6
+ import { CerebrasProvider } from "./cerebras.js";
7
+
8
+ export type { LLMProvider, ProviderOptions, StreamCallbacks, ProviderConfig } from "./base.js";
9
+ export { DEFAULT_PROVIDER_CONFIG } from "./base.js";
10
+
11
+ let _provider: LLMProvider | null = null;
12
+
13
+ /** Get the active LLM provider. Auto-detects based on available API keys. */
14
+ export function getProvider(config?: ProviderConfig): LLMProvider {
15
+ if (_provider) return _provider;
16
+
17
+ const cfg = config ?? DEFAULT_PROVIDER_CONFIG;
18
+ _provider = resolveProvider(cfg);
19
+ return _provider;
20
+ }
21
+
22
+ /** Reset the cached provider (useful when config changes). */
23
+ export function resetProvider() {
24
+ _provider = null;
25
+ }
26
+
27
+ function resolveProvider(config: ProviderConfig): LLMProvider {
28
+ if (config.provider === "cerebras") {
29
+ const p = new CerebrasProvider();
30
+ if (!p.isAvailable()) throw new Error("CEREBRAS_API_KEY not set. Run: export CEREBRAS_API_KEY=your-key");
31
+ return p;
32
+ }
33
+
34
+ if (config.provider === "anthropic") {
35
+ const p = new AnthropicProvider();
36
+ if (!p.isAvailable()) throw new Error("ANTHROPIC_API_KEY not set. Run: export ANTHROPIC_API_KEY=your-key");
37
+ return p;
38
+ }
39
+
40
+ // auto: prefer Cerebras (open-source friendly), fall back to Anthropic
41
+ const cerebras = new CerebrasProvider();
42
+ if (cerebras.isAvailable()) return cerebras;
43
+
44
+ const anthropic = new AnthropicProvider();
45
+ if (anthropic.isAvailable()) return anthropic;
46
+
47
+ throw new Error(
48
+ "No API key found. Set one of:\n" +
49
+ " export CEREBRAS_API_KEY=your-key (free, open-source)\n" +
50
+ " export ANTHROPIC_API_KEY=your-key (Claude)"
51
+ );
52
+ }
53
+
54
+ /** List available providers (for onboarding UI). */
55
+ export function availableProviders(): { name: string; available: boolean }[] {
56
+ return [
57
+ { name: "cerebras", available: new CerebrasProvider().isAvailable() },
58
+ { name: "anthropic", available: new AnthropicProvider().isAvailable() },
59
+ ];
60
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { availableProviders, resetProvider } from "./index.js";
3
+
4
+ describe("providers", () => {
5
+ it("lists available providers", () => {
6
+ const providers = availableProviders();
7
+ expect(providers.length).toBe(2);
8
+ expect(providers[0].name).toBe("cerebras");
9
+ expect(providers[1].name).toBe("anthropic");
10
+ });
11
+
12
+ it("resetProvider clears cache", () => {
13
+ // Should not throw
14
+ resetProvider();
15
+ });
16
+ });
@@ -0,0 +1,55 @@
1
+ // Recipes data model — reusable command templates with collections and projects
2
+
3
+ export interface RecipeVariable {
4
+ name: string;
5
+ description?: string;
6
+ default?: string;
7
+ required?: boolean;
8
+ }
9
+
10
+ export interface Recipe {
11
+ id: string;
12
+ name: string;
13
+ description?: string;
14
+ command: string;
15
+ tags: string[];
16
+ collection?: string;
17
+ project?: string; // project path — if set, recipe is project-scoped
18
+ variables: RecipeVariable[];
19
+ createdAt: number;
20
+ updatedAt: number;
21
+ }
22
+
23
+ export interface Collection {
24
+ id: string;
25
+ name: string;
26
+ description?: string;
27
+ project?: string;
28
+ createdAt: number;
29
+ }
30
+
31
+ export interface RecipeStore {
32
+ recipes: Recipe[];
33
+ collections: Collection[];
34
+ }
35
+
36
+ /** Generate a short random ID */
37
+ export function genId(): string {
38
+ return Math.random().toString(36).slice(2, 10);
39
+ }
40
+
41
+ /** Substitute variables in a command template */
42
+ export function substituteVariables(command: string, vars: Record<string, string>): string {
43
+ let result = command;
44
+ for (const [name, value] of Object.entries(vars)) {
45
+ result = result.replace(new RegExp(`\\{${name}\\}`, "g"), value);
46
+ }
47
+ return result;
48
+ }
49
+
50
+ /** Extract variable placeholders from a command */
51
+ export function extractVariables(command: string): string[] {
52
+ const matches = command.match(/\{(\w+)\}/g);
53
+ if (!matches) return [];
54
+ return [...new Set(matches.map(m => m.slice(1, -1)))];
55
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { genId, substituteVariables, extractVariables } from "./model.js";
3
+
4
+ describe("genId", () => {
5
+ it("generates unique ids", () => {
6
+ const a = genId();
7
+ const b = genId();
8
+ expect(a).not.toBe(b);
9
+ expect(a.length).toBe(8);
10
+ });
11
+ });
12
+
13
+ describe("substituteVariables", () => {
14
+ it("replaces {var} placeholders", () => {
15
+ expect(substituteVariables("kill -9 {pid}", { pid: "1234" })).toBe("kill -9 1234");
16
+ });
17
+
18
+ it("replaces multiple variables", () => {
19
+ expect(substituteVariables("curl {host}:{port}", { host: "localhost", port: "3000" }))
20
+ .toBe("curl localhost:3000");
21
+ });
22
+
23
+ it("replaces all occurrences of same variable", () => {
24
+ expect(substituteVariables("{x} and {x}", { x: "y" })).toBe("y and y");
25
+ });
26
+
27
+ it("leaves unmatched vars alone", () => {
28
+ expect(substituteVariables("{a} {b}", { a: "1" })).toBe("1 {b}");
29
+ });
30
+ });
31
+
32
+ describe("extractVariables", () => {
33
+ it("extracts variable names from command", () => {
34
+ expect(extractVariables("lsof -i :{port} -t | xargs kill")).toEqual(["port"]);
35
+ });
36
+
37
+ it("deduplicates", () => {
38
+ expect(extractVariables("{x} {x} {y}")).toEqual(["x", "y"]);
39
+ });
40
+
41
+ it("returns empty for no variables", () => {
42
+ expect(extractVariables("ls -la")).toEqual([]);
43
+ });
44
+ });
@@ -0,0 +1,142 @@
1
+ // Recipes storage — global (~/.terminal/recipes.json) + per-project (.terminal/recipes.json)
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import type { Recipe, Collection, RecipeStore } from "./model.js";
7
+ import { genId, extractVariables } from "./model.js";
8
+
9
+ const GLOBAL_DIR = join(homedir(), ".terminal");
10
+ const GLOBAL_FILE = join(GLOBAL_DIR, "recipes.json");
11
+
12
+ function projectFile(projectPath: string): string {
13
+ return join(projectPath, ".terminal", "recipes.json");
14
+ }
15
+
16
+ function loadStore(filePath: string): RecipeStore {
17
+ if (!existsSync(filePath)) return { recipes: [], collections: [] };
18
+ try {
19
+ return JSON.parse(readFileSync(filePath, "utf8"));
20
+ } catch {
21
+ return { recipes: [], collections: [] };
22
+ }
23
+ }
24
+
25
+ function saveStore(filePath: string, store: RecipeStore): void {
26
+ const dir = filePath.replace(/\/[^/]+$/, "");
27
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
28
+ writeFileSync(filePath, JSON.stringify(store, null, 2));
29
+ }
30
+
31
+ // ── CRUD operations ──────────────────────────────────────────────────────────
32
+
33
+ /** Get all recipes (merged: global + project-scoped) */
34
+ export function listRecipes(projectPath?: string): Recipe[] {
35
+ const global = loadStore(GLOBAL_FILE).recipes;
36
+ if (!projectPath) return global;
37
+ const project = loadStore(projectFile(projectPath)).recipes;
38
+ return [...project, ...global]; // project recipes first (higher priority)
39
+ }
40
+
41
+ /** Get recipes filtered by collection */
42
+ export function listByCollection(collection: string, projectPath?: string): Recipe[] {
43
+ return listRecipes(projectPath).filter(r => r.collection === collection);
44
+ }
45
+
46
+ /** Get a recipe by name */
47
+ export function getRecipe(name: string, projectPath?: string): Recipe | null {
48
+ return listRecipes(projectPath).find(r => r.name === name) ?? null;
49
+ }
50
+
51
+ /** Create a recipe */
52
+ export function createRecipe(opts: {
53
+ name: string;
54
+ command: string;
55
+ description?: string;
56
+ tags?: string[];
57
+ collection?: string;
58
+ project?: string;
59
+ variables?: { name: string; default?: string; required?: boolean }[];
60
+ }): Recipe {
61
+ const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
62
+ const store = loadStore(filePath);
63
+
64
+ // Auto-detect variables from command if not explicitly provided
65
+ const detectedVars = extractVariables(opts.command);
66
+ const variables = opts.variables ?? detectedVars.map(name => ({ name, required: true }));
67
+
68
+ const recipe: Recipe = {
69
+ id: genId(),
70
+ name: opts.name,
71
+ description: opts.description,
72
+ command: opts.command,
73
+ tags: opts.tags ?? [],
74
+ collection: opts.collection,
75
+ project: opts.project,
76
+ variables,
77
+ createdAt: Date.now(),
78
+ updatedAt: Date.now(),
79
+ };
80
+
81
+ store.recipes.push(recipe);
82
+ saveStore(filePath, store);
83
+ return recipe;
84
+ }
85
+
86
+ /** Delete a recipe by name — tries project first, then global */
87
+ export function deleteRecipe(name: string, projectPath?: string): boolean {
88
+ // Try project-scoped first
89
+ if (projectPath) {
90
+ const pFile = projectFile(projectPath);
91
+ const pStore = loadStore(pFile);
92
+ const before = pStore.recipes.length;
93
+ pStore.recipes = pStore.recipes.filter(r => r.name !== name);
94
+ if (pStore.recipes.length < before) {
95
+ saveStore(pFile, pStore);
96
+ return true;
97
+ }
98
+ }
99
+ // Fall back to global
100
+ const store = loadStore(GLOBAL_FILE);
101
+ const before = store.recipes.length;
102
+ store.recipes = store.recipes.filter(r => r.name !== name);
103
+ if (store.recipes.length < before) {
104
+ saveStore(GLOBAL_FILE, store);
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ // ── Collections ──────────────────────────────────────────────────────────────
111
+
112
+ export function listCollections(projectPath?: string): Collection[] {
113
+ const global = loadStore(GLOBAL_FILE).collections;
114
+ if (!projectPath) return global;
115
+ const project = loadStore(projectFile(projectPath)).collections;
116
+ return [...project, ...global];
117
+ }
118
+
119
+ export function createCollection(opts: { name: string; description?: string; project?: string }): Collection {
120
+ const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
121
+ const store = loadStore(filePath);
122
+
123
+ const collection: Collection = {
124
+ id: genId(),
125
+ name: opts.name,
126
+ description: opts.description,
127
+ project: opts.project,
128
+ createdAt: Date.now(),
129
+ };
130
+
131
+ store.collections.push(collection);
132
+ saveStore(filePath, store);
133
+ return collection;
134
+ }
135
+
136
+ /** Initialize project-scoped recipes file */
137
+ export function initProject(projectPath: string): void {
138
+ const file = projectFile(projectPath);
139
+ if (!existsSync(file)) {
140
+ saveStore(file, { recipes: [], collections: [] });
141
+ }
142
+ }
@@ -0,0 +1,97 @@
1
+ // Smart content search — structured grep/ripgrep with grouping and dedup
2
+
3
+ import { spawn } from "child_process";
4
+ import { DEFAULT_EXCLUDE_DIRS, relevanceScore } from "./filters.js";
5
+
6
+ export interface ContentMatch {
7
+ line: number;
8
+ content: string;
9
+ context?: string[];
10
+ }
11
+
12
+ export interface ContentFileMatch {
13
+ path: string;
14
+ matches: ContentMatch[];
15
+ relevance: number;
16
+ }
17
+
18
+ export interface ContentSearchResult {
19
+ query: string;
20
+ totalMatches: number;
21
+ files: ContentFileMatch[];
22
+ filtered: { count: number; reason: string }[];
23
+ tokensSaved?: number;
24
+ }
25
+
26
+ function exec(command: string, cwd: string): Promise<string> {
27
+ return new Promise((resolve) => {
28
+ const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
29
+ let out = "";
30
+ proc.stdout?.on("data", (d: Buffer) => { out += d.toString(); });
31
+ proc.on("close", () => resolve(out));
32
+ });
33
+ }
34
+
35
+ export async function searchContent(
36
+ pattern: string,
37
+ cwd: string,
38
+ options: { fileType?: string; maxResults?: number; contextLines?: number } = {}
39
+ ): Promise<ContentSearchResult> {
40
+ const { fileType, maxResults = 30, contextLines = 0 } = options;
41
+
42
+ // Prefer ripgrep, fall back to grep
43
+ const excludeArgs = DEFAULT_EXCLUDE_DIRS.map(d => `--glob '!${d}'`).join(" ");
44
+ const typeArg = fileType ? `--type ${fileType}` : "";
45
+ const contextArg = contextLines > 0 ? `-C ${contextLines}` : "";
46
+
47
+ // Try rg first, fall back to grep
48
+ const rgCmd = `rg --line-number --no-heading ${contextArg} ${typeArg} ${excludeArgs} '${pattern.replace(/'/g, "'\\''")}' 2>/dev/null | head -500`;
49
+ const grepCmd = `grep -rn ${contextArg} '${pattern.replace(/'/g, "'\\''")}' . --include='*.ts' --include='*.js' --include='*.py' --include='*.go' --include='*.rs' --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist 2>/dev/null | head -500`;
50
+
51
+ let raw = await exec(rgCmd, cwd);
52
+ if (!raw.trim()) {
53
+ raw = await exec(grepCmd, cwd);
54
+ }
55
+
56
+ const lines = raw.split("\n").filter(l => l.trim());
57
+ const fileMap = new Map<string, ContentMatch[]>();
58
+ let filteredCount = 0;
59
+
60
+ for (const line of lines) {
61
+ // Format: path:line:content
62
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
63
+ if (!match) continue;
64
+
65
+ const [, path, lineNum, content] = match;
66
+ if (DEFAULT_EXCLUDE_DIRS.some(d => path.includes(`/${d}/`))) {
67
+ filteredCount++;
68
+ continue;
69
+ }
70
+
71
+ if (!fileMap.has(path)) fileMap.set(path, []);
72
+ fileMap.get(path)!.push({
73
+ line: parseInt(lineNum),
74
+ content: content.trim(),
75
+ });
76
+ }
77
+
78
+ // Sort files by relevance
79
+ const files: ContentFileMatch[] = [...fileMap.entries()]
80
+ .map(([path, matches]) => ({
81
+ path,
82
+ matches: matches.slice(0, 5), // max 5 matches per file
83
+ relevance: relevanceScore(path),
84
+ }))
85
+ .sort((a, b) => b.relevance - a.relevance)
86
+ .slice(0, maxResults);
87
+
88
+ const totalMatches = [...fileMap.values()].reduce((sum, m) => sum + m.length, 0);
89
+ const filtered = filteredCount > 0 ? [{ count: filteredCount, reason: "excluded directories" }] : [];
90
+
91
+ const rawTokens = Math.ceil(raw.length / 4);
92
+ const result: ContentSearchResult = { query: pattern, totalMatches, files, filtered };
93
+ const resultTokens = Math.ceil(JSON.stringify(result).length / 4);
94
+ result.tokensSaved = Math.max(0, rawTokens - resultTokens);
95
+
96
+ return result;
97
+ }
@@ -0,0 +1,86 @@
1
+ // Smart file search — structured, filtered, token-efficient results
2
+
3
+ import { spawn } from "child_process";
4
+ import { DEFAULT_EXCLUDE_DIRS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
5
+
6
+ export interface FileSearchResult {
7
+ query: string;
8
+ total: number;
9
+ source: string[];
10
+ config: string[];
11
+ other: string[];
12
+ filtered: { count: number; reason: string }[];
13
+ tokensSaved?: number;
14
+ }
15
+
16
+ function exec(command: string, cwd: string): Promise<string> {
17
+ return new Promise((resolve) => {
18
+ const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
19
+ let out = "";
20
+ proc.stdout?.on("data", (d: Buffer) => { out += d.toString(); });
21
+ proc.stderr?.on("data", (d: Buffer) => { out += d.toString(); });
22
+ proc.on("close", () => resolve(out));
23
+ });
24
+ }
25
+
26
+ export async function searchFiles(
27
+ pattern: string,
28
+ cwd: string,
29
+ options: { includeNodeModules?: boolean; maxResults?: number } = {}
30
+ ): Promise<FileSearchResult> {
31
+ const { includeNodeModules = false, maxResults = 50 } = options;
32
+
33
+ // Build find command
34
+ const excludes = includeNodeModules
35
+ ? DEFAULT_EXCLUDE_DIRS.filter(d => d !== "node_modules")
36
+ : DEFAULT_EXCLUDE_DIRS;
37
+
38
+ const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
39
+ const command = `find . -name '${pattern}' -type f ${excludeArgs} 2>/dev/null | head -${maxResults * 3}`;
40
+
41
+ const raw = await exec(command, cwd);
42
+ const allPaths = raw.split("\n").filter(l => l.trim());
43
+
44
+ // Categorize
45
+ const source: string[] = [];
46
+ const config: string[] = [];
47
+ const other: string[] = [];
48
+ const filteredCounts: Record<string, number> = {};
49
+
50
+ for (const path of allPaths) {
51
+ if (isExcludedDir(path)) {
52
+ const dir = DEFAULT_EXCLUDE_DIRS.find(d => path.includes(`/${d}/`)) ?? "other";
53
+ filteredCounts[dir] = (filteredCounts[dir] ?? 0) + 1;
54
+ continue;
55
+ }
56
+
57
+ if (isSourceFile(path)) {
58
+ source.push(path);
59
+ } else if (path.match(/\.(json|yaml|yml|toml|ini|env)/)) {
60
+ config.push(path);
61
+ } else {
62
+ other.push(path);
63
+ }
64
+ }
65
+
66
+ // Sort by relevance
67
+ source.sort((a, b) => relevanceScore(b) - relevanceScore(a));
68
+
69
+ // Limit results
70
+ const filtered = Object.entries(filteredCounts).map(([reason, count]) => ({ reason, count }));
71
+
72
+ // Estimate token savings
73
+ const rawTokens = Math.ceil(raw.length / 4);
74
+ const result: FileSearchResult = {
75
+ query: pattern,
76
+ total: allPaths.length,
77
+ source: source.slice(0, maxResults),
78
+ config: config.slice(0, 10),
79
+ other: other.slice(0, 10),
80
+ filtered,
81
+ };
82
+ const resultTokens = Math.ceil(JSON.stringify(result).length / 4);
83
+ result.tokensSaved = Math.max(0, rawTokens - resultTokens);
84
+
85
+ return result;
86
+ }
@@ -0,0 +1,36 @@
1
+ // Smart filters for search results — auto-hide noise, prioritize source files
2
+
3
+ export const DEFAULT_EXCLUDE_DIRS = [
4
+ "node_modules", ".git", "dist", "build", ".next", "__pycache__",
5
+ "coverage", ".turbo", ".cache", ".output", "vendor", "target",
6
+ ];
7
+
8
+ export const SOURCE_EXTENSIONS = new Set([
9
+ ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".rb",
10
+ ".sh", ".c", ".cpp", ".h", ".css", ".scss", ".html", ".vue", ".svelte",
11
+ ".md", ".json", ".yaml", ".yml", ".toml", ".sql", ".graphql",
12
+ ]);
13
+
14
+ export const CONFIG_EXTENSIONS = new Set([
15
+ ".json", ".yaml", ".yml", ".toml", ".ini", ".env", ".config.js",
16
+ ".config.ts", ".config.mjs",
17
+ ]);
18
+
19
+ export function isSourceFile(path: string): boolean {
20
+ const ext = path.match(/\.\w+$/)?.[0] ?? "";
21
+ return SOURCE_EXTENSIONS.has(ext);
22
+ }
23
+
24
+ export function isExcludedDir(path: string): boolean {
25
+ return DEFAULT_EXCLUDE_DIRS.some(d => path.includes(`/${d}/`) || path.includes(`/${d}`));
26
+ }
27
+
28
+ /** Relevance score: higher = more relevant */
29
+ export function relevanceScore(path: string): number {
30
+ if (isExcludedDir(path)) return 0;
31
+ const ext = path.match(/\.\w+$/)?.[0] ?? "";
32
+ if (SOURCE_EXTENSIONS.has(ext)) return 10;
33
+ if (CONFIG_EXTENSIONS.has(ext)) return 5;
34
+ if (path.includes("/test") || path.includes(".test.") || path.includes(".spec.")) return 7;
35
+ return 3;
36
+ }
@@ -0,0 +1,7 @@
1
+ // Smart search — unified entry point for file + content search
2
+
3
+ export { searchFiles } from "./file-search.js";
4
+ export type { FileSearchResult } from "./file-search.js";
5
+ export { searchContent } from "./content-search.js";
6
+ export type { ContentSearchResult, ContentFileMatch, ContentMatch } from "./content-search.js";
7
+ export { DEFAULT_EXCLUDE_DIRS, SOURCE_EXTENSIONS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
3
+
4
+ describe("filters", () => {
5
+ it("identifies source files", () => {
6
+ expect(isSourceFile("src/app.ts")).toBe(true);
7
+ expect(isSourceFile("index.tsx")).toBe(true);
8
+ expect(isSourceFile("main.py")).toBe(true);
9
+ expect(isSourceFile("image.png")).toBe(false);
10
+ expect(isSourceFile("binary")).toBe(false);
11
+ });
12
+
13
+ it("identifies excluded directories", () => {
14
+ expect(isExcludedDir("./node_modules/foo/bar.js")).toBe(true);
15
+ expect(isExcludedDir("./dist/index.js")).toBe(true);
16
+ expect(isExcludedDir("./.git/config")).toBe(true);
17
+ expect(isExcludedDir("./src/lib/utils.ts")).toBe(false);
18
+ });
19
+
20
+ it("scores source files highest", () => {
21
+ expect(relevanceScore("src/app.ts")).toBe(10);
22
+ expect(relevanceScore("./node_modules/foo.js")).toBe(0);
23
+ expect(relevanceScore("binary")).toBe(3);
24
+ });
25
+ });