mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 13:03:42 +00:00
feat(coding-agent): add read-only exploration tools (grep, find, ls) and --tools flag
Add grep, find, and ls tools for safe code exploration without modification risk. These tools are available via the new --tools CLI flag. - grep: Uses ripgrep (auto-downloaded) for fast regex searching. Respects .gitignore, supports glob filtering, context lines, and hidden files. - find: Uses fd (auto-downloaded) for fast file finding. Respects .gitignore, supports glob patterns, and hidden files. - ls: Lists directory contents with proper sorting and directory indicators. - --tools flag: Specify available tools (e.g., --tools read,grep,find,ls for read-only mode) - Dynamic system prompt adapts to selected tools with relevant guidelines Closes #74
This commit is contained in:
parent
a61eca5dee
commit
186169a820
10 changed files with 928 additions and 25 deletions
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Read-Only Exploration Tools**: Added `grep`, `find`, and `ls` tools for safe code exploration without modification risk. These tools are available via the new `--tools` flag.
|
||||
- `grep`: Uses `ripgrep` (auto-downloaded) for fast regex searching. Respects `.gitignore` (including nested), supports glob filtering, context lines, and hidden files.
|
||||
- `find`: Uses `fd` (auto-downloaded) for fast file finding. Respects `.gitignore`, supports glob patterns, and hidden files.
|
||||
- `ls`: Lists directory contents with proper sorting and indicators.
|
||||
- **`--tools` Flag**: New CLI flag to specify available tools (e.g., `--tools read,grep,find,ls` for read-only mode). Default behavior remains unchanged (`read,bash,edit,write`).
|
||||
- **Dynamic System Prompt**: The system prompt now adapts to the selected tools, showing relevant guidelines and warnings (e.g., "READ-ONLY mode" when write tools are disabled).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prompt Restoration on API Key Error**: When submitting a message fails due to missing API key, the prompt is now restored to the editor instead of being lost. ([#77](https://github.com/badlogic/pi-mono/issues/77))
|
||||
|
|
|
|||
|
|
@ -757,6 +757,22 @@ Examples:
|
|||
- `--models sonnet:high,haiku:low` - Sonnet with high thinking, Haiku with low thinking
|
||||
- `--models sonnet,haiku` - Partial match for any model containing "sonnet" or "haiku"
|
||||
|
||||
**--tools <tools>**
|
||||
Comma-separated list of tools to enable. By default, pi uses `read,bash,edit,write`. This flag allows restricting or changing the available tools.
|
||||
|
||||
Available tools:
|
||||
- `read` - Read file contents
|
||||
- `bash` - Execute bash commands
|
||||
- `edit` - Make surgical edits to files
|
||||
- `write` - Create or overwrite files
|
||||
- `grep` - Search file contents for patterns (read-only, off by default)
|
||||
- `find` - Find files by glob pattern (read-only, off by default)
|
||||
- `ls` - List directory contents (read-only, off by default)
|
||||
|
||||
Examples:
|
||||
- `--tools read,grep,find,ls` - Read-only mode for code review/exploration
|
||||
- `--tools read,bash` - Only allow reading and bash commands
|
||||
|
||||
**--thinking <level>**
|
||||
Set thinking level for reasoning-capable models. Valid values: `off`, `minimal`, `low`, `medium`, `high`. Takes highest priority over all other thinking level sources (saved settings, `--models` pattern levels, session restore).
|
||||
|
||||
|
|
@ -810,13 +826,21 @@ pi --models sonnet:high,haiku:low
|
|||
|
||||
# Start with specific thinking level
|
||||
pi --thinking high "Solve this complex algorithm problem"
|
||||
|
||||
# Read-only mode (no file modifications possible)
|
||||
pi --tools read,grep,find,ls -p "Review the architecture in src/"
|
||||
|
||||
# Oracle-style subagent (bash for git/gh, no file modifications)
|
||||
pi --tools read,bash,grep,find,ls \
|
||||
--no-session \
|
||||
-p "Use bash only for read-only operations. Read issue #74 with gh, then review the implementation"
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Built-in Tools
|
||||
### Default Tools
|
||||
|
||||
The agent has access to four core tools for working with your codebase:
|
||||
By default, the agent has access to four core tools:
|
||||
|
||||
**read**
|
||||
Read file contents. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit parameters for large files. Lines longer than 2000 characters are truncated.
|
||||
|
|
@ -830,6 +854,19 @@ Edit a file by replacing exact text. The oldText must match exactly (including w
|
|||
**bash**
|
||||
Execute a bash command in the current working directory. Returns stdout and stderr. Optionally accepts a `timeout` parameter (in seconds) - no default timeout.
|
||||
|
||||
### Read-Only Exploration Tools
|
||||
|
||||
These tools are available via `--tools` flag for read-only code exploration:
|
||||
|
||||
**grep**
|
||||
Search file contents for a pattern (regex or literal). Returns matching lines with file paths and line numbers. Respects `.gitignore`. Parameters: `pattern` (required), `path`, `glob`, `ignoreCase`, `literal`, `context`, `limit`.
|
||||
|
||||
**find**
|
||||
Search for files by glob pattern (e.g., `**/*.ts`). Returns matching file paths relative to the search directory. Respects `.gitignore`. Parameters: `pattern` (required), `path`, `limit`.
|
||||
|
||||
**ls**
|
||||
List directory contents. Returns entries sorted alphabetically with `/` suffix for directories. Includes dotfiles. Parameters: `path`, `limit`.
|
||||
|
||||
### MCP & Adding Your Own Tools
|
||||
|
||||
**pi does and will not support MCP.** Instead, it relies on the four built-in tools above and assumes the agent can invoke pre-existing CLI tools or write them on the fly as needed.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config
|
|||
import { SessionManager } from "./session-manager.js";
|
||||
import { SettingsManager } from "./settings-manager.js";
|
||||
import { initTheme } from "./theme/theme.js";
|
||||
import { codingTools } from "./tools/index.js";
|
||||
import { allTools, codingTools, type ToolName } from "./tools/index.js";
|
||||
import { ensureTool } from "./tools-manager.js";
|
||||
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
||||
import { TuiRenderer } from "./tui/tui-renderer.js";
|
||||
|
|
@ -48,6 +48,7 @@ interface Args {
|
|||
noSession?: boolean;
|
||||
session?: string;
|
||||
models?: string[];
|
||||
tools?: ToolName[];
|
||||
print?: boolean;
|
||||
messages: string[];
|
||||
fileArgs: string[];
|
||||
|
|
@ -87,6 +88,19 @@ function parseArgs(args: string[]): Args {
|
|||
result.session = args[++i];
|
||||
} else if (arg === "--models" && i + 1 < args.length) {
|
||||
result.models = args[++i].split(",").map((s) => s.trim());
|
||||
} else if (arg === "--tools" && i + 1 < args.length) {
|
||||
const toolNames = args[++i].split(",").map((s) => s.trim());
|
||||
const validTools: ToolName[] = [];
|
||||
for (const name of toolNames) {
|
||||
if (name in allTools) {
|
||||
validTools.push(name as ToolName);
|
||||
} else {
|
||||
console.error(
|
||||
chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
result.tools = validTools;
|
||||
} else if (arg === "--thinking" && i + 1 < args.length) {
|
||||
const level = args[++i];
|
||||
if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") {
|
||||
|
|
@ -220,6 +234,8 @@ ${chalk.bold("Options:")}
|
|||
--session <path> Use specific session file
|
||||
--no-session Don't save session (ephemeral)
|
||||
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
|
||||
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
|
||||
Available: read, bash, edit, write, grep, find, ls
|
||||
--thinking <level> Set thinking level: off, minimal, low, medium, high
|
||||
--help, -h Show this help
|
||||
|
||||
|
|
@ -254,6 +270,9 @@ ${chalk.bold("Examples:")}
|
|||
# Start with a specific thinking level
|
||||
pi --thinking high "Solve this complex problem"
|
||||
|
||||
# Read-only mode (no file modifications possible)
|
||||
pi --tools read,grep,find,ls -p "Review the code in src/"
|
||||
|
||||
${chalk.bold("Environment Variables:")}
|
||||
ANTHROPIC_API_KEY - Anthropic Claude API key
|
||||
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
|
||||
|
|
@ -266,15 +285,29 @@ ${chalk.bold("Environment Variables:")}
|
|||
ZAI_API_KEY - ZAI API key
|
||||
PI_CODING_AGENT_DIR - Session storage directory (default: ~/.pi/agent)
|
||||
|
||||
${chalk.bold("Available Tools:")}
|
||||
${chalk.bold("Available Tools (default: read, bash, edit, write):")}
|
||||
read - Read file contents
|
||||
bash - Execute bash commands
|
||||
edit - Edit files with find/replace
|
||||
write - Write files (creates/overwrites)
|
||||
grep - Search file contents (read-only, off by default)
|
||||
find - Find files by glob pattern (read-only, off by default)
|
||||
ls - List directory contents (read-only, off by default)
|
||||
`);
|
||||
}
|
||||
|
||||
function buildSystemPrompt(customPrompt?: string): string {
|
||||
// Tool descriptions for system prompt
|
||||
const toolDescriptions: Record<ToolName, string> = {
|
||||
read: "Read file contents",
|
||||
bash: "Execute bash commands (ls, grep, find, etc.)",
|
||||
edit: "Make surgical edits to files (find exact text and replace)",
|
||||
write: "Create or overwrite files",
|
||||
grep: "Search file contents for patterns (respects .gitignore)",
|
||||
find: "Find files by glob pattern (respects .gitignore)",
|
||||
ls: "List directory contents",
|
||||
};
|
||||
|
||||
function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[]): string {
|
||||
// Check if customPrompt is a file path that exists
|
||||
if (customPrompt && existsSync(customPrompt)) {
|
||||
try {
|
||||
|
|
@ -333,22 +366,75 @@ function buildSystemPrompt(customPrompt?: string): string {
|
|||
// Get absolute path to README.md
|
||||
const readmePath = resolve(join(__dirname, "../README.md"));
|
||||
|
||||
// Build tools list based on selected tools
|
||||
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
|
||||
const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
|
||||
|
||||
// Build guidelines based on which tools are actually available
|
||||
const guidelinesList: string[] = [];
|
||||
|
||||
const hasBash = tools.includes("bash");
|
||||
const hasEdit = tools.includes("edit");
|
||||
const hasWrite = tools.includes("write");
|
||||
const hasGrep = tools.includes("grep");
|
||||
const hasFind = tools.includes("find");
|
||||
const hasLs = tools.includes("ls");
|
||||
const hasRead = tools.includes("read");
|
||||
|
||||
// Read-only mode notice (no bash, edit, or write)
|
||||
if (!hasBash && !hasEdit && !hasWrite) {
|
||||
guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands");
|
||||
}
|
||||
|
||||
// Bash without edit/write = read-only bash mode
|
||||
if (hasBash && !hasEdit && !hasWrite) {
|
||||
guidelinesList.push(
|
||||
"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
|
||||
);
|
||||
}
|
||||
|
||||
// File exploration guidelines
|
||||
if (hasBash && !hasGrep && !hasFind && !hasLs) {
|
||||
guidelinesList.push("Use bash for file operations like ls, grep, find");
|
||||
} else if (hasBash && (hasGrep || hasFind || hasLs)) {
|
||||
guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
|
||||
}
|
||||
|
||||
// Read before edit guideline
|
||||
if (hasRead && hasEdit) {
|
||||
guidelinesList.push("Use read to examine files before editing");
|
||||
}
|
||||
|
||||
// Edit guideline
|
||||
if (hasEdit) {
|
||||
guidelinesList.push("Use edit for precise changes (old text must match exactly)");
|
||||
}
|
||||
|
||||
// Write guideline
|
||||
if (hasWrite) {
|
||||
guidelinesList.push("Use write only for new files or complete rewrites");
|
||||
}
|
||||
|
||||
// Output guideline (only when actually writing/executing)
|
||||
if (hasEdit || hasWrite) {
|
||||
guidelinesList.push(
|
||||
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
||||
);
|
||||
}
|
||||
|
||||
// Always include these
|
||||
guidelinesList.push("Be concise in your responses");
|
||||
guidelinesList.push("Show file paths clearly when working with files");
|
||||
|
||||
const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n");
|
||||
|
||||
let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
- read: Read file contents
|
||||
- bash: Execute bash commands (ls, grep, find, etc.)
|
||||
- edit: Make surgical edits to files (find exact text and replace)
|
||||
- write: Create or overwrite files
|
||||
${toolsList}
|
||||
|
||||
Guidelines:
|
||||
- Always use bash tool for file operations like ls, grep, find
|
||||
- Use read to examine files before editing
|
||||
- Use edit for precise changes (old text must match exactly)
|
||||
- Use write only for new files or complete rewrites
|
||||
- Be concise in your responses
|
||||
- Show file paths clearly when working with files
|
||||
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
|
||||
${guidelines}
|
||||
|
||||
Documentation:
|
||||
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
|
||||
|
|
@ -913,7 +999,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(parsed.systemPrompt);
|
||||
const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools);
|
||||
|
||||
// Load previous messages if continuing or resuming
|
||||
// This may update initialModel if restoring from session
|
||||
|
|
@ -996,13 +1082,16 @@ export async function main(args: string[]) {
|
|||
initialThinking = parsed.thinking;
|
||||
}
|
||||
|
||||
// Determine which tools to use
|
||||
const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;
|
||||
|
||||
// Create agent (initialModel can be null in interactive mode)
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model: initialModel as any, // Can be null
|
||||
thinkingLevel: initialThinking,
|
||||
tools: codingTools,
|
||||
tools: selectedTools,
|
||||
},
|
||||
queueMode: settingsManager.getQueueMode(),
|
||||
transport: new ProviderTransport({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface ToolConfig {
|
|||
name: string;
|
||||
repo: string; // GitHub repo (e.g., "sharkdp/fd")
|
||||
binaryName: string; // Name of the binary inside the archive
|
||||
tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0)
|
||||
getAssetName: (version: string, plat: string, architecture: string) => string | null;
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ const TOOLS: Record<string, ToolConfig> = {
|
|||
name: "fd",
|
||||
repo: "sharkdp/fd",
|
||||
binaryName: "fd",
|
||||
tagPrefix: "v",
|
||||
getAssetName: (version, plat, architecture) => {
|
||||
if (plat === "darwin") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
|
|
@ -34,20 +36,42 @@ const TOOLS: Record<string, ToolConfig> = {
|
|||
return null;
|
||||
},
|
||||
},
|
||||
rg: {
|
||||
name: "ripgrep",
|
||||
repo: "BurntSushi/ripgrep",
|
||||
binaryName: "rg",
|
||||
tagPrefix: "",
|
||||
getAssetName: (version, plat, architecture) => {
|
||||
if (plat === "darwin") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
|
||||
} else if (plat === "linux") {
|
||||
if (architecture === "arm64") {
|
||||
return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;
|
||||
}
|
||||
return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;
|
||||
} else if (plat === "win32") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Check if a command exists in PATH by trying to run it
|
||||
function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||
return true;
|
||||
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||
// Check for ENOENT error (command not found)
|
||||
return result.error === undefined || result.error === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the path to a tool (system-wide or in our tools dir)
|
||||
export function getToolPath(tool: "fd"): string | null {
|
||||
export function getToolPath(tool: "fd" | "rg"): string | null {
|
||||
const config = TOOLS[tool];
|
||||
if (!config) return null;
|
||||
|
||||
|
|
@ -75,7 +99,7 @@ async function getLatestVersion(repo: string): Promise<string> {
|
|||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as { tag_name: string };
|
||||
return data.tag_name.replace(/^v/, "");
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +120,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
|||
}
|
||||
|
||||
// Download and install a tool
|
||||
async function downloadTool(tool: "fd"): Promise<string> {
|
||||
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
|
||||
const config = TOOLS[tool];
|
||||
if (!config) throw new Error(`Unknown tool: ${tool}`);
|
||||
|
||||
|
|
@ -115,7 +139,7 @@ async function downloadTool(tool: "fd"): Promise<string> {
|
|||
// Create tools directory
|
||||
mkdirSync(TOOLS_DIR, { recursive: true });
|
||||
|
||||
const downloadUrl = `https://github.com/${config.repo}/releases/download/v${version}/${assetName}`;
|
||||
const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
|
||||
const archivePath = join(TOOLS_DIR, assetName);
|
||||
const binaryExt = plat === "win32" ? ".exe" : "";
|
||||
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
|
||||
|
|
@ -159,7 +183,7 @@ async function downloadTool(tool: "fd"): Promise<string> {
|
|||
|
||||
// Ensure a tool is available, downloading if necessary
|
||||
// Returns the path to the tool, or null if unavailable
|
||||
export async function ensureTool(tool: "fd", silent: boolean = false): Promise<string | null> {
|
||||
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | null> {
|
||||
const existingPath = getToolPath(tool);
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
|
|
|
|||
169
packages/coding-agent/src/tools/find.ts
Normal file
169
packages/coding-agent/src/tools/find.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { globSync } from "glob";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { ensureTool } from "../tools-manager.js";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const findSchema = Type.Object({
|
||||
pattern: Type.String({
|
||||
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||
}),
|
||||
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
export const findTool: AgentTool<typeof findSchema> = {
|
||||
name: "find",
|
||||
label: "find",
|
||||
description:
|
||||
"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore.",
|
||||
parameters: findSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Ensure fd is available
|
||||
const fdPath = await ensureTool("fd", true);
|
||||
if (!fdPath) {
|
||||
reject(new Error("fd is not available and could not be downloaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPath = path.resolve(expandPath(searchDir || "."));
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Build fd arguments
|
||||
const args: string[] = [
|
||||
"--glob", // Use glob pattern
|
||||
"--color=never", // No ANSI colors
|
||||
"--hidden", // Search hidden files (but still respect .gitignore)
|
||||
"--max-results",
|
||||
String(effectiveLimit),
|
||||
];
|
||||
|
||||
// Include .gitignore files (root + nested) so fd respects them even outside git repos
|
||||
const gitignoreFiles = new Set<string>();
|
||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
||||
if (existsSync(rootGitignore)) {
|
||||
gitignoreFiles.add(rootGitignore);
|
||||
}
|
||||
|
||||
try {
|
||||
const nestedGitignores = globSync("**/.gitignore", {
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
absolute: true,
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
});
|
||||
for (const file of nestedGitignores) {
|
||||
gitignoreFiles.add(file);
|
||||
}
|
||||
} catch {
|
||||
// Ignore glob errors
|
||||
}
|
||||
|
||||
for (const gitignorePath of gitignoreFiles) {
|
||||
args.push("--ignore-file", gitignorePath);
|
||||
}
|
||||
|
||||
// Pattern and path
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
// Run fd
|
||||
const result = spawnSync(fdPath, args, {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (result.error) {
|
||||
reject(new Error(`Failed to run fd: ${result.error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let output = result.stdout?.trim() || "";
|
||||
|
||||
if (result.status !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
|
||||
// fd returns non-zero for some errors but may still have partial output
|
||||
if (!output) {
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
output = "No files found matching pattern";
|
||||
} else {
|
||||
const lines = output.split("\n");
|
||||
const relativized: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "").trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||
let relativePath = line;
|
||||
if (line.startsWith(searchPath)) {
|
||||
relativePath = line.slice(searchPath.length + 1); // +1 for the /
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
|
||||
if (hadTrailingSlash && !relativePath.endsWith("/")) {
|
||||
relativePath += "/";
|
||||
}
|
||||
|
||||
relativized.push(relativePath);
|
||||
}
|
||||
|
||||
output = relativized.join("\n");
|
||||
|
||||
const count = relativized.length;
|
||||
if (count >= effectiveLimit) {
|
||||
output += `\n\n(truncated, ${effectiveLimit} results shown)`;
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ content: [{ type: "text", text: output }], details: undefined });
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
267
packages/coding-agent/src/tools/grep.ts
Normal file
267
packages/coding-agent/src/tools/grep.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, type Stats, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { ensureTool } from "../tools-manager.js";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const grepSchema = Type.Object({
|
||||
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
|
||||
path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
|
||||
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
|
||||
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
||||
literal: Type.Optional(
|
||||
Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
|
||||
),
|
||||
context: Type.Optional(
|
||||
Type.Number({ description: "Number of lines to show before and after each match (default: 0)" }),
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export const grepTool: AgentTool<typeof grepSchema> = {
|
||||
name: "grep",
|
||||
label: "grep",
|
||||
description:
|
||||
"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore.",
|
||||
parameters: grepSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{
|
||||
pattern,
|
||||
path: searchDir,
|
||||
glob,
|
||||
ignoreCase,
|
||||
literal,
|
||||
context,
|
||||
limit,
|
||||
}: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
ignoreCase?: boolean;
|
||||
literal?: boolean;
|
||||
context?: number;
|
||||
limit?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const rgPath = await ensureTool("rg", true);
|
||||
if (!rgPath) {
|
||||
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPath = path.resolve(expandPath(searchDir || "."));
|
||||
let searchStat: Stats;
|
||||
try {
|
||||
searchStat = statSync(searchPath);
|
||||
} catch (err) {
|
||||
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
const isDirectory = searchStat.isDirectory();
|
||||
const contextValue = context && context > 0 ? context : 0;
|
||||
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
||||
|
||||
const formatPath = (filePath: string): string => {
|
||||
if (isDirectory) {
|
||||
const relative = path.relative(searchPath, filePath);
|
||||
if (relative && !relative.startsWith("..")) {
|
||||
return relative.replace(/\\/g, "/");
|
||||
}
|
||||
}
|
||||
return path.basename(filePath);
|
||||
};
|
||||
|
||||
const fileCache = new Map<string, string[]>();
|
||||
const getFileLines = (filePath: string): string[] => {
|
||||
let lines = fileCache.get(filePath);
|
||||
if (!lines) {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
} catch {
|
||||
lines = [];
|
||||
}
|
||||
fileCache.set(filePath, lines);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
|
||||
|
||||
if (ignoreCase) {
|
||||
args.push("--ignore-case");
|
||||
}
|
||||
|
||||
if (literal) {
|
||||
args.push("--fixed-strings");
|
||||
}
|
||||
|
||||
if (glob) {
|
||||
args.push("--glob", glob);
|
||||
}
|
||||
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
let stderr = "";
|
||||
let matchCount = 0;
|
||||
let truncated = false;
|
||||
let aborted = false;
|
||||
let killedDueToLimit = false;
|
||||
const outputLines: string[] = [];
|
||||
|
||||
const cleanup = () => {
|
||||
rl.close();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const stopChild = (dueToLimit: boolean = false) => {
|
||||
if (!child.killed) {
|
||||
killedDueToLimit = dueToLimit;
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
stopChild();
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const formatBlock = (filePath: string, lineNumber: number) => {
|
||||
const relativePath = formatPath(filePath);
|
||||
const lines = getFileLines(filePath);
|
||||
if (!lines.length) {
|
||||
return [`${relativePath}:${lineNumber}: (unable to read file)`];
|
||||
}
|
||||
|
||||
const block: string[] = [];
|
||||
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
|
||||
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
|
||||
|
||||
for (let current = start; current <= end; current++) {
|
||||
const lineText = lines[current - 1] ?? "";
|
||||
const sanitized = lineText.replace(/\r/g, "");
|
||||
const isMatchLine = current === lineNumber;
|
||||
|
||||
if (isMatchLine) {
|
||||
block.push(`${relativePath}:${current}: ${sanitized}`);
|
||||
} else {
|
||||
block.push(`${relativePath}-${current}- ${sanitized}`);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
};
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim() || matchCount >= effectiveLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "match") {
|
||||
matchCount++;
|
||||
const filePath = event.data?.path?.text;
|
||||
const lineNumber = event.data?.line_number;
|
||||
|
||||
if (filePath && typeof lineNumber === "number") {
|
||||
outputLines.push(...formatBlock(filePath, lineNumber));
|
||||
}
|
||||
|
||||
if (matchCount >= effectiveLimit) {
|
||||
truncated = true;
|
||||
stopChild(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
cleanup();
|
||||
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
cleanup();
|
||||
|
||||
if (aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!killedDueToLimit && code !== 0 && code !== 1) {
|
||||
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
|
||||
settle(() => reject(new Error(errorMsg)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchCount === 0) {
|
||||
settle(() =>
|
||||
resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let output = outputLines.join("\n");
|
||||
if (truncated) {
|
||||
output += `\n\n(truncated, limit of ${effectiveLimit} matches reached)`;
|
||||
}
|
||||
|
||||
settle(() => resolve({ content: [{ type: "text", text: output }], details: undefined }));
|
||||
});
|
||||
} catch (err) {
|
||||
settle(() => reject(err as Error));
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,11 +1,31 @@
|
|||
export { bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { findTool } from "./find.js";
|
||||
export { grepTool } from "./grep.js";
|
||||
export { lsTool } from "./ls.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { bashTool } from "./bash.js";
|
||||
import { editTool } from "./edit.js";
|
||||
import { findTool } from "./find.js";
|
||||
import { grepTool } from "./grep.js";
|
||||
import { lsTool } from "./ls.js";
|
||||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
|
||||
// Default tools for full access mode
|
||||
export const codingTools = [readTool, bashTool, editTool, writeTool];
|
||||
|
||||
// All available tools (including read-only exploration tools)
|
||||
export const allTools = {
|
||||
read: readTool,
|
||||
bash: bashTool,
|
||||
edit: editTool,
|
||||
write: writeTool,
|
||||
grep: grepTool,
|
||||
find: findTool,
|
||||
ls: lsTool,
|
||||
};
|
||||
|
||||
export type ToolName = keyof typeof allTools;
|
||||
|
|
|
|||
116
packages/coding-agent/src/tools/ls.ts
Normal file
116
packages/coding-agent/src/tools/ls.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import nodePath from "path";
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (filePath.startsWith("~/")) {
|
||||
return homedir() + filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const lsSchema = Type.Object({
|
||||
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
export const lsTool: AgentTool<typeof lsSchema> = {
|
||||
name: "ls",
|
||||
label: "ls",
|
||||
description:
|
||||
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles.",
|
||||
parameters: lsSchema,
|
||||
execute: async (_toolCallId: string, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const dirPath = nodePath.resolve(expandPath(path || "."));
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Check if path exists
|
||||
if (!existsSync(dirPath)) {
|
||||
reject(new Error(`Path not found: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path is a directory
|
||||
const stat = statSync(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reject(new Error(`Not a directory: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dirPath);
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Cannot read directory: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Format entries with directory indicators
|
||||
const results: string[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= effectiveLimit) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullPath = nodePath.join(dirPath, entry);
|
||||
let suffix = "";
|
||||
|
||||
try {
|
||||
const entryStat = statSync(fullPath);
|
||||
if (entryStat.isDirectory()) {
|
||||
suffix = "/";
|
||||
}
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(entry + suffix);
|
||||
}
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
let output = results.join("\n");
|
||||
if (truncated) {
|
||||
const remaining = entries.length - effectiveLimit;
|
||||
output += `\n\n(truncated, ${remaining} more entries)`;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
output = "(empty directory)";
|
||||
}
|
||||
|
||||
resolve({ content: [{ type: "text", text: output }], details: undefined });
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -198,6 +198,89 @@ export class ToolExecutionComponent extends Container {
|
|||
text += "\n\n" + coloredLines.join("\n");
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "ls") {
|
||||
const path = shortenPath(this.args?.path || ".");
|
||||
const limit = this.args?.limit;
|
||||
|
||||
text = theme.fg("toolTitle", theme.bold("ls")) + " " + theme.fg("accent", path);
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||
}
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 20;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "find") {
|
||||
const pattern = this.args?.pattern || "";
|
||||
const path = shortenPath(this.args?.path || ".");
|
||||
const limit = this.args?.limit;
|
||||
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("find")) +
|
||||
" " +
|
||||
theme.fg("accent", pattern) +
|
||||
theme.fg("toolOutput", ` in ${path}`);
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||
}
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 20;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "grep") {
|
||||
const pattern = this.args?.pattern || "";
|
||||
const path = shortenPath(this.args?.path || ".");
|
||||
const glob = this.args?.glob;
|
||||
const limit = this.args?.limit;
|
||||
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("grep")) +
|
||||
" " +
|
||||
theme.fg("accent", `/${pattern}/`) +
|
||||
theme.fg("toolOutput", ` in ${path}`);
|
||||
if (glob) {
|
||||
text += theme.fg("toolOutput", ` (${glob})`);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` limit ${limit}`);
|
||||
}
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 15;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generic tool
|
||||
text = theme.fg("toolTitle", theme.bold(this.toolName));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { join } from "path";
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { bashTool } from "../src/tools/bash.js";
|
||||
import { editTool } from "../src/tools/edit.js";
|
||||
import { findTool } from "../src/tools/find.js";
|
||||
import { grepTool } from "../src/tools/grep.js";
|
||||
import { lsTool } from "../src/tools/ls.js";
|
||||
import { readTool } from "../src/tools/read.js";
|
||||
import { writeTool } from "../src/tools/write.js";
|
||||
|
||||
|
|
@ -247,4 +250,90 @@ describe("Coding Agent Tools", () => {
|
|||
expect(getTextOutput(result)).toContain("Command failed");
|
||||
}, 35000);
|
||||
});
|
||||
|
||||
describe("grep tool", () => {
|
||||
it("should include filename when searching a single file", async () => {
|
||||
const testFile = join(testDir, "example.txt");
|
||||
writeFileSync(testFile, "first line\nmatch line\nlast line");
|
||||
|
||||
const result = await grepTool.execute("test-call-11", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("example.txt:2: match line");
|
||||
});
|
||||
|
||||
it("should respect global limit and include context lines", async () => {
|
||||
const testFile = join(testDir, "context.txt");
|
||||
const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n");
|
||||
writeFileSync(testFile, content);
|
||||
|
||||
const result = await grepTool.execute("test-call-12", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
limit: 1,
|
||||
context: 1,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("context.txt-1- before");
|
||||
expect(output).toContain("context.txt:2: match one");
|
||||
expect(output).toContain("context.txt-3- after");
|
||||
expect(output).toContain("(truncated, limit of 1 matches reached)");
|
||||
// Ensure second match is not present
|
||||
expect(output).not.toContain("match two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("find tool", () => {
|
||||
it("should include hidden files that are not gitignored", async () => {
|
||||
const hiddenDir = join(testDir, ".secret");
|
||||
mkdirSync(hiddenDir);
|
||||
writeFileSync(join(hiddenDir, "hidden.txt"), "hidden");
|
||||
writeFileSync(join(testDir, "visible.txt"), "visible");
|
||||
|
||||
const result = await findTool.execute("test-call-13", {
|
||||
pattern: "**/*.txt",
|
||||
path: testDir,
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(outputLines).toContain("visible.txt");
|
||||
expect(outputLines).toContain(".secret/hidden.txt");
|
||||
});
|
||||
|
||||
it("should respect .gitignore", async () => {
|
||||
writeFileSync(join(testDir, ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(testDir, "ignored.txt"), "ignored");
|
||||
writeFileSync(join(testDir, "kept.txt"), "kept");
|
||||
|
||||
const result = await findTool.execute("test-call-14", {
|
||||
pattern: "**/*.txt",
|
||||
path: testDir,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("kept.txt");
|
||||
expect(output).not.toContain("ignored.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ls tool", () => {
|
||||
it("should list dotfiles and directories", async () => {
|
||||
writeFileSync(join(testDir, ".hidden-file"), "secret");
|
||||
mkdirSync(join(testDir, ".hidden-dir"));
|
||||
|
||||
const result = await lsTool.execute("test-call-15", { path: testDir });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(output).toContain(".hidden-file");
|
||||
expect(output).toContain(".hidden-dir/");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue