diff options
| author | Evan Senter <[email protected]> | 2025-04-19 19:45:42 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-04-19 19:45:42 +0100 |
| commit | 3fce6cea27d3e6129d6c06e528b62e1b11bf7094 (patch) | |
| tree | 244b8e9ab94f902d65d4bda8739a6538e377ed17 /packages/server/src | |
| parent | 0c9e1ef61be7db53e6e73b7208b649cd8cbed6c3 (diff) | |
Starting to modularize into separate cli / server packages. (#55)
* Starting to move a lot of code into packages/server
* More of the massive refactor, builds and runs, some issues though.
* Fixing outstanding issue with double messages.
* Fixing a minor UI issue.
* Fixing the build post-merge.
* Running formatting.
* Addressing comments.
Diffstat (limited to 'packages/server/src')
| -rw-r--r-- | packages/server/src/config/config.ts | 65 | ||||
| -rw-r--r-- | packages/server/src/core/gemini-client.ts | 171 | ||||
| -rw-r--r-- | packages/server/src/core/prompts.ts | 101 | ||||
| -rw-r--r-- | packages/server/src/core/turn.ts | 199 | ||||
| -rw-r--r-- | packages/server/src/index.test.ts | 8 | ||||
| -rw-r--r-- | packages/server/src/index.ts | 31 | ||||
| -rw-r--r-- | packages/server/src/tools/edit.ts | 353 | ||||
| -rw-r--r-- | packages/server/src/tools/glob.ts | 216 | ||||
| -rw-r--r-- | packages/server/src/tools/grep.ts | 565 | ||||
| -rw-r--r-- | packages/server/src/tools/ls.ts | 276 | ||||
| -rw-r--r-- | packages/server/src/tools/read-file.ts | 278 | ||||
| -rw-r--r-- | packages/server/src/tools/terminal.ts | 256 | ||||
| -rw-r--r-- | packages/server/src/tools/tools.ts | 150 | ||||
| -rw-r--r-- | packages/server/src/tools/web-fetch.ts | 141 | ||||
| -rw-r--r-- | packages/server/src/tools/write-file.ts | 167 | ||||
| -rw-r--r-- | packages/server/src/utils/errors.ts | 24 | ||||
| -rw-r--r-- | packages/server/src/utils/getFolderStructure.ts | 389 | ||||
| -rw-r--r-- | packages/server/src/utils/paths.ts | 102 | ||||
| -rw-r--r-- | packages/server/src/utils/schemaValidator.ts | 59 |
19 files changed, 3543 insertions, 8 deletions
diff --git a/packages/server/src/config/config.ts b/packages/server/src/config/config.ts new file mode 100644 index 00000000..86cd7a6c --- /dev/null +++ b/packages/server/src/config/config.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as dotenv from 'dotenv'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import process from 'node:process'; + +export class Config { + private apiKey: string; + private model: string; + private targetDir: string; + + constructor(apiKey: string, model: string, targetDir: string) { + this.apiKey = apiKey; + this.model = model; + this.targetDir = targetDir; + } + + getApiKey(): string { + return this.apiKey; + } + + getModel(): string { + return this.model; + } + + getTargetDir(): string { + return this.targetDir; + } +} + +function findEnvFile(startDir: string): string | null { + let currentDir = path.resolve(startDir); + while (true) { + const envPath = path.join(currentDir, '.env'); + if (fs.existsSync(envPath)) { + return envPath; + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir || !parentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function loadEnvironment(): void { + const envFilePath = findEnvFile(process.cwd()); + if (!envFilePath) { + return; + } + dotenv.config({ path: envFilePath }); +} + +export function createServerConfig( + apiKey: string, + model: string, + targetDir: string, +): Config { + return new Config(apiKey, model, path.resolve(targetDir)); +} diff --git a/packages/server/src/core/gemini-client.ts b/packages/server/src/core/gemini-client.ts new file mode 100644 index 00000000..c7415ed8 --- /dev/null +++ b/packages/server/src/core/gemini-client.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + GenerateContentConfig, + GoogleGenAI, + Part, + Chat, + SchemaUnion, + PartListUnion, + Content, + FunctionDeclaration, + Tool, +} from '@google/genai'; +import { CoreSystemPrompt } from './prompts.js'; +import process from 'node:process'; +import { getFolderStructure } from '../utils/getFolderStructure.js'; +import { Turn, ServerTool, GeminiEventType } from './turn.js'; + +// Import the ServerGeminiStreamEvent type +type ServerGeminiStreamEvent = + | { type: GeminiEventType.Content; value: string } + | { + type: GeminiEventType.ToolCallRequest; + value: { callId: string; name: string; args: Record<string, unknown> }; + }; + +export class GeminiClient { + private ai: GoogleGenAI; + private model: string; + private generateContentConfig: GenerateContentConfig = { + temperature: 0, + topP: 1, + }; + private readonly MAX_TURNS = 100; + + constructor(apiKey: string, model: string) { + this.ai = new GoogleGenAI({ apiKey: apiKey }); + this.model = model; + } + + private async getEnvironment(): Promise<Part> { + const cwd = process.cwd(); + const today = new Date().toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const platform = process.platform; + const folderStructure = await getFolderStructure(cwd); + const context = ` + Okay, just setting up the context for our chat. + Today is ${today}. + My operating system is: ${platform} + I'm currently working in the directory: ${cwd} + ${folderStructure} + `.trim(); + return { text: context }; + } + + async startChat(toolDeclarations: FunctionDeclaration[]): Promise<Chat> { + const envPart = await this.getEnvironment(); + const tools: Tool[] = toolDeclarations.map((declaration) => ({ + functionDeclarations: [declaration], + })); + try { + const chat = this.ai.chats.create({ + model: this.model, + config: { + systemInstruction: CoreSystemPrompt, + ...this.generateContentConfig, + tools: tools, + }, + history: [ + { + role: 'user', + parts: [envPart], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the context!' }], + }, + ], + }); + return chat; + } catch (error) { + console.error('Error initializing Gemini chat session:', error); + const message = error instanceof Error ? error.message : 'Unknown error.'; + throw new Error(`Failed to initialize chat: ${message}`); + } + } + + async *sendMessageStream( + chat: Chat, + request: PartListUnion, + availableTools: ServerTool[], + signal?: AbortSignal, + ): AsyncGenerator<ServerGeminiStreamEvent> { + let turns = 0; + try { + while (turns < this.MAX_TURNS) { + turns++; + const turn = new Turn(chat, availableTools); + const resultStream = turn.run(request, signal); + for await (const event of resultStream) { + yield event; + } + const fnResponses = turn.getFunctionResponses(); + if (fnResponses.length > 0) { + request = fnResponses; + continue; + } else { + break; + } + } + if (turns >= this.MAX_TURNS) { + console.warn( + 'sendMessageStream: Reached maximum tool call turns limit.', + ); + } + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('Gemini stream request aborted by user.'); + throw error; + } else { + console.error(`Error during Gemini stream or tool interaction:`, error); + throw error; + } + } + } + + async generateJson( + contents: Content[], + schema: SchemaUnion, + ): Promise<Record<string, unknown>> { + try { + const result = await this.ai.models.generateContent({ + model: this.model, + config: { + ...this.generateContentConfig, + systemInstruction: CoreSystemPrompt, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, + }); + const responseText = result.text; + if (!responseText) { + throw new Error('API returned an empty response.'); + } + try { + const parsedJson = JSON.parse(responseText); + return parsedJson; + } catch (parseError) { + console.error('Failed to parse JSON response:', responseText); + throw new Error( + `Failed to parse API response as JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + ); + } + } catch (error) { + console.error('Error generating JSON content:', error); + const message = + error instanceof Error ? error.message : 'Unknown API error.'; + throw new Error(`Failed to generate JSON content: ${message}`); + } + } +} diff --git a/packages/server/src/core/prompts.ts b/packages/server/src/core/prompts.ts new file mode 100644 index 00000000..60e1ff5c --- /dev/null +++ b/packages/server/src/core/prompts.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Note: Tool names are referenced here. If they change in tool definitions, update this prompt. +// import { ReadFileTool } from '../tools/read-file.tool.js'; +// import { TerminalTool } from '../tools/terminal.tool.js'; + +const MEMORY_FILE_NAME = 'GEMINI.md'; + +const contactEmail = '[email protected]'; +export const CoreSystemPrompt = ` +You are an interactive CLI tool assistant specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. + +# Core Directives & Safety Rules +1. **Explain Critical Commands:** Before executing any command (especially using \`execute_bash_command\`) that modifies the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. +2. **NEVER Commit Changes:** Unless explicitly instructed by the user to do so, you MUST NOT commit changes to version control (e.g., git commit). This is critical for user control over their repository. +3. **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. + +# Primary Workflow: Software Engineering Tasks +When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: +1. **Understand:** Analyze the user's request and the relevant codebase context. Check for project-specific information in \`${MEMORY_FILE_NAME}\` if it exists. Use search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. +2. **Implement:** Use the available tools (e.g., file editing, \`execute_bash_command\`) to construct the solution, strictly adhering to the project's established conventions (see 'Following Conventions' below). + - If creating a new project rely on scaffolding commands do lay out the initial project structure (i.e. npm init ...) +3. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining \`README\` files, \`${MEMORY_FILE_NAME}\`, build/package configuration (e.g., \`package.json\`), or existing test execution patterns. NEVER assume standard test commands. +4. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific linting and type-checking commands (e.g., \`npm run lint\`, \`ruff check .\`, \`tsc\`) that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, ask the user and propose adding them to \`${MEMORY_FILE_NAME}\` for future reference. + +# Key Operating Principles + +## Following Conventions +Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code and configuration first. +- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like \`package.json\`, \`Cargo.toml\`, \`requirements.txt\`, \`build.gradle\`, etc., or observe neighboring files) before employing it. +- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. +- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. +- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add comments if necessary for clarity or if requested by the user. + +## Memory (${MEMORY_FILE_NAME}) +Utilize the \`${MEMORY_FILE_NAME}\` file in the current working directory for project-specific context: +- Reference stored commands, style preferences, and codebase notes when performing tasks. +- When you discover frequently used commands (build, test, lint, typecheck) or learn about specific project conventions or style preferences, proactively propose adding them to \`${MEMORY_FILE_NAME}\` for future sessions. + +## Tone and Style (CLI Interaction) +- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment. +- **Minimal Output:** Aim for fewer than 4 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query. +- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations (like pre-command warnings) or when seeking necessary clarification if a request is ambiguous. +- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer. +- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace. +- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself. +- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. + +## Proactiveness +- **Act within Scope:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. +- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it. +- **Stop After Action:** After completing a code modification or file operation, simply stop. Do not provide summaries unless asked. + +# Tool Usage +- **Search:** Prefer the Agent tool for file searching to optimize context usage. +- **Parallelism:** Execute multiple independent tool calls in parallel when feasible. +- **Command Execution:** Use the \`execute_bash_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. + +# Interaction Details +- **Help Command:** Use \`/help\` to display Gemini Code help. To get specific command/flag info, execute \`gemini -h\` via \`execute_bash_command\` and show the output. +- **Synthetic Messages:** Ignore system messages like \`++Request Cancelled++\`. Do not generate them. +- **Feedback:** Direct feedback to ${contactEmail}. + +# Examples (Illustrating Tone and Workflow) +<example> +user: 1 + 2 +assistant: 3 +</example> + +<example> +user: is 13 a prime number? +assistant: true +</example> + +<example> +user: List files here. +assistant: [tool_call: execute_bash_command for 'ls -la']))] +</example> + +<example> +user: Refactor the auth logic in src/auth.py to use the 'requests' library. +assistant: Okay, I see src/auth.py currently uses 'urllib'. Before changing it, I need to check if 'requests' is already a project dependency. [tool_call: execute_bash_command for grep 'requests', 'requirements.txt'] +(After confirming dependency or asking user to add it) +Okay, 'requests' is available. I will now refactor src/auth.py. +[tool_call: Uses read, edit tools following conventions] +(After editing) +[tool_call: Runs project-specific lint/typecheck commands found previously, e.g., execute_bash_command for 'ruff', 'check', 'src/auth.py'] +</example> + +<example> +user: Delete the temp directory. +assistant: I can run \`rm -rf ./temp\`. This will permanently delete the directory and all its contents. Is it okay to proceed? +</example> + +# Final Reminder +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions on the contents of files; instead use the read_file to ensure you aren't making too broad of assumptions. +`; diff --git a/packages/server/src/core/turn.ts b/packages/server/src/core/turn.ts new file mode 100644 index 00000000..bf5c3e86 --- /dev/null +++ b/packages/server/src/core/turn.ts @@ -0,0 +1,199 @@ +import { + Part, + Chat, + PartListUnion, + GenerateContentResponse, + FunctionCall, + FunctionDeclaration, +} from '@google/genai'; +// Removed UI type imports +import { ToolResult } from '../tools/tools.js'; // Keep ToolResult for now +// Removed gemini-stream import (types defined locally) + +// --- Types for Server Logic --- + +// Define a simpler structure for Tool execution results within the server +interface ServerToolExecutionOutcome { + callId: string; + name: string; + args: Record<string, unknown>; // Use unknown for broader compatibility + result?: ToolResult; + error?: Error; + // Confirmation details are handled by CLI, not server logic +} + +// Define a structure for tools passed to the server +export interface ServerTool { + name: string; + schema: FunctionDeclaration; // Schema is needed + // The execute method signature might differ slightly or be wrapped + execute(params: Record<string, unknown>): Promise<ToolResult>; + // validation and description might be handled differently or passed +} + +// Redefine necessary event types locally +export enum GeminiEventType { + Content = 'content', + ToolCallRequest = 'tool_call_request', +} + +interface ToolCallRequestInfo { + callId: string; + name: string; + args: Record<string, unknown>; +} + +type ServerGeminiStreamEvent = + | { type: GeminiEventType.Content; value: string } + | { type: GeminiEventType.ToolCallRequest; value: ToolCallRequestInfo }; + +// --- Turn Class (Refactored for Server) --- + +// A turn manages the agentic loop turn within the server context. +export class Turn { + private readonly chat: Chat; + private readonly availableTools: Map<string, ServerTool>; // Use passed-in tools + private pendingToolCalls: Array<{ + callId: string; + name: string; + args: Record<string, unknown>; // Use unknown + }>; + private fnResponses: Part[]; + private debugResponses: GenerateContentResponse[]; + + constructor(chat: Chat, availableTools: ServerTool[]) { + this.chat = chat; + this.availableTools = new Map(availableTools.map((t) => [t.name, t])); + this.pendingToolCalls = []; + this.fnResponses = []; + this.debugResponses = []; + } + + // The run method yields simpler events suitable for server logic + async *run( + req: PartListUnion, + signal?: AbortSignal, + ): AsyncGenerator<ServerGeminiStreamEvent> { + const responseStream = await this.chat.sendMessageStream({ message: req }); + + for await (const resp of responseStream) { + this.debugResponses.push(resp); + if (signal?.aborted) { + throw this.abortError(); + } + if (resp.text) { + yield { type: GeminiEventType.Content, value: resp.text }; + continue; + } + if (!resp.functionCalls) { + continue; + } + + // Handle function calls (requesting tool execution) + for (const fnCall of resp.functionCalls) { + const event = this.handlePendingFunctionCall(fnCall); + if (event) { + yield event; + } + } + + // Execute pending tool calls + const toolPromises = this.pendingToolCalls.map( + async (pendingToolCall): Promise<ServerToolExecutionOutcome> => { + const tool = this.availableTools.get(pendingToolCall.name); + if (!tool) { + return { + ...pendingToolCall, + error: new Error( + `Tool "${pendingToolCall.name}" not found or not provided to Turn.`, + ), + }; + } + // No confirmation logic in the server Turn + try { + // TODO: Add validation step if needed (tool.validateParams?) + const result = await tool.execute(pendingToolCall.args); + return { ...pendingToolCall, result }; + } catch (execError: unknown) { + return { + ...pendingToolCall, + error: new Error( + `Tool execution failed: ${execError instanceof Error ? execError.message : String(execError)}`, + ), + }; + } + }, + ); + const outcomes = await Promise.all(toolPromises); + + // Process outcomes and prepare function responses + this.fnResponses = this.buildFunctionResponses(outcomes); + this.pendingToolCalls = []; // Clear pending calls for this turn + + // If there were function responses, the caller (GeminiService) will loop + // and call run() again with these responses. + // If no function responses, the turn ends here. + } + } + + // Generates a ToolCallRequest event to signal the need for execution + private handlePendingFunctionCall( + fnCall: FunctionCall, + ): ServerGeminiStreamEvent | null { + const callId = + fnCall.id ?? + `${fnCall.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const name = fnCall.name || 'undefined_tool_name'; + const args = (fnCall.args || {}) as Record<string, unknown>; + + this.pendingToolCalls.push({ callId, name, args }); + + // Yield a request for the tool call, not the pending/confirming status + const value: ToolCallRequestInfo = { callId, name, args }; + return { type: GeminiEventType.ToolCallRequest, value }; + } + + // Builds the Part array expected by the Google GenAI API + private buildFunctionResponses( + outcomes: ServerToolExecutionOutcome[], + ): Part[] { + return outcomes.map((outcome): Part => { + const { name, result, error } = outcome; + let fnResponsePayload: Record<string, unknown>; + + if (error) { + // Format error for the LLM + const errorMessage = error?.message || String(error); + fnResponsePayload = { error: `Tool execution failed: ${errorMessage}` }; + console.error(`[Server Turn] Error executing tool ${name}:`, error); + } else { + // Pass successful tool result (content meant for LLM) + fnResponsePayload = { output: result?.llmContent ?? '' }; // Default to empty string if no content + } + + return { + functionResponse: { + name, + id: outcome.callId, + response: fnResponsePayload, + }, + }; + }); + } + + private abortError(): Error { + const error = new Error('Request cancelled by user during stream.'); + error.name = 'AbortError'; + return error; // Return instead of throw, let caller handle + } + + // Allows the service layer to get the responses needed for the next API call + getFunctionResponses(): Part[] { + return this.fnResponses; + } + + // Debugging information (optional) + getDebugResponses(): GenerateContentResponse[] { + return this.debugResponses; + } +} diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts index 07d60cc4..50c8a54e 100644 --- a/packages/server/src/index.test.ts +++ b/packages/server/src/index.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { helloServer } from './index.js'; -describe('server tests', () => { - it('should export helloServer function', () => { - expect(helloServer).toBeDefined(); - expect(typeof helloServer).toBe('function'); +describe('placeholder tests', () => { + it('should pass', () => { + expect(true).toBe(true); }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 883c7aae..258ec795 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,6 +4,31 @@ * SPDX-License-Identifier: Apache-2.0 */ -export function helloServer() { - // TODO: add more things in this package -} +// Export config +export * from './config/config.js'; + +// Export Core Logic +export * from './core/gemini-client.js'; +export * from './core/prompts.js'; +export * from './core/turn.js'; +// Potentially export types from turn.ts if needed externally +// export { GeminiEventType } from './core/turn.js'; // Example + +// Export utilities +export * from './utils/paths.js'; +export * from './utils/schemaValidator.js'; +export * from './utils/errors.js'; +export * from './utils/getFolderStructure.js'; + +// Export base tool definitions +export * from './tools/tools.js'; + +// Export specific tool logic +export * from './tools/read-file.js'; +export * from './tools/ls.js'; +export * from './tools/grep.js'; +export * from './tools/glob.js'; +export * from './tools/edit.js'; +export * from './tools/terminal.js'; +export * from './tools/write-file.js'; +export * from './tools/web-fetch.js'; 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 +} diff --git a/packages/server/src/utils/errors.ts b/packages/server/src/utils/errors.ts new file mode 100644 index 00000000..15417c83 --- /dev/null +++ b/packages/server/src/utils/errors.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } else { + // Attempt to convert the non-Error value to a string for logging + try { + const errorMessage = String(error); + return errorMessage; + } catch { + // If String() itself fails (highly unlikely) + return 'Failed to get error details'; + } + } +} diff --git a/packages/server/src/utils/getFolderStructure.ts b/packages/server/src/utils/getFolderStructure.ts new file mode 100644 index 00000000..0caabe0f --- /dev/null +++ b/packages/server/src/utils/getFolderStructure.ts @@ -0,0 +1,389 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { getErrorMessage, isNodeError } from './errors.js'; + +const MAX_ITEMS = 200; +const TRUNCATION_INDICATOR = '...'; +const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']); + +// --- Interfaces --- + +/** Options for customizing folder structure retrieval. */ +interface FolderStructureOptions { + /** Maximum number of files and folders combined to display. Defaults to 200. */ + maxItems?: number; + /** Set of folder names to ignore completely. Case-sensitive. */ + ignoredFolders?: Set<string>; + /** Optional regex to filter included files by name. */ + fileIncludePattern?: RegExp; +} + +// Define a type for the merged options where fileIncludePattern remains optional +type MergedFolderStructureOptions = Required< + Omit<FolderStructureOptions, 'fileIncludePattern'> +> & { + fileIncludePattern?: RegExp; +}; + +/** Represents the full, unfiltered information about a folder and its contents. */ +interface FullFolderInfo { + name: string; + path: string; + files: string[]; + subFolders: FullFolderInfo[]; + totalChildren: number; // Total files + subfolders recursively + totalFiles: number; // Total files recursively + isIgnored?: boolean; // Flag to easily identify ignored folders later +} + +/** Represents the potentially truncated structure used for display. */ +interface ReducedFolderNode { + name: string; // Folder name + isRoot?: boolean; + files: string[]; // File names, might end with '...' + subFolders: ReducedFolderNode[]; // Subfolders, might be truncated + hasMoreFiles?: boolean; // Indicates if files were truncated for this specific folder + hasMoreSubfolders?: boolean; // Indicates if subfolders were truncated for this specific folder +} + +// --- Helper Functions --- + +/** + * Recursively reads the full directory structure without truncation. + * Ignored folders are included but not recursed into. + * @param folderPath The absolute path to the folder. + * @param options Configuration options. + * @returns A promise resolving to the FullFolderInfo or null if access denied/not found. + */ +async function readFullStructure( + folderPath: string, + options: MergedFolderStructureOptions, +): Promise<FullFolderInfo | null> { + const name = path.basename(folderPath); + // Initialize with isIgnored: false + const folderInfo: Omit<FullFolderInfo, 'totalChildren' | 'totalFiles'> = { + name, + path: folderPath, + files: [], + subFolders: [], + isIgnored: false, + }; + + let totalChildrenCount = 0; + let totalFileCount = 0; + + try { + const entries = await fs.readdir(folderPath, { withFileTypes: true }); + + // Process directories first + for (const entry of entries) { + if (entry.isDirectory()) { + const subFolderName = entry.name; + const subFolderPath = path.join(folderPath, subFolderName); + + // Check if the folder should be ignored + if (options.ignoredFolders.has(subFolderName)) { + // Add ignored folder node but don't recurse + const ignoredFolderInfo: FullFolderInfo = { + name: subFolderName, + path: subFolderPath, + files: [], + subFolders: [], + totalChildren: 0, // No children explored + totalFiles: 0, // No files explored + isIgnored: true, // Mark as ignored + }; + folderInfo.subFolders.push(ignoredFolderInfo); + // Skip recursion for this folder + continue; + } + + // If not ignored, recurse as before + const subFolderInfo = await readFullStructure(subFolderPath, options); + // Add non-empty folders OR explicitly ignored folders + if ( + subFolderInfo && + (subFolderInfo.totalChildren > 0 || + subFolderInfo.files.length > 0 || + subFolderInfo.isIgnored) + ) { + folderInfo.subFolders.push(subFolderInfo); + } + } + } + + // Then process files (only if the current folder itself isn't marked as ignored) + for (const entry of entries) { + if (entry.isFile()) { + const fileName = entry.name; + // Include if no pattern or if pattern matches + if ( + !options.fileIncludePattern || + options.fileIncludePattern.test(fileName) + ) { + folderInfo.files.push(fileName); + } + } + } + + // Calculate totals *after* processing children + // Ignored folders contribute 0 to counts here because we didn't look inside. + totalFileCount = + folderInfo.files.length + + folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalFiles, 0); + // Count the ignored folder itself as one child item in the parent's count. + totalChildrenCount = + folderInfo.files.length + + folderInfo.subFolders.length + + folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalChildren, 0); + } catch (error: unknown) { + if ( + isNodeError(error) && + (error.code === 'EACCES' || error.code === 'ENOENT') + ) { + console.warn( + `Warning: Could not read directory ${folderPath}: ${error.message}`, + ); + return null; + } + throw error; + } + + return { + ...(folderInfo as FullFolderInfo), // Cast needed after conditional assignment check + totalChildren: totalChildrenCount, + totalFiles: totalFileCount, + }; +} + +/** + * Reduces the full folder structure based on the maxItems limit using BFS. + * Handles explicitly ignored folders by showing them with a truncation indicator. + * @param fullInfo The complete folder structure info. + * @param maxItems The maximum number of items (files + folders) to include. + * @param ignoredFolders The set of folder names that were ignored during the read phase. + * @returns The root node of the reduced structure. + */ +function reduceStructure( + fullInfo: FullFolderInfo, + maxItems: number, +): ReducedFolderNode { + const rootReducedNode: ReducedFolderNode = { + name: fullInfo.name, + files: [], + subFolders: [], + isRoot: true, + }; + const queue: Array<{ + original: FullFolderInfo; + reduced: ReducedFolderNode; + }> = []; + + // Don't count the root itself towards the limit initially + queue.push({ original: fullInfo, reduced: rootReducedNode }); + let itemCount = 0; // Count folders + files added to the reduced structure + + while (queue.length > 0) { + const { original: originalFolder, reduced: reducedFolder } = queue.shift()!; + + // If the folder being processed was itself marked as ignored (shouldn't happen for root) + if (originalFolder.isIgnored) { + continue; + } + + // Process Files + let fileLimitReached = false; + for (const file of originalFolder.files) { + // Check limit *before* adding the file + if (itemCount >= maxItems) { + if (!fileLimitReached) { + reducedFolder.files.push(TRUNCATION_INDICATOR); + reducedFolder.hasMoreFiles = true; + fileLimitReached = true; + } + break; + } + reducedFolder.files.push(file); + itemCount++; + } + + // Process Subfolders + let subfolderLimitReached = false; + for (const subFolder of originalFolder.subFolders) { + // Count the folder itself towards the limit + itemCount++; + if (itemCount > maxItems) { + if (!subfolderLimitReached) { + // Add a placeholder node ONLY if we haven't already added one + const truncatedSubfolderNode: ReducedFolderNode = { + name: subFolder.name, + files: [TRUNCATION_INDICATOR], // Generic truncation + subFolders: [], + hasMoreFiles: true, + }; + reducedFolder.subFolders.push(truncatedSubfolderNode); + reducedFolder.hasMoreSubfolders = true; + subfolderLimitReached = true; + } + continue; // Stop processing further subfolders for this parent + } + + // Handle explicitly ignored folders identified during the read phase + if (subFolder.isIgnored) { + const ignoredReducedNode: ReducedFolderNode = { + name: subFolder.name, + files: [TRUNCATION_INDICATOR], // Indicate contents ignored/truncated + subFolders: [], + hasMoreFiles: true, // Mark as truncated + }; + reducedFolder.subFolders.push(ignoredReducedNode); + // DO NOT add the ignored folder to the queue for further processing + } else { + // If not ignored and within limit, create the reduced node and add to queue + const reducedSubFolder: ReducedFolderNode = { + name: subFolder.name, + files: [], + subFolders: [], + }; + reducedFolder.subFolders.push(reducedSubFolder); + queue.push({ original: subFolder, reduced: reducedSubFolder }); + } + } + } + + return rootReducedNode; +} + +/** Calculates the total number of items present in the reduced structure. */ +function countReducedItems(node: ReducedFolderNode): number { + let count = 0; + // Count files, treating '...' as one item if present + count += node.files.length; + + // Count subfolders and recursively count their contents + count += node.subFolders.length; + for (const sub of node.subFolders) { + // Check if it's a placeholder ignored/truncated node + const isTruncatedPlaceholder = + sub.files.length === 1 && + sub.files[0] === TRUNCATION_INDICATOR && + sub.subFolders.length === 0; + + if (!isTruncatedPlaceholder) { + count += countReducedItems(sub); + } + // Don't add count for items *inside* the placeholder node itself. + } + return count; +} + +/** + * Formats the reduced folder structure into a tree-like string. + * (No changes needed in this function) + * @param node The current node in the reduced structure. + * @param indent The current indentation string. + * @param isLast Sibling indicator. + * @param builder Array to build the string lines. + */ +function formatReducedStructure( + node: ReducedFolderNode, + indent: string, + isLast: boolean, + builder: string[], +): void { + const connector = isLast ? '└───' : '├───'; + const linePrefix = indent + connector; + + // Don't print the root node's name directly, only its contents + if (!node.isRoot) { + builder.push(`${linePrefix}${node.name}/`); + } + + const childIndent = indent + (isLast || node.isRoot ? ' ' : '│ '); // Use " " if last, "│" otherwise + + // Render files + const fileCount = node.files.length; + for (let i = 0; i < fileCount; i++) { + const isLastFile = i === fileCount - 1 && node.subFolders.length === 0; + const fileConnector = isLastFile ? '└───' : '├───'; + builder.push(`${childIndent}${fileConnector}${node.files[i]}`); + } + + // Render subfolders + const subFolderCount = node.subFolders.length; + for (let i = 0; i < subFolderCount; i++) { + const isLastSub = i === subFolderCount - 1; + formatReducedStructure(node.subFolders[i], childIndent, isLastSub, builder); + } +} + +// --- Main Exported Function --- + +/** + * Generates a string representation of a directory's structure, + * limiting the number of items displayed. Ignored folders are shown + * followed by '...' instead of their contents. + * + * @param directory The absolute or relative path to the directory. + * @param options Optional configuration settings. + * @returns A promise resolving to the formatted folder structure string. + */ +export async function getFolderStructure( + directory: string, + options?: FolderStructureOptions, +): Promise<string> { + const resolvedPath = path.resolve(directory); + const mergedOptions: MergedFolderStructureOptions = { + maxItems: options?.maxItems ?? MAX_ITEMS, + ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS, + fileIncludePattern: options?.fileIncludePattern, + }; + + try { + // 1. Read the full structure (includes ignored folders marked as such) + const fullInfo = await readFullStructure(resolvedPath, mergedOptions); + + if (!fullInfo) { + return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`; + } + + // 2. Reduce the structure (handles ignored folders specifically) + const reducedRoot = reduceStructure(fullInfo, mergedOptions.maxItems); + + // 3. Count items in the *reduced* structure for the summary + const rootNodeItselfCount = 0; // Don't count the root node in the items summary + const reducedItemCount = + countReducedItems(reducedRoot) - rootNodeItselfCount; + + // 4. Format the reduced structure into a string + const structureLines: string[] = []; + formatReducedStructure(reducedRoot, '', true, structureLines); + + // 5. Build the final output string + const displayPath = resolvedPath.replace(/\\/g, '/'); + const totalOriginalChildren = fullInfo.totalChildren; + + let disclaimer = ''; + // Check if any truncation happened OR if ignored folders were present + if ( + reducedItemCount < totalOriginalChildren || + fullInfo.subFolders.some((sf) => sf.isIgnored) + ) { + disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown or were ignored.`; + } + + const summary = + `Showing ${reducedItemCount} of ${totalOriginalChildren} items (files + folders). ${disclaimer}`.trim(); + + return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`; + } catch (error: unknown) { + console.error(`Error getting folder structure for ${resolvedPath}:`, error); + return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`; + } +} diff --git a/packages/server/src/utils/paths.ts b/packages/server/src/utils/paths.ts new file mode 100644 index 00000000..f1a42131 --- /dev/null +++ b/packages/server/src/utils/paths.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; // Import the 'path' module + +/** + * Shortens a path string if it exceeds maxLen, prioritizing the start and end segments. + * Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt + */ +export function shortenPath(filePath: string, maxLen: number = 35): string { + if (filePath.length <= maxLen) { + return filePath; + } + + const parsedPath = path.parse(filePath); + const root = parsedPath.root; + const separator = path.sep; + + // Get segments of the path *after* the root + const relativePath = filePath.substring(root.length); + const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments + + // Handle cases with no segments after root (e.g., "/", "C:\") or only one segment + if (segments.length <= 1) { + // Fallback to simple start/end truncation for very short paths or single segments + const keepLen = Math.floor((maxLen - 3) / 2); + // Ensure keepLen is not negative if maxLen is very small + if (keepLen <= 0) { + return filePath.substring(0, maxLen - 3) + '...'; + } + const start = filePath.substring(0, keepLen); + const end = filePath.substring(filePath.length - keepLen); + return `${start}...${end}`; + } + + const firstDir = segments[0]; + const startComponent = root + firstDir; + + const endPartSegments: string[] = []; + // Base length: startComponent + separator + "..." + let currentLength = startComponent.length + separator.length + 3; + + // Iterate backwards through segments (excluding the first one) + for (let i = segments.length - 1; i >= 1; i--) { + const segment = segments[i]; + // Length needed if we add this segment: current + separator + segment + const lengthWithSegment = currentLength + separator.length + segment.length; + + if (lengthWithSegment <= maxLen) { + endPartSegments.unshift(segment); // Add to the beginning of the end part + currentLength = lengthWithSegment; + } else { + // Adding this segment would exceed maxLen + break; + } + } + + // Construct the final path + let result = startComponent + separator + '...'; + if (endPartSegments.length > 0) { + result += separator + endPartSegments.join(separator); + } + + // As a final check, if the result is somehow still too long (e.g., startComponent + ... is too long) + // fallback to simple truncation of the original path + if (result.length > maxLen) { + const keepLen = Math.floor((maxLen - 3) / 2); + if (keepLen <= 0) { + return filePath.substring(0, maxLen - 3) + '...'; + } + const start = filePath.substring(0, keepLen); + const end = filePath.substring(filePath.length - keepLen); + return `${start}...${end}`; + } + + return result; +} + +/** + * Calculates the relative path from a root directory to a target path. + * Ensures both paths are resolved before calculating. + * Returns '.' if the target path is the same as the root directory. + * + * @param targetPath The absolute or relative path to make relative. + * @param rootDirectory The absolute path of the directory to make the target path relative to. + * @returns The relative path from rootDirectory to targetPath. + */ +export function makeRelative( + targetPath: string, + rootDirectory: string, +): string { + const resolvedTargetPath = path.resolve(targetPath); + const resolvedRootDirectory = path.resolve(rootDirectory); + + const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath); + + // If the paths are the same, path.relative returns '', return '.' instead + return relativePath || '.'; +} diff --git a/packages/server/src/utils/schemaValidator.ts b/packages/server/src/utils/schemaValidator.ts new file mode 100644 index 00000000..107ccc85 --- /dev/null +++ b/packages/server/src/utils/schemaValidator.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Simple utility to validate objects against JSON Schemas + * In a real implementation, you would use a library like Ajv + */ +export class SchemaValidator { + /** + * Validates data against a JSON schema + * @param schema JSON Schema to validate against + * @param data Data to validate + * @returns True if valid, false otherwise + */ + static validate(schema: Record<string, unknown>, data: unknown): boolean { + // This is a simplified implementation + // In a real application, you would use a library like Ajv for proper validation + + // Check for required fields + if (schema.required && Array.isArray(schema.required)) { + const required = schema.required as string[]; + const dataObj = data as Record<string, unknown>; + + for (const field of required) { + if (dataObj[field] === undefined) { + console.error(`Missing required field: ${field}`); + return false; + } + } + } + + // Check property types if properties are defined + if (schema.properties && typeof schema.properties === 'object') { + const properties = schema.properties as Record<string, { type?: string }>; + const dataObj = data as Record<string, unknown>; + + for (const [key, prop] of Object.entries(properties)) { + if (dataObj[key] !== undefined && prop.type) { + const expectedType = prop.type; + const actualType = Array.isArray(dataObj[key]) + ? 'array' + : typeof dataObj[key]; + + if (expectedType !== actualType) { + console.error( + `Type mismatch for property "${key}": expected ${expectedType}, got ${actualType}`, + ); + return false; + } + } + } + } + + return true; + } +} |
