src / toolsProvider.ts
import { tool, type ToolsProvider } from "@lmstudio/sdk";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { listFiles, formatFilesList } from "./list-files";
import { regexSearchFiles } from "./ripgrep";
import { globalConfigSchematics } from "./config";
function resolvePath(targetPath: string, baseDirectory: string): string {
const resolved = path.resolve(baseDirectory || ".", targetPath);
if (baseDirectory) {
const base = path.resolve(baseDirectory);
if (!resolved.startsWith(base + path.sep) && resolved !== base) {
throw new Error(`Access denied: "${resolved}" is outside the allowed base directory "${base}".`);
}
}
return resolved;
}
export const toolsProvider: ToolsProvider = async (ctl) => {
const globalConfig = ctl.getGlobalPluginConfig(globalConfigSchematics);
const baseDirectory: string = globalConfig.get("baseDirectory") ?? "";
const readFileTool = tool({
name: "read_file",
description: "Read the full contents of a file.",
parameters: { path: z.string().describe("Path to the file to read.") },
implementation: async ({ path: filePath }, ctx) => {
ctx.status("Reading file...");
try { return fs.readFileSync(resolvePath(filePath, baseDirectory), "utf-8"); }
catch (err: any) { return `Error: ${err.message}`; }
},
});
const writeFileTool = tool({
name: "write_file",
description: "Write (or overwrite) a file with the given content. Creates parent directories if needed.",
parameters: {
path: z.string().describe("Path to the file to write."),
content: z.string().describe("Content to write into the file."),
},
implementation: async ({ path: filePath, content }, ctx) => {
ctx.status("Writing file...");
try {
const resolved = resolvePath(filePath, baseDirectory);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, "utf-8");
return `File written: ${resolved}`;
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const applyDiffTool = tool({
name: "apply_diff",
description: "Apply a targeted search-and-replace edit to a file. The match must be unique.",
parameters: {
path: z.string().describe("Path to the file to edit."),
oldText: z.string().describe("Exact text to search for (must appear exactly once)."),
newText: z.string().describe("Text to replace oldText with."),
},
implementation: async ({ path: filePath, oldText, newText }, ctx) => {
ctx.status("Applying diff...");
try {
const resolved = resolvePath(filePath, baseDirectory);
const original = fs.readFileSync(resolved, "utf-8");
const count = original.split(oldText).length - 1;
if (count === 0) return "Error: oldText not found in file.";
if (count > 1) return `Error: oldText found ${count} times, make it more specific.`;
fs.writeFileSync(resolved, original.replace(oldText, newText), "utf-8");
return `Diff applied successfully to ${resolved}.`;
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const replaceInFileTool = tool({
name: "replace_in_file",
description: "Make targeted edits using SEARCH/REPLACE blocks. Format: ------- SEARCH / [content] / ======= / [replacement] / +++++++ REPLACE",
parameters: {
path: z.string().describe("Path to the file to modify."),
diff: z.string().describe("One or more SEARCH/REPLACE blocks."),
},
implementation: async ({ path: filePath, diff }, ctx) => {
ctx.status("Applying replace...");
try {
const resolved = resolvePath(filePath, baseDirectory);
let fileContent = fs.readFileSync(resolved, "utf-8");
// Normalise les fins de ligne (\r\n et \r -> \n) pour comparer correctement
const normalize = (s: string) => s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const usedCRLF = fileContent.includes("\r\n");
fileContent = normalize(fileContent);
const normalizedDiff = normalize(diff);
const blocks = normalizedDiff.match(/[-]{3,} SEARCH[\s\S]*?[+]{3,} REPLACE/g);
if (!blocks || blocks.length === 0) return "Error: no valid SEARCH/REPLACE blocks found.";
for (const block of blocks) {
const sm = block.match(/[-]{3,} SEARCH\n?([\s\S]*?)\n?={3,}/);
const rm = block.match(/={3,}\n?([\s\S]*?)\n?[+]{3,} REPLACE/);
if (!sm || !rm) return "Error: malformed SEARCH/REPLACE block.";
const searchText = sm[1];
const replaceText = rm[1];
if (!fileContent.includes(searchText)) return `Error: SEARCH block not found:\n${searchText}`;
fileContent = fileContent.replace(searchText, replaceText);
}
// Rétablit les fins de ligne d'origine si le fichier utilisait \r\n
const finalContent = usedCRLF ? fileContent.replace(/\n/g, "\r\n") : fileContent;
fs.writeFileSync(resolved, finalContent, "utf-8");
return "Replace applied successfully.";
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const appendToFileTool = tool({
name: "append_to_file",
description: "Append content to the end of a file. Creates the file if it does not exist.",
parameters: {
path: z.string().describe("Path to the file."),
content: z.string().describe("Content to append."),
add_newline: z.boolean().optional().default(true).describe("Ensure content starts on a new line. Default: true."),
},
implementation: async ({ path: filePath, content, add_newline }, ctx) => {
ctx.status("Appending...");
try {
const resolved = resolvePath(filePath, baseDirectory);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
let toAppend = content;
if (add_newline && fs.existsSync(resolved)) {
const existing = fs.readFileSync(resolved, "utf-8");
if (existing.length > 0 && !existing.endsWith("\n")) toAppend = "\n" + toAppend;
}
fs.appendFileSync(resolved, toAppend, "utf-8");
return "Content appended successfully.";
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const copyFileTool = tool({
name: "copy_file",
description: "Copy a file from one location to another. Creates parent directories if needed.",
parameters: {
source: z.string().describe("Path of the source file."),
destination: z.string().describe("Path of the destination file."),
overwrite: z.boolean().optional().default(false).describe("Overwrite if exists. Default: false."),
},
implementation: async ({ source, destination, overwrite }, ctx) => {
ctx.status("Copying file...");
try {
const src = resolvePath(source, baseDirectory);
const dst = resolvePath(destination, baseDirectory);
if (!fs.existsSync(src)) return `Error: source not found: ${src}`;
if (!overwrite && fs.existsSync(dst)) return "Error: destination already exists. Use overwrite: true.";
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
return `File copied: ${src} -> ${dst}`;
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const fileExistsTool = tool({
name: "file_exists",
description: "Check whether a file or directory exists. Returns existence status and type.",
parameters: { path: z.string().describe("Path to check.") },
implementation: async ({ path: filePath }, ctx) => {
ctx.status("Checking...");
try {
const resolved = resolvePath(filePath, baseDirectory);
if (!fs.existsSync(resolved)) return JSON.stringify({ exists: false, type: null, path: resolved });
const s = fs.statSync(resolved);
return JSON.stringify({ exists: true, type: s.isDirectory() ? "directory" : "file", path: resolved });
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const createDirectoryTool = tool({
name: "create_directory",
description: "Create a directory (and all missing parent directories) at the given path.",
parameters: { path: z.string().describe("Path of the directory to create.") },
implementation: async ({ path: dirPath }, ctx) => {
ctx.status("Creating directory...");
try {
const resolved = resolvePath(dirPath, baseDirectory);
fs.mkdirSync(resolved, { recursive: true });
return `Directory created: ${resolved}`;
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const listFilesTool = tool({
name: "list_files",
description: "List files and directories at a given path. Set recursive=true to include subdirectories.",
parameters: {
path: z.string().describe("Directory to list."),
recursive: z.boolean().optional().default(false).describe("Whether to list files recursively."),
},
implementation: async ({ path: dirPath, recursive }, ctx) => {
ctx.status("Listing files...");
try {
const resolved = resolvePath(dirPath, baseDirectory);
const [files, hitLimit] = await listFiles(resolved, recursive ?? false, 500);
return formatFilesList(resolved, files, hitLimit);
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const searchFilesTool = tool({
name: "search_files",
description: "Search for a regex pattern inside files using ripgrep.",
parameters: {
path: z.string().describe("Directory to search in."),
regex: z.string().describe("Regular expression to search for."),
filePattern: z.string().optional().describe("Glob pattern to restrict which files are searched (e.g. '*.ts')."),
},
implementation: async ({ path: dirPath, regex, filePattern }, ctx) => {
ctx.status("Searching files...");
try {
const resolved = resolvePath(dirPath, baseDirectory);
const cwd = path.resolve(__dirname, "..");
return await regexSearchFiles(cwd, resolved, regex, filePattern);
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const deleteFileTool = tool({
name: "delete_file",
description: "Delete a file at the given path.",
parameters: { path: z.string().describe("Path to the file to delete.") },
implementation: async ({ path: filePath }, ctx) => {
ctx.status("Deleting file...");
try {
fs.rmSync(resolvePath(filePath, baseDirectory), { force: true });
return "File deleted.";
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const moveFileTool = tool({
name: "move_file",
description: "Move or rename a file or directory.",
parameters: {
source: z.string().describe("Current path of the file or directory."),
destination: z.string().describe("New path (target location)."),
},
implementation: async ({ source, destination }, ctx) => {
ctx.status("Moving file...");
try {
const src = resolvePath(source, baseDirectory);
const dst = resolvePath(destination, baseDirectory);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.renameSync(src, dst);
return `Moved "${src}" -> "${dst}"`;
} catch (err: any) { return `Error: ${err.message}`; }
},
});
const fileInfoTool = tool({
name: "file_info",
description: "Get metadata about a file or directory (size, type, dates).",
parameters: { path: z.string().describe("Path to inspect.") },
implementation: async ({ path: filePath }, ctx) => {
ctx.status("Getting file info...");
try {
const resolved = resolvePath(filePath, baseDirectory);
const stat = fs.statSync(resolved);
return JSON.stringify({ path: resolved, type: stat.isDirectory() ? "directory" : "file", sizeBytes: stat.size, created: stat.birthtime.toISOString(), modified: stat.mtime.toISOString(), accessed: stat.atime.toISOString() }, null, 2);
} catch (err: any) { return `Error: ${err.message}`; }
},
});
return [
readFileTool, writeFileTool, applyDiffTool,
replaceInFileTool, appendToFileTool, copyFileTool, fileExistsTool,
createDirectoryTool, listFilesTool, searchFilesTool,
deleteFileTool, moveFileTool, fileInfoTool,
];
};