@hasna/terminal 2.3.1 → 3.0.1

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 (99) hide show
  1. package/dist/App.js +404 -0
  2. package/dist/Browse.js +79 -0
  3. package/dist/FuzzyPicker.js +47 -0
  4. package/dist/Onboarding.js +51 -0
  5. package/dist/Spinner.js +12 -0
  6. package/dist/StatusBar.js +49 -0
  7. package/dist/ai.js +296 -0
  8. package/dist/cache.js +42 -0
  9. package/dist/cli.js +1 -1
  10. package/dist/command-rewriter.js +64 -0
  11. package/dist/command-validator.js +86 -0
  12. package/dist/compression.js +85 -0
  13. package/dist/context-hints.js +285 -0
  14. package/dist/diff-cache.js +107 -0
  15. package/dist/discover.js +212 -0
  16. package/dist/economy.js +155 -0
  17. package/dist/expand-store.js +44 -0
  18. package/dist/file-cache.js +72 -0
  19. package/dist/file-index.js +62 -0
  20. package/dist/history.js +62 -0
  21. package/dist/lazy-executor.js +54 -0
  22. package/dist/line-dedup.js +59 -0
  23. package/dist/loop-detector.js +75 -0
  24. package/dist/mcp/install.js +98 -0
  25. package/dist/mcp/server.js +545 -0
  26. package/dist/noise-filter.js +86 -0
  27. package/dist/output-processor.js +132 -0
  28. package/dist/output-router.js +41 -0
  29. package/dist/output-store.js +111 -0
  30. package/dist/parsers/base.js +2 -0
  31. package/dist/parsers/build.js +64 -0
  32. package/dist/parsers/errors.js +101 -0
  33. package/dist/parsers/files.js +78 -0
  34. package/dist/parsers/git.js +99 -0
  35. package/dist/parsers/index.js +48 -0
  36. package/dist/parsers/tests.js +89 -0
  37. package/dist/providers/anthropic.js +43 -0
  38. package/dist/providers/base.js +4 -0
  39. package/dist/providers/cerebras.js +8 -0
  40. package/dist/providers/groq.js +8 -0
  41. package/dist/providers/index.js +122 -0
  42. package/dist/providers/openai-compat.js +93 -0
  43. package/dist/providers/xai.js +8 -0
  44. package/dist/recipes/model.js +20 -0
  45. package/dist/recipes/storage.js +136 -0
  46. package/dist/search/content-search.js +68 -0
  47. package/dist/search/file-search.js +61 -0
  48. package/dist/search/filters.js +34 -0
  49. package/dist/search/index.js +5 -0
  50. package/dist/search/semantic.js +320 -0
  51. package/dist/session-boot.js +59 -0
  52. package/dist/session-context.js +55 -0
  53. package/dist/sessions-db.js +173 -0
  54. package/dist/smart-display.js +286 -0
  55. package/dist/snapshots.js +51 -0
  56. package/dist/supervisor.js +112 -0
  57. package/dist/test-watchlist.js +131 -0
  58. package/dist/tokens.js +17 -0
  59. package/dist/tool-profiles.js +129 -0
  60. package/dist/tree.js +94 -0
  61. package/dist/usage-cache.js +65 -0
  62. package/package.json +8 -1
  63. package/src/ai.ts +60 -90
  64. package/src/cache.ts +3 -2
  65. package/src/cli.tsx +1 -1
  66. package/src/compression.ts +8 -35
  67. package/src/context-hints.ts +20 -10
  68. package/src/diff-cache.ts +1 -1
  69. package/src/discover.ts +1 -1
  70. package/src/economy.ts +37 -5
  71. package/src/expand-store.ts +8 -1
  72. package/src/mcp/server.ts +45 -73
  73. package/src/output-processor.ts +11 -8
  74. package/src/providers/anthropic.ts +6 -2
  75. package/src/providers/base.ts +2 -0
  76. package/src/providers/cerebras.ts +6 -105
  77. package/src/providers/groq.ts +6 -105
  78. package/src/providers/index.ts +84 -33
  79. package/src/providers/openai-compat.ts +109 -0
  80. package/src/providers/xai.ts +6 -105
  81. package/src/tokens.ts +18 -0
  82. package/src/tool-profiles.ts +9 -2
  83. package/.claude/scheduled_tasks.lock +0 -1
  84. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -20
  85. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -14
  86. package/CONTRIBUTING.md +0 -80
  87. package/benchmarks/benchmark.mjs +0 -115
  88. package/imported_modules.txt +0 -0
  89. package/src/compression.test.ts +0 -49
  90. package/src/output-router.ts +0 -56
  91. package/src/parsers/base.ts +0 -72
  92. package/src/parsers/build.ts +0 -73
  93. package/src/parsers/errors.ts +0 -107
  94. package/src/parsers/files.ts +0 -91
  95. package/src/parsers/git.ts +0 -101
  96. package/src/parsers/index.ts +0 -66
  97. package/src/parsers/parsers.test.ts +0 -153
  98. package/src/parsers/tests.ts +0 -98
  99. package/tsconfig.json +0 -15
