Forked from levpro/files
src / toolsProvider.ts
import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { join, resolve, dirname, normalize } from "path";
import {
readFile,
writeFile,
unlink,
mkdir,
readdir,
stat,
appendFile,
chmod,
rename,
copyFile,
rm
} from "fs/promises";
import { Dirent } from "fs";
// ΠΡΠΏΠΎΠΌΠΎΠ³Π°ΡΠ΅Π»ΡΠ½Π°Ρ ΡΡΠ½ΠΊΡΠΈΡ Π΄Π»Ρ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΠ³ΠΎ ΡΠ°Π·ΡΠ΅ΡΠ΅Π½ΠΈΡ ΠΏΡΡΠ΅ΠΉ Ρ Π·Π°ΡΠΈΡΠΎΠΉ ΠΎΡ Path Traversal
const getSafePath = (ctl: ToolsProviderController, filePath: string): string => {
const workDir = normalize(ctl.getWorkingDirectory());
// Π Π°Π·ΡΠ΅ΡΠ°Π΅ΠΌ ΠΏΡΡΡ ΠΎΡΠ½ΠΎΡΠΈΡΠ΅Π»ΡΠ½ΠΎ workDir
const fullPath = normalize(resolve(workDir, filePath));
// ΠΠΎΡΠΌΠ°Π»ΠΈΠ·ΡΠ΅ΠΌ ΠΎΠ±Π° ΠΏΡΡΠΈ ΠΊ ΠΎΠ΄Π½ΠΎΠΌΡ ΡΠΎΡΠΌΠ°ΡΡ (Π΄Π»Ρ Windows / -> \)
const normalizedWorkDir = workDir.toLowerCase();
const normalizedFullPath = fullPath.toLowerCase();
// ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ, ΡΡΠΎ ΠΈΡΠΎΠ³ΠΎΠ²ΡΠΉ ΠΏΡΡΡ Π½Π°ΡΠΈΠ½Π°Π΅ΡΡΡ Ρ workDir
if (!normalizedFullPath.startsWith(normalizedWorkDir)) {
throw new Error("Access denied: Cannot access files outside the working directory.");
}
return fullPath;
};
// Π Π΅ΠΊΡΡΡΠΈΠ²Π½Π°Ρ ΡΡΠ½ΠΊΡΠΈΡ Π΄Π»Ρ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ ΠΏΡΠ°Π² Π΄ΠΎΡΡΡΠΏΠ°
const chmodRecursive = async (path: string, mode: string): Promise<void> => {
const stats = await stat(path);
if (stats.isDirectory()) {
const entries = await readdir(path, { withFileTypes: true });
for (const entry of entries) {
const entryPath = join(path, entry.name);
await chmod(entryPath, mode);
if (entry.isDirectory()) {
await chmodRecursive(entryPath, mode);
}
}
}
};
// Π Π΅ΠΊΡΡΡΠΈΠ²Π½Π°Ρ ΡΡΠ½ΠΊΡΠΈΡ Π΄Π»Ρ ΠΊΠΎΠΏΠΈΡΠΎΠ²Π°Π½ΠΈΡ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΠΈ
const copyDirectory = async (src: string, dest: string): Promise<void> => {
await mkdir(dest, { recursive: true });
const entries = await readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await copyFile(srcPath, destPath);
}
}
};
// Π Π΅ΠΊΡΡΡΠΈΠ²Π½Π°Ρ ΡΡΠ½ΠΊΡΠΈΡ Π΄Π»Ρ ΡΠ΄Π°Π»Π΅Π½ΠΈΡ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΠΈ ΡΠΎ Π²ΡΠ΅ΠΌ ΡΠΎΠ΄Π΅ΡΠΆΠΈΠΌΡΠΌ
const removeDirectoryRecursive = async (path: string): Promise<void> => {
try {
await rm(path, { recursive: true, force: true });
} catch (error: any) {
// ΠΡΠ»ΠΈ rm Π½Π΅ ΡΠ°Π±ΠΎΡΠ°Π΅Ρ (ΡΡΠ°ΡΡΠ΅ Π²Π΅ΡΡΠΈΠΈ Node), ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ ΡΡΡΠ½ΡΡ ΡΠ΅Π°Π»ΠΈΠ·Π°ΡΠΈΡ
const stats = await stat(path);
if (stats.isDirectory()) {
const entries = await readdir(path, { withFileTypes: true });
for (const entry of entries) {
const entryPath = join(path, entry.name);
if (entry.isDirectory()) {
await removeDirectoryRecursive(entryPath);
} else {
await unlink(entryPath);
}
}
await mkdir(path, { recursive: true }); // ΠΠ΅ Π½ΡΠΆΠ½ΠΎ, rmdir ΡΠ΄Π°Π»ΠΈΡ ΡΠ°ΠΌΠ°
// ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ rm Π±Π΅Π· force Π΄Π»Ρ ΡΡΠ°ΡΡΡ
Node
try {
await rm(path, { recursive: true });
} catch {
// ΠΠ»Ρ ΡΠΎΠ²ΠΌΠ΅ΡΡΠΈΠΌΠΎΡΡΠΈ
}
}
}
};
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const fsTools: Tool[] = [];
// --- 1. Π§ΡΠ΅Π½ΠΈΠ΅ ΡΠ°ΠΉΠ»Π° ---
fsTools.push(tool({
name: "Read File",
description: "Reads the content of a text file.",
parameters: {
path: z.string().describe("The relative path to the file"),
},
implementation: async ({ path }, { status }) => {
try {
status(`Reading file: ${path}`);
const fullPath = getSafePath(ctl, path);
const stats = await stat(fullPath);
if (stats.size > 50 * 1024) {
return `Error: File too large (${stats.size} bytes). Limit is 50KB.`;
}
const content = await readFile(fullPath, "utf-8");
return content;
} catch (error: any) {
return `Error reading file: ${error.message}`;
}
}
}));
// --- 2. ΠΠΎΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΡΠΎΠ΄Π΅ΡΠΆΠΈΠΌΠΎΠ³ΠΎ ΠΊΠ°ΡΠ°Π»ΠΎΠ³Π° ---
fsTools.push(tool({
name: "List Directory",
description: "Lists all files and subdirectories in a directory.",
parameters: {
path: z.string().describe("The relative path to the directory").optional(),
},
implementation: async ({ path }, { status }) => {
try {
const targetPath = getSafePath(ctl, path || ".");
status(`Listing directory: ${path || "."}`);
const entries: Dirent[] = await readdir(targetPath, { withFileTypes: true });
const items = entries.map((entry: Dirent) => ({
name: entry.name,
type: entry.isDirectory() ? "directory" : "file"
}));
return items;
} catch (error: any) {
return `Error listing directory: ${error.message}`;
}
}
}));
// --- 3. Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΊΠ°ΡΠ°Π»ΠΎΠ³Π° ---
fsTools.push(tool({
name: "Create Directory",
description: "Creates a new directory at the specified path. Creates parent directories if they don't exist.",
parameters: {
path: z.string().describe("The relative path where the directory should be created"),
},
implementation: async ({ path }, { status }) => {
try {
status(`Creating directory: ${path}`);
const fullPath = getSafePath(ctl, path);
await mkdir(fullPath, { recursive: true });
return "Directory created successfully.";
} catch (error: any) {
return `Error creating directory: ${error.message}`;
}
}
}));
// --- 4. Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΡΠ°ΠΉΠ»Π° (Ρ ΠΊΠΎΠ½ΡΠ΅Π½ΡΠΎΠΌ ΠΈΠ»ΠΈ ΠΏΡΡΡΠΎΠΉ) ---
fsTools.push(tool({
name: "Create File",
description: "Creates a new file at the specified path.",
parameters: {
path: z.string().describe("The relative path where the file should be created"),
content: z.string().optional().default("").describe("The initial content of the file"),
},
implementation: async ({ path, content }, { status }) => {
try {
status(`Creating file: ${path}`);
const fullPath = getSafePath(ctl, path);
const dir = dirname(fullPath);
await mkdir(dir, { recursive: true });
await writeFile(fullPath, content, "utf-8");
return "File created successfully.";
} catch (error: any) {
return `Error creating file: ${error.message}`;
}
}
}));
// --- 5. ΠΠ°ΠΏΠΈΡΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ Π² ΡΠ°ΠΉΠ» (ΠΠ΅ΡΠ΅Π·Π°ΠΏΠΈΡΡ) ---
fsTools.push(tool({
name: "Write File",
description: "Overwrites the entire content of an existing file.",
parameters: {
path: z.string().describe("The relative path to the file"),
content: z.string().describe("The new content to write"),
},
implementation: async ({ path, content }, { status }) => {
try {
status(`Writing to file: ${path}`);
const fullPath = getSafePath(ctl, path);
await writeFile(fullPath, content, "utf-8");
return "File overwritten successfully.";
} catch (error: any) {
return `Error writing file: ${error.message}`;
}
}
}));
// --- 6. Π§Π°ΡΡΠΈΡΠ½ΠΎΠ΅ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ ΡΠ°ΠΉΠ»Π° (ΡΠ΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ Π΄ΠΈΠ°ΠΏΠ°Π·ΠΎΠ½Π° ΡΡΡΠΎΠΊ) ---
fsTools.push(tool({
name: "Edit File Lines",
description: "Edits a specific range of lines in a file. Replaces lines from startLine to endLine with new content, or inserts content at a specific position.",
parameters: {
path: z.string().describe("The relative path to the file"),
startLine: z.number().describe("The starting line number (1-indexed). Use 1 for first line."),
endLine: z.number().describe("The ending line number (1-indexed). Must be >= startLine."),
content: z.string().describe("The content to insert at the specified position. If empty and mode is 'replace', lines will be deleted."),
mode: z.enum(["replace", "insert"]).optional().default("replace").describe("'replace' - replaces the range with new content. 'insert' - inserts content before startLine without deleting existing lines."),
},
implementation: async ({ path, startLine, endLine, content, mode }, { status }) => {
try {
status(`Editing file: ${path}, lines ${startLine}-${endLine} (mode: ${mode})`);
const fullPath = getSafePath(ctl, path);
// ΠΡΠΎΠ²Π΅ΡΠΊΠ° ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎΡΡΠΈ Π½ΠΎΠΌΠ΅ΡΠΎΠ² ΡΡΡΠΎΠΊ
if (startLine < 1 || endLine < startLine) {
return `Error: Invalid line numbers. startLine must be >= 1 and endLine must be >= startLine.`;
}
// Π§ΠΈΡΠ°Π΅ΠΌ ΡΠ°ΠΉΠ»
const fileContent = await readFile(fullPath, "utf-8");
const lines = fileContent.split(/\r?\n/);
const totalLines = lines.length;
// ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ, ΡΡΠΎ Π½Π°ΡΠ°Π»ΡΠ½Π°Ρ ΡΡΡΠΎΠΊΠ° Π½Π΅ Π±ΠΎΠ»ΡΡΠ΅ ΠΊΠΎΠ»ΠΈΡΠ΅ΡΡΠ²Π° ΡΡΡΠΎΠΊ
if (startLine > totalLines) {
return `Error: startLine (${startLine}) exceeds file length (${totalLines} lines).`;
}
// ΠΡΠ»ΠΈ endLine Π±ΠΎΠ»ΡΡΠ΅ ΠΊΠΎΠ»ΠΈΡΠ΅ΡΡΠ²Π° ΡΡΡΠΎΠΊ, ΠΎΠ³ΡΠ°Π½ΠΈΡΠΈΠ²Π°Π΅ΠΌ Π΅Π³ΠΎ
const actualEndLine = Math.min(endLine, totalLines);
// ΠΡΡΠΈΡΠ»ΡΠ΅ΠΌ ΠΈΠ½Π΄Π΅ΠΊΡΡ (0-Π±Π°Π·Π°)
const startIndex = startLine - 1;
const endIndex = actualEndLine - 1;
let newLines: string[];
if (mode === "insert") {
// Π Π΅ΠΆΠΈΠΌ Π²ΡΡΠ°Π²ΠΊΠΈ: Π²ΡΡΠ°Π²Π»ΡΠ΅ΠΌ ΠΊΠΎΠ½ΡΠ΅Π½Ρ ΠΏΠ΅ΡΠ΅Π΄ startLine, Π½Π΅ ΡΠ΄Π°Π»ΡΡ ΡΡΡΠ΅ΡΡΠ²ΡΡΡΠΈΠ΅ ΡΡΡΠΎΠΊΠΈ
const before = lines.slice(0, startIndex);
const after = lines.slice(startIndex);
const newContentLines = content.split(/\r?\n/);
newLines = [...before, ...newContentLines, ...after];
} else {
// Π Π΅ΠΆΠΈΠΌ Π·Π°ΠΌΠ΅Π½Ρ: Π·Π°ΠΌΠ΅Π½ΡΠ΅ΠΌ Π΄ΠΈΠ°ΠΏΠ°Π·ΠΎΠ½ ΡΡΡΠΎΠΊ Π½ΠΎΠ²ΡΠΌ ΠΊΠΎΠ½ΡΠ΅Π½ΡΠΎΠΌ
const before = lines.slice(0, startIndex);
const after = lines.slice(endIndex + 1);
const newContentLines = content.split(/\r?\n/);
newLines = [...before, ...newContentLines, ...after];
}
// ΠΠ°ΠΏΠΈΡΡΠ²Π°Π΅ΠΌ ΠΈΠ·ΠΌΠ΅Π½ΡΠ½Π½ΡΠΉ ΡΠ°ΠΉΠ»
await writeFile(fullPath, newLines.join("\n"), "utf-8");
const linesAffected = actualEndLine - startIndex;
return `File edited successfully. ${mode === "insert" ? "Inserted" : "Replaced"} ${linesAffected} line(s).`;
} catch (error: any) {
return `Error editing file: ${error.message}`;
}
}
}));
// --- 7. Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ ΡΠ°ΠΉΠ»Π° ---
fsTools.push(tool({
name: "Delete File",
description: "Permanently deletes a file.",
parameters: {
path: z.string().describe("The relative path to the file to delete"),
},
implementation: async ({ path }, { status }) => {
try {
status(`Deleting file: ${path}`);
const fullPath = getSafePath(ctl, path);
await unlink(fullPath);
return "File deleted successfully.";
} catch (error: any) {
return `Error deleting file: ${error.message}`;
}
}
}));
// --- 8. Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ ΠΊΠ°ΡΠ°Π»ΠΎΠ³Π° (ΡΠΎ Π²ΡΠ΅ΠΌ ΡΠΎΠ΄Π΅ΡΠΆΠΈΠΌΡΠΌ) ---
fsTools.push(tool({
name: "Delete Directory",
description: "Permanently deletes a directory and all its contents (files and subdirectories).",
parameters: {
path: z.string().describe("The relative path to the directory to delete"),
},
implementation: async ({ path }, { status }) => {
try {
status(`Deleting directory: ${path}`);
const fullPath = getSafePath(ctl, path);
await rm(fullPath, { recursive: true, force: true });
return "Directory deleted successfully.";
} catch (error: any) {
return `Error deleting directory: ${error.message}`;
}
}
}));
// --- 9. ΠΠΎΠΏΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΡΠ°ΠΉΠ»Π° ΠΈΠ»ΠΈ ΠΊΠ°ΡΠ°Π»ΠΎΠ³Π° ---
fsTools.push(tool({
name: "Copy",
description: "Copies a file or directory to a new location. For directories, copies all contents recursively.",
parameters: {
source: z.string().describe("The relative path to the source file or directory"),
destination: z.string().describe("The relative path to the destination"),
},
implementation: async ({ source, destination }, { status }) => {
try {
status(`Copying: ${source} -> ${destination}`);
const srcPath = getSafePath(ctl, source);
const destPath = getSafePath(ctl, destination);
const stats = await stat(srcPath);
if (stats.isDirectory()) {
await copyDirectory(srcPath, destPath);
return "Directory copied successfully.";
} else {
// Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΡΠΎΠ΄ΠΈΡΠ΅Π»ΡΡΠΊΡΡ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΡ Π΄Π»Ρ ΡΠ°ΠΉΠ»Π°
const destDir = dirname(destPath);
await mkdir(destDir, { recursive: true });
await copyFile(srcPath, destPath);
return "File copied successfully.";
}
} catch (error: any) {
return `Error copying: ${error.message}`;
}
}
}));
// --- 10. ΠΠ΅ΡΠ΅ΠΌΠ΅ΡΠ΅Π½ΠΈΠ΅ (ΠΏΠ΅ΡΠ΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΈΠ΅) ΡΠ°ΠΉΠ»Π° ΠΈΠ»ΠΈ ΠΊΠ°ΡΠ°Π»ΠΎΠ³Π° ---
fsTools.push(tool({
name: "Move",
description: "Moves or renames a file or directory to a new location.",
parameters: {
source: z.string().describe("The relative path to the source file or directory"),
destination: z.string().describe("The relative path to the destination"),
},
implementation: async ({ source, destination }, { status }) => {
status(`Moving: ${source} -> ${destination}`);
const srcPath = getSafePath(ctl, source);
const destPath = getSafePath(ctl, destination);
try {
// ΠΠ»Ρ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΠΉ ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ rename (ΡΠ°Π±ΠΎΡΠ°Π΅Ρ ΠΈ Π΄Π»Ρ ΡΠ°ΠΉΠ»ΠΎΠ²)
await rename(srcPath, destPath);
return "Moved successfully.";
} catch (error: any) {
// ΠΡΠ»ΠΈ rename Π½Π΅ ΡΠ°Π±ΠΎΡΠ°Π΅Ρ (Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ, ΠΊΡΠΎΡΡ-Π΄ΠΈΡΠΊΠΎΠ²ΠΎΠ΅ ΠΏΠ΅ΡΠ΅ΠΌΠ΅ΡΠ΅Π½ΠΈΠ΅ Π½Π° Windows),
// ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ ΠΊΠΎΠΏΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ + ΡΠ΄Π°Π»Π΅Π½ΠΈΠ΅
try {
const stats = await stat(srcPath);
if (stats.isDirectory()) {
await copyDirectory(srcPath, destPath);
await rm(srcPath, { recursive: true, force: true });
} else {
const destDir = dirname(destPath);
await mkdir(destDir, { recursive: true });
await copyFile(srcPath, destPath);
await unlink(srcPath);
}
return "Moved successfully (copy + delete).";
} catch (fallbackError: any) {
return `Error moving: ${fallbackError.message}`;
}
}
}
}));
// --- 11. ΠΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ ΠΏΡΠ°Π² Π΄ΠΎΡΡΡΠΏΠ° ---
fsTools.push(tool({
name: "Change Permissions",
description: "Changes permissions (mode) of a file or directory. For directories, optionally changes permissions recursively for all contents.",
parameters: {
path: z.string().describe("The relative path to the file or directory"),
mode: z.string().describe("The permission mode (e.g., '755', '644', '0o755')"),
recursive: z.boolean().optional().default(false).describe("Whether to apply changes recursively to directory contents"),
},
implementation: async ({ path, mode, recursive }, { status }) => {
try {
status(`Changing permissions: ${path} to ${mode}`);
const fullPath = getSafePath(ctl, path);
// ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ, ΡΡΠΎ mode Π² ΠΏΡΠ°Π²ΠΈΠ»ΡΠ½ΠΎΠΌ ΡΠΎΡΠΌΠ°ΡΠ΅
let modeNum: number;
if (mode.startsWith('0o')) {
modeNum = parseInt(mode.substring(2), 8);
} else if (mode.startsWith('0')) {
modeNum = parseInt(mode, 8);
} else {
modeNum = parseInt(mode, 8);
}
const stats = await stat(fullPath);
if (stats.isDirectory() && recursive) {
// ΠΡΠΈΠΌΠ΅Π½ΡΠ΅ΠΌ ΠΊ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΠΈ ΠΈ Π²ΡΠ΅ΠΌ Π΅Ρ ΡΠΎΠ΄Π΅ΡΠΆΠΈΠΌΠΎΠΌΡ
await chmod(fullPath, modeNum);
await chmodRecursive(fullPath, mode.toString());
return "Permissions changed successfully (recursive).";
} else {
await chmod(fullPath, modeNum);
return "Permissions changed successfully.";
}
} catch (error: any) {
return `Error changing permissions: ${error.message}`;
}
}
}));
// --- 11. ΠΠΎΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΡΠ΅ΠΊΡΡΠ΅ΠΉ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΠΈ ---
fsTools.push(tool({
name: "Get Current Directory",
description: "Get current working directory path",
parameters: {},
implementation: () => {
try {
return normalize(ctl.getWorkingDirectory());
} catch (error: any) {
return `Error when get current directory: ${error.message}`;
}
}
}));
return fsTools;
}