summaryrefslogtreecommitdiff
path: root/packages/server/src/tools
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src/tools')
-rw-r--r--packages/server/src/tools/edit.ts353
-rw-r--r--packages/server/src/tools/glob.ts216
-rw-r--r--packages/server/src/tools/grep.ts565
-rw-r--r--packages/server/src/tools/ls.ts276
-rw-r--r--packages/server/src/tools/read-file.ts278
-rw-r--r--packages/server/src/tools/terminal.ts256
-rw-r--r--packages/server/src/tools/tools.ts150
-rw-r--r--packages/server/src/tools/web-fetch.ts141
-rw-r--r--packages/server/src/tools/write-file.ts167
9 files changed, 2402 insertions, 0 deletions
diff --git a/packages/server/src/tools/edit.ts b/packages/server/src/tools/edit.ts
new file mode 100644
index 00000000..67c5a37b
--- /dev/null
+++ b/packages/server/src/tools/edit.ts
@@ -0,0 +1,353 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import * as Diff from 'diff';
+import { BaseTool, ToolResult, ToolResultDisplay } from './tools.js';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { makeRelative, shortenPath } from '../utils/paths.js';
+import { isNodeError } from '../utils/errors.js';
+
+/**
+ * Parameters for the Edit tool
+ */
+export interface EditToolParams {
+ /**
+ * The absolute path to the file to modify
+ */
+ file_path: string;
+
+ /**
+ * The text to replace
+ */
+ old_string: string;
+
+ /**
+ * The text to replace it with
+ */
+ new_string: string;
+
+ /**
+ * The expected number of replacements to perform (optional, defaults to 1)
+ */
+ expected_replacements?: number;
+}
+
+interface CalculatedEdit {
+ currentContent: string | null;
+ newContent: string;
+ occurrences: number;
+ error?: { display: string; raw: string };
+ isNewFile: boolean;
+}
+
+/**
+ * Implementation of the Edit tool logic (moved from CLI)
+ */
+export class EditLogic extends BaseTool<EditToolParams, ToolResult> {
+ static readonly Name = 'replace'; // Keep static name
+
+ private readonly rootDirectory: string;
+
+ /**
+ * Creates a new instance of the EditLogic
+ * @param rootDirectory Root directory to ground this tool in.
+ */
+ constructor(rootDirectory: string) {
+ // Note: The description here mentions other tools like ReadFileTool/WriteFileTool
+ // by name. This might need updating if those tool names change.
+ super(
+ EditLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ file_path: {
+ description:
+ 'The absolute path to the file to modify. Must start with /. When creating a new file, ensure the parent directory exists (use the `LS` tool to verify).',
+ type: 'string',
+ },
+ old_string: {
+ description:
+ 'The exact text to replace. CRITICAL: Must uniquely identify the single instance to change. Include at least 3-5 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations or does not match exactly, the tool will fail. Use an empty string ("") when creating a new file.',
+ type: 'string',
+ },
+ new_string: {
+ description:
+ 'The text to replace the `old_string` with. When creating a new file (using an empty `old_string`), this should contain the full desired content of the new file. Ensure the resulting code is correct and idiomatic.',
+ type: 'string',
+ },
+ },
+ required: ['file_path', 'old_string', 'new_string'],
+ type: 'object',
+ },
+ );
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ /**
+ * Checks if a path is within the root directory.
+ * @param pathToCheck The absolute path to check.
+ * @returns True if the path is within the root directory, false otherwise.
+ */
+ private isWithinRoot(pathToCheck: string): boolean {
+ const normalizedPath = path.normalize(pathToCheck);
+ const normalizedRoot = this.rootDirectory;
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ return (
+ normalizedPath === normalizedRoot ||
+ normalizedPath.startsWith(rootWithSep)
+ );
+ }
+
+ /**
+ * Validates the parameters for the Edit tool
+ * @param params Parameters to validate
+ * @returns Error message string or null if valid
+ */
+ validateParams(params: EditToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+
+ if (!path.isAbsolute(params.file_path)) {
+ return `File path must be absolute: ${params.file_path}`;
+ }
+
+ if (!this.isWithinRoot(params.file_path)) {
+ return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`;
+ }
+
+ if (
+ params.expected_replacements !== undefined &&
+ params.expected_replacements < 0
+ ) {
+ return 'Expected replacements must be a non-negative number';
+ }
+
+ return null;
+ }
+
+ /**
+ * Calculates the potential outcome of an edit operation.
+ * @param params Parameters for the edit operation
+ * @returns An object describing the potential edit outcome
+ * @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
+ */
+ private calculateEdit(params: EditToolParams): CalculatedEdit {
+ const expectedReplacements =
+ params.expected_replacements === undefined
+ ? 1
+ : params.expected_replacements;
+ let currentContent: string | null = null;
+ let fileExists = false;
+ let isNewFile = false;
+ let newContent = '';
+ let occurrences = 0;
+ let error: { display: string; raw: string } | undefined = undefined;
+
+ try {
+ currentContent = fs.readFileSync(params.file_path, 'utf8');
+ fileExists = true;
+ } catch (err: unknown) {
+ if (!isNodeError(err) || err.code !== 'ENOENT') {
+ // Rethrow unexpected FS errors (permissions, etc.)
+ throw err;
+ }
+ fileExists = false;
+ }
+
+ if (params.old_string === '' && !fileExists) {
+ // Creating a new file
+ isNewFile = true;
+ newContent = params.new_string;
+ occurrences = 0;
+ } else if (!fileExists) {
+ // Trying to edit a non-existent file (and old_string is not empty)
+ error = {
+ display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`,
+ raw: `File not found: ${params.file_path}`,
+ };
+ } else if (currentContent !== null) {
+ // Editing an existing file
+ occurrences = this.countOccurrences(currentContent, params.old_string);
+
+ if (params.old_string === '') {
+ // Error: Trying to create a file that already exists
+ error = {
+ display: `File already exists. Use a non-empty old_string to edit.`,
+ raw: `File already exists, cannot create: ${params.file_path}`,
+ };
+ } else if (occurrences === 0) {
+ error = {
+ display: `No edits made. The exact text in old_string was not found. Check whitespace, indentation, and context. Use ReadFile tool to verify. `,
+ raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}`,
+ };
+ } else if (occurrences !== expectedReplacements) {
+ error = {
+ display: `Failed to edit, expected ${expectedReplacements} occurrence(s) but found ${occurrences}. Make old_string more specific with more context.`,
+ raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} for old_string in file: ${params.file_path}`,
+ };
+ } else {
+ // Successful edit calculation
+ newContent = this.replaceAll(
+ currentContent,
+ params.old_string,
+ params.new_string,
+ );
+ }
+ } else {
+ // Should not happen if fileExists and no exception was thrown, but defensively:
+ error = {
+ display: `Failed to read content of existing file.`,
+ raw: `Failed to read content of existing file: ${params.file_path}`,
+ };
+ }
+
+ return {
+ currentContent,
+ newContent,
+ occurrences,
+ error,
+ isNewFile,
+ };
+ }
+
+ // Removed shouldConfirmExecute - Confirmation is handled by the CLI wrapper
+
+ getDescription(params: EditToolParams): string {
+ const relativePath = makeRelative(params.file_path, this.rootDirectory);
+ if (params.old_string === '') {
+ return `Create ${shortenPath(relativePath)}`;
+ }
+ const oldStringSnippet =
+ params.old_string.split('\n')[0].substring(0, 30) +
+ (params.old_string.length > 30 ? '...' : '');
+ const newStringSnippet =
+ params.new_string.split('\n')[0].substring(0, 30) +
+ (params.new_string.length > 30 ? '...' : '');
+ return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
+ }
+
+ /**
+ * Executes the edit operation with the given parameters.
+ * @param params Parameters for the edit operation
+ * @returns Result of the edit operation
+ */
+ async execute(params: EditToolParams): Promise<ToolResult> {
+ const validationError = this.validateParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: `Error: ${validationError}`,
+ };
+ }
+
+ let editData: CalculatedEdit;
+ try {
+ editData = this.calculateEdit(params);
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ return {
+ llmContent: `Error preparing edit: ${errorMsg}`,
+ returnDisplay: `Error preparing edit: ${errorMsg}`,
+ };
+ }
+
+ if (editData.error) {
+ return {
+ llmContent: editData.error.raw,
+ returnDisplay: `Error: ${editData.error.display}`,
+ };
+ }
+
+ try {
+ this.ensureParentDirectoriesExist(params.file_path);
+ fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
+
+ let displayResult: ToolResultDisplay;
+ if (editData.isNewFile) {
+ displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
+ } else {
+ // Generate diff for display, even though core logic doesn't technically need it
+ // The CLI wrapper will use this part of the ToolResult
+ const fileName = path.basename(params.file_path);
+ const fileDiff = Diff.createPatch(
+ fileName,
+ editData.currentContent ?? '', // Should not be null here if not isNewFile
+ editData.newContent,
+ 'Current',
+ 'Proposed',
+ { context: 3 }, // Removed ignoreWhitespace for potentially more accurate display diff
+ );
+ displayResult = { fileDiff };
+ }
+
+ const llmSuccessMessage = editData.isNewFile
+ ? `Created new file: ${params.file_path} with provided content.`
+ : `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`;
+
+ return {
+ llmContent: llmSuccessMessage,
+ returnDisplay: displayResult,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ return {
+ llmContent: `Error executing edit: ${errorMsg}`,
+ returnDisplay: `Error writing file: ${errorMsg}`,
+ };
+ }
+ }
+
+ /**
+ * Counts occurrences of a substring in a string
+ */
+ private countOccurrences(str: string, substr: string): number {
+ if (substr === '') {
+ return 0;
+ }
+ let count = 0;
+ let pos = str.indexOf(substr);
+ while (pos !== -1) {
+ count++;
+ pos = str.indexOf(substr, pos + 1); // Ensure overlap is not counted if substr repeats
+ }
+ return count;
+ }
+
+ /**
+ * Replaces all occurrences of a substring in a string
+ */
+ private replaceAll(str: string, find: string, replace: string): string {
+ if (find === '') {
+ return str;
+ }
+ // Use RegExp with global flag for true replacement of all instances
+ // Escape special regex characters in the find string
+ const escapedFind = find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return str.replace(new RegExp(escapedFind, 'g'), replace);
+ }
+
+ /**
+ * Creates parent directories if they don't exist
+ */
+ private ensureParentDirectoriesExist(filePath: string): void {
+ const dirName = path.dirname(filePath);
+ if (!fs.existsSync(dirName)) {
+ fs.mkdirSync(dirName, { recursive: true });
+ }
+ }
+}
diff --git a/packages/server/src/tools/glob.ts b/packages/server/src/tools/glob.ts
new file mode 100644
index 00000000..b9a3143c
--- /dev/null
+++ b/packages/server/src/tools/glob.ts
@@ -0,0 +1,216 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import fg from 'fast-glob';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { BaseTool, ToolResult } from './tools.js';
+import { shortenPath, makeRelative } from '../utils/paths.js';
+
+/**
+ * Parameters for the GlobTool
+ */
+export interface GlobToolParams {
+ /**
+ * The glob pattern to match files against
+ */
+ pattern: string;
+
+ /**
+ * The directory to search in (optional, defaults to current directory)
+ */
+ path?: string;
+}
+
+/**
+ * Implementation of the Glob tool logic (moved from CLI)
+ */
+export class GlobLogic extends BaseTool<GlobToolParams, ToolResult> {
+ static readonly Name = 'glob'; // Keep static name
+
+ /**
+ * The root directory that this tool is grounded in.
+ */
+ private rootDirectory: string;
+
+ /**
+ * Creates a new instance of the GlobLogic
+ * @param rootDirectory Root directory to ground this tool in.
+ */
+ constructor(rootDirectory: string) {
+ super(
+ GlobLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ pattern: {
+ description:
+ "The glob pattern to match against (e.g., '*.py', 'src/**/*.js', 'docs/*.md').",
+ type: 'string',
+ },
+ path: {
+ description:
+ 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
+ type: 'string',
+ },
+ },
+ required: ['pattern'],
+ type: 'object',
+ },
+ );
+
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ /**
+ * Checks if a path is within the root directory.
+ */
+ private isWithinRoot(pathToCheck: string): boolean {
+ const absolutePathToCheck = path.resolve(pathToCheck);
+ const normalizedPath = path.normalize(absolutePathToCheck);
+ const normalizedRoot = path.normalize(this.rootDirectory);
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ return (
+ normalizedPath === normalizedRoot ||
+ normalizedPath.startsWith(rootWithSep)
+ );
+ }
+
+ /**
+ * Validates the parameters for the tool.
+ */
+ validateToolParams(params: GlobToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return "Parameters failed schema validation. Ensure 'pattern' is a string and 'path' (if provided) is a string.";
+ }
+
+ const searchDirAbsolute = path.resolve(
+ this.rootDirectory,
+ params.path || '.',
+ );
+
+ if (!this.isWithinRoot(searchDirAbsolute)) {
+ return `Search path ("${searchDirAbsolute}") resolves outside the tool's root directory ("${this.rootDirectory}").`;
+ }
+
+ try {
+ if (!fs.existsSync(searchDirAbsolute)) {
+ return `Search path does not exist: ${shortenPath(makeRelative(searchDirAbsolute, this.rootDirectory))} (absolute: ${searchDirAbsolute})`;
+ }
+ if (!fs.statSync(searchDirAbsolute).isDirectory()) {
+ return `Search path is not a directory: ${shortenPath(makeRelative(searchDirAbsolute, this.rootDirectory))} (absolute: ${searchDirAbsolute})`;
+ }
+ } catch (e: unknown) {
+ return `Error accessing search path: ${e}`;
+ }
+
+ if (
+ !params.pattern ||
+ typeof params.pattern !== 'string' ||
+ params.pattern.trim() === ''
+ ) {
+ return "The 'pattern' parameter cannot be empty.";
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets a description of the glob operation.
+ */
+ getDescription(params: GlobToolParams): string {
+ let description = `'${params.pattern}'`;
+ if (params.path) {
+ const searchDir = path.resolve(this.rootDirectory, params.path || '.');
+ const relativePath = makeRelative(searchDir, this.rootDirectory);
+ description += ` within ${shortenPath(relativePath)}`;
+ }
+ return description;
+ }
+
+ /**
+ * Executes the glob search with the given parameters
+ */
+ async execute(params: GlobToolParams): Promise<ToolResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: `Error: Failed to execute tool.`,
+ };
+ }
+
+ try {
+ const searchDirAbsolute = path.resolve(
+ this.rootDirectory,
+ params.path || '.',
+ );
+
+ const entries = await fg(params.pattern, {
+ cwd: searchDirAbsolute,
+ absolute: true,
+ onlyFiles: true,
+ stats: true,
+ dot: true,
+ ignore: ['**/node_modules/**', '**/.git/**'],
+ followSymbolicLinks: false,
+ suppressErrors: true,
+ });
+
+ if (!entries || entries.length === 0) {
+ const displayPath = makeRelative(searchDirAbsolute, this.rootDirectory);
+ return {
+ llmContent: `No files found matching pattern "${params.pattern}" within ${displayPath || '.'}.`,
+ returnDisplay: `No files found`,
+ };
+ }
+
+ entries.sort((a, b) => {
+ const mtimeA = a.stats?.mtime?.getTime() ?? 0;
+ const mtimeB = b.stats?.mtime?.getTime() ?? 0;
+ return mtimeB - mtimeA;
+ });
+
+ const sortedAbsolutePaths = entries.map((entry) => entry.path);
+ const sortedRelativePaths = sortedAbsolutePaths.map((absPath) =>
+ makeRelative(absPath, this.rootDirectory),
+ );
+
+ const fileListDescription = sortedRelativePaths.join('\n');
+ const fileCount = sortedRelativePaths.length;
+ const relativeSearchDir = makeRelative(
+ searchDirAbsolute,
+ this.rootDirectory,
+ );
+ const displayPath = shortenPath(
+ relativeSearchDir === '.' ? 'root directory' : relativeSearchDir,
+ );
+
+ return {
+ llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${displayPath}, sorted by modification time (newest first):\n${fileListDescription}`,
+ returnDisplay: `Found ${fileCount} matching file(s)`,
+ };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ console.error(`GlobLogic execute Error: ${errorMessage}`, error);
+ return {
+ llmContent: `Error during glob search operation: ${errorMessage}`,
+ returnDisplay: `Error: An unexpected error occurred.`,
+ };
+ }
+ }
+}
diff --git a/packages/server/src/tools/grep.ts b/packages/server/src/tools/grep.ts
new file mode 100644
index 00000000..b0d4637c
--- /dev/null
+++ b/packages/server/src/tools/grep.ts
@@ -0,0 +1,565 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import fsPromises from 'fs/promises';
+import path from 'path';
+import { EOL } from 'os';
+import { spawn } from 'child_process';
+import fastGlob from 'fast-glob';
+import { BaseTool, ToolResult } from './tools.js';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { makeRelative, shortenPath } from '../utils/paths.js';
+import { getErrorMessage, isNodeError } from '../utils/errors.js';
+
+// --- Interfaces ---
+
+/**
+ * Parameters for the GrepTool
+ */
+export interface GrepToolParams {
+ /**
+ * The regular expression pattern to search for in file contents
+ */
+ pattern: string;
+
+ /**
+ * The directory to search in (optional, defaults to current directory relative to root)
+ */
+ path?: string;
+
+ /**
+ * File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
+ */
+ include?: string;
+}
+
+/**
+ * Result object for a single grep match
+ */
+interface GrepMatch {
+ filePath: string;
+ lineNumber: number;
+ line: string;
+}
+
+// --- GrepLogic Class ---
+
+/**
+ * Implementation of the Grep tool logic (moved from CLI)
+ */
+export class GrepLogic extends BaseTool<GrepToolParams, ToolResult> {
+ static readonly Name = 'search_file_content'; // Keep static name
+
+ private rootDirectory: string;
+
+ /**
+ * Creates a new instance of the GrepLogic
+ * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory.
+ */
+ constructor(rootDirectory: string) {
+ super(
+ GrepLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ pattern: {
+ description:
+ "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
+ type: 'string',
+ },
+ path: {
+ description:
+ 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
+ type: 'string',
+ },
+ include: {
+ description:
+ "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
+ type: 'string',
+ },
+ },
+ required: ['pattern'],
+ type: 'object',
+ },
+ );
+ // Ensure rootDirectory is absolute and normalized
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ // --- Validation Methods ---
+
+ /**
+ * Checks if a path is within the root directory and resolves it.
+ * @param relativePath Path relative to the root directory (or undefined for root).
+ * @returns The absolute path if valid and exists.
+ * @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
+ */
+ private resolveAndValidatePath(relativePath?: string): string {
+ const targetPath = path.resolve(this.rootDirectory, relativePath || '.');
+
+ // Security Check: Ensure the resolved path is still within the root directory.
+ if (
+ !targetPath.startsWith(this.rootDirectory) &&
+ targetPath !== this.rootDirectory
+ ) {
+ throw new Error(
+ `Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`,
+ );
+ }
+
+ // Check existence and type after resolving
+ try {
+ const stats = fs.statSync(targetPath);
+ if (!stats.isDirectory()) {
+ throw new Error(`Path is not a directory: ${targetPath}`);
+ }
+ } catch (error: unknown) {
+ if (isNodeError(error) && error.code !== 'ENOENT') {
+ throw new Error(`Path does not exist: ${targetPath}`);
+ }
+ throw new Error(
+ `Failed to access path stats for ${targetPath}: ${error}`,
+ );
+ }
+
+ return targetPath;
+ }
+
+ /**
+ * Validates the parameters for the tool
+ * @param params Parameters to validate
+ * @returns An error message string if invalid, null otherwise
+ */
+ validateToolParams(params: GrepToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+
+ try {
+ new RegExp(params.pattern);
+ } catch (error) {
+ return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${error instanceof Error ? error.message : String(error)}`;
+ }
+
+ try {
+ this.resolveAndValidatePath(params.path);
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+
+ return null; // Parameters are valid
+ }
+
+ // --- Core Execution ---
+
+ /**
+ * Executes the grep search with the given parameters
+ * @param params Parameters for the grep search
+ * @returns Result of the grep search
+ */
+ async execute(params: GrepToolParams): Promise<ToolResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ console.error(
+ `GrepLogic Parameter Validation Failed: ${validationError}`,
+ );
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: `Error: Failed to execute tool.`,
+ };
+ }
+
+ let searchDirAbs: string;
+ try {
+ searchDirAbs = this.resolveAndValidatePath(params.path);
+ const searchDirDisplay = params.path || '.';
+
+ const matches: GrepMatch[] = await this.performGrepSearch({
+ pattern: params.pattern,
+ path: searchDirAbs,
+ include: params.include,
+ });
+
+ if (matches.length === 0) {
+ const noMatchMsg = `No matches found for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}.`;
+ return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
+ }
+
+ const matchesByFile = matches.reduce(
+ (acc, match) => {
+ const relativeFilePath =
+ path.relative(
+ searchDirAbs,
+ path.resolve(searchDirAbs, match.filePath),
+ ) || path.basename(match.filePath);
+ if (!acc[relativeFilePath]) {
+ acc[relativeFilePath] = [];
+ }
+ acc[relativeFilePath].push(match);
+ acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber);
+ return acc;
+ },
+ {} as Record<string, GrepMatch[]>,
+ );
+
+ let llmContent = `Found ${matches.length} match(es) for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`;
+
+ for (const filePath in matchesByFile) {
+ llmContent += `File: ${filePath}\n`;
+ matchesByFile[filePath].forEach((match) => {
+ const trimmedLine = match.line.trim();
+ llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
+ });
+ llmContent += '---\n';
+ }
+
+ return {
+ llmContent: llmContent.trim(),
+ returnDisplay: `Found ${matches.length} matche(s)`,
+ };
+ } catch (error) {
+ console.error(`Error during GrepLogic execution: ${error}`);
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ return {
+ llmContent: `Error during grep search operation: ${errorMessage}`,
+ returnDisplay: `Error: ${errorMessage}`,
+ };
+ }
+ }
+
+ // --- Grep Implementation Logic ---
+
+ /**
+ * Checks if a command is available in the system's PATH.
+ * @param {string} command The command name (e.g., 'git', 'grep').
+ * @returns {Promise<boolean>} True if the command is available, false otherwise.
+ */
+ private isCommandAvailable(command: string): Promise<boolean> {
+ return new Promise((resolve) => {
+ const checkCommand = process.platform === 'win32' ? 'where' : 'command';
+ const checkArgs =
+ process.platform === 'win32' ? [command] : ['-v', command];
+ try {
+ const child = spawn(checkCommand, checkArgs, {
+ stdio: 'ignore',
+ shell: process.platform === 'win32',
+ });
+ child.on('close', (code) => resolve(code === 0));
+ child.on('error', () => resolve(false));
+ } catch {
+ resolve(false);
+ }
+ });
+ }
+
+ /**
+ * Checks if a directory or its parent directories contain a .git folder.
+ * @param {string} dirPath Absolute path to the directory to check.
+ * @returns {Promise<boolean>} True if it's a Git repository, false otherwise.
+ */
+ private async isGitRepository(dirPath: string): Promise<boolean> {
+ let currentPath = path.resolve(dirPath);
+ const root = path.parse(currentPath).root;
+
+ try {
+ while (true) {
+ const gitPath = path.join(currentPath, '.git');
+ try {
+ const stats = await fsPromises.stat(gitPath);
+ if (stats.isDirectory() || stats.isFile()) {
+ return true;
+ }
+ // If .git exists but isn't a file/dir, something is weird, return false
+ return false;
+ } catch (error: unknown) {
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
+ console.error(
+ `Error checking for .git in ${currentPath}: ${error}`,
+ );
+ return false;
+ }
+ }
+
+ if (currentPath === root) {
+ break;
+ }
+ currentPath = path.dirname(currentPath);
+ }
+ } catch (error: unknown) {
+ console.error(
+ `Error traversing directory structure upwards from ${dirPath}: ${getErrorMessage(error)}`,
+ );
+ }
+ return false;
+ }
+
+ /**
+ * Parses the standard output of grep-like commands (git grep, system grep).
+ * Expects format: filePath:lineNumber:lineContent
+ * Handles colons within file paths and line content correctly.
+ * @param {string} output The raw stdout string.
+ * @param {string} basePath The absolute directory the search was run from, for relative paths.
+ * @returns {GrepMatch[]} Array of match objects.
+ */
+ private parseGrepOutput(output: string, basePath: string): GrepMatch[] {
+ const results: GrepMatch[] = [];
+ if (!output) return results;
+
+ const lines = output.split(EOL); // Use OS-specific end-of-line
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+
+ // Find the index of the first colon.
+ const firstColonIndex = line.indexOf(':');
+ if (firstColonIndex === -1) continue; // Malformed
+
+ // Find the index of the second colon, searching *after* the first one.
+ const secondColonIndex = line.indexOf(':', firstColonIndex + 1);
+ if (secondColonIndex === -1) continue; // Malformed
+
+ // Extract parts based on the found colon indices
+ const filePathRaw = line.substring(0, firstColonIndex);
+ const lineNumberStr = line.substring(
+ firstColonIndex + 1,
+ secondColonIndex,
+ );
+ const lineContent = line.substring(secondColonIndex + 1);
+
+ const lineNumber = parseInt(lineNumberStr, 10);
+
+ if (!isNaN(lineNumber)) {
+ const absoluteFilePath = path.resolve(basePath, filePathRaw);
+ const relativeFilePath = path.relative(basePath, absoluteFilePath);
+
+ results.push({
+ filePath: relativeFilePath || path.basename(absoluteFilePath),
+ lineNumber,
+ line: lineContent,
+ });
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Gets a description of the grep operation
+ * @param params Parameters for the grep operation
+ * @returns A string describing the grep
+ */
+ getDescription(params: GrepToolParams): string {
+ let description = `'${params.pattern}'`;
+ if (params.include) {
+ description += ` in ${params.include}`;
+ }
+ if (params.path) {
+ const searchDir = params.path || this.rootDirectory;
+ const relativePath = makeRelative(searchDir, this.rootDirectory);
+ description += ` within ${shortenPath(relativePath || './')}`;
+ }
+ return description;
+ }
+
+ /**
+ * Performs the actual search using the prioritized strategies.
+ * @param options Search options including pattern, absolute path, and include glob.
+ * @returns A promise resolving to an array of match objects.
+ */
+ private async performGrepSearch(options: {
+ pattern: string;
+ path: string; // Expects absolute path
+ include?: string;
+ }): Promise<GrepMatch[]> {
+ const { pattern, path: absolutePath, include } = options;
+ let strategyUsed = 'none';
+
+ try {
+ // --- Strategy 1: git grep ---
+ const isGit = await this.isGitRepository(absolutePath);
+ const gitAvailable = isGit && (await this.isCommandAvailable('git'));
+
+ if (gitAvailable) {
+ strategyUsed = 'git grep';
+ const gitArgs = [
+ 'grep',
+ '--untracked',
+ '-n',
+ '-E',
+ '--ignore-case',
+ pattern,
+ ];
+ if (include) {
+ gitArgs.push('--', include);
+ }
+
+ try {
+ const output = await new Promise<string>((resolve, reject) => {
+ const child = spawn('git', gitArgs, {
+ cwd: absolutePath,
+ windowsHide: true,
+ });
+ const stdoutChunks: Buffer[] = [];
+ const stderrChunks: Buffer[] = [];
+
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
+ child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
+ child.on('error', (err) =>
+ reject(new Error(`Failed to start git grep: ${err.message}`)),
+ );
+ child.on('close', (code) => {
+ const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
+ const stderrData = Buffer.concat(stderrChunks).toString('utf8');
+ if (code === 0) resolve(stdoutData);
+ else if (code === 1)
+ resolve(''); // No matches
+ else
+ reject(
+ new Error(`git grep exited with code ${code}: ${stderrData}`),
+ );
+ });
+ });
+ return this.parseGrepOutput(output, absolutePath);
+ } catch (gitError: unknown) {
+ console.warn(
+ `GrepLogic: git grep failed: ${getErrorMessage(gitError)}. Falling back...`,
+ );
+ }
+ }
+
+ // --- Strategy 2: System grep ---
+ const grepAvailable = await this.isCommandAvailable('grep');
+ if (grepAvailable) {
+ strategyUsed = 'system grep';
+ const grepArgs = ['-r', '-n', '-H', '-E'];
+ const commonExcludes = ['.git', 'node_modules', 'bower_components'];
+ commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
+ if (include) {
+ grepArgs.push(`--include=${include}`);
+ }
+ grepArgs.push(pattern);
+ grepArgs.push('.');
+
+ try {
+ const output = await new Promise<string>((resolve, reject) => {
+ const child = spawn('grep', grepArgs, {
+ cwd: absolutePath,
+ windowsHide: true,
+ });
+ const stdoutChunks: Buffer[] = [];
+ const stderrChunks: Buffer[] = [];
+
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
+ child.stderr.on('data', (chunk) => {
+ const stderrStr = chunk.toString();
+ // Suppress common harmless stderr messages
+ if (
+ !stderrStr.includes('Permission denied') &&
+ !/grep:.*: Is a directory/i.test(stderrStr)
+ ) {
+ stderrChunks.push(chunk);
+ }
+ });
+ child.on('error', (err) =>
+ reject(new Error(`Failed to start system grep: ${err.message}`)),
+ );
+ child.on('close', (code) => {
+ const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
+ const stderrData = Buffer.concat(stderrChunks)
+ .toString('utf8')
+ .trim();
+ if (code === 0) resolve(stdoutData);
+ else if (code === 1)
+ resolve(''); // No matches
+ else {
+ if (stderrData)
+ reject(
+ new Error(
+ `System grep exited with code ${code}: ${stderrData}`,
+ ),
+ );
+ else resolve(''); // Exit code > 1 but no stderr, likely just suppressed errors
+ }
+ });
+ });
+ return this.parseGrepOutput(output, absolutePath);
+ } catch (grepError: unknown) {
+ console.warn(
+ `GrepLogic: System grep failed: ${getErrorMessage(grepError)}. Falling back...`,
+ );
+ }
+ }
+
+ // --- Strategy 3: Pure JavaScript Fallback ---
+ console.warn(
+ 'GrepLogic: Falling back to JavaScript grep implementation.',
+ );
+ strategyUsed = 'javascript fallback';
+ const globPattern = include ? include : '**/*';
+ const ignorePatterns = [
+ '.git/**',
+ 'node_modules/**',
+ 'bower_components/**',
+ '.svn/**',
+ '.hg/**',
+ ]; // Use glob patterns for ignores here
+
+ const filesStream = fastGlob.stream(globPattern, {
+ cwd: absolutePath,
+ dot: true,
+ ignore: ignorePatterns,
+ absolute: true,
+ onlyFiles: true,
+ suppressErrors: true,
+ stats: false,
+ });
+
+ const regex = new RegExp(pattern, 'i');
+ const allMatches: GrepMatch[] = [];
+
+ for await (const filePath of filesStream) {
+ const fileAbsolutePath = filePath as string;
+ try {
+ const content = await fsPromises.readFile(fileAbsolutePath, 'utf8');
+ const lines = content.split(/\r?\n/);
+ lines.forEach((line, index) => {
+ if (regex.test(line)) {
+ allMatches.push({
+ filePath:
+ path.relative(absolutePath, fileAbsolutePath) ||
+ path.basename(fileAbsolutePath),
+ lineNumber: index + 1,
+ line,
+ });
+ }
+ });
+ } catch (readError: unknown) {
+ // Ignore errors like permission denied or file gone during read
+ if (!isNodeError(readError) || readError.code !== 'ENOENT') {
+ console.warn(
+ `GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage(readError)}`,
+ );
+ }
+ }
+ }
+
+ return allMatches;
+ } catch (error: unknown) {
+ console.error(
+ `GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage(error)}`,
+ );
+ throw error; // Re-throw
+ }
+ }
+}
diff --git a/packages/server/src/tools/ls.ts b/packages/server/src/tools/ls.ts
new file mode 100644
index 00000000..0e856e80
--- /dev/null
+++ b/packages/server/src/tools/ls.ts
@@ -0,0 +1,276 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { BaseTool, ToolResult } from './tools.js';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { makeRelative, shortenPath } from '../utils/paths.js';
+
+/**
+ * Parameters for the LS tool
+ */
+export interface LSToolParams {
+ /**
+ * The absolute path to the directory to list
+ */
+ path: string;
+
+ /**
+ * List of glob patterns to ignore
+ */
+ ignore?: string[];
+}
+
+/**
+ * File entry returned by LS tool
+ */
+export interface FileEntry {
+ /**
+ * Name of the file or directory
+ */
+ name: string;
+
+ /**
+ * Absolute path to the file or directory
+ */
+ path: string;
+
+ /**
+ * Whether this entry is a directory
+ */
+ isDirectory: boolean;
+
+ /**
+ * Size of the file in bytes (0 for directories)
+ */
+ size: number;
+
+ /**
+ * Last modified timestamp
+ */
+ modifiedTime: Date;
+}
+
+/**
+ * Implementation of the LS tool logic
+ */
+export class LSLogic extends BaseTool<LSToolParams, ToolResult> {
+ static readonly Name = 'list_directory';
+
+ /**
+ * The root directory that this tool is grounded in.
+ * All path operations will be restricted to this directory.
+ */
+ private rootDirectory: string;
+
+ /**
+ * Creates a new instance of the LSLogic
+ * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory.
+ */
+ constructor(rootDirectory: string) {
+ super(
+ LSLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ path: {
+ description:
+ 'The absolute path to the directory to list (must be absolute, not relative)',
+ type: 'string',
+ },
+ ignore: {
+ description: 'List of glob patterns to ignore',
+ items: {
+ type: 'string',
+ },
+ type: 'array',
+ },
+ },
+ required: ['path'],
+ type: 'object',
+ },
+ );
+
+ // Set the root directory
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ /**
+ * Checks if a path is within the root directory
+ * @param dirpath The path to check
+ * @returns True if the path is within the root directory, false otherwise
+ */
+ private isWithinRoot(dirpath: string): boolean {
+ const normalizedPath = path.normalize(dirpath);
+ const normalizedRoot = path.normalize(this.rootDirectory);
+ // Ensure the normalizedRoot ends with a path separator for proper path comparison
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ return (
+ normalizedPath === normalizedRoot ||
+ normalizedPath.startsWith(rootWithSep)
+ );
+ }
+
+ /**
+ * Validates the parameters for the tool
+ * @param params Parameters to validate
+ * @returns An error message string if invalid, null otherwise
+ */
+ validateToolParams(params: LSToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+ if (!path.isAbsolute(params.path)) {
+ return `Path must be absolute: ${params.path}`;
+ }
+ if (!this.isWithinRoot(params.path)) {
+ return `Path must be within the root directory (${this.rootDirectory}): ${params.path}`;
+ }
+ return null;
+ }
+
+ /**
+ * Checks if a filename matches any of the ignore patterns
+ * @param filename Filename to check
+ * @param patterns Array of glob patterns to check against
+ * @returns True if the filename should be ignored
+ */
+ private shouldIgnore(filename: string, patterns?: string[]): boolean {
+ if (!patterns || patterns.length === 0) {
+ return false;
+ }
+ for (const pattern of patterns) {
+ // Convert glob pattern to RegExp
+ const regexPattern = pattern
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
+ .replace(/\*/g, '.*')
+ .replace(/\?/g, '.');
+ const regex = new RegExp(`^${regexPattern}$`);
+ if (regex.test(filename)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Gets a description of the file reading operation
+ * @param params Parameters for the file reading
+ * @returns A string describing the file being read
+ */
+ getDescription(params: LSToolParams): string {
+ const relativePath = makeRelative(params.path, this.rootDirectory);
+ return shortenPath(relativePath);
+ }
+
+ // Helper for consistent error formatting
+ private errorResult(llmContent: string, returnDisplay: string): ToolResult {
+ return {
+ llmContent,
+ // Keep returnDisplay simpler in core logic
+ returnDisplay: `Error: ${returnDisplay}`,
+ };
+ }
+
+ /**
+ * Executes the LS operation with the given parameters
+ * @param params Parameters for the LS operation
+ * @returns Result of the LS operation
+ */
+ async execute(params: LSToolParams): Promise<ToolResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ return this.errorResult(
+ `Error: Invalid parameters provided. Reason: ${validationError}`,
+ `Failed to execute tool.`,
+ );
+ }
+
+ try {
+ const stats = fs.statSync(params.path);
+ if (!stats) {
+ // fs.statSync throws on non-existence, so this check might be redundant
+ // but keeping for clarity. Error message adjusted.
+ return this.errorResult(
+ `Error: Directory not found or inaccessible: ${params.path}`,
+ `Directory not found or inaccessible.`,
+ );
+ }
+ if (!stats.isDirectory()) {
+ return this.errorResult(
+ `Error: Path is not a directory: ${params.path}`,
+ `Path is not a directory.`,
+ );
+ }
+
+ const files = fs.readdirSync(params.path);
+ const entries: FileEntry[] = [];
+ if (files.length === 0) {
+ // Changed error message to be more neutral for LLM
+ return {
+ llmContent: `Directory ${params.path} is empty.`,
+ returnDisplay: `Directory is empty.`,
+ };
+ }
+
+ for (const file of files) {
+ if (this.shouldIgnore(file, params.ignore)) {
+ continue;
+ }
+
+ const fullPath = path.join(params.path, file);
+ try {
+ const stats = fs.statSync(fullPath);
+ const isDir = stats.isDirectory();
+ entries.push({
+ name: file,
+ path: fullPath,
+ isDirectory: isDir,
+ size: isDir ? 0 : stats.size,
+ modifiedTime: stats.mtime,
+ });
+ } catch (error) {
+ // Log error internally but don't fail the whole listing
+ console.error(`Error accessing ${fullPath}: ${error}`);
+ }
+ }
+
+ // Sort entries (directories first, then alphabetically)
+ entries.sort((a, b) => {
+ if (a.isDirectory && !b.isDirectory) return -1;
+ if (!a.isDirectory && b.isDirectory) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ // Create formatted content for LLM
+ const directoryContent = entries
+ .map((entry) => {
+ // More concise format for LLM
+ return `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`;
+ })
+ .join('\n');
+
+ return {
+ llmContent: `Directory listing for ${params.path}:\n${directoryContent}`,
+ // Simplified display, CLI wrapper can enhance
+ returnDisplay: `Listed ${entries.length} item(s).`,
+ };
+ } catch (error) {
+ const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
+ return this.errorResult(errorMsg, 'Failed to list directory.');
+ }
+ }
+}
diff --git a/packages/server/src/tools/read-file.ts b/packages/server/src/tools/read-file.ts
new file mode 100644
index 00000000..9d053003
--- /dev/null
+++ b/packages/server/src/tools/read-file.ts
@@ -0,0 +1,278 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { makeRelative, shortenPath } from '../utils/paths.js';
+import { BaseTool, ToolResult } from './tools.js';
+
+/**
+ * Parameters for the ReadFile tool
+ */
+export interface ReadFileToolParams {
+ /**
+ * The absolute path to the file to read
+ */
+ file_path: string;
+
+ /**
+ * The line number to start reading from (optional)
+ */
+ offset?: number;
+
+ /**
+ * The number of lines to read (optional)
+ */
+ limit?: number;
+}
+
+/**
+ * Implementation of the ReadFile tool logic
+ */
+export class ReadFileLogic extends BaseTool<ReadFileToolParams, ToolResult> {
+ static readonly Name: string = 'read_file';
+ private static readonly DEFAULT_MAX_LINES = 2000;
+ private static readonly MAX_LINE_LENGTH = 2000;
+ private rootDirectory: string;
+
+ constructor(rootDirectory: string) {
+ super(
+ ReadFileLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ file_path: {
+ description:
+ "The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
+ type: 'string',
+ },
+ offset: {
+ description:
+ "Optional: The 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
+ type: 'number',
+ },
+ limit: {
+ description:
+ "Optional: Maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible).",
+ type: 'number',
+ },
+ },
+ required: ['file_path'],
+ type: 'object',
+ },
+ );
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ /**
+ * Checks if a path is within the root directory
+ * @param pathToCheck The path to check
+ * @returns True if the path is within the root directory, false otherwise
+ */
+ private isWithinRoot(pathToCheck: string): boolean {
+ const normalizedPath = path.normalize(pathToCheck);
+ const normalizedRoot = path.normalize(this.rootDirectory);
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ return (
+ normalizedPath === normalizedRoot ||
+ normalizedPath.startsWith(rootWithSep)
+ );
+ }
+
+ /**
+ * Validates the parameters for the ReadFile tool
+ * @param params Parameters to validate
+ * @returns True if parameters are valid, false otherwise
+ */
+ validateToolParams(params: ReadFileToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+ const filePath = params.file_path;
+ if (!path.isAbsolute(filePath)) {
+ return `File path must be absolute: ${filePath}`;
+ }
+ if (!this.isWithinRoot(filePath)) {
+ return `File path must be within the root directory (${this.rootDirectory}): ${filePath}`;
+ }
+ if (params.offset !== undefined && params.offset < 0) {
+ return 'Offset must be a non-negative number';
+ }
+ if (params.limit !== undefined && params.limit <= 0) {
+ return 'Limit must be a positive number';
+ }
+ return null;
+ }
+
+ /**
+ * Determines if a file is likely binary based on content sampling
+ * @param filePath Path to the file
+ * @returns True if the file appears to be binary
+ */
+ private isBinaryFile(filePath: string): boolean {
+ try {
+ // Read the first 4KB of the file
+ const fd = fs.openSync(filePath, 'r');
+ const buffer = Buffer.alloc(4096);
+ const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
+ fs.closeSync(fd);
+
+ // Check for null bytes or high concentration of non-printable characters
+ let nonPrintableCount = 0;
+ for (let i = 0; i < bytesRead; i++) {
+ // Null byte is a strong indicator of binary data
+ if (buffer[i] === 0) {
+ return true;
+ }
+
+ // Count non-printable characters
+ if (buffer[i] < 9 || (buffer[i] > 13 && buffer[i] < 32)) {
+ nonPrintableCount++;
+ }
+ }
+
+ // If more than 30% are non-printable, likely binary
+ return nonPrintableCount / bytesRead > 0.3;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Detects the type of file based on extension and content
+ * @param filePath Path to the file
+ * @returns File type description
+ */
+ private detectFileType(filePath: string): string {
+ const ext = path.extname(filePath).toLowerCase();
+
+ // Common image formats
+ if (
+ ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(ext)
+ ) {
+ return 'image';
+ }
+
+ // Other known binary formats
+ if (['.pdf', '.zip', '.tar', '.gz', '.exe', '.dll', '.so'].includes(ext)) {
+ return 'binary';
+ }
+
+ // Check content for binary indicators
+ if (this.isBinaryFile(filePath)) {
+ return 'binary';
+ }
+
+ return 'text';
+ }
+
+ /**
+ * Gets a description of the file reading operation
+ * @param params Parameters for the file reading
+ * @returns A string describing the file being read
+ */
+ getDescription(params: ReadFileToolParams): string {
+ const relativePath = makeRelative(params.file_path, this.rootDirectory);
+ return shortenPath(relativePath);
+ }
+
+ /**
+ * Reads a file and returns its contents with line numbers
+ * @param params Parameters for the file reading
+ * @returns Result with file contents
+ */
+ async execute(params: ReadFileToolParams): Promise<ToolResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: '**Error:** Failed to execute tool.',
+ };
+ }
+
+ const filePath = params.file_path;
+ try {
+ if (!fs.existsSync(filePath)) {
+ return {
+ llmContent: `File not found: ${filePath}`,
+ returnDisplay: `File not found.`,
+ };
+ }
+
+ const stats = fs.statSync(filePath);
+ if (stats.isDirectory()) {
+ return {
+ llmContent: `Path is a directory, not a file: ${filePath}`,
+ returnDisplay: `File is directory.`,
+ };
+ }
+
+ const fileType = this.detectFileType(filePath);
+ if (fileType !== 'text') {
+ return {
+ llmContent: `Binary file: ${filePath} (${fileType})`,
+ // For binary files, maybe returnDisplay should be empty or indicate binary?
+ // Keeping it empty for now.
+ returnDisplay: ``,
+ };
+ }
+
+ const content = fs.readFileSync(filePath, 'utf8');
+ const lines = content.split('\n');
+
+ const startLine = params.offset || 0;
+ const endLine = params.limit
+ ? startLine + params.limit
+ : Math.min(startLine + ReadFileLogic.DEFAULT_MAX_LINES, lines.length);
+ const selectedLines = lines.slice(startLine, endLine);
+
+ let truncated = false;
+ const formattedLines = selectedLines.map((line) => {
+ let processedLine = line;
+ if (line.length > ReadFileLogic.MAX_LINE_LENGTH) {
+ processedLine =
+ line.substring(0, ReadFileLogic.MAX_LINE_LENGTH) +
+ '... [truncated]';
+ truncated = true;
+ }
+
+ return processedLine;
+ });
+
+ const contentTruncated = endLine < lines.length || truncated;
+
+ let llmContent = '';
+ if (contentTruncated) {
+ llmContent += `[File truncated: showing lines ${startLine + 1}-${endLine} of ${lines.length} total lines. Use offset parameter to view more.]\n`;
+ }
+ llmContent += formattedLines.join('\n');
+
+ // Here, returnDisplay could potentially be enhanced, but for now,
+ // it's kept empty as the LLM content itself is descriptive.
+ return {
+ llmContent,
+ returnDisplay: '',
+ };
+ } catch (error) {
+ const errorMsg = `Error reading file: ${error instanceof Error ? error.message : String(error)}`;
+
+ return {
+ llmContent: `Error reading file ${filePath}: ${errorMsg}`,
+ returnDisplay: `Failed to read file: ${errorMsg}`,
+ };
+ }
+ }
+}
diff --git a/packages/server/src/tools/terminal.ts b/packages/server/src/tools/terminal.ts
new file mode 100644
index 00000000..6366106c
--- /dev/null
+++ b/packages/server/src/tools/terminal.ts
@@ -0,0 +1,256 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { spawn, SpawnOptions } from 'child_process';
+import path from 'path';
+import { BaseTool, ToolResult } from './tools.js';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { getErrorMessage } from '../utils/errors.js';
+
+export interface TerminalToolParams {
+ command: string;
+}
+
+const MAX_OUTPUT_LENGTH = 10000;
+const DEFAULT_EXEC_TIMEOUT_MS = 5 * 60 * 1000;
+
+const BANNED_COMMAND_ROOTS = [
+ 'alias',
+ 'bg',
+ 'command',
+ 'declare',
+ 'dirs',
+ 'disown',
+ 'enable',
+ 'eval',
+ 'exec',
+ 'exit',
+ 'export',
+ 'fc',
+ 'fg',
+ 'getopts',
+ 'hash',
+ 'history',
+ 'jobs',
+ 'kill',
+ 'let',
+ 'local',
+ 'logout',
+ 'popd',
+ 'printf',
+ 'pushd',
+ 'read',
+ 'readonly',
+ 'set',
+ 'shift',
+ 'shopt',
+ 'source',
+ 'suspend',
+ 'test',
+ 'times',
+ 'trap',
+ 'type',
+ 'typeset',
+ 'ulimit',
+ 'umask',
+ 'unalias',
+ 'unset',
+ 'wait',
+ 'curl',
+ 'wget',
+ 'nc',
+ 'telnet',
+ 'ssh',
+ 'scp',
+ 'ftp',
+ 'sftp',
+ 'http',
+ 'https',
+ 'rsync',
+ 'lynx',
+ 'w3m',
+ 'links',
+ 'elinks',
+ 'httpie',
+ 'xh',
+ 'http-prompt',
+ 'chrome',
+ 'firefox',
+ 'safari',
+ 'edge',
+ 'xdg-open',
+ 'open',
+];
+
+/**
+ * Simplified implementation of the Terminal tool logic for single command execution.
+ */
+export class TerminalLogic extends BaseTool<TerminalToolParams, ToolResult> {
+ static readonly Name = 'execute_bash_command';
+ private readonly rootDirectory: string;
+
+ constructor(rootDirectory: string) {
+ super(
+ TerminalLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ type: 'object',
+ properties: {
+ command: {
+ description: `The exact bash command or sequence of commands (using ';' or '&&') to execute. Must adhere to usage guidelines. Example: 'npm install && npm run build'`,
+ type: 'string',
+ },
+ },
+ required: ['command'],
+ },
+ );
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ validateParams(params: TerminalToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return "Parameters failed schema validation (expecting only 'command').";
+ }
+ const commandOriginal = params.command.trim();
+ if (!commandOriginal) {
+ return 'Command cannot be empty.';
+ }
+ const commandParts = commandOriginal.split(/[\s;&&|]+/);
+ for (const part of commandParts) {
+ if (!part) continue;
+ const cleanPart =
+ part
+ .replace(/^[^a-zA-Z0-9]+/, '')
+ .split(/[/\\]/)
+ .pop() || part.replace(/^[^a-zA-Z0-9]+/, '');
+ if (cleanPart && BANNED_COMMAND_ROOTS.includes(cleanPart.toLowerCase())) {
+ return `Command contains a banned keyword: '${cleanPart}'. Banned list includes network tools, session control, etc.`;
+ }
+ }
+ return null;
+ }
+
+ getDescription(params: TerminalToolParams): string {
+ return params.command;
+ }
+
+ async execute(
+ params: TerminalToolParams,
+ executionCwd?: string,
+ timeout: number = DEFAULT_EXEC_TIMEOUT_MS,
+ ): Promise<ToolResult> {
+ const validationError = this.validateParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Command rejected: ${params.command}\nReason: ${validationError}`,
+ returnDisplay: `Error: ${validationError}`,
+ };
+ }
+
+ const cwd = executionCwd ? path.resolve(executionCwd) : this.rootDirectory;
+ if (!cwd.startsWith(this.rootDirectory) && cwd !== this.rootDirectory) {
+ const message = `Execution CWD validation failed: Attempted path "${cwd}" resolves outside the allowed root directory "${this.rootDirectory}".`;
+ return {
+ llmContent: `Command rejected: ${params.command}\nReason: ${message}`,
+ returnDisplay: `Error: ${message}`,
+ };
+ }
+
+ return new Promise((resolve) => {
+ const spawnOptions: SpawnOptions = {
+ cwd,
+ shell: true,
+ env: { ...process.env },
+ stdio: 'pipe',
+ windowsHide: true,
+ timeout: timeout,
+ };
+ let stdout = '';
+ let stderr = '';
+ let processError: Error | null = null;
+ let timedOut = false;
+
+ try {
+ const child = spawn(params.command, spawnOptions);
+ child.stdout!.on('data', (data) => {
+ stdout += data.toString();
+ if (stdout.length > MAX_OUTPUT_LENGTH) {
+ stdout = this.truncateOutput(stdout);
+ child.stdout!.pause();
+ }
+ });
+ child.stderr!.on('data', (data) => {
+ stderr += data.toString();
+ if (stderr.length > MAX_OUTPUT_LENGTH) {
+ stderr = this.truncateOutput(stderr);
+ child.stderr!.pause();
+ }
+ });
+ child.on('error', (err) => {
+ processError = err;
+ console.error(
+ `TerminalLogic spawn error for "${params.command}":`,
+ err,
+ );
+ });
+ child.on('close', (code, signal) => {
+ const exitCode = code ?? (signal ? -1 : -2);
+ if (signal === 'SIGTERM' || signal === 'SIGKILL') {
+ if (child.killed && timeout > 0) timedOut = true;
+ }
+ const finalStdout = this.truncateOutput(stdout);
+ const finalStderr = this.truncateOutput(stderr);
+ let llmContent = `Command: ${params.command}\nExecuted in: ${cwd}\nExit Code: ${exitCode}\n`;
+ if (timedOut) llmContent += `Status: Timed Out after ${timeout}ms\n`;
+ if (processError)
+ llmContent += `Process Error: ${processError.message}\n`;
+ llmContent += `Stdout:\n${finalStdout}\nStderr:\n${finalStderr}`;
+ let displayOutput = finalStderr.trim() || finalStdout.trim();
+ if (timedOut)
+ displayOutput = `Timeout: ${displayOutput || 'No output before timeout'}`;
+ else if (exitCode !== 0 && !displayOutput)
+ displayOutput = `Failed (Exit Code: ${exitCode})`;
+ else if (exitCode === 0 && !displayOutput)
+ displayOutput = `Success (no output)`;
+ resolve({
+ llmContent,
+ returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}`,
+ });
+ });
+ } catch (spawnError: unknown) {
+ const errMsg = getErrorMessage(spawnError);
+ console.error(
+ `TerminalLogic failed to spawn "${params.command}":`,
+ spawnError,
+ );
+ resolve({
+ llmContent: `Failed to start command: ${params.command}\nError: ${errMsg}`,
+ returnDisplay: `Error spawning command: ${errMsg}`,
+ });
+ }
+ });
+ }
+
+ private truncateOutput(
+ output: string,
+ limit: number = MAX_OUTPUT_LENGTH,
+ ): string {
+ if (output.length > limit) {
+ return (
+ output.substring(0, limit) +
+ `\n... [Output truncated at ${limit} characters]`
+ );
+ }
+ return output;
+ }
+}
diff --git a/packages/server/src/tools/tools.ts b/packages/server/src/tools/tools.ts
new file mode 100644
index 00000000..4851f164
--- /dev/null
+++ b/packages/server/src/tools/tools.ts
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { FunctionDeclaration, Schema } from '@google/genai';
+// Removed import for ../ui/types.js as confirmation is UI-specific
+
+/**
+ * Interface representing the base Tool functionality
+ */
+export interface Tool<
+ TParams = unknown,
+ TResult extends ToolResult = ToolResult,
+> {
+ /**
+ * The internal name of the tool (used for API calls)
+ */
+ name: string;
+
+ /**
+ * The user-friendly display name of the tool
+ */
+ displayName: string;
+
+ /**
+ * Description of what the tool does
+ */
+ description: string;
+
+ /**
+ * Function declaration schema from @google/genai
+ */
+ schema: FunctionDeclaration;
+
+ /**
+ * Validates the parameters for the tool
+ * @param params Parameters to validate
+ * @returns An error message string if invalid, null otherwise
+ */
+ validateToolParams(params: TParams): string | null;
+
+ /**
+ * Gets a pre-execution description of the tool operation
+ * @param params Parameters for the tool execution
+ * @returns A markdown string describing what the tool will do
+ * Optional for backward compatibility
+ */
+ getDescription(params: TParams): string;
+
+ // Removed shouldConfirmExecute as it's UI-specific
+
+ /**
+ * Executes the tool with the given parameters
+ * @param params Parameters for the tool execution
+ * @returns Result of the tool execution
+ */
+ execute(params: TParams): Promise<TResult>;
+}
+
+/**
+ * Base implementation for tools with common functionality
+ */
+export abstract class BaseTool<
+ TParams = unknown,
+ TResult extends ToolResult = ToolResult,
+> implements Tool<TParams, TResult>
+{
+ /**
+ * Creates a new instance of BaseTool
+ * @param name Internal name of the tool (used for API calls)
+ * @param displayName User-friendly display name of the tool
+ * @param description Description of what the tool does
+ * @param parameterSchema JSON Schema defining the parameters
+ */
+ constructor(
+ readonly name: string,
+ readonly displayName: string,
+ readonly description: string,
+ readonly parameterSchema: Record<string, unknown>,
+ ) {}
+
+ /**
+ * Function declaration schema computed from name, description, and parameterSchema
+ */
+ get schema(): FunctionDeclaration {
+ return {
+ name: this.name,
+ description: this.description,
+ parameters: this.parameterSchema as Schema,
+ };
+ }
+
+ /**
+ * Validates the parameters for the tool
+ * This is a placeholder implementation and should be overridden
+ * @param params Parameters to validate
+ * @returns An error message string if invalid, null otherwise
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ validateToolParams(params: TParams): string | null {
+ // Implementation would typically use a JSON Schema validator
+ // This is a placeholder that should be implemented by derived classes
+ return null;
+ }
+
+ /**
+ * Gets a pre-execution description of the tool operation
+ * Default implementation that should be overridden by derived classes
+ * @param params Parameters for the tool execution
+ * @returns A markdown string describing what the tool will do
+ */
+ getDescription(params: TParams): string {
+ return JSON.stringify(params);
+ }
+
+ // Removed shouldConfirmExecute as it's UI-specific
+
+ /**
+ * Abstract method to execute the tool with the given parameters
+ * Must be implemented by derived classes
+ * @param params Parameters for the tool execution
+ * @returns Result of the tool execution
+ */
+ abstract execute(params: TParams): Promise<TResult>;
+}
+
+export interface ToolResult {
+ /**
+ * Content meant to be included in LLM history.
+ * This should represent the factual outcome of the tool execution.
+ */
+ llmContent: string;
+
+ /**
+ * Markdown string for user display.
+ * This provides a user-friendly summary or visualization of the result.
+ * NOTE: This might also be considered UI-specific and could potentially be
+ * removed or modified in a further refactor if the server becomes purely API-driven.
+ * For now, we keep it as the core logic in ReadFileTool currently produces it.
+ */
+ returnDisplay: ToolResultDisplay;
+}
+
+export type ToolResultDisplay = string | FileDiff;
+
+export interface FileDiff {
+ fileDiff: string;
+}
diff --git a/packages/server/src/tools/web-fetch.ts b/packages/server/src/tools/web-fetch.ts
new file mode 100644
index 00000000..29e33fbe
--- /dev/null
+++ b/packages/server/src/tools/web-fetch.ts
@@ -0,0 +1,141 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { BaseTool, ToolResult } from './tools.js';
+import { getErrorMessage } from '../utils/errors.js';
+
+/**
+ * Parameters for the WebFetch tool
+ */
+export interface WebFetchToolParams {
+ /**
+ * The URL to fetch content from.
+ */
+ url: string;
+}
+
+/**
+ * Implementation of the WebFetch tool logic (moved from CLI)
+ */
+export class WebFetchLogic extends BaseTool<WebFetchToolParams, ToolResult> {
+ static readonly Name: string = 'web_fetch';
+
+ constructor() {
+ super(
+ WebFetchLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ url: {
+ description:
+ "The URL to fetch. Must be an absolute URL (e.g., 'https://example.com/file.txt').",
+ type: 'string',
+ },
+ },
+ required: ['url'],
+ type: 'object',
+ },
+ );
+ }
+
+ validateParams(params: WebFetchToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+ try {
+ const parsedUrl = new URL(params.url);
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
+ return `Invalid URL protocol: "${parsedUrl.protocol}". Only 'http:' and 'https:' are supported.`;
+ }
+ } catch {
+ return `Invalid URL format: "${params.url}". Please provide a valid absolute URL (e.g., 'https://example.com').`;
+ }
+ return null;
+ }
+
+ getDescription(params: WebFetchToolParams): string {
+ const displayUrl =
+ params.url.length > 80 ? params.url.substring(0, 77) + '...' : params.url;
+ return `Fetching content from ${displayUrl}`;
+ }
+
+ // Removed shouldConfirmExecute - handled by CLI
+
+ async execute(params: WebFetchToolParams): Promise<ToolResult> {
+ const validationError = this.validateParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: `Error: ${validationError}`,
+ };
+ }
+
+ const url = params.url;
+
+ try {
+ const response = await fetch(url, {
+ headers: {
+ // Identify the client making the request
+ 'User-Agent': 'GeminiCode-ServerLogic/1.0',
+ },
+ signal: AbortSignal.timeout(15000), // Use AbortSignal for timeout
+ });
+
+ if (!response.ok) {
+ const errorText = `Failed to fetch data from ${url}. Status: ${response.status} ${response.statusText}`;
+ return {
+ llmContent: `Error: ${errorText}`,
+ returnDisplay: `Error: ${errorText}`,
+ };
+ }
+
+ // Basic check for text-based content types
+ const contentType = response.headers.get('content-type') || '';
+ if (
+ !contentType.includes('text/') &&
+ !contentType.includes('json') &&
+ !contentType.includes('xml')
+ ) {
+ const errorText = `Unsupported content type: ${contentType} from ${url}`;
+ return {
+ llmContent: `Error: ${errorText}`,
+ returnDisplay: `Error: ${errorText}`,
+ };
+ }
+
+ const data = await response.text();
+ const MAX_LLM_CONTENT_LENGTH = 200000; // Truncate large responses
+ const truncatedData =
+ data.length > MAX_LLM_CONTENT_LENGTH
+ ? data.substring(0, MAX_LLM_CONTENT_LENGTH) +
+ '\n... [Content truncated]'
+ : data;
+
+ const llmContent = data
+ ? `Fetched data from ${url}:\n\n${truncatedData}`
+ : `No text data fetched from ${url}. Status: ${response.status}`; // Adjusted message for clarity
+
+ return {
+ llmContent,
+ returnDisplay: `Fetched content from ${url}`,
+ };
+ } catch (error: unknown) {
+ const errorMessage = `Failed to fetch data from ${url}. Error: ${getErrorMessage(error)}`;
+ return {
+ llmContent: `Error: ${errorMessage}`,
+ returnDisplay: `Error: ${errorMessage}`,
+ };
+ }
+ }
+}
diff --git a/packages/server/src/tools/write-file.ts b/packages/server/src/tools/write-file.ts
new file mode 100644
index 00000000..ce723061
--- /dev/null
+++ b/packages/server/src/tools/write-file.ts
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import * as Diff from 'diff'; // Keep for result generation
+import { BaseTool, ToolResult, FileDiff } from './tools.js'; // Updated import (Removed ToolResultDisplay)
+import { SchemaValidator } from '../utils/schemaValidator.js'; // Updated import
+import { makeRelative, shortenPath } from '../utils/paths.js'; // Updated import
+import { isNodeError } from '../utils/errors.js'; // Import isNodeError
+
+/**
+ * Parameters for the WriteFile tool
+ */
+export interface WriteFileToolParams {
+ /**
+ * The absolute path to the file to write to
+ */
+ file_path: string;
+
+ /**
+ * The content to write to the file
+ */
+ content: string;
+}
+
+/**
+ * Implementation of the WriteFile tool logic (moved from CLI)
+ */
+export class WriteFileLogic extends BaseTool<WriteFileToolParams, ToolResult> {
+ static readonly Name: string = 'write_file';
+
+ private readonly rootDirectory: string;
+
+ constructor(rootDirectory: string) {
+ super(
+ WriteFileLogic.Name,
+ '', // Display name handled by CLI wrapper
+ '', // Description handled by CLI wrapper
+ {
+ properties: {
+ file_path: {
+ // Renamed from filePath in original schema
+ description:
+ "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
+ type: 'string',
+ },
+ content: {
+ description: 'The content to write to the file.',
+ type: 'string',
+ },
+ },
+ required: ['file_path', 'content'], // Use correct param names
+ type: 'object',
+ },
+ );
+ this.rootDirectory = path.resolve(rootDirectory);
+ }
+
+ private isWithinRoot(pathToCheck: string): boolean {
+ const normalizedPath = path.normalize(pathToCheck);
+ const normalizedRoot = path.normalize(this.rootDirectory);
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ return (
+ normalizedPath === normalizedRoot ||
+ normalizedPath.startsWith(rootWithSep)
+ );
+ }
+
+ validateParams(params: WriteFileToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+ if (!path.isAbsolute(params.file_path)) {
+ return `File path must be absolute: ${params.file_path}`;
+ }
+ if (!this.isWithinRoot(params.file_path)) {
+ return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`;
+ }
+ return null;
+ }
+
+ // Removed shouldConfirmExecute - handled by CLI
+
+ getDescription(params: WriteFileToolParams): string {
+ const relativePath = makeRelative(params.file_path, this.rootDirectory);
+ return `Writing to ${shortenPath(relativePath)}`;
+ }
+
+ async execute(params: WriteFileToolParams): Promise<ToolResult> {
+ const validationError = this.validateParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: `Error: ${validationError}`,
+ };
+ }
+
+ let currentContent = '';
+ let isNewFile = false;
+ try {
+ currentContent = fs.readFileSync(params.file_path, 'utf8');
+ } catch (err: unknown) {
+ if (isNodeError(err) && err.code === 'ENOENT') {
+ isNewFile = true;
+ } else {
+ // Rethrow other read errors (permissions etc.)
+ const errorMsg = `Error checking existing file: ${err instanceof Error ? err.message : String(err)}`;
+ return {
+ llmContent: `Error checking existing file ${params.file_path}: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ };
+ }
+ }
+
+ try {
+ const dirName = path.dirname(params.file_path);
+ if (!fs.existsSync(dirName)) {
+ fs.mkdirSync(dirName, { recursive: true });
+ }
+
+ fs.writeFileSync(params.file_path, params.content, 'utf8');
+
+ // Generate diff for display result
+ const fileName = path.basename(params.file_path);
+ const fileDiff = Diff.createPatch(
+ fileName,
+ currentContent, // Empty if it was a new file
+ params.content,
+ 'Original',
+ 'Written',
+ { context: 3 },
+ );
+
+ const llmSuccessMessage = isNewFile
+ ? `Successfully created and wrote to new file: ${params.file_path}`
+ : `Successfully overwrote file: ${params.file_path}`;
+
+ // The returnDisplay contains the diff
+ const displayResult: FileDiff = { fileDiff };
+
+ return {
+ llmContent: llmSuccessMessage,
+ returnDisplay: displayResult,
+ };
+ } catch (error) {
+ const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`;
+ return {
+ llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ };
+ }
+ }
+
+ // ensureParentDirectoriesExist logic moved into execute
+}