agent-sh 0.4.0 → 0.6.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/README.md +37 -115
- package/dist/agent/agent-loop.d.ts +86 -0
- package/dist/agent/agent-loop.js +704 -0
- package/dist/agent/conversation-state.d.ts +27 -0
- package/dist/agent/conversation-state.js +59 -0
- package/dist/agent/index.d.ts +11 -0
- package/dist/agent/index.js +9 -0
- package/dist/agent/skills.d.ts +25 -0
- package/dist/agent/skills.js +186 -0
- package/dist/agent/subagent.d.ts +37 -0
- package/dist/agent/subagent.js +119 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +103 -0
- package/dist/agent/tool-registry.d.ts +15 -0
- package/dist/agent/tool-registry.js +30 -0
- package/dist/agent/tools/bash.d.ts +7 -0
- package/dist/agent/tools/bash.js +71 -0
- package/dist/agent/tools/display.d.ts +13 -0
- package/dist/agent/tools/display.js +70 -0
- package/dist/agent/tools/edit-file.d.ts +2 -0
- package/dist/agent/tools/edit-file.js +148 -0
- package/dist/agent/tools/glob.d.ts +2 -0
- package/dist/agent/tools/glob.js +87 -0
- package/dist/agent/tools/grep.d.ts +2 -0
- package/dist/agent/tools/grep.js +168 -0
- package/dist/agent/tools/list-skills.d.ts +2 -0
- package/dist/agent/tools/list-skills.js +28 -0
- package/dist/agent/tools/ls.d.ts +2 -0
- package/dist/agent/tools/ls.js +72 -0
- package/dist/agent/tools/read-file.d.ts +10 -0
- package/dist/agent/tools/read-file.js +101 -0
- package/dist/agent/tools/user-shell.d.ts +13 -0
- package/dist/agent/tools/user-shell.js +84 -0
- package/dist/agent/tools/write-file.d.ts +2 -0
- package/dist/agent/tools/write-file.js +82 -0
- package/dist/agent/types.d.ts +78 -0
- package/dist/agent/types.js +1 -0
- package/dist/core.d.ts +22 -14
- package/dist/core.js +256 -36
- package/dist/event-bus.d.ts +98 -17
- package/dist/event-bus.js +10 -1
- package/dist/extension-loader.d.ts +1 -1
- package/dist/extension-loader.js +10 -1
- package/dist/extensions/command-suggest.d.ts +10 -0
- package/dist/extensions/command-suggest.js +41 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +161 -64
- package/dist/extensions/tui-renderer.js +426 -126
- package/dist/index.js +110 -129
- package/dist/input-handler.js +78 -9
- package/dist/output-parser.d.ts +7 -0
- package/dist/output-parser.js +27 -0
- package/dist/settings.d.ts +53 -2
- package/dist/settings.js +46 -3
- package/dist/shell.js +35 -28
- package/dist/types.d.ts +33 -6
- package/dist/utils/box-frame.d.ts +3 -1
- package/dist/utils/box-frame.js +12 -5
- package/dist/utils/diff.js +10 -0
- package/dist/utils/llm-client.d.ts +45 -0
- package/dist/utils/llm-client.js +60 -0
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +25 -3
- package/dist/utils/stream-transform.js +20 -47
- package/dist/utils/tool-display.d.ts +4 -0
- package/dist/utils/tool-display.js +35 -8
- package/examples/extensions/claude-code-bridge/README.md +35 -0
- package/examples/extensions/claude-code-bridge/index.ts +194 -0
- package/examples/extensions/claude-code-bridge/package.json +11 -0
- package/examples/extensions/openrouter.ts +87 -0
- package/examples/extensions/pi-bridge/README.md +35 -0
- package/examples/extensions/pi-bridge/index.ts +263 -0
- package/examples/extensions/pi-bridge/package.json +13 -0
- package/examples/extensions/secret-guard.ts +100 -0
- package/examples/extensions/subagents.ts +87 -0
- package/package.json +3 -5
- package/dist/acp-client.d.ts +0 -105
- package/dist/acp-client.js +0 -684
- package/dist/extensions/shell-exec.d.ts +0 -24
- package/dist/extensions/shell-exec.js +0 -188
- package/dist/mcp-server.d.ts +0 -13
- package/dist/mcp-server.js +0 -234
- package/examples/pi-agent-sh.ts +0 -166
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { executeCommand } from "../../executor.js";
|
|
2
|
+
export function createBashTool(opts) {
|
|
3
|
+
return {
|
|
4
|
+
name: "bash",
|
|
5
|
+
description: "Execute a bash command in an isolated subprocess. Output is captured and returned. " +
|
|
6
|
+
"Does not affect the user's shell state (use user_shell for cd, export, source). " +
|
|
7
|
+
"Do NOT use bash for file searching — use grep/glob instead. " +
|
|
8
|
+
"Do NOT use bash for reading files — use read_file instead. " +
|
|
9
|
+
"Provide a description parameter to explain what the command does.",
|
|
10
|
+
input_schema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
command: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "The bash command to execute",
|
|
16
|
+
},
|
|
17
|
+
timeout: {
|
|
18
|
+
type: "number",
|
|
19
|
+
description: "Timeout in seconds (default: 60)",
|
|
20
|
+
},
|
|
21
|
+
description: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Short description of what this command does (e.g., 'Install dependencies', 'Run test suite')",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ["command"],
|
|
27
|
+
},
|
|
28
|
+
showOutput: true,
|
|
29
|
+
modifiesFiles: true,
|
|
30
|
+
requiresPermission: true,
|
|
31
|
+
getDisplayInfo: (args) => ({
|
|
32
|
+
kind: "execute",
|
|
33
|
+
icon: "▶",
|
|
34
|
+
locations: [],
|
|
35
|
+
}),
|
|
36
|
+
async execute(args, onChunk) {
|
|
37
|
+
const command = args.command;
|
|
38
|
+
const timeout = (args.timeout ?? 60) * 1000;
|
|
39
|
+
// Let extensions intercept before execution
|
|
40
|
+
const intercepted = opts.bus.emitPipe("agent:terminal-intercept", {
|
|
41
|
+
command,
|
|
42
|
+
cwd: opts.getCwd(),
|
|
43
|
+
intercepted: false,
|
|
44
|
+
output: "",
|
|
45
|
+
});
|
|
46
|
+
if (intercepted.intercepted) {
|
|
47
|
+
return {
|
|
48
|
+
content: intercepted.output,
|
|
49
|
+
exitCode: 0,
|
|
50
|
+
isError: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const { session, done } = executeCommand({
|
|
54
|
+
command,
|
|
55
|
+
cwd: opts.getCwd(),
|
|
56
|
+
env: opts.getEnv(),
|
|
57
|
+
timeout,
|
|
58
|
+
onOutput: onChunk,
|
|
59
|
+
});
|
|
60
|
+
await done;
|
|
61
|
+
const content = session.truncated
|
|
62
|
+
? `[output truncated, showing last portion]\n${session.output}`
|
|
63
|
+
: session.output;
|
|
64
|
+
return {
|
|
65
|
+
content: content || "(no output)",
|
|
66
|
+
exitCode: session.exitCode,
|
|
67
|
+
isError: session.exitCode !== 0,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EventBus } from "../../event-bus.js";
|
|
2
|
+
import type { ToolDefinition } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* display — shows command output to the user in their live terminal.
|
|
5
|
+
*
|
|
6
|
+
* Unlike bash (scratchpad), the user sees the output directly in their shell.
|
|
7
|
+
* Unlike user_shell, this is for read-only display — no lasting side effects.
|
|
8
|
+
* The agent does NOT receive the output back.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createDisplayTool(opts: {
|
|
11
|
+
getCwd: () => string;
|
|
12
|
+
bus: EventBus;
|
|
13
|
+
}): ToolDefinition;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* display — shows command output to the user in their live terminal.
|
|
3
|
+
*
|
|
4
|
+
* Unlike bash (scratchpad), the user sees the output directly in their shell.
|
|
5
|
+
* Unlike user_shell, this is for read-only display — no lasting side effects.
|
|
6
|
+
* The agent does NOT receive the output back.
|
|
7
|
+
*/
|
|
8
|
+
export function createDisplayTool(opts) {
|
|
9
|
+
return {
|
|
10
|
+
name: "display",
|
|
11
|
+
description: "Show command output to the user in their terminal. Use when the user asks to see something (cat, git log, diff, man, etc.) and you don't need to process the output yourself. Output is NOT returned to you.",
|
|
12
|
+
input_schema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
command: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Command to run and display output to the user",
|
|
18
|
+
},
|
|
19
|
+
timeout: {
|
|
20
|
+
type: "number",
|
|
21
|
+
description: "Timeout in seconds (default: 30)",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ["command"],
|
|
25
|
+
},
|
|
26
|
+
showOutput: false,
|
|
27
|
+
modifiesFiles: false,
|
|
28
|
+
getDisplayInfo: () => ({
|
|
29
|
+
kind: "display",
|
|
30
|
+
icon: "◇",
|
|
31
|
+
locations: [],
|
|
32
|
+
}),
|
|
33
|
+
async execute(args) {
|
|
34
|
+
const command = args.command;
|
|
35
|
+
const timeoutSec = args.timeout ?? 30;
|
|
36
|
+
let result;
|
|
37
|
+
try {
|
|
38
|
+
const execPromise = opts.bus.emitPipeAsync("shell:exec-request", {
|
|
39
|
+
command,
|
|
40
|
+
output: "",
|
|
41
|
+
cwd: opts.getCwd(),
|
|
42
|
+
exitCode: null,
|
|
43
|
+
done: false,
|
|
44
|
+
});
|
|
45
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutSec * 1000));
|
|
46
|
+
result = await Promise.race([execPromise, timeoutPromise]);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
50
|
+
if (msg === "timeout") {
|
|
51
|
+
return {
|
|
52
|
+
content: `Command timed out after ${timeoutSec}s.`,
|
|
53
|
+
exitCode: -1,
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { content: `Error: ${msg}`, exitCode: -1, isError: true };
|
|
58
|
+
}
|
|
59
|
+
const exitCode = result.exitCode ?? 0;
|
|
60
|
+
const isError = exitCode !== 0 && exitCode !== null;
|
|
61
|
+
return {
|
|
62
|
+
content: isError
|
|
63
|
+
? `Command failed with exit code ${exitCode}.`
|
|
64
|
+
: "Output displayed to user.",
|
|
65
|
+
exitCode,
|
|
66
|
+
isError,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { computeDiff } from "../../utils/diff.js";
|
|
4
|
+
/**
|
|
5
|
+
* Find the closest matching region in the file content to help diagnose
|
|
6
|
+
* why an exact match failed. Returns a hint string.
|
|
7
|
+
*/
|
|
8
|
+
function findClosestMatch(content, needle) {
|
|
9
|
+
const hints = [];
|
|
10
|
+
// Check if trimming whitespace would match
|
|
11
|
+
const trimmedNeedle = needle.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
|
|
12
|
+
const trimmedContent = content.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
|
|
13
|
+
if (trimmedContent.includes(trimmedNeedle)) {
|
|
14
|
+
hints.push(" Whitespace (indentation or trailing spaces) differs — check leading/trailing spaces on each line.");
|
|
15
|
+
return hints.join("");
|
|
16
|
+
}
|
|
17
|
+
// Check if the first line exists to narrow down the region
|
|
18
|
+
const needleLines = needle.split("\n");
|
|
19
|
+
const firstLine = needleLines[0].trim();
|
|
20
|
+
if (firstLine.length > 10) {
|
|
21
|
+
const contentLines = content.split("\n");
|
|
22
|
+
const matches = contentLines
|
|
23
|
+
.map((l, i) => ({ line: i + 1, text: l }))
|
|
24
|
+
.filter((l) => l.text.includes(firstLine));
|
|
25
|
+
if (matches.length > 0 && needleLines.length > 1) {
|
|
26
|
+
const loc = matches.map((m) => `line ${m.line}`).join(", ");
|
|
27
|
+
hints.push(` First line found at ${loc}, but subsequent lines differ. The file may have changed — use read_file to see current content around that region.`);
|
|
28
|
+
return hints.join("");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
hints.push(" Use read_file to verify the current file contents before retrying.");
|
|
32
|
+
return hints.join("");
|
|
33
|
+
}
|
|
34
|
+
export function createEditFileTool(getCwd) {
|
|
35
|
+
return {
|
|
36
|
+
name: "edit_file",
|
|
37
|
+
description: "Edit a file by replacing an exact text match with new text. " +
|
|
38
|
+
"The old_text must appear exactly once unless replace_all=true. " +
|
|
39
|
+
"Include enough context to make the match unique. " +
|
|
40
|
+
"Use replace_all for variable renames or bulk string replacements.",
|
|
41
|
+
input_schema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
path: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "Absolute or relative file path",
|
|
47
|
+
},
|
|
48
|
+
old_text: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Exact text to find (must appear exactly once)",
|
|
51
|
+
},
|
|
52
|
+
new_text: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Replacement text",
|
|
55
|
+
},
|
|
56
|
+
replace_all: {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
description: "Replace ALL occurrences instead of requiring a unique match. Useful for variable renames.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ["path", "old_text", "new_text"],
|
|
62
|
+
},
|
|
63
|
+
showOutput: true,
|
|
64
|
+
modifiesFiles: true,
|
|
65
|
+
requiresPermission: true,
|
|
66
|
+
getDisplayInfo: (args) => ({
|
|
67
|
+
kind: "write",
|
|
68
|
+
icon: "✎",
|
|
69
|
+
locations: [{ path: args.path }],
|
|
70
|
+
}),
|
|
71
|
+
formatResult: (_args, result) => {
|
|
72
|
+
if (result.isError)
|
|
73
|
+
return {};
|
|
74
|
+
const m = result.content.match(/\((\+\d+(?:\s-\d+)?)\)/);
|
|
75
|
+
return m ? { summary: m[1] } : {};
|
|
76
|
+
},
|
|
77
|
+
async execute(args, onChunk) {
|
|
78
|
+
const filePath = args.path;
|
|
79
|
+
const oldText = args.old_text;
|
|
80
|
+
const newText = args.new_text;
|
|
81
|
+
const replaceAll = args.replace_all ?? false;
|
|
82
|
+
const absPath = path.resolve(getCwd(), filePath);
|
|
83
|
+
try {
|
|
84
|
+
const content = await fs.readFile(absPath, "utf-8");
|
|
85
|
+
// Normalize line endings for matching
|
|
86
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
87
|
+
const normalizedOld = oldText.replace(/\r\n/g, "\n");
|
|
88
|
+
const occurrences = normalized.split(normalizedOld).length - 1;
|
|
89
|
+
if (occurrences === 0) {
|
|
90
|
+
// Try to find the closest match to help the agent self-correct
|
|
91
|
+
const hint = findClosestMatch(normalized, normalizedOld);
|
|
92
|
+
return {
|
|
93
|
+
content: `Error: old_text not found in ${filePath}.${hint}`,
|
|
94
|
+
exitCode: 1,
|
|
95
|
+
isError: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (occurrences > 1 && !replaceAll) {
|
|
99
|
+
return {
|
|
100
|
+
content: `Error: old_text found ${occurrences} times, must be unique. Add more surrounding context or use replace_all=true.`,
|
|
101
|
+
exitCode: 1,
|
|
102
|
+
isError: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const normalizedNew = newText.replace(/\r\n/g, "\n");
|
|
106
|
+
const newContent = replaceAll
|
|
107
|
+
? normalized.split(normalizedOld).join(normalizedNew)
|
|
108
|
+
: normalized.replace(normalizedOld, normalizedNew);
|
|
109
|
+
// Restore original line endings — only convert if the file was
|
|
110
|
+
// predominantly CRLF (>50% of line endings), to avoid corrupting
|
|
111
|
+
// mixed-ending files.
|
|
112
|
+
const crlfCount = (content.match(/\r\n/g) || []).length;
|
|
113
|
+
const lfCount = (content.match(/(?<!\r)\n/g) || []).length;
|
|
114
|
+
const useCRLF = crlfCount > 0 && crlfCount >= lfCount;
|
|
115
|
+
const finalContent = useCRLF
|
|
116
|
+
? newContent.replace(/\n/g, "\r\n")
|
|
117
|
+
: newContent;
|
|
118
|
+
await fs.writeFile(absPath, finalContent);
|
|
119
|
+
// Compute and stream diff for display
|
|
120
|
+
const diff = computeDiff(normalized, newContent);
|
|
121
|
+
if (onChunk && diff.hunks.length > 0) {
|
|
122
|
+
for (const hunk of diff.hunks) {
|
|
123
|
+
for (const line of hunk.lines) {
|
|
124
|
+
if (line.type === "added")
|
|
125
|
+
onChunk(`+${line.text}\n`);
|
|
126
|
+
else if (line.type === "removed")
|
|
127
|
+
onChunk(`-${line.text}\n`);
|
|
128
|
+
else
|
|
129
|
+
onChunk(` ${line.text}\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const stats = diff.isNewFile
|
|
134
|
+
? `+${diff.added}`
|
|
135
|
+
: `+${diff.added} -${diff.removed}`;
|
|
136
|
+
return {
|
|
137
|
+
content: `Edited ${absPath} (${stats})`,
|
|
138
|
+
exitCode: 0,
|
|
139
|
+
isError: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
return { content: `Error: ${msg}`, exitCode: 1, isError: true };
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { executeCommand } from "../../executor.js";
|
|
4
|
+
export function createGlobTool(getCwd) {
|
|
5
|
+
return {
|
|
6
|
+
name: "glob",
|
|
7
|
+
description: "Find files by name pattern. Returns paths sorted by modification time (newest first). " +
|
|
8
|
+
"ALWAYS use this instead of find/ls via bash. " +
|
|
9
|
+
"Use glob to locate files, then read_file or grep to inspect contents.",
|
|
10
|
+
input_schema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
pattern: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Glob pattern (e.g., 'src/**/*.ts', '*.json')",
|
|
16
|
+
},
|
|
17
|
+
path: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "Base directory to search (default: cwd)",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
required: ["pattern"],
|
|
23
|
+
},
|
|
24
|
+
showOutput: false,
|
|
25
|
+
formatResult: (_args, result) => {
|
|
26
|
+
if (result.isError || result.content === "No files matched.")
|
|
27
|
+
return { summary: "0 files" };
|
|
28
|
+
const lines = result.content.split("\n").filter(l => l && !l.startsWith("["));
|
|
29
|
+
return { summary: `${lines.length} files` };
|
|
30
|
+
},
|
|
31
|
+
getDisplayInfo: (args) => ({
|
|
32
|
+
kind: "search",
|
|
33
|
+
icon: "⌕",
|
|
34
|
+
locations: args.path
|
|
35
|
+
? [{ path: args.path }]
|
|
36
|
+
: [],
|
|
37
|
+
}),
|
|
38
|
+
async execute(args) {
|
|
39
|
+
const pattern = args.pattern;
|
|
40
|
+
const searchPath = args.path ?? ".";
|
|
41
|
+
// Use ripgrep for correct glob matching + .gitignore awareness
|
|
42
|
+
const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
43
|
+
const parts = [
|
|
44
|
+
"rg", "--files",
|
|
45
|
+
"--glob", shellEsc(pattern),
|
|
46
|
+
shellEsc(searchPath),
|
|
47
|
+
];
|
|
48
|
+
const { session, done } = executeCommand({
|
|
49
|
+
command: parts.join(" "),
|
|
50
|
+
cwd: getCwd(),
|
|
51
|
+
timeout: 10_000,
|
|
52
|
+
});
|
|
53
|
+
await done;
|
|
54
|
+
if (!session.output.trim()) {
|
|
55
|
+
return {
|
|
56
|
+
content: "No files matched.",
|
|
57
|
+
exitCode: 0,
|
|
58
|
+
isError: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const cwd = getCwd();
|
|
62
|
+
const files = session.output.trim().split("\n");
|
|
63
|
+
// Sort by modification time (newest first)
|
|
64
|
+
const withMtime = await Promise.all(files.map(async (f) => {
|
|
65
|
+
try {
|
|
66
|
+
const abs = path.resolve(cwd, f);
|
|
67
|
+
const stat = await fs.stat(abs);
|
|
68
|
+
return { file: f, mtime: stat.mtimeMs };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { file: f, mtime: 0 };
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
75
|
+
const sorted = withMtime.slice(0, 200).map((e) => e.file);
|
|
76
|
+
const truncated = files.length > 200;
|
|
77
|
+
const suffix = truncated
|
|
78
|
+
? `\n[Results capped at 200 files, ${files.length - 200} more matched]`
|
|
79
|
+
: "";
|
|
80
|
+
return {
|
|
81
|
+
content: sorted.join("\n") + suffix,
|
|
82
|
+
exitCode: 0,
|
|
83
|
+
isError: false,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { executeCommand } from "../../executor.js";
|
|
2
|
+
export function createGrepTool(getCwd) {
|
|
3
|
+
return {
|
|
4
|
+
name: "grep",
|
|
5
|
+
description: "Search file contents using ripgrep. ALWAYS use this instead of running grep/rg via bash. " +
|
|
6
|
+
"Supports three output modes: " +
|
|
7
|
+
"'files_with_matches' (default, returns file paths only — use this to find which files contain a pattern), " +
|
|
8
|
+
"'content' (matching lines with optional context_before/context_after), and " +
|
|
9
|
+
"'count' (match counts per file). " +
|
|
10
|
+
"Use head_limit and offset for pagination.",
|
|
11
|
+
input_schema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
pattern: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Regex pattern to search for",
|
|
17
|
+
},
|
|
18
|
+
path: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Directory or file to search (default: cwd)",
|
|
21
|
+
},
|
|
22
|
+
include: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Glob pattern for files to include (e.g., '*.ts')",
|
|
25
|
+
},
|
|
26
|
+
output_mode: {
|
|
27
|
+
type: "string",
|
|
28
|
+
enum: ["files_with_matches", "content", "count"],
|
|
29
|
+
description: "Output mode: 'files_with_matches' (default, file paths only), " +
|
|
30
|
+
"'content' (matching lines), 'count' (match counts per file)",
|
|
31
|
+
},
|
|
32
|
+
case_insensitive: {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
description: "Case insensitive search (default: false)",
|
|
35
|
+
},
|
|
36
|
+
context_before: {
|
|
37
|
+
type: "number",
|
|
38
|
+
description: "Lines to show before each match (content mode only)",
|
|
39
|
+
},
|
|
40
|
+
context_after: {
|
|
41
|
+
type: "number",
|
|
42
|
+
description: "Lines to show after each match (content mode only)",
|
|
43
|
+
},
|
|
44
|
+
head_limit: {
|
|
45
|
+
type: "number",
|
|
46
|
+
description: "Max lines/entries to return (default: 200 for files_with_matches, 150 for content/count). Pass 0 for unlimited.",
|
|
47
|
+
},
|
|
48
|
+
offset: {
|
|
49
|
+
type: "number",
|
|
50
|
+
description: "Skip first N lines/entries before applying head_limit. Use with head_limit for pagination.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
required: ["pattern"],
|
|
54
|
+
},
|
|
55
|
+
showOutput: false,
|
|
56
|
+
formatResult: (args, result) => {
|
|
57
|
+
if (result.isError || result.content === "No matches found.")
|
|
58
|
+
return { summary: "0 matches" };
|
|
59
|
+
const lines = result.content.split("\n").filter(Boolean);
|
|
60
|
+
// Strip pagination info line from count
|
|
61
|
+
const resultLines = lines.filter(l => !l.startsWith("[Showing "));
|
|
62
|
+
const mode = args.output_mode ?? "files_with_matches";
|
|
63
|
+
if (mode === "files_with_matches") {
|
|
64
|
+
return { summary: `${resultLines.length} files` };
|
|
65
|
+
}
|
|
66
|
+
if (mode === "count") {
|
|
67
|
+
const total = resultLines.reduce((sum, l) => sum + (parseInt(l.split(":").pop() ?? "0", 10) || 0), 0);
|
|
68
|
+
return { summary: `${total} matches` };
|
|
69
|
+
}
|
|
70
|
+
return { summary: `${resultLines.length} lines` };
|
|
71
|
+
},
|
|
72
|
+
getDisplayInfo: (args) => ({
|
|
73
|
+
kind: "search",
|
|
74
|
+
icon: "⌕",
|
|
75
|
+
locations: args.path
|
|
76
|
+
? [{ path: args.path }]
|
|
77
|
+
: [],
|
|
78
|
+
}),
|
|
79
|
+
async execute(args) {
|
|
80
|
+
const pattern = args.pattern;
|
|
81
|
+
const searchPath = args.path ?? ".";
|
|
82
|
+
const include = args.include;
|
|
83
|
+
const mode = args.output_mode ?? "files_with_matches";
|
|
84
|
+
const caseInsensitive = args.case_insensitive;
|
|
85
|
+
const contextBefore = args.context_before;
|
|
86
|
+
const contextAfter = args.context_after;
|
|
87
|
+
const headLimit = args.head_limit;
|
|
88
|
+
const offset = args.offset ?? 0;
|
|
89
|
+
const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
90
|
+
const parts = ["rg", "--color=never"];
|
|
91
|
+
// Mode-specific flags
|
|
92
|
+
if (mode === "files_with_matches") {
|
|
93
|
+
parts.push("--files-with-matches");
|
|
94
|
+
}
|
|
95
|
+
else if (mode === "count") {
|
|
96
|
+
parts.push("--count");
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// content mode
|
|
100
|
+
parts.push("--line-number", "--no-heading");
|
|
101
|
+
if (contextBefore != null && contextBefore > 0) {
|
|
102
|
+
parts.push(`-B${contextBefore}`);
|
|
103
|
+
}
|
|
104
|
+
if (contextAfter != null && contextAfter > 0) {
|
|
105
|
+
parts.push(`-A${contextAfter}`);
|
|
106
|
+
}
|
|
107
|
+
// If neither -A nor -B specified, use --max-count to limit per-file
|
|
108
|
+
if (contextBefore == null && contextAfter == null) {
|
|
109
|
+
parts.push("--max-count=50");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (caseInsensitive) {
|
|
113
|
+
parts.push("-i");
|
|
114
|
+
}
|
|
115
|
+
if (include) {
|
|
116
|
+
parts.push("--glob", shellEsc(include));
|
|
117
|
+
}
|
|
118
|
+
parts.push("-e", shellEsc(pattern), shellEsc(searchPath));
|
|
119
|
+
const { session, done } = executeCommand({
|
|
120
|
+
command: parts.join(" "),
|
|
121
|
+
cwd: getCwd(),
|
|
122
|
+
timeout: 10_000,
|
|
123
|
+
maxOutputBytes: 64 * 1024,
|
|
124
|
+
});
|
|
125
|
+
await done;
|
|
126
|
+
if (session.exitCode === 1 && !session.output.trim()) {
|
|
127
|
+
return {
|
|
128
|
+
content: "No matches found.",
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
isError: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
let output = session.output;
|
|
134
|
+
// Cap individual line lengths to 500 chars to prevent minified/base64 flood
|
|
135
|
+
if (mode === "content") {
|
|
136
|
+
const MAX_LINE_LEN = 500;
|
|
137
|
+
output = output
|
|
138
|
+
.split("\n")
|
|
139
|
+
.map((line) => line.length > MAX_LINE_LEN
|
|
140
|
+
? line.slice(0, MAX_LINE_LEN) + "… [truncated]"
|
|
141
|
+
: line)
|
|
142
|
+
.join("\n");
|
|
143
|
+
}
|
|
144
|
+
// Apply pagination (offset + head_limit)
|
|
145
|
+
const defaultLimit = mode === "files_with_matches" ? 200 : 150;
|
|
146
|
+
const limit = headLimit === 0 ? Infinity : (headLimit ?? defaultLimit);
|
|
147
|
+
const lines = output.split("\n");
|
|
148
|
+
const total = lines.length;
|
|
149
|
+
// Apply offset then limit
|
|
150
|
+
const sliced = lines.slice(offset, offset + limit);
|
|
151
|
+
const paginated = sliced.join("\n");
|
|
152
|
+
const parts2 = [];
|
|
153
|
+
if (paginated)
|
|
154
|
+
parts2.push(paginated);
|
|
155
|
+
// Show pagination info when offset is used or results were truncated
|
|
156
|
+
if (offset > 0 || offset + limit < total) {
|
|
157
|
+
const shown = sliced.length;
|
|
158
|
+
const remaining = Math.max(0, total - offset - shown);
|
|
159
|
+
parts2.push(`\n[Showing ${shown} results (offset=${offset}, limit=${limit === Infinity ? "unlimited" : limit})${remaining > 0 ? `, ${remaining} more available` : ""}]`);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
content: parts2.join("\n") || "No matches found.",
|
|
163
|
+
exitCode: 0,
|
|
164
|
+
isError: false,
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { discoverSkills } from "../skills.js";
|
|
2
|
+
export function createListSkillsTool(getCwd) {
|
|
3
|
+
return {
|
|
4
|
+
name: "list_skills",
|
|
5
|
+
description: "List available skills. Use read_file on a skill's path to load its full instructions.",
|
|
6
|
+
input_schema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {},
|
|
9
|
+
},
|
|
10
|
+
showOutput: false,
|
|
11
|
+
async execute() {
|
|
12
|
+
const skills = discoverSkills(getCwd());
|
|
13
|
+
if (skills.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
content: "No skills found.",
|
|
16
|
+
exitCode: 0,
|
|
17
|
+
isError: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const lines = skills.map((s) => `${s.name} ${s.filePath}\n ${s.description}`);
|
|
21
|
+
return {
|
|
22
|
+
content: lines.join("\n\n"),
|
|
23
|
+
exitCode: 0,
|
|
24
|
+
isError: false,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|