@@ -0,0 +1,89 @@
1
+ // Parser for test runner output (jest, vitest, bun test, pytest, go test)
2
+ export const testParser = {
3
+ name: "test",
4
+ detect(command, output) {
5
+ if (/\b(jest|vitest|bun\s+test|pytest|go\s+test|mocha|ava|tap)\b/.test(command))
6
+ return true;
7
+ if (/\b(npm|bun|pnpm|yarn)\s+(run\s+)?test\b/.test(command))
8
+ return true;
9
+ // Detect by output patterns
10
+ return /Tests:\s+\d+/.test(output) || /\d+\s+(passing|passed|failed)/.test(output) || /PASS|FAIL/.test(output);
11
+ },
12
+ parse(_command, output) {
13
+ const failures = [];
14
+ let passed = 0, failed = 0, skipped = 0, duration;
15
+ // Jest/Vitest style: Tests: 5 passed, 2 failed, 7 total
16
+ const jestMatch = output.match(/Tests:\s+(?:(\d+)\s+passed)?[,\s]*(?:(\d+)\s+failed)?[,\s]*(?:(\d+)\s+skipped)?[,\s]*(\d+)\s+total/);
17
+ if (jestMatch) {
18
+ passed = parseInt(jestMatch[1] ?? "0");
19
+ failed = parseInt(jestMatch[2] ?? "0");
20
+ skipped = parseInt(jestMatch[3] ?? "0");
21
+ }
22
+ // Bun test style: 5 pass, 2 fail
23
+ const bunMatch = output.match(/(\d+)\s+pass.*?(\d+)\s+fail/);
24
+ if (!jestMatch && bunMatch) {
25
+ passed = parseInt(bunMatch[1]);
26
+ failed = parseInt(bunMatch[2]);
27
+ }
28
+ // Pytest style: 5 passed, 2 failed
29
+ const pytestMatch = output.match(/(\d+)\s+passed(?:.*?(\d+)\s+failed)?/);
30
+ if (!jestMatch && !bunMatch && pytestMatch) {
31
+ passed = parseInt(pytestMatch[1]);
32
+ failed = parseInt(pytestMatch[2] ?? "0");
33
+ }
34
+ // Go test: ok/FAIL + count
35
+ const goPassMatch = output.match(/ok\s+\S+\s+([\d.]+s)/);
36
+ const goFailMatch = output.match(/FAIL\s+\S+/);
37
+ if (!jestMatch && !bunMatch && !pytestMatch && (goPassMatch || goFailMatch)) {
38
+ const passLines = (output.match(/--- PASS/g) || []).length;
39
+ const failLines = (output.match(/--- FAIL/g) || []).length;
40
+ passed = passLines;
41
+ failed = failLines;
42
+ if (goPassMatch)
43
+ duration = goPassMatch[1];
44
+ }
45
+ // Duration
46
+ const timeMatch = output.match(/Time:\s+([\d.]+\s*(?:s|ms|m))/i) || output.match(/in\s+([\d.]+\s*(?:s|ms|m))/i);
47
+ if (timeMatch)
48
+ duration = timeMatch[1];
49
+ // Extract failure details: lines starting with FAIL or ✗ or ×
50
+ const lines = output.split("\n");
51
+ let capturingFailure = false;
52
+ let currentTest = "";
53
+ let currentError = [];
54
+ for (const line of lines) {
55
+ const failMatch = line.match(/(?:FAIL|✗|×|✕)\s+(.+)/);
56
+ if (failMatch) {
57
+ if (capturingFailure && currentTest) {
58
+ failures.push({ test: currentTest, error: currentError.join("\n").trim() });
59
+ }
60
+ currentTest = failMatch[1].trim();
61
+ currentError = [];
62
+ capturingFailure = true;
63
+ continue;
64
+ }
65
+ if (capturingFailure) {
66
+ if (line.match(/^(PASS|✓|✔|FAIL|✗|×|✕)\s/) || line.match(/^Tests:|^\d+ pass/)) {
67
+ failures.push({ test: currentTest, error: currentError.join("\n").trim() });
68
+ capturingFailure = false;
69
+ currentTest = "";
70
+ currentError = [];
71
+ }
72
+ else {
73
+ currentError.push(line);
74
+ }
75
+ }
76
+ }
77
+ if (capturingFailure && currentTest) {
78
+ failures.push({ test: currentTest, error: currentError.join("\n").trim() });
79
+ }
80
+ return {
81
+ passed,
82
+ failed,
83
+ skipped,
84
+ total: passed + failed + skipped,
85
+ duration,
86
+ failures,
87
+ };
88
+ },
89
+ };
@@ -0,0 +1,43 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ export class AnthropicProvider {
3
+ name = "anthropic";
4
+ client;
5
+ constructor() {
6
+ this.client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
7
+ }
8
+ isAvailable() {
9
+ return !!process.env.ANTHROPIC_API_KEY;
10
+ }
11
+ async complete(prompt, options) {
12
+ const message = await this.client.messages.create({
13
+ model: options.model ?? "claude-haiku-4-5-20251001",
14
+ max_tokens: options.maxTokens ?? 256,
15
+ temperature: options.temperature ?? 0,
16
+ ...(options.stop ? { stop_sequences: options.stop } : {}),
17
+ system: [{ type: "text", text: options.system, cache_control: { type: "ephemeral" } }],
18
+ messages: [{ role: "user", content: prompt }],
19
+ });
20
+ const block = message.content[0];
21
+ if (block.type !== "text")
22
+ throw new Error("Unexpected response type");
23
+ return block.text.trim();
24
+ }
25
+ async stream(prompt, options, callbacks) {
26
+ let result = "";
27
+ const stream = await this.client.messages.stream({
28
+ model: options.model ?? "claude-haiku-4-5-20251001",
29
+ max_tokens: options.maxTokens ?? 256,
30
+ temperature: options.temperature ?? 0,
31
+ ...(options.stop ? { stop_sequences: options.stop } : {}),
32
+ system: [{ type: "text", text: options.system, cache_control: { type: "ephemeral" } }],
33
+ messages: [{ role: "user", content: prompt }],
34
+ });
35
+ for await (const chunk of stream) {
36
+ if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
37
+ result += chunk.delta.text;
38
+ callbacks.onToken(result.trim());
39
+ }
40
+ }
41
+ return result.trim();
42
+ }
43
+ }
@@ -0,0 +1,4 @@
1
+ // Provider interface for LLM backends (Anthropic, Cerebras, etc.)
2
+ export const DEFAULT_PROVIDER_CONFIG = {
3
+ provider: "auto",
4
+ };
@@ -0,0 +1,8 @@
1
+ // Cerebras provider — fast inference on Qwen/Llama models
2
+ import { OpenAICompatibleProvider } from "./openai-compat.js";
3
+ export class CerebrasProvider extends OpenAICompatibleProvider {
4
+ name = "cerebras";
5
+ baseUrl = "https://api.cerebras.ai/v1";
6
+ defaultModel = "qwen-3-235b-a22b-instruct-2507";
7
+ apiKeyEnvVar = "CEREBRAS_API_KEY";
8
+ }
@@ -0,0 +1,8 @@
1
+ // Groq provider — ultra-fast inference
2
+ import { OpenAICompatibleProvider } from "./openai-compat.js";
3
+ export class GroqProvider extends OpenAICompatibleProvider {
4
+ name = "groq";
5
+ baseUrl = "https://api.groq.com/openai/v1";
6
+ defaultModel = "openai/gpt-oss-120b";
7
+ apiKeyEnvVar = "GROQ_API_KEY";
8
+ }
@@ -0,0 +1,122 @@
1
+ // Provider auto-detection and management — with fallback on failure
2
+ import { DEFAULT_PROVIDER_CONFIG } from "./base.js";
3
+ import { AnthropicProvider } from "./anthropic.js";
4
+ import { CerebrasProvider } from "./cerebras.js";
5
+ import { GroqProvider } from "./groq.js";
6
+ import { XaiProvider } from "./xai.js";
7
+ export { DEFAULT_PROVIDER_CONFIG } from "./base.js";
8
+ let _provider = null;
9
+ let _failedProviders = new Set();
10
+ /** Get the active LLM provider. Auto-detects based on available API keys. */
11
+ export function getProvider(config) {
12
+ if (_provider && !_failedProviders.has(_provider.name))
13
+ return _provider;
14
+ const cfg = config ?? DEFAULT_PROVIDER_CONFIG;
15
+ _provider = resolveProvider(cfg);
16
+ return _provider;
17
+ }
18
+ /** Reset the cached provider (useful when config changes). */
19
+ export function resetProvider() {
20
+ _provider = null;
21
+ _failedProviders.clear();
22
+ }
23
+ /** Get a fallback-wrapped provider that tries alternatives on failure */
24
+ export function getProviderWithFallback(config) {
25
+ const primary = getProvider(config);
26
+ return new FallbackProvider(primary);
27
+ }
28
+ function resolveProvider(config) {
29
+ if (config.provider !== "auto") {
30
+ const providers = {
31
+ cerebras: () => new CerebrasProvider(),
32
+ anthropic: () => new AnthropicProvider(),
33
+ groq: () => new GroqProvider(),
34
+ xai: () => new XaiProvider(),
35
+ };
36
+ const factory = providers[config.provider];
37
+ if (factory) {
38
+ const p = factory();
39
+ if (!p.isAvailable())
40
+ throw new Error(`${config.provider.toUpperCase()}_API_KEY not set`);
41
+ return p;
42
+ }
43
+ }
44
+ // auto: prefer Cerebras, then xAI, then Groq, then Anthropic — skip failed
45
+ const candidates = [
46
+ new CerebrasProvider(),
47
+ new XaiProvider(),
48
+ new GroqProvider(),
49
+ new AnthropicProvider(),
50
+ ];
51
+ for (const p of candidates) {
52
+ if (p.isAvailable() && !_failedProviders.has(p.name))
53
+ return p;
54
+ }
55
+ // If all failed, clear failures and try again
56
+ if (_failedProviders.size > 0) {
57
+ _failedProviders.clear();
58
+ for (const p of candidates) {
59
+ if (p.isAvailable())
60
+ return p;
61
+ }
62
+ }
63
+ throw new Error("No API key found. Set one of:\n" +
64
+ " export CEREBRAS_API_KEY=your-key (free, open-source)\n" +
65
+ " export GROQ_API_KEY=your-key (free, fast)\n" +
66
+ " export XAI_API_KEY=your-key (Grok, code-optimized)\n" +
67
+ " export ANTHROPIC_API_KEY=your-key (Claude)");
68
+ }
69
+ /** Provider wrapper that falls back to alternatives on API errors */
70
+ class FallbackProvider {
71
+ name;
72
+ primary;
73
+ constructor(primary) {
74
+ this.primary = primary;
75
+ this.name = primary.name;
76
+ }
77
+ isAvailable() {
78
+ return this.primary.isAvailable();
79
+ }
80
+ async complete(prompt, options) {
81
+ try {
82
+ return await this.primary.complete(prompt, options);
83
+ }
84
+ catch (err) {
85
+ const fallback = this.getFallback();
86
+ if (fallback)
87
+ return fallback.complete(prompt, options);
88
+ throw err;
89
+ }
90
+ }
91
+ async stream(prompt, options, callbacks) {
92
+ try {
93
+ return await this.primary.stream(prompt, options, callbacks);
94
+ }
95
+ catch (err) {
96
+ const fallback = this.getFallback();
97
+ if (fallback)
98
+ return fallback.complete(prompt, options); // fallback doesn't stream
99
+ throw err;
100
+ }
101
+ }
102
+ getFallback() {
103
+ _failedProviders.add(this.primary.name);
104
+ _provider = null; // force re-resolve
105
+ try {
106
+ const next = getProvider();
107
+ if (next.name !== this.primary.name)
108
+ return next;
109
+ }
110
+ catch { }
111
+ return null;
112
+ }
113
+ }
114
+ /** List available providers (for onboarding UI). */
115
+ export function availableProviders() {
116
+ return [
117
+ { name: "cerebras", available: new CerebrasProvider().isAvailable() },
118
+ { name: "groq", available: new GroqProvider().isAvailable() },
119
+ { name: "xai", available: new XaiProvider().isAvailable() },
120
+ { name: "anthropic", available: new AnthropicProvider().isAvailable() },
121
+ ];
122
+ }
@@ -0,0 +1,93 @@
1
+ // Shared base class for OpenAI-compatible providers (Cerebras, Groq, xAI)
2
+ // Eliminates ~200 lines of duplicated streaming SSE parsing
3
+ export class OpenAICompatibleProvider {
4
+ get apiKey() {
5
+ return process.env[this.apiKeyEnvVar] ?? "";
6
+ }
7
+ isAvailable() {
8
+ return !!process.env[this.apiKeyEnvVar];
9
+ }
10
+ async complete(prompt, options) {
11
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
12
+ method: "POST",
13
+ headers: {
14
+ "Content-Type": "application/json",
15
+ Authorization: `Bearer ${this.apiKey}`,
16
+ },
17
+ body: JSON.stringify({
18
+ model: options.model ?? this.defaultModel,
19
+ max_tokens: options.maxTokens ?? 256,
20
+ temperature: options.temperature ?? 0,
21
+ ...(options.stop ? { stop: options.stop } : {}),
22
+ messages: [
23
+ { role: "system", content: options.system },
24
+ { role: "user", content: prompt },
25
+ ],
26
+ }),
27
+ });
28
+ if (!res.ok) {
29
+ const text = await res.text();
30
+ throw new Error(`${this.name} API error ${res.status}: ${text}`);
31
+ }
32
+ const json = (await res.json());
33
+ return (json.choices?.[0]?.message?.content ?? "").trim();
34
+ }
35
+ async stream(prompt, options, callbacks) {
36
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Authorization: `Bearer ${this.apiKey}`,
41
+ },
42
+ body: JSON.stringify({
43
+ model: options.model ?? this.defaultModel,
44
+ max_tokens: options.maxTokens ?? 256,
45
+ temperature: options.temperature ?? 0,
46
+ stream: true,
47
+ ...(options.stop ? { stop: options.stop } : {}),
48
+ messages: [
49
+ { role: "system", content: options.system },
50
+ { role: "user", content: prompt },
51
+ ],
52
+ }),
53
+ });
54
+ if (!res.ok) {
55
+ const text = await res.text();
56
+ throw new Error(`${this.name} API error ${res.status}: ${text}`);
57
+ }
58
+ let result = "";
59
+ const reader = res.body?.getReader();
60
+ if (!reader)
61
+ throw new Error("No response body");
62
+ const decoder = new TextDecoder();
63
+ let buffer = "";
64
+ while (true) {
65
+ const { done, value } = await reader.read();
66
+ if (done)
67
+ break;
68
+ buffer += decoder.decode(value, { stream: true });
69
+ const lines = buffer.split("\n");
70
+ buffer = lines.pop() ?? "";
71
+ for (const line of lines) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed.startsWith("data: "))
74
+ continue;
75
+ const data = trimmed.slice(6);
76
+ if (data === "[DONE]")
77
+ break;
78
+ try {
79
+ const parsed = JSON.parse(data);
80
+ const delta = parsed.choices?.[0]?.delta?.content;
81
+ if (delta) {
82
+ result += delta;
83
+ callbacks.onToken(result.trim());
84
+ }
85
+ }
86
+ catch {
87
+ // skip malformed chunks
88
+ }
89
+ }
90
+ }
91
+ return result.trim();
92
+ }
93
+ }
@@ -0,0 +1,8 @@
1
+ // xAI/Grok provider — code-optimized models
2
+ import { OpenAICompatibleProvider } from "./openai-compat.js";
3
+ export class XaiProvider extends OpenAICompatibleProvider {
4
+ name = "xai";
5
+ baseUrl = "https://api.x.ai/v1";
6
+ defaultModel = "grok-code-fast-1";
7
+ apiKeyEnvVar = "XAI_API_KEY";
8
+ }
@@ -0,0 +1,20 @@
1
+ // Recipes data model — reusable command templates with collections and projects
2
+ /** Generate a short random ID */
3
+ export function genId() {
4
+ return Math.random().toString(36).slice(2, 10);
5
+ }
6
+ /** Substitute variables in a command template */
7
+ export function substituteVariables(command, vars) {
8
+ let result = command;
9
+ for (const [name, value] of Object.entries(vars)) {
10
+ result = result.replace(new RegExp(`\\{${name}\\}`, "g"), value);
11
+ }
12
+ return result;
13
+ }
14
+ /** Extract variable placeholders from a command */
15
+ export function extractVariables(command) {
16
+ const matches = command.match(/\{(\w+)\}/g);
17
+ if (!matches)
18
+ return [];
19
+ return [...new Set(matches.map(m => m.slice(1, -1)))];
20
+ }
@@ -0,0 +1,136 @@
1
+ // Recipes storage — global (~/.terminal/recipes.json) + per-project (.terminal/recipes.json)
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { genId, extractVariables } from "./model.js";
6
+ const GLOBAL_DIR = join(homedir(), ".terminal");
7
+ const GLOBAL_FILE = join(GLOBAL_DIR, "recipes.json");
8
+ function projectFile(projectPath) {
9
+ return join(projectPath, ".terminal", "recipes.json");
10
+ }
11
+ function loadStore(filePath) {
12
+ if (!existsSync(filePath))
13
+ return { recipes: [], collections: [] };
14
+ try {
15
+ return JSON.parse(readFileSync(filePath, "utf8"));
16
+ }
17
+ catch {
18
+ return { recipes: [], collections: [] };
19
+ }
20
+ }
21
+ function saveStore(filePath, store) {
22
+ const dir = filePath.replace(/\/[^/]+$/, "");
23
+ if (!existsSync(dir))
24
+ mkdirSync(dir, { recursive: true });
25
+ writeFileSync(filePath, JSON.stringify(store, null, 2));
26
+ }
27
+ // ── CRUD operations ──────────────────────────────────────────────────────────
28
+ /** Get all recipes (merged: global + project-scoped) */
29
+ export function listRecipes(projectPath) {
30
+ const global = loadStore(GLOBAL_FILE).recipes;
31
+ if (!projectPath)
32
+ return global;
33
+ const project = loadStore(projectFile(projectPath)).recipes;
34
+ return [...project, ...global]; // project recipes first (higher priority)
35
+ }
36
+ /** Get recipes filtered by collection */
37
+ export function listByCollection(collection, projectPath) {
38
+ return listRecipes(projectPath).filter(r => r.collection === collection);
39
+ }
40
+ /** Get a recipe by name */
41
+ export function getRecipe(name, projectPath) {
42
+ return listRecipes(projectPath).find(r => r.name === name) ?? null;
43
+ }
44
+ /** Create a recipe */
45
+ export function createRecipe(opts) {
46
+ const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
47
+ const store = loadStore(filePath);
48
+ // Prevent duplicates — update existing if same name
49
+ const existingIdx = store.recipes.findIndex(r => r.name === opts.name);
50
+ if (existingIdx >= 0) {
51
+ store.recipes[existingIdx].command = opts.command;
52
+ store.recipes[existingIdx].updatedAt = Date.now();
53
+ if (opts.description)
54
+ store.recipes[existingIdx].description = opts.description;
55
+ if (opts.tags)
56
+ store.recipes[existingIdx].tags = opts.tags;
57
+ if (opts.collection)
58
+ store.recipes[existingIdx].collection = opts.collection;
59
+ saveStore(filePath, store);
60
+ return store.recipes[existingIdx];
61
+ }
62
+ // Auto-detect variables from command if not explicitly provided
63
+ const detectedVars = extractVariables(opts.command);
64
+ const variables = opts.variables ?? detectedVars.map(name => ({ name, required: true }));
65
+ const recipe = {
66
+ id: genId(),
67
+ name: opts.name,
68
+ description: opts.description,
69
+ command: opts.command,
70
+ tags: opts.tags ?? [],
71
+ collection: opts.collection,
72
+ project: opts.project,
73
+ variables,
74
+ createdAt: Date.now(),
75
+ updatedAt: Date.now(),
76
+ };
77
+ store.recipes.push(recipe);
78
+ saveStore(filePath, store);
79
+ return recipe;
80
+ }
81
+ /** Delete a recipe by name — tries project first, then global */
82
+ export function deleteRecipe(name, projectPath) {
83
+ // Try project-scoped first
84
+ if (projectPath) {
85
+ const pFile = projectFile(projectPath);
86
+ const pStore = loadStore(pFile);
87
+ const before = pStore.recipes.length;
88
+ pStore.recipes = pStore.recipes.filter(r => r.name !== name);
89
+ if (pStore.recipes.length < before) {
90
+ saveStore(pFile, pStore);
91
+ return true;
92
+ }
93
+ }
94
+ // Fall back to global
95
+ const store = loadStore(GLOBAL_FILE);
96
+ const before = store.recipes.length;
97
+ store.recipes = store.recipes.filter(r => r.name !== name);
98
+ if (store.recipes.length < before) {
99
+ saveStore(GLOBAL_FILE, store);
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ // ── Collections ──────────────────────────────────────────────────────────────
105
+ export function listCollections(projectPath) {
106
+ const global = loadStore(GLOBAL_FILE).collections;
107
+ if (!projectPath)
108
+ return global;
109
+ const project = loadStore(projectFile(projectPath)).collections;
110
+ return [...project, ...global];
111
+ }
112
+ export function createCollection(opts) {
113
+ const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
114
+ const store = loadStore(filePath);
115
+ // Prevent duplicates — return existing if same name
116
+ const existing = store.collections.find(c => c.name === opts.name);
117
+ if (existing)
118
+ return existing;
119
+ const collection = {
120
+ id: genId(),
121
+ name: opts.name,
122
+ description: opts.description,
123
+ project: opts.project,
124
+ createdAt: Date.now(),
125
+ };
126
+ store.collections.push(collection);
127
+ saveStore(filePath, store);
128
+ return collection;
129
+ }
130
+ /** Initialize project-scoped recipes file */
131
+ export function initProject(projectPath) {
132
+ const file = projectFile(projectPath);
133
+ if (!existsSync(file)) {
134
+ saveStore(file, { recipes: [], collections: [] });
135
+ }
136
+ }
@@ -0,0 +1,68 @@
1
+ // Smart content search — structured grep/ripgrep with grouping and dedup
2
+ import { spawn } from "child_process";
3
+ import { DEFAULT_EXCLUDE_DIRS, relevanceScore } from "./filters.js";
4
+ function exec(command, cwd) {
5
+ return new Promise((resolve) => {
6
+ const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
7
+ let out = "";
8
+ proc.stdout?.on("data", (d) => { out += d.toString(); });
9
+ proc.on("close", () => resolve(out));
10
+ });
11
+ }
12
+ export async function searchContent(pattern, cwd, options = {}) {
13
+ const { fileType, maxResults = 30, contextLines = 0 } = options;
14
+ // Prefer ripgrep, fall back to grep
15
+ const excludeArgs = DEFAULT_EXCLUDE_DIRS.map(d => `--glob '!${d}'`).join(" ");
16
+ const typeArg = fileType ? `--type ${fileType}` : "";
17
+ const contextArg = contextLines > 0 ? `-C ${contextLines}` : "";
18
+ // Try rg first, fall back to grep
19
+ const rgCmd = `rg --line-number --no-heading ${contextArg} ${typeArg} ${excludeArgs} '${pattern.replace(/'/g, "'\\''")}' 2>/dev/null | head -500`;
20
+ 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`;
21
+ let raw = await exec(rgCmd, cwd);
22
+ if (!raw.trim()) {
23
+ raw = await exec(grepCmd, cwd);
24
+ }
25
+ const lines = raw.split("\n").filter(l => l.trim());
26
+ const fileMap = new Map();
27
+ let filteredCount = 0;
28
+ for (const line of lines) {
29
+ // Format: path:line:content
30
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
31
+ if (!match)
32
+ continue;
33
+ const [, path, lineNum, content] = match;
34
+ if (DEFAULT_EXCLUDE_DIRS.some(d => path.includes(`/${d}/`))) {
35
+ filteredCount++;
36
+ continue;
37
+ }
38
+ if (!fileMap.has(path))
39
+ fileMap.set(path, []);
40
+ fileMap.get(path).push({
41
+ line: parseInt(lineNum),
42
+ content: content.trim(),
43
+ });
44
+ }
45
+ // Sort files by relevance
46
+ const files = [...fileMap.entries()]
47
+ .map(([path, matches]) => ({
48
+ path,
49
+ matches: matches.slice(0, 5), // max 5 matches per file
50
+ relevance: relevanceScore(path),
51
+ }))
52
+ .sort((a, b) => b.relevance - a.relevance)
53
+ .slice(0, maxResults);
54
+ const totalMatches = [...fileMap.values()].reduce((sum, m) => sum + m.length, 0);
55
+ const filtered = filteredCount > 0 ? [{ count: filteredCount, reason: "excluded directories" }] : [];
56
+ const rawTokens = Math.ceil(raw.length / 4);
57
+ const result = { query: pattern, totalMatches, files, filtered };
58
+ const resultTokens = Math.ceil(JSON.stringify(result).length / 4);
59
+ result.tokensSaved = Math.max(0, rawTokens - resultTokens);
60
+ // Overflow guard — warn when results are truncated
61
+ if (totalMatches > maxResults * 3) {
62
+ result.overflow = {
63
+ warning: `${totalMatches} total matches across ${fileMap.size} files — showing top ${files.length}`,
64
+ suggestion: "Try a more specific pattern, add fileType filter, or use -l to list files only",
65
+ };
66
+ }
67
+ return result;
68
+ }
@@ -0,0 +1,61 @@
1
+ // Smart file search — structured, filtered, token-efficient results
2
+ import { spawn } from "child_process";
3
+ import { DEFAULT_EXCLUDE_DIRS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
4
+ function exec(command, cwd) {
5
+ return new Promise((resolve) => {
6
+ const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
7
+ let out = "";
8
+ proc.stdout?.on("data", (d) => { out += d.toString(); });
9
+ proc.stderr?.on("data", (d) => { out += d.toString(); });
10
+ proc.on("close", () => resolve(out));
11
+ });
12
+ }
13
+ export async function searchFiles(pattern, cwd, options = {}) {
14
+ const { includeNodeModules = false, maxResults = 50 } = options;
15
+ // Build find command
16
+ const excludes = includeNodeModules
17
+ ? DEFAULT_EXCLUDE_DIRS.filter(d => d !== "node_modules")
18
+ : DEFAULT_EXCLUDE_DIRS;
19
+ const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
20
+ const command = `find . -name '${pattern}' -type f ${excludeArgs} 2>/dev/null | head -${maxResults * 3}`;
21
+ const raw = await exec(command, cwd);
22
+ const allPaths = raw.split("\n").filter(l => l.trim());
23
+ // Categorize
24
+ const source = [];
25
+ const config = [];
26
+ const other = [];
27
+ const filteredCounts = {};
28
+ for (const path of allPaths) {
29
+ if (isExcludedDir(path)) {
30
+ const dir = DEFAULT_EXCLUDE_DIRS.find(d => path.includes(`/${d}/`)) ?? "other";
31
+ filteredCounts[dir] = (filteredCounts[dir] ?? 0) + 1;
32
+ continue;
33
+ }
34
+ if (isSourceFile(path)) {
35
+ source.push(path);
36
+ }
37
+ else if (path.match(/\.(json|yaml|yml|toml|ini|env)/)) {
38
+ config.push(path);
39
+ }
40
+ else {
41
+ other.push(path);
42
+ }
43
+ }
44
+ // Sort by relevance
45
+ source.sort((a, b) => relevanceScore(b) - relevanceScore(a));
46
+ // Limit results
47
+ const filtered = Object.entries(filteredCounts).map(([reason, count]) => ({ reason, count }));
48
+ // Estimate token savings
49
+ const rawTokens = Math.ceil(raw.length / 4);
50
+ const result = {
51
+ query: pattern,
52
+ total: allPaths.length,
53
+ source: source.slice(0, maxResults),
54
+ config: config.slice(0, 10),
55
+ other: other.slice(0, 10),
56
+ filtered,
57
+ };
58
+ const resultTokens = Math.ceil(JSON.stringify(result).length / 4);
59
+ result.tokensSaved = Math.max(0, rawTokens - resultTokens);
60
+ return result;
61
+ }