src / toolsProvider.ts
import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import path from "node:path";
import os from "node:os";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { configSchematics } from "./config";
import { listTree, readTextFileSafe, writeTextFileSafe, deletePathSafe } from "./utils/fs";
import { scaffoldPluginProject } from "./utils/scaffold";
import { validateManifest, writeManifest } from "./utils/manifest";
import { zipDirectory } from "./utils/zip";
import { runAllowedCommand } from "./utils/commands";
function getConfig(ctl: ToolsProviderController) {
return ctl.getPluginConfig(configSchematics);
}
function workspaceRoot(ctl: ToolsProviderController): string {
const config = getConfig(ctl);
const root = config.workspaceRoot?.trim?.();
if (root) return path.resolve(root);
try {
return ctl.getWorkingDirectory();
} catch {
return os.homedir();
}
}
function maxBytes(ctl: ToolsProviderController): number {
return getConfig(ctl).maxFileBytes ?? 250000;
}
function networkInstallAllowed(ctl: ToolsProviderController): boolean {
return getConfig(ctl).allowNetworkInstall ?? true;
}
function ok(message: string, data?: unknown) {
return data === undefined ? message : { ok: true as const, message, data };
}
function err(error: string) {
return { ok: false as const, error };
}
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [];
const base = workspaceRoot(ctl);
const limit = maxBytes(ctl);
const networkInstall = networkInstallAllowed(ctl);
// ── Workspace info ──────────────────────────────────────────────────────────
tools.push(tool({
name: "get_workspace_info",
description: "Get the current workspace root path and basic info. Always call this first before doing any file operations.",
parameters: {
_unused: z.string().optional(),
},
implementation: async () => {
try {
const tree = await listTree(base, ".", 2);
return ok("Workspace info.", { workspaceRoot: base, maxFileBytes: limit, allowNetworkInstall: networkInstall, tree });
} catch (e: any) {
return ok("Workspace info (no tree).", { workspaceRoot: base, maxFileBytes: limit, allowNetworkInstall: networkInstall });
}
}
}));
// ── Planning ────────────────────────────────────────────────────────────────
tools.push(tool({
name: "plan_plugin",
description: "Create a concise implementation plan for a new LM Studio plugin.",
parameters: {
goal: z.string(),
constraints: z.array(z.string()).default([]),
},
implementation: async ({ goal, constraints }) => {
const steps = [
"Call get_workspace_info to confirm the workspace path.",
"Define the plugin purpose and LM Studio hook type.",
"Choose the minimum file set and dependencies.",
"Use scaffold_plugin to generate the baseline structure.",
"Use write_file to add or update source files.",
"Use patch_json to update manifest.json or package.json fields.",
"Use build_project to compile.",
"Fix any build errors by reading and rewriting files.",
"Use zip_project to package the plugin.",
];
return ok("Plan created.", { goal, constraints, steps });
}
}));
// ── Scaffold ────────────────────────────────────────────────────────────────
tools.push(tool({
name: "scaffold_plugin",
description: "Create a new LM Studio plugin project with a safe baseline structure.",
parameters: {
name: z.string(),
description: z.string(),
author: z.string().optional(),
includePromptPreprocessor: z.boolean().default(false),
includeExampleTools: z.boolean().default(true),
},
implementation: async ({ name, description, author, includePromptPreprocessor, includeExampleTools }) => {
try {
const result = await scaffoldPluginProject(base, {
name, description, author,
includePromptPreprocessor, includeExampleTools,
maxFileBytes: limit,
});
return ok("Plugin scaffolded.", result);
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
// ── File operations ─────────────────────────────────────────────────────────
tools.push(tool({
name: "list_files",
description: "List files and folders in the workspace.",
parameters: {
subPath: z.string().default("."),
depth: z.number().int().min(0).max(10).default(3),
},
implementation: async ({ subPath, depth }) => {
try {
return ok("Tree listed.", { tree: await listTree(base, subPath, depth) });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
tools.push(tool({
name: "read_file",
description: "Read a text file from the workspace. Returns content with line numbers prefixed so you can use patch_lines accurately.",
parameters: {
filePath: z.string(),
},
implementation: async ({ filePath }) => {
try {
const content = await readTextFileSafe(base, filePath, limit);
const numbered = content.split("\n").map((l, i) => `${i + 1}: ${l}`).join("\n");
return ok("File read.", { filePath, lineCount: content.split("\n").length, content: numbered });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
tools.push(tool({
name: "write_file",
description: "Write or fully overwrite a file. Best for new files or complete rewrites. Do NOT include line numbers in content.",
parameters: {
filePath: z.string(),
content: z.string(),
},
implementation: async ({ filePath, content }) => {
try {
const full = await writeTextFileSafe(base, filePath, content, limit);
return ok("File written.", { filePath, full });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
tools.push(tool({
name: "patch_lines",
description: "Replace a range of lines in a file by line number. Use read_file first to get line numbers. fromLine and toLine are 1-based inclusive.",
parameters: {
filePath: z.string(),
fromLine: z.number().int().min(1),
toLine: z.number().int().min(1),
newContent: z.string(),
},
implementation: async ({ filePath, fromLine, toLine, newContent }) => {
try {
const content = await readTextFileSafe(base, filePath, limit);
const lines = content.split("\n");
if (fromLine > lines.length) return err(`fromLine ${fromLine} exceeds file length ${lines.length}`);
const clamped = Math.min(toLine, lines.length);
lines.splice(fromLine - 1, clamped - fromLine + 1, ...newContent.split("\n"));
await writeTextFileSafe(base, filePath, lines.join("\n"), limit);
return ok("Lines patched.", { filePath, fromLine, toLine: clamped });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
tools.push(tool({
name: "patch_json",
description: "Merge fields into a JSON file. Existing keys are overwritten, new keys are added. Set a value to null to remove a key. Always reads the file first so you can see current state.",
parameters: {
filePath: z.string(),
patch: z.record(z.unknown()),
},
implementation: async ({ filePath, patch }) => {
try {
const content = await readTextFileSafe(base, filePath, limit);
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(content);
} catch {
return err(`File is not valid JSON. Current content:\n${content}`);
}
const before = { ...parsed };
for (const [k, v] of Object.entries(patch)) {
if (v === null) delete parsed[k];
else parsed[k] = v;
}
await writeTextFileSafe(base, filePath, JSON.stringify(parsed, null, 2) + "\n", limit);
return ok("JSON patched.", { filePath, before, after: parsed });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
tools.push(tool({
name: "delete_path",
description: "Delete a file or folder inside the workspace.",
parameters: {
filePath: z.string(),
},
implementation: async ({ filePath }) => {
try {
const full = await deletePathSafe(base, filePath);
return ok("Path deleted.", { filePath, full });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
// ── Manifest ────────────────────────────────────────────────────────────────
tools.push(tool({
name: "validate_manifest",
description: "Read and validate manifest.json. Returns current content and any errors.",
parameters: {
_unused: z.string().optional(),
},
implementation: async () => {
const result = await validateManifest(base);
return result.ok ? ok("Manifest is valid.", result.data) : err(result.error);
}
}));
tools.push(tool({
name: "write_manifest",
description: "Update specific fields in manifest.json. Only provided fields are changed.",
parameters: {
name: z.string().optional(),
version: z.string().optional(),
description: z.string().optional(),
owner: z.string().optional(),
main: z.string().optional(),
},
implementation: async (fields) => {
try {
const existing = await validateManifest(base);
const current = existing.ok ? (existing.data as Record<string, unknown>) : {};
const update: Record<string, unknown> = { ...current };
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) update[k] = v;
}
await writeManifest(base, update);
return ok("Manifest written.", { manifest: update });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
// ── Build & package ─────────────────────────────────────────────────────────
tools.push(tool({
name: "run_command",
description: "Run an allowlisted shell command in the workspace. Allowed commands: npm, npx, node, tsc.",
parameters: {
command: z.string(),
args: z.array(z.string()).default([]),
},
implementation: async ({ command, args }) => {
const result = await runAllowedCommand(command, args, base, networkInstall);
return result.ok ? ok("Command succeeded.", result) : err((result as any).error + "\n" + ((result as any).stderr ?? ""));
}
}));
tools.push(tool({
name: "build_project",
description: "Run npm run build in the workspace and return full output including errors.",
parameters: {
_unused: z.string().optional(),
},
implementation: async () => {
const result = await runAllowedCommand("npm", ["run", "build"], base, networkInstall);
if (result.ok) return ok("Build succeeded.", result);
return err(`Build failed.\nSTDOUT:\n${(result as any).stdout}\nSTDERR:\n${(result as any).stderr}`);
}
}));
tools.push(tool({
name: "zip_project",
description: "Create a ZIP archive of the current workspace.",
parameters: {
outputFile: z.string().default("plugin-export.zip"),
},
implementation: async ({ outputFile }) => {
try {
const out = await zipDirectory(base, outputFile);
const hash = crypto.createHash("sha256").update(await fs.readFile(out)).digest("hex");
return ok("ZIP created.", { outputFile: out, sha256: hash });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
return tools;
}