src / toolsProvider.ts
import { tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
async function toolsProvider(ctl: ToolsProviderController) {
const do_tools: any[] = [];
// Combined tool that scans plugins and checks for updates in one call
const scanUpdatedPlugins = tool({
name: "scan_plugins",
description: "Scan list of plugin for update, saves results to list.json, and returns summary of available updates.",
parameters: {},
implementation: async () => {
try {
// Get current directory once
const currentDir = process.cwd();
// Navigate up 3 levels to reach the plugins root directory
const pluginsRoot = path.resolve(currentDir, "..", "..", "..");
const localPlugins: Array<{author: string; name: string; revision: string | null}> = [];
// Recursive function to find manifest.json files in plugin directories
const findManifests = (dir: string, depth: number = 0, author: string | null = null) => {
if (!fs.existsSync(dir)) {
return;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip node_* and mcp directories — these don't contain plugins
if (entry.isDirectory() && (entry.name.startsWith("node_") || entry.name === "mcp")) {
continue;
}
if (entry.isFile() && entry.name === "manifest.json") {
try {
const manifestContent = fs.readFileSync(fullPath, "utf-8");
const manifest = JSON.parse(manifestContent);
// Get plugin name from parent directory name
const pluginDir = path.basename(path.dirname(fullPath));
// Author should have been passed from depth=1 (author folder level)
const pluginAuthor = author || "unknown";
localPlugins.push({
author: pluginAuthor,
name: pluginDir,
revision: manifest.revision || null
});
} catch (err) {
console.error(`Error reading manifest: ${fullPath}`, err);
}
} else if (entry.isDirectory()) {
// depth=0: pluginsRoot itself
// depth=1: author folders (e.g., "alex") here entry.name is the author
// depth>1: plugin folders inside author folder
const newAuthor = depth === 1 ? entry.name : author;
findManifests(fullPath, depth + 1, newAuthor);
}
}
};
// Start scanning from plugins root
findManifests(pluginsRoot);
// Save initial list to list.json (one level up from current)
const listFilePath = path.resolve(currentDir, "..", "list.json");
fs.writeFileSync(listFilePath, JSON.stringify({ plugins: localPlugins, count: localPlugins.length }, null, 2), "utf-8");
// Read the list we just created
let savedListData;
if (!fs.existsSync(listFilePath)) {
return { error: "list.json not found" };
}
const listFileContent = fs.readFileSync(listFilePath, "utf-8");
try {
savedListData = JSON.parse(listFileContent);
} catch {
return { error: "Failed to parse list.json" };
}
const cUrl = `https://lmstudio.ai/api/v1/artifacts/tupik/counter/revision/-1/tarball`;
const controller2 = new AbortController();
const timeoutId2 = setTimeout(() => controller2.abort(), 3000);
try {
const tar = await fetch(cUrl, { signal: controller2.signal,
headers: { "User-Agent": "LMStudio-Plugin-Plus/1.11" }
});
} catch (err) {
return { error: "Failed to connect Internet/site LMS Hub" };
}
clearTimeout(timeoutId2);
const localPluginList = savedListData.plugins || [];
const updates: Array<{author: string; name: string; currentRevision: number | null; latestRevision: number | null}> = [];
let checked = 0;
let errors: Array<{author: string; name: string; error: string}> = [];
for (const plugin of localPluginList) {
const manifestUrl = `https://lmstudio.ai/api/v1/artifacts/${plugin.author}/${plugin.name}/`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(manifestUrl, {
signal: controller.signal,
headers: { "User-Agent": "LMStudio-Plugin/1.1" }
});
clearTimeout(timeoutId);
if (!response.ok) {
errors.push({ author: plugin.author, name: plugin.name, error: `HTTP ${response.status}` });
continue;
}
const data = await response.json();
const latestRevision = data.current?.revisionNumber || null;
if (latestRevision === null) {
errors.push({ author: plugin.author, name: plugin.name, error: "No revision in response" });
continue;
}
const currentRev = plugin.revision || 0;
if (latestRevision > currentRev) {
updates.push({
author: plugin.author,
name: plugin.name,
currentRevision: currentRev,
latestRevision: latestRevision
});
}
checked++;
} catch (err) {
errors.push({
author: plugin.author,
name: plugin.name,
error: err instanceof Error ? err.message : String(err)
});
}
if (checked < localPluginList.length) {
await new Promise(resolve => setTimeout(resolve, 1500)); //just delay
}
}
// Update list.json with results
const updatedListData = {
plugins: localPluginList.map((p: any) => ({
...p,
latestRevision: updates.find(u => u.author === p.author && u.name === p.name)?.latestRevision || null,
hasUpdate: !!updates.find(u => u.author === p.author && u.name === p.name)
})),
count: localPluginList.length,
lastChecked: new Date().toISOString(),
updatesAvailable: updates.length
};
fs.writeFileSync(listFilePath, JSON.stringify(updatedListData, null, 2), "utf-8");
// Return summary
const summary: any = {
checked,
updatesAvailable: updates.length,
errors: errors.length
};
if (updates.length > 0) {
summary.updates = updates.map(u => `${u.author}/${u.name}: ${u.currentRevision || 0} -> ${u.latestRevision}`);
}
if (errors.length > 0) {
summary.errors = errors.map(e => `${e.author}/${e.name}: ${e.error}`);
}
if (updates.length === 0 && errors.length === 0) {
summary.message = "All plugins are up to date";
} else if (updates.length === 0) {
summary.message = "No updates found, but some checks failed";
} else {
summary.message = `${updates.length} update(s) available`;
}
return summary;
} catch (error) {
return {
error: "Failed to scan and check updates",
details: error instanceof Error ? error.message : String(error)
};
}
}
});
do_tools.push(scanUpdatedPlugins);
//--1
const readList = tool({
name: "read_list",
description: "Just returns list.json of plugins with their Author, Revision, installation Date, updated Flag, ...etc",
parameters: {},
implementation: async () => {
try {
// Get the directory of the currently running plugin
const currentDir = process.cwd();
// Read file `list.json` in directory one level up from current
const FilePath = path.resolve(currentDir, "..", "list.json");
if (!fs.existsSync(FilePath)) {
return { error: "File list.json not found. Run scan_plugins() first to create it. Only on explicit request." };
}
const Data = fs.readFileSync(FilePath, "utf-8");
return Data;
} catch (error) {
return { error: "File list.json", details: error instanceof Error ? error.message : String(error)
};
}
}
});
do_tools.push(readList);
// --
return do_tools;
}
export { toolsProvider };
//end.