src / toolsProvider.ts
import { text, tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./configSchematics";
import { canonicalizeRoots, PathError } from "./pathGuard";
import { grepImpl, readDirImpl, readFileImpl, writeFileImpl } from "./fsTools";
function rootsHint(roots: string[]): string {
if (!roots.length) {
return "(no roots configured — set `Allowed root paths` in the plugin config first)";
}
return roots.join(", ");
}
function safeRun<T>(fn: () => Promise<T>): Promise<T | { error: string }> {
return fn().catch((e: unknown) => {
if (e instanceof PathError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
});
}
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const rawRoots = config.get("allowedPaths");
const roots = await canonicalizeRoots(rawRoots);
const maxFileSizeKb = config.get("maxFileSizeKb");
const maxGrepResults = config.get("maxGrepResults");
const maxGrepFiles = config.get("maxGrepFiles");
const rootsList = rootsHint(roots);
const readFile = tool({
name: "read_file",
description: text`
Read a UTF-8 text file from the user's filesystem.
Allowed root directories: ${rootsList}.
Pass an absolute path within one of these roots (or a path relative to the first root).
Files larger than the configured size cap (${maxFileSizeKb} KB) are refused.
`,
parameters: {
path: z.string().min(1).describe("Absolute path to the file, or a path relative to the first allowed root."),
},
implementation: ({ path }) =>
safeRun(() => readFileImpl({ path }, { allowedRoots: roots, maxFileSizeKb })),
});
const readDir = tool({
name: "read_dir",
description: text`
List the entries (files and subdirectories) of a directory.
Allowed root directories: ${rootsList}.
If \`path\` is omitted, the allowed root is listed (or the list of roots
if several are configured). When the user says "the current folder" /
"this directory" / "le dossier courant" without further context, call
this tool WITHOUT \`path\` instead of asking — do not ask which folder.
Returns directories first, then files, alphabetically.
`,
parameters: {
path: z.string().min(1).optional().describe(
"Absolute path to the directory. Omit to list the allowed root(s).",
),
},
implementation: ({ path }) =>
safeRun(() => readDirImpl({ path }, { allowedRoots: roots })),
});
const writeFile = tool({
name: "write_file",
description: text`
Create or overwrite a UTF-8 text file on the user's filesystem.
**You DO have filesystem write access through this tool.** Use it whenever
the user asks you to create, save, write, or update a file. Do NOT refuse
or say "I don't have access" or "I can't write files locally" — you can,
via this tool, and refusing is the wrong answer.
A successful write requires TWO calls (this is normal, not a restriction):
1. **Preview call** — pass \`path\` and \`content\`, leave \`confirm\` unset.
The tool returns a preview (path, sizes, first lines of old and new
content) and a \`confirm_token\`. The file is NOT written yet.
2. **Reply to the user** with a one-line summary: the path and what you're
about to write. Wait for them to agree.
3. **Confirm call** — once the user agrees, call \`write_file\` again with
the SAME \`path\` and \`content\`, plus \`confirm=true\` and
\`confirm_token=<token from step 1>\`. This is what actually writes the
file.
The token is bound to (path, content); if either differs between the two
calls, the write is refused.
Existing files are backed up to \`<root>/.lmstudio-fs-backup/\` before
being overwritten — the previous version is recoverable.
Allowed root directories: ${rootsList}.
`,
parameters: {
path: z.string().min(1).describe("Absolute path of the file to write."),
content: z.string().describe("Full new content of the file."),
confirm: z.boolean().optional().describe(
"Set to true on the second call, after the user has approved the preview.",
),
confirm_token: z.string().optional().describe(
"The token returned by the preview call. Required when confirm=true.",
),
},
implementation: (args) =>
safeRun(() => writeFileImpl(args, { allowedRoots: roots })),
});
const grep = tool({
name: "grep",
description: text`
Search files for lines matching a regular expression. Returns up to
${maxGrepResults} matches across at most ${maxGrepFiles} files. Skips binary
files, dotfiles (except .env and .gitignore), and the standard noise dirs
(\`node_modules\`, \`dist\`, \`target\`, \`.git\`, \`.lmstudio-fs-backup\`).
Allowed root directories: ${rootsList}.
If \`path\` is omitted, all allowed roots are searched.
`,
parameters: {
pattern: z.string().min(1).describe("JavaScript-style regular expression."),
path: z.string().optional().describe(
"Optional absolute path of a directory or file to limit the search to.",
),
ignore_case: z.boolean().optional().describe("Case-insensitive match."),
max_results: z.number().int().min(1).optional().describe(
"Override the default match cap (capped by the plugin config).",
),
},
implementation: (args) =>
safeRun(() =>
grepImpl(args, {
allowedRoots: roots,
maxGrepResults,
maxGrepFiles,
}),
),
});
return [readFile, readDir, writeFile, grep];
}