src / utils / scaffold.ts
import path from "node:path";
import { ensureProjectRoot, exists, writeTextFileSafe } from "./fs";
import { writeManifest } from "./manifest";
export async function scaffoldPluginProject(baseDir: string, input: {
name: string;
description: string;
author?: string;
includePromptPreprocessor?: boolean;
includeExampleTools?: boolean;
maxFileBytes: number;
}): Promise<{ root: string; created: string[] }> {
await ensureProjectRoot(baseDir);
const created: string[] = [];
const srcDir = path.join(baseDir, "src");
if (!(await exists(baseDir, "manifest.json"))) {
await writeManifest(baseDir, {
name: input.name,
version: "1.0.0",
description: input.description,
owner: input.author ?? "",
main: "dist/index.js",
});
created.push("manifest.json");
}
if (!(await exists(baseDir, "package.json"))) {
const pkg = {
name: input.name,
private: true,
version: "1.0.0",
type: "module",
main: "dist/index.js",
scripts: {
build: "tsc -p tsconfig.json",
typecheck: "tsc -p tsconfig.json --noEmit"
},
dependencies: {
"@lmstudio/sdk": "^1.0.0",
"archiver": "^7.0.1",
"execa": "^9.5.2",
"zod": "^3.24.1"
},
devDependencies: {
"typescript": "^5.8.3"
}
};
await writeTextFileSafe(baseDir, "package.json", JSON.stringify(pkg, null, 2) + "\n", input.maxFileBytes);
created.push("package.json");
}
if (!(await exists(baseDir, "tsconfig.json"))) {
const tsconfig = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ES2022"],
outDir: "dist",
rootDir: "src",
strict: true,
skipLibCheck: true,
esModuleInterop: true,
forceConsistentCasingInFileNames: true
},
include: ["src/**/*.ts"]
};
await writeTextFileSafe(baseDir, "tsconfig.json", JSON.stringify(tsconfig, null, 2) + "\n", input.maxFileBytes);
created.push("tsconfig.json");
}
const indexTs = `import type { PluginContext } from "@lmstudio/sdk";
import { toolsProvider } from "./toolsProvider";
import { configSchematics } from "./config";
export async function main(context: PluginContext) {
context.withToolsProvider(toolsProvider);
context.withConfigSchematics(configSchematics);
}
`;
if (!(await exists(baseDir, "src/index.ts"))) {
await writeTextFileSafe(baseDir, "src/index.ts", indexTs, input.maxFileBytes);
created.push("src/index.ts");
}
if (!(await exists(baseDir, "src/config.ts"))) {
await writeTextFileSafe(baseDir, "src/config.ts", `import { createConfigSchematics } from "@lmstudio/sdk";
export const configSchematics = createConfigSchematics()
.field(
"workspaceRoot",
"string",
{
displayName: "Workspace Root",
subtitle: "Optional path. Leave blank to use the plugin working directory.",
},
"",
)
.field(
"maxFileBytes",
"numeric",
{
displayName: "Maximum File Size",
subtitle: "Maximum size in bytes for reads and writes.",
min: 1024,
max: 5000000,
},
250000,
)
.field(
"allowNetworkInstall",
"boolean",
{
displayName: "Allow Network Install",
subtitle: "Permit npm install inside generated projects.",
},
true,
)
.build();
`, input.maxFileBytes);
created.push("src/config.ts");
}
if (!(await exists(baseDir, "src/toolsProvider.ts"))) {
await writeTextFileSafe(baseDir, "src/toolsProvider.ts", await defaultToolsProviderSource(), input.maxFileBytes);
created.push("src/toolsProvider.ts");
}
if (input.includePromptPreprocessor && !(await exists(baseDir, "src/promptPreprocessor.ts"))) {
await writeTextFileSafe(baseDir, "src/promptPreprocessor.ts", `export async function promptPreprocessor() {
return undefined;
}
`, input.maxFileBytes);
created.push("src/promptPreprocessor.ts");
}
if (input.includeExampleTools && !(await exists(baseDir, "src/templates.ts"))) {
await writeTextFileSafe(baseDir, "src/templates.ts", `export const exampleTemplates = [];
`, input.maxFileBytes);
created.push("src/templates.ts");
}
return { root: baseDir, created };
}
async function defaultToolsProviderSource(): Promise<string> {
return `import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
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 workspaceRoot(ctl: ToolsProviderController): string {
const config = ctl.getPluginConfig?.() ?? {};
const root = config.workspaceRoot?.trim?.();
return root ? path.resolve(root) : ctl.getWorkingDirectory();
}
function maxBytes(ctl: ToolsProviderController): number {
const config = ctl.getPluginConfig?.() ?? {};
return Number(config.maxFileBytes ?? 250000);
}
function networkInstallAllowed(ctl: ToolsProviderController): boolean {
const config = ctl.getPluginConfig?.() ?? {};
return Boolean(config.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);
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 = [
"Define the plugin purpose and LM Studio hook type.",
"Choose the minimum file set and dependencies.",
"Generate manifest.json, package.json, tsconfig.json, and src entry files.",
"Add the tools provider or prompt preprocessor code.",
"Validate the manifest and typecheck the project.",
"Build, fix errors, and package the plugin."
];
return ok("Plan created.", { goal, constraints, steps });
}
}));
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));
}
}
}));
tools.push(tool({
name: "list_files",
description: "List files and folders in the current plugin 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.",
parameters: {
filePath: z.string(),
},
implementation: async ({ filePath }) => {
try {
const content = await readTextFileSafe(base, filePath, limit);
return ok("File read.", { filePath, content });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
tools.push(tool({
name: "write_file",
description: "Write a text file safely inside the workspace.",
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_file",
description: "Replace exact text in a file. Best for small targeted edits.",
parameters: {
filePath: z.string(),
find: z.string(),
replace: z.string(),
all: z.boolean().default(false),
},
implementation: async ({ filePath, find, replace, all }) => {
try {
const content = await readTextFileSafe(base, filePath, limit);
const next = all ? content.split(find).join(replace) : content.replace(find, replace);
if (next === content) return err("No match found; file unchanged.");
await writeTextFileSafe(base, filePath, next, limit);
return ok("File patched.", { filePath });
} 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));
}
}
}));
tools.push(tool({
name: "validate_manifest",
description: "Validate manifest.json for common LM Studio plugin mistakes.",
parameters: {},
implementation: async () => {
const result = await validateManifest(base);
return result.ok ? ok("Manifest is valid.", result.data) : err(result.error);
}
}));
tools.push(tool({
name: "run_command",
description: "Run an allowlisted command inside the workspace, such as npm install or npm run build.",
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.error);
}
}));
tools.push(tool({
name: "build_project",
description: "Run the plugin build command.",
parameters: {},
implementation: async () => {
const result = await runAllowedCommand("npm", ["run", "build"], base, networkInstall);
return result.ok ? ok("Build succeeded.", result) : err(result.error);
}
}));
tools.push(tool({
name: "zip_project",
description: "Create a ZIP archive of the current workspace, excluding generated artifacts and dependency folders.",
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));
}
}
}));
tools.push(tool({
name: "write_manifest",
description: "Overwrite manifest.json with the provided fields.",
parameters: {
manifest: z.record(z.unknown()),
},
implementation: async ({ manifest }) => {
try {
await writeManifest(base, manifest as Record<string, unknown>);
return ok("Manifest written.", { path: "manifest.json" });
} catch (e: any) {
return err(e?.message ?? String(e));
}
}
}));
return tools;
}
`;
}