src / toolsProvider.ts
import { join } from "path";
import { existsSync, readdirSync } from "fs";
import { spawn } from "child_process";
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./config.js";
import { GodotEngine } from "./core/godotCore.js";
import { searchDocs, fetchDocPage } from "./core/godotDocs.js";
/**
* Build a standardized error message string for the model.
*/
function fail(message: string, solutions?: string[]): string {
if (!solutions || solutions.length === 0) return `Error: ${message}`;
return `Error: ${message}\n\nPossible solutions:\n- ${solutions.join("\n- ")}`;
}
/**
* Read the current per-chat configuration.
*/
function readConfig(ctl: ToolsProviderController) {
return ctl.getPluginConfig(configSchematics);
}
/**
* Guard for engine-dependent tools: they are gated by the per-chat
* "toolsEnabled" setting. Returns an error string when disabled, else null.
*/
function guardEngineTools(ctl: ToolsProviderController): string | null {
const config = readConfig(ctl);
if (config.get("toolsEnabled") === false) {
return fail(
"Godot engine tools are disabled. Enable 'Enable Godot Tools' in this chat's settings."
);
}
return null;
}
/**
* Resolve the Godot executable path using the configured override, env var and
* platform auto-detection.
*/
async function resolveGodot(
engine: GodotEngine,
ctl: ToolsProviderController
): Promise<string | null> {
const explicit = readConfig(ctl).get("godotPath");
const path =
typeof explicit === "string" && explicit.trim() ? explicit : undefined;
return engine.detectGodotPath(path);
}
/**
* ToolsProvider entry point for LM Studio.
*/
export async function toolsProvider(
ctl: ToolsProviderController
): Promise<Tool[]> {
const engine = new GodotEngine({ godotDebugMode: true });
// ---------------------------------------------------------------------------
// Godot editor / project lifecycle
// ---------------------------------------------------------------------------
const launchEditor = tool({
name: "launch_editor",
description:
"Launch the Godot editor for a specific project directory. The directory must contain a project.godot file.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
},
implementation: async ({ projectPath }, ctx) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.', [
"Provide an absolute path to a directory containing project.godot.",
]);
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`, [
"Ensure the path points to a directory containing a project.godot file.",
"Use list_projects to find valid Godot projects.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.", [
"Set the 'Godot Executable Path' setting or the GODOT_PATH environment variable.",
]);
}
ctx?.status?.(`Launching Godot editor for ${projectPath}`);
spawn(godotPath, ["-e", "--path", projectPath], { stdio: "pipe" });
return `Godot editor launched successfully for project at ${projectPath}.`;
},
});
const runProject = tool({
name: "run_project",
description:
"Run a Godot project in debug (headless) mode and capture its output. Use get_debug_output to read the output afterwards.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scene: z
.string()
.optional()
.describe("Optional scene path to open directly (relative to the project)."),
},
implementation: async ({ projectPath, scene }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`, [
"Ensure the path points to a directory containing a project.godot file.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.", [
"Set the 'Godot Executable Path' setting or the GODOT_PATH environment variable.",
]);
}
try {
engine.runProject(projectPath, scene || undefined);
return "Godot project started in debug mode. Use get_debug_output to read output.";
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to run Godot project: ${message}`, [
"Ensure Godot is installed correctly.",
"Verify the project path is accessible.",
]);
}
},
});
const getDebugOutput = tool({
name: "get_debug_output",
description:
"Return the captured stdout and stderr of the currently running Godot project.",
parameters: {},
implementation: async () => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
const active = engine.getDebugOutput();
if (!active) {
return fail("No active Godot process. Use run_project first.", [
"Start a project with run_project before reading its output.",
]);
}
return JSON.stringify(active, null, 2);
},
});
const stopProject = tool({
name: "stop_project",
description: "Stop the currently running Godot project and return its final output.",
parameters: {},
implementation: async () => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
const result = engine.stopProject();
if (!result) {
return fail("No active Godot process to stop.", [
"Use run_project to start a project first.",
]);
}
return JSON.stringify(
{ message: "Godot project stopped", finalOutput: result.finalOutput, finalErrors: result.finalErrors },
null,
2
);
},
});
const getGodotVersion = tool({
name: "get_godot_version",
description: "Return the version string of the resolved Godot executable.",
parameters: {},
implementation: async () => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.", [
"Set the 'Godot Executable Path' setting or the GODOT_PATH environment variable.",
]);
}
try {
const version = await engine.getVersion();
return version || "Unknown version.";
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to get Godot version: ${message}`);
}
},
});
// ---------------------------------------------------------------------------
// Project discovery / inspection
// ---------------------------------------------------------------------------
const listProjects = tool({
name: "list_projects",
description:
"Find Godot projects (directories containing project.godot) inside a directory.",
parameters: {
directory: z.string().describe("Directory to search for Godot projects."),
recursive: z
.boolean()
.optional()
.describe("Whether to search recursively through subdirectories."),
},
implementation: async ({ directory, recursive }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!directory) return fail("directory is required.");
if (!engine.validatePath(directory)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(directory)) {
return fail(`Directory does not exist: ${directory}`, [
"Provide a valid directory path that exists on the system.",
]);
}
const projects = engine.findGodotProjects(directory, recursive === true);
return JSON.stringify(projects, null, 2);
},
});
const getProjectInfo = tool({
name: "get_project_info",
description:
"Retrieve metadata about a Godot project: name, path, Godot version and a file-type breakdown.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
},
implementation: async ({ projectPath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`, [
"Ensure the path points to a directory containing a project.godot file.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.");
}
try {
const version = await engine.getVersion();
const structure = countProjectFiles(projectPath);
return JSON.stringify(
{
name: projectPath.split(/[/\\]/).pop(),
path: projectPath,
godotVersion: version,
structure,
},
null,
2
);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to get project info: ${message}`);
}
},
});
// ---------------------------------------------------------------------------
// Scene / node editing (via bundled GDScript operations)
// ---------------------------------------------------------------------------
const createScene = tool({
name: "create_scene",
description:
"Create a new Godot scene file with a given root node type.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project (e.g. 'ui/main.tscn')."),
rootNodeType: z
.string()
.optional()
.describe("Root node type name, e.g. Node2D or Node3D (default: Node2D)."),
},
implementation: async ({ projectPath, scenePath, rootNodeType }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath) {
return fail("projectPath and scenePath are required.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(scenePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
const root = rootNodeType || "Node2D";
if (!engine.validateClassName(root)) {
return fail("Invalid rootNodeType.", [
"rootNodeType must be a built-in Godot class name (no paths, no extensions).",
]);
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
try {
const { stdout, stderr } = await engine.executeOperation("create_scene", { scenePath, rootNodeType: root }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to create scene: ${stderr}`, [
"Check that the root node type is valid.",
"Ensure you have write permission to the scene path.",
]);
}
return `Scene created successfully at: ${scenePath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to create scene: ${message}`);
}
},
});
const addNode = tool({
name: "add_node",
description: "Add a node to an existing Godot scene.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project."),
nodeType: z.string().describe("Node type to add, e.g. Sprite2D or CollisionShape2D."),
nodeName: z.string().describe("Name for the new node."),
parentNodePath: z
.string()
.optional()
.describe('Parent node path, e.g. "root" or "root/Player".'),
properties: z
.record(z.unknown())
.optional()
.describe("Optional key/value properties to set on the node."),
},
implementation: async ({ projectPath, scenePath, nodeType, nodeName, parentNodePath, properties }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath || !nodeType || !nodeName) {
return fail("Missing required parameters: projectPath, scenePath, nodeType, nodeName.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(scenePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!engine.validateClassName(nodeType)) {
return fail("Invalid nodeType.", [
"nodeType must be a built-in Godot class name.",
]);
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`, [
"Use create_scene to create a new scene first.",
]);
}
const params: Record<string, unknown> = { scenePath, nodeType, nodeName };
if (parentNodePath) params.parentNodePath = parentNodePath;
if (properties) params.properties = properties;
try {
const { stdout, stderr } = await engine.executeOperation("add_node", params, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to add node: ${stderr}`, [
"Check that the node type is valid.",
"Ensure the parent node path exists.",
]);
}
return `Node '${nodeName}' of type '${nodeType}' added to '${scenePath}'.\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to add node: ${message}`);
}
},
});
const loadSprite = tool({
name: "load_sprite",
description: "Load a texture into a Sprite2D / Sprite3D / TextureRect node.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project."),
nodePath: z.string().describe("Target node path, e.g. 'root/Player/Sprite2D'."),
texturePath: z.string().describe("Texture file path relative to the project."),
},
implementation: async ({ projectPath, scenePath, nodePath, texturePath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath || !nodePath || !texturePath) {
return fail("Missing required parameters.");
}
if (
!engine.validatePath(projectPath) ||
!engine.validatePath(scenePath) ||
!engine.validatePath(nodePath) ||
!engine.validatePath(texturePath)
) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`);
}
if (!existsSync(join(projectPath, texturePath))) {
return fail(`Texture file does not exist: ${texturePath}`, [
"Upload or create the texture file first.",
]);
}
try {
const { stdout, stderr } = await engine.executeOperation("load_sprite", { scenePath, nodePath, texturePath }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to load sprite: ${stderr}`, [
"Ensure the target node is a Sprite2D, Sprite3D, or TextureRect.",
]);
}
return `Sprite loaded with texture: ${texturePath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to load sprite: ${message}`);
}
},
});
const exportMeshLibrary = tool({
name: "export_mesh_library",
description: "Export a 3D scene as a MeshLibrary resource (.res) for use with GridMap.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file (.tscn) path relative to the project."),
outputPath: z.string().describe("Output .res path relative to the project."),
meshItemNames: z
.array(z.string())
.optional()
.describe("Optional subset of mesh item names to include (default: all)."),
},
implementation: async ({ projectPath, scenePath, outputPath, meshItemNames }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath || !outputPath) {
return fail("projectPath, scenePath, and outputPath are required.");
}
if (
!engine.validatePath(projectPath) ||
!engine.validatePath(scenePath) ||
!engine.validatePath(outputPath)
) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`);
}
const params: Record<string, unknown> = { scenePath, outputPath };
if (meshItemNames) params.meshItemNames = meshItemNames;
try {
const { stdout, stderr } = await engine.executeOperation("export_mesh_library", params, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to export mesh library: ${stderr}`, [
"Check that the scene contains valid 3D meshes.",
]);
}
return `MeshLibrary exported successfully to: ${outputPath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to export mesh library: ${message}`);
}
},
});
const saveScene = tool({
name: "save_scene",
description: "Save changes to a scene file, optionally writing a variant to a new path.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project."),
newPath: z
.string()
.optional()
.describe("Optional new path to save a variant (relative to the project)."),
},
implementation: async ({ projectPath, scenePath, newPath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath) {
return fail("projectPath and scenePath are required.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(scenePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (newPath && !engine.validatePath(newPath)) {
return fail("Invalid new path: avoid '..' or unsafe characters.");
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`, [
"Use create_scene to create a new scene first.",
]);
}
const params: Record<string, unknown> = { scenePath };
if (newPath) params.newPath = newPath;
try {
const { stdout, stderr } = await engine.executeOperation("save_scene", params, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to save scene: ${stderr}`, [
"Check that the scene file is valid.",
]);
}
return `Scene saved successfully to: ${newPath || scenePath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to save scene: ${message}`);
}
},
});
const getUid = tool({
name: "get_uid",
description:
"Get the UID for a specific file in a Godot project (Godot 4.4+ only).",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
filePath: z.string().describe("File path relative to the project."),
},
implementation: async ({ projectPath, filePath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !filePath) {
return fail("projectPath and filePath are required.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(filePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, filePath))) {
return fail(`File does not exist: ${filePath}`, [
"Ensure the file path is correct.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) return fail("Could not find a valid Godot executable.");
try {
const version = await engine.getVersion();
if (!engine.isGodot44OrLater(version)) {
return fail(
`UIDs require Godot 4.4 or later. Current: ${version}`,
["Upgrade to Godot 4.4+ or use resource paths instead of UIDs."]
);
}
const { stdout, stderr } = await engine.executeOperation("get_uid", { filePath }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to get UID: ${stderr}`, [
"Check that the file is a valid Godot resource.",
]);
}
return `UID for ${filePath}: ${stdout.trim()}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to get UID: ${message}`);
}
},
});
const updateProjectUids = tool({
name: "update_project_uids",
description:
"Refresh UID references across a Godot project by resaving resources (Godot 4.4+ only).",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
},
implementation: async ({ projectPath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) return fail("Could not find a valid Godot executable.");
try {
const version = await engine.getVersion();
if (!engine.isGodot44OrLater(version)) {
return fail(
`UIDs require Godot 4.4 or later. Current: ${version}`,
["Upgrade to Godot 4.4+ to use UIDs."]
);
}
const { stdout, stderr } = await engine.executeOperation("resave_resources", { projectPath }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to update project UIDs: ${stderr}`, [
"Ensure you have write permission to the project directory.",
]);
}
return `Project UIDs updated successfully.\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to update project UIDs: ${message}`);
}
},
});
// ---------------------------------------------------------------------------
// Godot documentation tools (network-only, always available)
// ---------------------------------------------------------------------------
const searchGodotDocs = tool({
name: "search_godot_docs",
description:
"Search the official Godot documentation and return a list of matching pages with titles, URLs and short snippets.",
parameters: {
query: z.string().describe("Search query, e.g. 'Widget', 'Signal', 'GridMap'."),
maxResults: z.number().int().positive().optional().describe("Max results to return (overrides the chat setting)."),
version: z.string().optional().describe("Documentation version (overrides the chat setting)."),
},
implementation: async ({ query, maxResults, version }, ctx) => {
if (!query || !query.trim()) return fail("query is required.");
const config = readConfig(ctl);
const limit =
typeof maxResults === "number"
? Math.min(Math.max(Math.trunc(maxResults), 1), 20)
: (config.get("docsMaxResults") as number);
const docsVersion =
typeof version === "string" && version.trim() ? version : (config.get("docsVersion") as string);
ctx?.status?.(`Searching Godot docs for "${query}"`);
try {
const results = await searchDocs(query, docsVersion, limit, { signal: ctx?.signal });
if (results.length === 0) {
return `No results found for "${query}" in the ${docsVersion} documentation.`;
}
return JSON.stringify(results, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Godot documentation search failed: ${message}`, [
"Check the network connection and the search query.",
]);
}
},
});
const fetchGodotDocs = tool({
name: "fetch_godot_docs",
description:
"Fetch and return the readable text content of a single Godot documentation page (URL).",
parameters: {
url: z.string().describe("Full URL of the Godot documentation page to fetch."),
maxChars: z.number().int().positive().optional().describe("Maximum characters to return (overrides the chat setting)."),
},
implementation: async ({ url, maxChars }, ctx) => {
if (!url || !/^https?:\/\//.test(url)) {
return fail("A valid https URL is required in the 'url' parameter.");
}
const config = readConfig(ctl);
const maxOutputChars =
typeof maxChars === "number"
? Math.min(Math.max(Math.trunc(maxChars), 500), 100000)
: (config.get("docsMaxChars") as number);
ctx?.status?.(`Fetching ${url}`);
try {
const text = await fetchDocPage(url, { maxChars: maxOutputChars, signal: ctx?.signal });
if (!text || text.length === 0) {
return "The page did not contain extractable documentation content.";
}
return text;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to fetch Godot documentation: ${message}`, [
"Verify the URL is correct and reachable.",
]);
}
},
});
return [
launchEditor,
runProject,
getDebugOutput,
stopProject,
getGodotVersion,
listProjects,
getProjectInfo,
createScene,
addNode,
loadSprite,
exportMeshLibrary,
saveScene,
getUid,
updateProjectUids,
searchGodotDocs,
fetchGodotDocs,
];
}
/**
* Count project files by category. Kept local to avoid pulling the engine in.
*/
function countProjectFiles(projectPath: string): {
scenes: number;
scripts: number;
assets: number;
other: number;
} {
const counts = { scenes: 0, scripts: 0, assets: 0, other: 0 };
const extensions = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "wav", "mp3", "ogg"]);
const scan = (currentPath: string): void => {
let entries;
try {
entries = readdirSync(currentPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!entry.name.startsWith(".")) scan(join(currentPath, entry.name));
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
if (ext === "tscn") counts.scenes++;
else if (ext === "gd" || ext === "cs") counts.scripts++;
else if (ext && extensions.has(ext)) counts.assets++;
else counts.other++;
}
}
};
scan(projectPath);
return counts;
}
src / toolsProvider.ts
import { join } from "path";
import { existsSync, readdirSync } from "fs";
import { spawn } from "child_process";
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./config.js";
import { GodotEngine } from "./core/godotCore.js";
import { searchDocs, fetchDocPage } from "./core/godotDocs.js";
/**
* Build a standardized error message string for the model.
*/
function fail(message: string, solutions?: string[]): string {
if (!solutions || solutions.length === 0) return `Error: ${message}`;
return `Error: ${message}\n\nPossible solutions:\n- ${solutions.join("\n- ")}`;
}
/**
* Read the current per-chat configuration.
*/
function readConfig(ctl: ToolsProviderController) {
return ctl.getPluginConfig(configSchematics);
}
/**
* Guard for engine-dependent tools: they are gated by the per-chat
* "toolsEnabled" setting. Returns an error string when disabled, else null.
*/
function guardEngineTools(ctl: ToolsProviderController): string | null {
const config = readConfig(ctl);
if (config.get("toolsEnabled") === false) {
return fail(
"Godot engine tools are disabled. Enable 'Enable Godot Tools' in this chat's settings."
);
}
return null;
}
/**
* Resolve the Godot executable path using the configured override, env var and
* platform auto-detection.
*/
async function resolveGodot(
engine: GodotEngine,
ctl: ToolsProviderController
): Promise<string | null> {
const explicit = readConfig(ctl).get("godotPath");
const path =
typeof explicit === "string" && explicit.trim() ? explicit : undefined;
return engine.detectGodotPath(path);
}
/**
* ToolsProvider entry point for LM Studio.
*/
export async function toolsProvider(
ctl: ToolsProviderController
): Promise<Tool[]> {
const engine = new GodotEngine({ godotDebugMode: true });
// ---------------------------------------------------------------------------
// Godot editor / project lifecycle
// ---------------------------------------------------------------------------
const launchEditor = tool({
name: "launch_editor",
description:
"Launch the Godot editor for a specific project directory. The directory must contain a project.godot file.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
},
implementation: async ({ projectPath }, ctx) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.', [
"Provide an absolute path to a directory containing project.godot.",
]);
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`, [
"Ensure the path points to a directory containing a project.godot file.",
"Use list_projects to find valid Godot projects.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.", [
"Set the 'Godot Executable Path' setting or the GODOT_PATH environment variable.",
]);
}
ctx?.status?.(`Launching Godot editor for ${projectPath}`);
spawn(godotPath, ["-e", "--path", projectPath], { stdio: "pipe" });
return `Godot editor launched successfully for project at ${projectPath}.`;
},
});
const runProject = tool({
name: "run_project",
description:
"Run a Godot project in debug (headless) mode and capture its output. Use get_debug_output to read the output afterwards.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scene: z
.string()
.optional()
.describe("Optional scene path to open directly (relative to the project)."),
},
implementation: async ({ projectPath, scene }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`, [
"Ensure the path points to a directory containing a project.godot file.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.", [
"Set the 'Godot Executable Path' setting or the GODOT_PATH environment variable.",
]);
}
try {
engine.runProject(projectPath, scene || undefined);
return "Godot project started in debug mode. Use get_debug_output to read output.";
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to run Godot project: ${message}`, [
"Ensure Godot is installed correctly.",
"Verify the project path is accessible.",
]);
}
},
});
const getDebugOutput = tool({
name: "get_debug_output",
description:
"Return the captured stdout and stderr of the currently running Godot project.",
parameters: {},
implementation: async () => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
const active = engine.getDebugOutput();
if (!active) {
return fail("No active Godot process. Use run_project first.", [
"Start a project with run_project before reading its output.",
]);
}
return JSON.stringify(active, null, 2);
},
});
const stopProject = tool({
name: "stop_project",
description: "Stop the currently running Godot project and return its final output.",
parameters: {},
implementation: async () => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
const result = engine.stopProject();
if (!result) {
return fail("No active Godot process to stop.", [
"Use run_project to start a project first.",
]);
}
return JSON.stringify(
{ message: "Godot project stopped", finalOutput: result.finalOutput, finalErrors: result.finalErrors },
null,
2
);
},
});
const getGodotVersion = tool({
name: "get_godot_version",
description: "Return the version string of the resolved Godot executable.",
parameters: {},
implementation: async () => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.", [
"Set the 'Godot Executable Path' setting or the GODOT_PATH environment variable.",
]);
}
try {
const version = await engine.getVersion();
return version || "Unknown version.";
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to get Godot version: ${message}`);
}
},
});
// ---------------------------------------------------------------------------
// Project discovery / inspection
// ---------------------------------------------------------------------------
const listProjects = tool({
name: "list_projects",
description:
"Find Godot projects (directories containing project.godot) inside a directory.",
parameters: {
directory: z.string().describe("Directory to search for Godot projects."),
recursive: z
.boolean()
.optional()
.describe("Whether to search recursively through subdirectories."),
},
implementation: async ({ directory, recursive }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!directory) return fail("directory is required.");
if (!engine.validatePath(directory)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(directory)) {
return fail(`Directory does not exist: ${directory}`, [
"Provide a valid directory path that exists on the system.",
]);
}
const projects = engine.findGodotProjects(directory, recursive === true);
return JSON.stringify(projects, null, 2);
},
});
const getProjectInfo = tool({
name: "get_project_info",
description:
"Retrieve metadata about a Godot project: name, path, Godot version and a file-type breakdown.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
},
implementation: async ({ projectPath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`, [
"Ensure the path points to a directory containing a project.godot file.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) {
return fail("Could not find a valid Godot executable.");
}
try {
const version = await engine.getVersion();
const structure = countProjectFiles(projectPath);
return JSON.stringify(
{
name: projectPath.split(/[/\\]/).pop(),
path: projectPath,
godotVersion: version,
structure,
},
null,
2
);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to get project info: ${message}`);
}
},
});
// ---------------------------------------------------------------------------
// Scene / node editing (via bundled GDScript operations)
// ---------------------------------------------------------------------------
const createScene = tool({
name: "create_scene",
description:
"Create a new Godot scene file with a given root node type.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project (e.g. 'ui/main.tscn')."),
rootNodeType: z
.string()
.optional()
.describe("Root node type name, e.g. Node2D or Node3D (default: Node2D)."),
},
implementation: async ({ projectPath, scenePath, rootNodeType }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath) {
return fail("projectPath and scenePath are required.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(scenePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
const root = rootNodeType || "Node2D";
if (!engine.validateClassName(root)) {
return fail("Invalid rootNodeType.", [
"rootNodeType must be a built-in Godot class name (no paths, no extensions).",
]);
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
try {
const { stdout, stderr } = await engine.executeOperation("create_scene", { scenePath, rootNodeType: root }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to create scene: ${stderr}`, [
"Check that the root node type is valid.",
"Ensure you have write permission to the scene path.",
]);
}
return `Scene created successfully at: ${scenePath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to create scene: ${message}`);
}
},
});
const addNode = tool({
name: "add_node",
description: "Add a node to an existing Godot scene.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project."),
nodeType: z.string().describe("Node type to add, e.g. Sprite2D or CollisionShape2D."),
nodeName: z.string().describe("Name for the new node."),
parentNodePath: z
.string()
.optional()
.describe('Parent node path, e.g. "root" or "root/Player".'),
properties: z
.record(z.unknown())
.optional()
.describe("Optional key/value properties to set on the node."),
},
implementation: async ({ projectPath, scenePath, nodeType, nodeName, parentNodePath, properties }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath || !nodeType || !nodeName) {
return fail("Missing required parameters: projectPath, scenePath, nodeType, nodeName.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(scenePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!engine.validateClassName(nodeType)) {
return fail("Invalid nodeType.", [
"nodeType must be a built-in Godot class name.",
]);
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`, [
"Use create_scene to create a new scene first.",
]);
}
const params: Record<string, unknown> = { scenePath, nodeType, nodeName };
if (parentNodePath) params.parentNodePath = parentNodePath;
if (properties) params.properties = properties;
try {
const { stdout, stderr } = await engine.executeOperation("add_node", params, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to add node: ${stderr}`, [
"Check that the node type is valid.",
"Ensure the parent node path exists.",
]);
}
return `Node '${nodeName}' of type '${nodeType}' added to '${scenePath}'.\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to add node: ${message}`);
}
},
});
const loadSprite = tool({
name: "load_sprite",
description: "Load a texture into a Sprite2D / Sprite3D / TextureRect node.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project."),
nodePath: z.string().describe("Target node path, e.g. 'root/Player/Sprite2D'."),
texturePath: z.string().describe("Texture file path relative to the project."),
},
implementation: async ({ projectPath, scenePath, nodePath, texturePath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath || !nodePath || !texturePath) {
return fail("Missing required parameters.");
}
if (
!engine.validatePath(projectPath) ||
!engine.validatePath(scenePath) ||
!engine.validatePath(nodePath) ||
!engine.validatePath(texturePath)
) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`);
}
if (!existsSync(join(projectPath, texturePath))) {
return fail(`Texture file does not exist: ${texturePath}`, [
"Upload or create the texture file first.",
]);
}
try {
const { stdout, stderr } = await engine.executeOperation("load_sprite", { scenePath, nodePath, texturePath }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to load sprite: ${stderr}`, [
"Ensure the target node is a Sprite2D, Sprite3D, or TextureRect.",
]);
}
return `Sprite loaded with texture: ${texturePath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to load sprite: ${message}`);
}
},
});
const exportMeshLibrary = tool({
name: "export_mesh_library",
description: "Export a 3D scene as a MeshLibrary resource (.res) for use with GridMap.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file (.tscn) path relative to the project."),
outputPath: z.string().describe("Output .res path relative to the project."),
meshItemNames: z
.array(z.string())
.optional()
.describe("Optional subset of mesh item names to include (default: all)."),
},
implementation: async ({ projectPath, scenePath, outputPath, meshItemNames }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath || !outputPath) {
return fail("projectPath, scenePath, and outputPath are required.");
}
if (
!engine.validatePath(projectPath) ||
!engine.validatePath(scenePath) ||
!engine.validatePath(outputPath)
) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`);
}
const params: Record<string, unknown> = { scenePath, outputPath };
if (meshItemNames) params.meshItemNames = meshItemNames;
try {
const { stdout, stderr } = await engine.executeOperation("export_mesh_library", params, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to export mesh library: ${stderr}`, [
"Check that the scene contains valid 3D meshes.",
]);
}
return `MeshLibrary exported successfully to: ${outputPath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to export mesh library: ${message}`);
}
},
});
const saveScene = tool({
name: "save_scene",
description: "Save changes to a scene file, optionally writing a variant to a new path.",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
scenePath: z.string().describe("Scene file path relative to the project."),
newPath: z
.string()
.optional()
.describe("Optional new path to save a variant (relative to the project)."),
},
implementation: async ({ projectPath, scenePath, newPath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !scenePath) {
return fail("projectPath and scenePath are required.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(scenePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (newPath && !engine.validatePath(newPath)) {
return fail("Invalid new path: avoid '..' or unsafe characters.");
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, scenePath))) {
return fail(`Scene file does not exist: ${scenePath}`, [
"Use create_scene to create a new scene first.",
]);
}
const params: Record<string, unknown> = { scenePath };
if (newPath) params.newPath = newPath;
try {
const { stdout, stderr } = await engine.executeOperation("save_scene", params, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to save scene: ${stderr}`, [
"Check that the scene file is valid.",
]);
}
return `Scene saved successfully to: ${newPath || scenePath}\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to save scene: ${message}`);
}
},
});
const getUid = tool({
name: "get_uid",
description:
"Get the UID for a specific file in a Godot project (Godot 4.4+ only).",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
filePath: z.string().describe("File path relative to the project."),
},
implementation: async ({ projectPath, filePath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath || !filePath) {
return fail("projectPath and filePath are required.");
}
if (!engine.validatePath(projectPath) || !engine.validatePath(filePath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
if (!existsSync(join(projectPath, filePath))) {
return fail(`File does not exist: ${filePath}`, [
"Ensure the file path is correct.",
]);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) return fail("Could not find a valid Godot executable.");
try {
const version = await engine.getVersion();
if (!engine.isGodot44OrLater(version)) {
return fail(
`UIDs require Godot 4.4 or later. Current: ${version}`,
["Upgrade to Godot 4.4+ or use resource paths instead of UIDs."]
);
}
const { stdout, stderr } = await engine.executeOperation("get_uid", { filePath }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to get UID: ${stderr}`, [
"Check that the file is a valid Godot resource.",
]);
}
return `UID for ${filePath}: ${stdout.trim()}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to get UID: ${message}`);
}
},
});
const updateProjectUids = tool({
name: "update_project_uids",
description:
"Refresh UID references across a Godot project by resaving resources (Godot 4.4+ only).",
parameters: {
projectPath: z.string().describe("Absolute path to the Godot project directory."),
},
implementation: async ({ projectPath }) => {
const guard = guardEngineTools(ctl);
if (guard) return guard;
if (!projectPath) return fail("projectPath is required.");
if (!engine.validatePath(projectPath)) {
return fail('Invalid path: avoid ".." or unsafe characters.');
}
if (!existsSync(join(projectPath, "project.godot"))) {
return fail(`Not a valid Godot project: ${projectPath}`);
}
const godotPath = await resolveGodot(engine, ctl);
if (!godotPath) return fail("Could not find a valid Godot executable.");
try {
const version = await engine.getVersion();
if (!engine.isGodot44OrLater(version)) {
return fail(
`UIDs require Godot 4.4 or later. Current: ${version}`,
["Upgrade to Godot 4.4+ to use UIDs."]
);
}
const { stdout, stderr } = await engine.executeOperation("resave_resources", { projectPath }, projectPath);
if (stderr.includes("Failed to")) {
return fail(`Failed to update project UIDs: ${stderr}`, [
"Ensure you have write permission to the project directory.",
]);
}
return `Project UIDs updated successfully.\n\nOutput: ${stdout}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to update project UIDs: ${message}`);
}
},
});
// ---------------------------------------------------------------------------
// Godot documentation tools (network-only, always available)
// ---------------------------------------------------------------------------
const searchGodotDocs = tool({
name: "search_godot_docs",
description:
"Search the official Godot documentation and return a list of matching pages with titles, URLs and short snippets.",
parameters: {
query: z.string().describe("Search query, e.g. 'Widget', 'Signal', 'GridMap'."),
maxResults: z.number().int().positive().optional().describe("Max results to return (overrides the chat setting)."),
version: z.string().optional().describe("Documentation version (overrides the chat setting)."),
},
implementation: async ({ query, maxResults, version }, ctx) => {
if (!query || !query.trim()) return fail("query is required.");
const config = readConfig(ctl);
const limit =
typeof maxResults === "number"
? Math.min(Math.max(Math.trunc(maxResults), 1), 20)
: (config.get("docsMaxResults") as number);
const docsVersion =
typeof version === "string" && version.trim() ? version : (config.get("docsVersion") as string);
ctx?.status?.(`Searching Godot docs for "${query}"`);
try {
const results = await searchDocs(query, docsVersion, limit, { signal: ctx?.signal });
if (results.length === 0) {
return `No results found for "${query}" in the ${docsVersion} documentation.`;
}
return JSON.stringify(results, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Godot documentation search failed: ${message}`, [
"Check the network connection and the search query.",
]);
}
},
});
const fetchGodotDocs = tool({
name: "fetch_godot_docs",
description:
"Fetch and return the readable text content of a single Godot documentation page (URL).",
parameters: {
url: z.string().describe("Full URL of the Godot documentation page to fetch."),
maxChars: z.number().int().positive().optional().describe("Maximum characters to return (overrides the chat setting)."),
},
implementation: async ({ url, maxChars }, ctx) => {
if (!url || !/^https?:\/\//.test(url)) {
return fail("A valid https URL is required in the 'url' parameter.");
}
const config = readConfig(ctl);
const maxOutputChars =
typeof maxChars === "number"
? Math.min(Math.max(Math.trunc(maxChars), 500), 100000)
: (config.get("docsMaxChars") as number);
ctx?.status?.(`Fetching ${url}`);
try {
const text = await fetchDocPage(url, { maxChars: maxOutputChars, signal: ctx?.signal });
if (!text || text.length === 0) {
return "The page did not contain extractable documentation content.";
}
return text;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return fail(`Failed to fetch Godot documentation: ${message}`, [
"Verify the URL is correct and reachable.",
]);
}
},
});
return [
launchEditor,
runProject,
getDebugOutput,
stopProject,
getGodotVersion,
listProjects,
getProjectInfo,
createScene,
addNode,
loadSprite,
exportMeshLibrary,
saveScene,
getUid,
updateProjectUids,
searchGodotDocs,
fetchGodotDocs,
];
}
/**
* Count project files by category. Kept local to avoid pulling the engine in.
*/
function countProjectFiles(projectPath: string): {
scenes: number;
scripts: number;
assets: number;
other: number;
} {
const counts = { scenes: 0, scripts: 0, assets: 0, other: 0 };
const extensions = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "wav", "mp3", "ogg"]);
const scan = (currentPath: string): void => {
let entries;
try {
entries = readdirSync(currentPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!entry.name.startsWith(".")) scan(join(currentPath, entry.name));
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
if (ext === "tscn") counts.scenes++;
else if (ext === "gd" || ext === "cs") counts.scripts++;
else if (ext && extensions.has(ext)) counts.assets++;
else counts.other++;
}
}
};
scan(projectPath);
return counts;
}