@hasna/terminal 3.5.0 → 3.7.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/dist/mcp/server.js +136 -2
- package/dist/recipes/storage.js +20 -3
- package/package.json +1 -1
- package/src/mcp/server.ts +157 -2
- package/src/recipes/storage.ts +25 -3
package/dist/mcp/server.js
CHANGED
|
@@ -153,7 +153,7 @@ export function createServer() {
|
|
|
153
153
|
return { content: [{ type: "text", text: output }] };
|
|
154
154
|
});
|
|
155
155
|
// ── execute_smart: AI-powered output processing ────────────────────────────
|
|
156
|
-
server.tool("execute_smart", "Run a command and get AI-summarized output
|
|
156
|
+
server.tool("execute_smart", "Run a command and get AI-summarized output (80-95% token savings). Use this for: test runs, builds, git operations, process management, system info. Do NOT use for file read/write — use your native Read/Write/Edit tools instead (they're faster, no shell overhead).", {
|
|
157
157
|
command: z.string().describe("Shell command to execute"),
|
|
158
158
|
cwd: z.string().optional().describe("Working directory"),
|
|
159
159
|
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
@@ -478,7 +478,7 @@ export function createServer() {
|
|
|
478
478
|
};
|
|
479
479
|
});
|
|
480
480
|
// ── read_file: cached file reading ─────────────────────────────────────────
|
|
481
|
-
server.tool("read_file", "Read a file with
|
|
481
|
+
server.tool("read_file", "Read a file with summarize=true for AI outline (~90% fewer tokens). For full file reads without summarization, prefer your native Read tool (faster, no MCP overhead). Use this when you want cached reads or AI summaries.", {
|
|
482
482
|
path: z.string().describe("File path"),
|
|
483
483
|
offset: z.number().optional().describe("Start line (0-indexed)"),
|
|
484
484
|
limit: z.number().optional().describe("Max lines to return"),
|
|
@@ -620,6 +620,140 @@ Match by function name, class name, method name (including ClassName.method), in
|
|
|
620
620
|
logCall("read_symbol", { command: `${filePath}:${name}`, outputTokens: estimateTokens(result.content), tokensSaved: Math.max(0, estimateTokens(result.content) - estimateTokens(JSON.stringify(parsed))), durationMs: Date.now() - start, aiProcessed: true });
|
|
621
621
|
return { content: [{ type: "text", text: JSON.stringify(parsed) }] };
|
|
622
622
|
});
|
|
623
|
+
// ── Intent-level tools — agents express WHAT, we handle HOW ───────────────
|
|
624
|
+
server.tool("commit", "Commit and optionally push. Agent says what to commit, we handle git add/commit/push. Saves ~400 tokens vs raw git commands.", {
|
|
625
|
+
message: z.string().describe("Commit message"),
|
|
626
|
+
files: z.array(z.string()).optional().describe("Files to stage (default: all changed)"),
|
|
627
|
+
push: z.boolean().optional().describe("Push after commit (default: false)"),
|
|
628
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
629
|
+
}, async ({ message, files, push, cwd }) => {
|
|
630
|
+
const start = Date.now();
|
|
631
|
+
const workDir = cwd ?? process.cwd();
|
|
632
|
+
const addCmd = files && files.length > 0 ? `git add ${files.map(f => `"${f}"`).join(" ")}` : "git add -A";
|
|
633
|
+
const commitCmd = `${addCmd} && git commit -m ${JSON.stringify(message)}`;
|
|
634
|
+
const fullCmd = push ? `${commitCmd} && git push` : commitCmd;
|
|
635
|
+
const result = await exec(fullCmd, workDir, 30000);
|
|
636
|
+
const output = (result.stdout + result.stderr).trim();
|
|
637
|
+
logCall("commit", { command: `commit: ${message.slice(0, 80)}`, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
638
|
+
invalidateBootCache();
|
|
639
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
640
|
+
exitCode: result.exitCode,
|
|
641
|
+
output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
|
|
642
|
+
pushed: push ?? false,
|
|
643
|
+
}) }] };
|
|
644
|
+
});
|
|
645
|
+
server.tool("run", "Run a project task by intent — test, build, lint, dev, typecheck, format. Auto-detects toolchain (bun/npm/pnpm/yarn/cargo/go/make). Saves ~100 tokens vs raw commands.", {
|
|
646
|
+
task: z.enum(["test", "build", "lint", "dev", "start", "typecheck", "format", "check"]).describe("What to run"),
|
|
647
|
+
args: z.string().optional().describe("Extra arguments (e.g., '--watch', 'src/foo.test.ts')"),
|
|
648
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
649
|
+
}, async ({ task, args, cwd }) => {
|
|
650
|
+
const start = Date.now();
|
|
651
|
+
const workDir = cwd ?? process.cwd();
|
|
652
|
+
// Detect toolchain from project files
|
|
653
|
+
const { existsSync } = await import("fs");
|
|
654
|
+
const { join } = await import("path");
|
|
655
|
+
let runner = "npm run";
|
|
656
|
+
if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock")))
|
|
657
|
+
runner = "bun run";
|
|
658
|
+
else if (existsSync(join(workDir, "pnpm-lock.yaml")))
|
|
659
|
+
runner = "pnpm run";
|
|
660
|
+
else if (existsSync(join(workDir, "yarn.lock")))
|
|
661
|
+
runner = "yarn";
|
|
662
|
+
else if (existsSync(join(workDir, "Cargo.toml")))
|
|
663
|
+
runner = "cargo";
|
|
664
|
+
else if (existsSync(join(workDir, "go.mod")))
|
|
665
|
+
runner = "go";
|
|
666
|
+
else if (existsSync(join(workDir, "Makefile")))
|
|
667
|
+
runner = "make";
|
|
668
|
+
// Map intent to command
|
|
669
|
+
let cmd;
|
|
670
|
+
if (runner === "cargo") {
|
|
671
|
+
cmd = `cargo ${task}${args ? ` ${args}` : ""}`;
|
|
672
|
+
}
|
|
673
|
+
else if (runner === "go") {
|
|
674
|
+
const goMap = { test: "go test ./...", build: "go build ./...", lint: "golangci-lint run", format: "gofmt -w .", check: "go vet ./..." };
|
|
675
|
+
cmd = goMap[task] ?? `go ${task}`;
|
|
676
|
+
}
|
|
677
|
+
else if (runner === "make") {
|
|
678
|
+
cmd = `make ${task}${args ? ` ${args}` : ""}`;
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
// JS/TS ecosystem
|
|
682
|
+
const jsMap = { test: "test", build: "build", lint: "lint", dev: "dev", start: "start", typecheck: "typecheck", format: "format", check: "check" };
|
|
683
|
+
cmd = `${runner} ${jsMap[task] ?? task}${args ? ` ${args}` : ""}`;
|
|
684
|
+
}
|
|
685
|
+
const result = await exec(cmd, workDir, 120000);
|
|
686
|
+
const output = (result.stdout + result.stderr).trim();
|
|
687
|
+
const processed = await processOutput(cmd, output);
|
|
688
|
+
logCall("run", { command: `${task}${args ? ` ${args}` : ""}`, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
|
|
689
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
690
|
+
exitCode: result.exitCode,
|
|
691
|
+
task,
|
|
692
|
+
runner,
|
|
693
|
+
summary: processed.summary,
|
|
694
|
+
tokensSaved: processed.tokensSaved,
|
|
695
|
+
}) }] };
|
|
696
|
+
});
|
|
697
|
+
server.tool("edit", "Find and replace in a file. For simple edits, prefer your native Edit tool (faster). Use this for batch replacements (all=true) or when you don't have a native Edit tool available.", {
|
|
698
|
+
file: z.string().describe("File path"),
|
|
699
|
+
find: z.string().describe("Text to find (exact match)"),
|
|
700
|
+
replace: z.string().describe("Replacement text"),
|
|
701
|
+
all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
|
|
702
|
+
}, async ({ file, find, replace, all }) => {
|
|
703
|
+
const start = Date.now();
|
|
704
|
+
const { readFileSync, writeFileSync } = await import("fs");
|
|
705
|
+
try {
|
|
706
|
+
let content = readFileSync(file, "utf8");
|
|
707
|
+
const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
|
|
708
|
+
if (count === 0) {
|
|
709
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Text not found", file }) }] };
|
|
710
|
+
}
|
|
711
|
+
if (all) {
|
|
712
|
+
content = content.split(find).join(replace);
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
content = content.replace(find, replace);
|
|
716
|
+
}
|
|
717
|
+
writeFileSync(file, content);
|
|
718
|
+
logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
|
|
719
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, file, replacements: all ? count : 1 }) }] };
|
|
720
|
+
}
|
|
721
|
+
catch (e) {
|
|
722
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
server.tool("lookup", "Search for specific items in a file by name or pattern. Agent says what to find, not how to grep. Saves ~300 tokens vs constructing grep pipelines.", {
|
|
726
|
+
file: z.string().describe("File path to search in"),
|
|
727
|
+
items: z.array(z.string()).describe("Names or patterns to look up"),
|
|
728
|
+
context: z.number().optional().describe("Lines of context around each match (default: 3)"),
|
|
729
|
+
}, async ({ file, items, context }) => {
|
|
730
|
+
const start = Date.now();
|
|
731
|
+
const { readFileSync } = await import("fs");
|
|
732
|
+
try {
|
|
733
|
+
const content = readFileSync(file, "utf8");
|
|
734
|
+
const lines = content.split("\n");
|
|
735
|
+
const ctx = context ?? 3;
|
|
736
|
+
const results = {};
|
|
737
|
+
for (const item of items) {
|
|
738
|
+
results[item] = [];
|
|
739
|
+
const pattern = new RegExp(item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
740
|
+
for (let i = 0; i < lines.length; i++) {
|
|
741
|
+
if (pattern.test(lines[i])) {
|
|
742
|
+
results[item].push({
|
|
743
|
+
line: i + 1,
|
|
744
|
+
text: lines[i].trim(),
|
|
745
|
+
context: lines.slice(Math.max(0, i - ctx), i + ctx + 1).map(l => l.trimEnd()),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
logCall("lookup", { command: `lookup ${file} [${items.join(",")}]`, durationMs: Date.now() - start });
|
|
751
|
+
return { content: [{ type: "text", text: JSON.stringify(results) }] };
|
|
752
|
+
}
|
|
753
|
+
catch (e) {
|
|
754
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
|
|
755
|
+
}
|
|
756
|
+
});
|
|
623
757
|
return server;
|
|
624
758
|
}
|
|
625
759
|
// ── main: start MCP server via stdio ─────────────────────────────────────────
|
package/dist/recipes/storage.js
CHANGED
|
@@ -24,14 +24,31 @@ function saveStore(filePath, store) {
|
|
|
24
24
|
mkdirSync(dir, { recursive: true });
|
|
25
25
|
writeFileSync(filePath, JSON.stringify(store, null, 2));
|
|
26
26
|
}
|
|
27
|
+
// ── Built-in system recipes (always available, zero config) ─────────────────
|
|
28
|
+
const SYSTEM_RECIPES = [
|
|
29
|
+
// Git workflows
|
|
30
|
+
{ id: "sys-commit-push", name: "commit-push", description: "Stage all, commit, push to origin", command: "git add -A && git commit -m \"{message}\" && git push", tags: ["git"], collection: "git", variables: [{ name: "message", required: true }], createdAt: 0, updatedAt: 0 },
|
|
31
|
+
{ id: "sys-pr", name: "create-pr", description: "Create GitHub PR from current branch", command: "gh pr create --title \"{title}\" --body \"{body}\"", tags: ["git", "github"], collection: "git", variables: [{ name: "title", required: true }, { name: "body", default: "" }], createdAt: 0, updatedAt: 0 },
|
|
32
|
+
{ id: "sys-stash", name: "stash-switch", description: "Stash changes, switch branch, pop", command: "git stash && git checkout {branch} && git stash pop", tags: ["git"], collection: "git", variables: [{ name: "branch", required: true }], createdAt: 0, updatedAt: 0 },
|
|
33
|
+
// Quality checks
|
|
34
|
+
{ id: "sys-todos", name: "find-todos", description: "Find all TODO/FIXME/HACK in source code", command: "grep -rn 'TODO\\|FIXME\\|HACK\\|XXX' {path} --include='*.ts' --include='*.tsx' --include='*.js' --include='*.py' --include='*.go' --include='*.rs' --include='*.java' --include='*.rb'", tags: ["quality"], collection: "quality", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
35
|
+
{ id: "sys-deadcode", name: "find-unused-exports", description: "Find exported symbols that may be unused", command: "grep -rn 'export ' {path} --include='*.ts' | sed 's/.*export //' | sed 's/[(<:].*//' | sort -u", tags: ["quality"], collection: "quality", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
36
|
+
{ id: "sys-security", name: "security-scan", description: "Scan for common security anti-patterns", command: "grep -rn 'eval\\|exec\\|spawn\\|innerHTML\\|dangerouslySetInnerHTML\\|password.*=.*[\"'\\']' {path} --include='*.ts' --include='*.js' --include='*.py'", tags: ["security"], collection: "quality", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
37
|
+
// Project info
|
|
38
|
+
{ id: "sys-deps", name: "list-deps", description: "Show project dependencies", command: "cat package.json 2>/dev/null | grep -A 100 '\"dependencies\"' | head -30 || cat requirements.txt 2>/dev/null || cat Cargo.toml 2>/dev/null | grep -A 50 'dependencies'", tags: ["deps"], collection: "project", variables: [], createdAt: 0, updatedAt: 0 },
|
|
39
|
+
{ id: "sys-size", name: "project-size", description: "Count lines of code by file type", command: "find {path} -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/.git/*' -type f | xargs wc -l 2>/dev/null | sort -rn | head -20", tags: ["stats"], collection: "project", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
40
|
+
// Process management
|
|
41
|
+
{ id: "sys-port", name: "kill-port", description: "Kill whatever is running on a port", command: "lsof -ti :{port} | xargs kill -9 2>/dev/null || echo 'Port {port} is free'", tags: ["process"], collection: "system", variables: [{ name: "port", required: true }], createdAt: 0, updatedAt: 0 },
|
|
42
|
+
{ id: "sys-disk", name: "disk-usage", description: "Show disk usage of current directory", command: "du -sh {path}/* 2>/dev/null | sort -rh | head -15", tags: ["system"], collection: "system", variables: [{ name: "path", default: "." }], createdAt: 0, updatedAt: 0 },
|
|
43
|
+
];
|
|
27
44
|
// ── CRUD operations ──────────────────────────────────────────────────────────
|
|
28
|
-
/** Get all recipes (merged: global + project-scoped) */
|
|
45
|
+
/** Get all recipes (merged: system + global + project-scoped) */
|
|
29
46
|
export function listRecipes(projectPath) {
|
|
30
47
|
const global = loadStore(GLOBAL_FILE).recipes;
|
|
31
48
|
if (!projectPath)
|
|
32
|
-
return global;
|
|
49
|
+
return [...global, ...SYSTEM_RECIPES];
|
|
33
50
|
const project = loadStore(projectFile(projectPath)).recipes;
|
|
34
|
-
return [...project, ...global]; // project
|
|
51
|
+
return [...project, ...global, ...SYSTEM_RECIPES]; // project > global > system priority
|
|
35
52
|
}
|
|
36
53
|
/** Get recipes filtered by collection */
|
|
37
54
|
export function listByCollection(collection, projectPath) {
|
package/package.json
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -181,7 +181,7 @@ export function createServer(): McpServer {
|
|
|
181
181
|
|
|
182
182
|
server.tool(
|
|
183
183
|
"execute_smart",
|
|
184
|
-
"Run a command and get AI-summarized output
|
|
184
|
+
"Run a command and get AI-summarized output (80-95% token savings). Use this for: test runs, builds, git operations, process management, system info. Do NOT use for file read/write — use your native Read/Write/Edit tools instead (they're faster, no shell overhead).",
|
|
185
185
|
{
|
|
186
186
|
command: z.string().describe("Shell command to execute"),
|
|
187
187
|
cwd: z.string().optional().describe("Working directory"),
|
|
@@ -678,7 +678,7 @@ export function createServer(): McpServer {
|
|
|
678
678
|
|
|
679
679
|
server.tool(
|
|
680
680
|
"read_file",
|
|
681
|
-
"Read a file with
|
|
681
|
+
"Read a file with summarize=true for AI outline (~90% fewer tokens). For full file reads without summarization, prefer your native Read tool (faster, no MCP overhead). Use this when you want cached reads or AI summaries.",
|
|
682
682
|
{
|
|
683
683
|
path: z.string().describe("File path"),
|
|
684
684
|
offset: z.number().optional().describe("Start line (0-indexed)"),
|
|
@@ -858,6 +858,161 @@ Match by function name, class name, method name (including ClassName.method), in
|
|
|
858
858
|
}
|
|
859
859
|
);
|
|
860
860
|
|
|
861
|
+
// ── Intent-level tools — agents express WHAT, we handle HOW ───────────────
|
|
862
|
+
|
|
863
|
+
server.tool(
|
|
864
|
+
"commit",
|
|
865
|
+
"Commit and optionally push. Agent says what to commit, we handle git add/commit/push. Saves ~400 tokens vs raw git commands.",
|
|
866
|
+
{
|
|
867
|
+
message: z.string().describe("Commit message"),
|
|
868
|
+
files: z.array(z.string()).optional().describe("Files to stage (default: all changed)"),
|
|
869
|
+
push: z.boolean().optional().describe("Push after commit (default: false)"),
|
|
870
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
871
|
+
},
|
|
872
|
+
async ({ message, files, push, cwd }) => {
|
|
873
|
+
const start = Date.now();
|
|
874
|
+
const workDir = cwd ?? process.cwd();
|
|
875
|
+
const addCmd = files && files.length > 0 ? `git add ${files.map(f => `"${f}"`).join(" ")}` : "git add -A";
|
|
876
|
+
const commitCmd = `${addCmd} && git commit -m ${JSON.stringify(message)}`;
|
|
877
|
+
const fullCmd = push ? `${commitCmd} && git push` : commitCmd;
|
|
878
|
+
|
|
879
|
+
const result = await exec(fullCmd, workDir, 30000);
|
|
880
|
+
const output = (result.stdout + result.stderr).trim();
|
|
881
|
+
logCall("commit", { command: `commit: ${message.slice(0, 80)}`, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
882
|
+
invalidateBootCache();
|
|
883
|
+
|
|
884
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
885
|
+
exitCode: result.exitCode,
|
|
886
|
+
output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
|
|
887
|
+
pushed: push ?? false,
|
|
888
|
+
}) }] };
|
|
889
|
+
}
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
server.tool(
|
|
893
|
+
"run",
|
|
894
|
+
"Run a project task by intent — test, build, lint, dev, typecheck, format. Auto-detects toolchain (bun/npm/pnpm/yarn/cargo/go/make). Saves ~100 tokens vs raw commands.",
|
|
895
|
+
{
|
|
896
|
+
task: z.enum(["test", "build", "lint", "dev", "start", "typecheck", "format", "check"]).describe("What to run"),
|
|
897
|
+
args: z.string().optional().describe("Extra arguments (e.g., '--watch', 'src/foo.test.ts')"),
|
|
898
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
899
|
+
},
|
|
900
|
+
async ({ task, args, cwd }) => {
|
|
901
|
+
const start = Date.now();
|
|
902
|
+
const workDir = cwd ?? process.cwd();
|
|
903
|
+
|
|
904
|
+
// Detect toolchain from project files
|
|
905
|
+
const { existsSync } = await import("fs");
|
|
906
|
+
const { join } = await import("path");
|
|
907
|
+
let runner = "npm run";
|
|
908
|
+
if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) runner = "bun run";
|
|
909
|
+
else if (existsSync(join(workDir, "pnpm-lock.yaml"))) runner = "pnpm run";
|
|
910
|
+
else if (existsSync(join(workDir, "yarn.lock"))) runner = "yarn";
|
|
911
|
+
else if (existsSync(join(workDir, "Cargo.toml"))) runner = "cargo";
|
|
912
|
+
else if (existsSync(join(workDir, "go.mod"))) runner = "go";
|
|
913
|
+
else if (existsSync(join(workDir, "Makefile"))) runner = "make";
|
|
914
|
+
|
|
915
|
+
// Map intent to command
|
|
916
|
+
let cmd: string;
|
|
917
|
+
if (runner === "cargo") {
|
|
918
|
+
cmd = `cargo ${task}${args ? ` ${args}` : ""}`;
|
|
919
|
+
} else if (runner === "go") {
|
|
920
|
+
const goMap: Record<string, string> = { test: "go test ./...", build: "go build ./...", lint: "golangci-lint run", format: "gofmt -w .", check: "go vet ./..." };
|
|
921
|
+
cmd = goMap[task] ?? `go ${task}`;
|
|
922
|
+
} else if (runner === "make") {
|
|
923
|
+
cmd = `make ${task}${args ? ` ${args}` : ""}`;
|
|
924
|
+
} else {
|
|
925
|
+
// JS/TS ecosystem
|
|
926
|
+
const jsMap: Record<string, string> = { test: "test", build: "build", lint: "lint", dev: "dev", start: "start", typecheck: "typecheck", format: "format", check: "check" };
|
|
927
|
+
cmd = `${runner} ${jsMap[task] ?? task}${args ? ` ${args}` : ""}`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const result = await exec(cmd, workDir, 120000);
|
|
931
|
+
const output = (result.stdout + result.stderr).trim();
|
|
932
|
+
const processed = await processOutput(cmd, output);
|
|
933
|
+
logCall("run", { command: `${task}${args ? ` ${args}` : ""}`, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
|
|
934
|
+
|
|
935
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
936
|
+
exitCode: result.exitCode,
|
|
937
|
+
task,
|
|
938
|
+
runner,
|
|
939
|
+
summary: processed.summary,
|
|
940
|
+
tokensSaved: processed.tokensSaved,
|
|
941
|
+
}) }] };
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
server.tool(
|
|
946
|
+
"edit",
|
|
947
|
+
"Find and replace in a file. For simple edits, prefer your native Edit tool (faster). Use this for batch replacements (all=true) or when you don't have a native Edit tool available.",
|
|
948
|
+
{
|
|
949
|
+
file: z.string().describe("File path"),
|
|
950
|
+
find: z.string().describe("Text to find (exact match)"),
|
|
951
|
+
replace: z.string().describe("Replacement text"),
|
|
952
|
+
all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
|
|
953
|
+
},
|
|
954
|
+
async ({ file, find, replace, all }) => {
|
|
955
|
+
const start = Date.now();
|
|
956
|
+
const { readFileSync, writeFileSync } = await import("fs");
|
|
957
|
+
try {
|
|
958
|
+
let content = readFileSync(file, "utf8");
|
|
959
|
+
const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
|
|
960
|
+
if (count === 0) {
|
|
961
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Text not found", file }) }] };
|
|
962
|
+
}
|
|
963
|
+
if (all) {
|
|
964
|
+
content = content.split(find).join(replace);
|
|
965
|
+
} else {
|
|
966
|
+
content = content.replace(find, replace);
|
|
967
|
+
}
|
|
968
|
+
writeFileSync(file, content);
|
|
969
|
+
logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
|
|
970
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, file, replacements: all ? count : 1 }) }] };
|
|
971
|
+
} catch (e: any) {
|
|
972
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message }) }] };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
server.tool(
|
|
978
|
+
"lookup",
|
|
979
|
+
"Search for specific items in a file by name or pattern. Agent says what to find, not how to grep. Saves ~300 tokens vs constructing grep pipelines.",
|
|
980
|
+
{
|
|
981
|
+
file: z.string().describe("File path to search in"),
|
|
982
|
+
items: z.array(z.string()).describe("Names or patterns to look up"),
|
|
983
|
+
context: z.number().optional().describe("Lines of context around each match (default: 3)"),
|
|
984
|
+
},
|
|
985
|
+
async ({ file, items, context }) => {
|
|
986
|
+
const start = Date.now();
|
|
987
|
+
const { readFileSync } = await import("fs");
|
|
988
|
+
try {
|
|
989
|
+
const content = readFileSync(file, "utf8");
|
|
990
|
+
const lines = content.split("\n");
|
|
991
|
+
const ctx = context ?? 3;
|
|
992
|
+
const results: Record<string, { line: number; text: string; context: string[] }[]> = {};
|
|
993
|
+
|
|
994
|
+
for (const item of items) {
|
|
995
|
+
results[item] = [];
|
|
996
|
+
const pattern = new RegExp(item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
997
|
+
for (let i = 0; i < lines.length; i++) {
|
|
998
|
+
if (pattern.test(lines[i])) {
|
|
999
|
+
results[item].push({
|
|
1000
|
+
line: i + 1,
|
|
1001
|
+
text: lines[i].trim(),
|
|
1002
|
+
context: lines.slice(Math.max(0, i - ctx), i + ctx + 1).map(l => l.trimEnd()),
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
logCall("lookup", { command: `lookup ${file} [${items.join(",")}]`, durationMs: Date.now() - start });
|
|
1009
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(results) }] };
|
|
1010
|
+
} catch (e: any) {
|
|
1011
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message }) }] };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
);
|
|
1015
|
+
|
|
861
1016
|
return server;
|
|
862
1017
|
}
|
|
863
1018
|
|
package/src/recipes/storage.ts
CHANGED
|
@@ -28,14 +28,36 @@ function saveStore(filePath: string, store: RecipeStore): void {
|
|
|
28
28
|
writeFileSync(filePath, JSON.stringify(store, null, 2));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// ── Built-in system recipes (always available, zero config) ─────────────────
|
|
32
|
+
|
|
33
|
+
const SYSTEM_RECIPES: Recipe[] = [
|
|
34
|
+
// Git workflows
|
|
35
|
+
{ id: "sys-commit-push", name: "commit-push", description: "Stage all, commit, push to origin", command: "git add -A && git commit -m \"{message}\" && git push", tags: ["git"], collection: "git", variables: [{ name: "message", required: true }], createdAt: 0, updatedAt: 0 },
|
|
36
|
+
{ id: "sys-pr", name: "create-pr", description: "Create GitHub PR from current branch", command: "gh pr create --title \"{title}\" --body \"{body}\"", tags: ["git", "github"], collection: "git", variables: [{ name: "title", required: true }, { name: "body", default: "" }], createdAt: 0, updatedAt: 0 },
|
|
37
|
+
{ id: "sys-stash", name: "stash-switch", description: "Stash changes, switch branch, pop", command: "git stash && git checkout {branch} && git stash pop", tags: ["git"], collection: "git", variables: [{ name: "branch", required: true }], createdAt: 0, updatedAt: 0 },
|
|
38
|
+
|
|
39
|
+
// Quality checks
|
|
40
|
+
{ id: "sys-todos", name: "find-todos", description: "Find all TODO/FIXME/HACK in source code", command: "grep -rn 'TODO\\|FIXME\\|HACK\\|XXX' {path} --include='*.ts' --include='*.tsx' --include='*.js' --include='*.py' --include='*.go' --include='*.rs' --include='*.java' --include='*.rb'", tags: ["quality"], collection: "quality", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
41
|
+
{ id: "sys-deadcode", name: "find-unused-exports", description: "Find exported symbols that may be unused", command: "grep -rn 'export ' {path} --include='*.ts' | sed 's/.*export //' | sed 's/[(<:].*//' | sort -u", tags: ["quality"], collection: "quality", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
42
|
+
{ id: "sys-security", name: "security-scan", description: "Scan for common security anti-patterns", command: "grep -rn 'eval\\|exec\\|spawn\\|innerHTML\\|dangerouslySetInnerHTML\\|password.*=.*[\"'\\']' {path} --include='*.ts' --include='*.js' --include='*.py'", tags: ["security"], collection: "quality", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
43
|
+
|
|
44
|
+
// Project info
|
|
45
|
+
{ id: "sys-deps", name: "list-deps", description: "Show project dependencies", command: "cat package.json 2>/dev/null | grep -A 100 '\"dependencies\"' | head -30 || cat requirements.txt 2>/dev/null || cat Cargo.toml 2>/dev/null | grep -A 50 'dependencies'", tags: ["deps"], collection: "project", variables: [], createdAt: 0, updatedAt: 0 },
|
|
46
|
+
{ id: "sys-size", name: "project-size", description: "Count lines of code by file type", command: "find {path} -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/.git/*' -type f | xargs wc -l 2>/dev/null | sort -rn | head -20", tags: ["stats"], collection: "project", variables: [{ name: "path", default: "src/" }], createdAt: 0, updatedAt: 0 },
|
|
47
|
+
|
|
48
|
+
// Process management
|
|
49
|
+
{ id: "sys-port", name: "kill-port", description: "Kill whatever is running on a port", command: "lsof -ti :{port} | xargs kill -9 2>/dev/null || echo 'Port {port} is free'", tags: ["process"], collection: "system", variables: [{ name: "port", required: true }], createdAt: 0, updatedAt: 0 },
|
|
50
|
+
{ id: "sys-disk", name: "disk-usage", description: "Show disk usage of current directory", command: "du -sh {path}/* 2>/dev/null | sort -rh | head -15", tags: ["system"], collection: "system", variables: [{ name: "path", default: "." }], createdAt: 0, updatedAt: 0 },
|
|
51
|
+
];
|
|
52
|
+
|
|
31
53
|
// ── CRUD operations ──────────────────────────────────────────────────────────
|
|
32
54
|
|
|
33
|
-
/** Get all recipes (merged: global + project-scoped) */
|
|
55
|
+
/** Get all recipes (merged: system + global + project-scoped) */
|
|
34
56
|
export function listRecipes(projectPath?: string): Recipe[] {
|
|
35
57
|
const global = loadStore(GLOBAL_FILE).recipes;
|
|
36
|
-
if (!projectPath) return global;
|
|
58
|
+
if (!projectPath) return [...global, ...SYSTEM_RECIPES];
|
|
37
59
|
const project = loadStore(projectFile(projectPath)).recipes;
|
|
38
|
-
return [...project, ...global]; // project
|
|
60
|
+
return [...project, ...global, ...SYSTEM_RECIPES]; // project > global > system priority
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
/** Get recipes filtered by collection */
|