src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: any) => Promise<string>
): (params: T, ctx: any) => Promise<string> {
return async (params: T, ctx: any) => {
if (ctx.signal.aborted) {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
try {
return await fn(params, ctx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({
tool_error: true,
tool: name,
error: msg,
hint: "Read the error above, fix the parameter causing the issue, and retry the tool call.",
}, null, 2);
}
};
}
import { readFile, readdir } from "fs/promises";
import * as path from "path";
import * as os from "os";
const MAX_READ_BYTES = 64 * 1024;
function resolvePath(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return path.resolve(p);
}
function parseDescription(content: string): string {
const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!m) return "";
const dm = m[1].match(/description:\s*["']?([^"'\n]+)["']?/);
return dm ? dm[1].trim() : "";
}
async function scanSkills(root: string): Promise<Array<{ name: string; description: string; path: string }>> {
const results: Array<{ name: string; description: string; path: string }> = [];
async function walk(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(full);
} else if (e.isFile() && e.name === "SKILL.md") {
const rel = path.relative(root, full).replace(/\\/g, "/");
const name = rel.replace(/\/SKILL\.md$/, "");
const content = await readFile(full, "utf-8");
results.push({ name, description: parseDescription(content), path: full });
}
}
}
await walk(root);
return results;
}
async function findSkill(root: string, name: string): Promise<string> {
const skills = await scanSkills(root);
const n = name.trim();
const exact = skills.find((s) => s.name === n || s.name.endsWith("/" + n));
if (exact) return exact.path;
const fuzzy = skills.find((s) => s.name.toLowerCase().includes(n.toLowerCase()));
if (fuzzy) return fuzzy.path;
throw new Error(`Skill "${name}" not found. Use list_skills to see available skills.`);
}
// Claude Agent Skills spec: each skill directory may contain auxiliary
// files alongside SKILL.md, conventionally organized in references/,
// scripts/, and assets/ subdirectories (any file/subdir is allowed).
const LINKED_FILE_MAX_BYTES = 256 * 1024;
async function listSkillFiles(skillDir: string): Promise<Array<{ path: string; kind: "file" | "dir"; size?: number }>> {
const out: Array<{ path: string; kind: "file" | "dir"; size?: number }> = [];
async function walk(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
const rel = path.relative(skillDir, full).replace(/\\/g, "/");
if (e.isDirectory()) {
out.push({ path: rel, kind: "dir" });
await walk(full);
} else if (e.isFile()) {
let size = 0;
try { size = (await import("fs")).statSync(full).size; } catch { /* ignore */ }
out.push({ path: rel, kind: "file", size });
}
}
}
await walk(skillDir);
out.sort((a, b) => a.path.localeCompare(b.path));
return out;
}
async function resolveSkillFile(skillDir: string, relPath: string): Promise<string> {
const rel = relPath.replace(/\\/g, "/").trim();
if (rel.length === 0) throw new Error("'path' must be a non-empty relative path within the skill directory.");
if (rel.startsWith("/") || /^[a-zA-Z]:/.test(rel)) {
throw new Error("Absolute paths are not allowed — use a path relative to the skill directory.");
}
const full = path.resolve(skillDir, rel);
if (!full.startsWith(path.resolve(skillDir) + path.sep) && full !== path.resolve(skillDir)) {
throw new Error(`Path "${relPath}" escapes the skill directory.`);
}
const st = await (await import("fs")).promises.stat(full).catch(() => null);
if (!st || !st.isFile()) {
const files = await listSkillFiles(skillDir);
const listing = files.filter((f) => f.kind === "file").map((f) => f.path).join("\n ");
throw new Error(`File "${relPath}" not found in this skill. Available files:\n ${listing}`);
}
if (st.size > LINKED_FILE_MAX_BYTES) {
throw new Error(`File is ${st.size} bytes — too large to read (limit ${LINKED_FILE_MAX_BYTES}).`);
}
return full;
}
export async function toolsProvider(ctl: ToolsProviderController) {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const tools: Tool[] = [
tool({
name: "list_skills",
description: text`
List all available skills in the skills directory, with their names and descriptions. Use this first to discover what skills exist and which one matches the current task.
`,
parameters: {},
implementation: safe_impl("list_skills", async ({ }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const skills = await scanSkills(root);
return json({ ok: true, count: skills.length, skillsDir: root, skills });
}),
}),
tool({
name: "read_skill",
description: text`
Read the full content of a skill (its SKILL.md procedure). Use when you need the complete step-by-step instructions for a named skill. Returns the raw markdown.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills (e.g. 'software-development/lmstudio-plugin-authoring' or 'lmstudio-plugin-authoring')."),
},
implementation: safe_impl("read_skill", async ({ name }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const content = await readFile(target, 'utf-8');
if (content.length > MAX_READ_BYTES) {
throw new Error(`Skill is ${content.length} bytes — too large to read (limit ${MAX_READ_BYTES}).`);
}
const skillDir = path.dirname(target);
const linkedFiles = (await listSkillFiles(skillDir)).filter((f) => f.path !== "SKILL.md");
return json({
ok: true,
name,
path: target,
linkedFiles,
linkedFilesHint: linkedFiles.length
? "This skill ships auxiliary files (references/, scripts/, assets/). Use read_skill_file to load one when the SKILL.md points you to it — load only what you need."
: undefined,
content,
});
}),
}),
tool({
name: "apply_skill",
description: text`
Load a skill and apply it to a specific task. Returns the skill's full procedure framed as instructions to follow for the given task. Use when a task matches a skill's purpose and you intend to execute it.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills."),
task: z.string().describe("The task to accomplish using the skill's procedure."),
},
implementation: safe_impl("apply_skill", async ({ name, task }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const content = await readFile(target, 'utf-8');
if (content.length > MAX_READ_BYTES) {
throw new Error(`Skill is ${content.length} bytes — too large to read (limit ${MAX_READ_BYTES}).`);
}
return json({
ok: true,
name,
path: target,
task,
linkedFiles: (await listSkillFiles(path.dirname(target))).filter((f) => f.path !== "SKILL.md"),
instructions: `Follow this skill's procedure to accomplish the task: "${task}".\n\n${content}`,
});
}),
}),
tool({
name: "list_skill_files",
description: text`
List the auxiliary files shipped inside a skill's directory (references/, scripts/, assets/, etc.).
Use after reading a skill when the SKILL.md points to a reference document, script, or template you need.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills."),
},
implementation: safe_impl("list_skill_files", async ({ name }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const skillDir = path.dirname(target);
const files = (await listSkillFiles(skillDir)).filter((f) => f.path !== "SKILL.md");
return json({ ok: true, name, skillDir, files });
}),
}),
tool({
name: "read_skill_file",
description: text`
Read one auxiliary file from a skill's directory, e.g. 'references/api.md', 'scripts/build.sh', or 'assets/template.yaml'.
Use when a skill's SKILL.md directs you to one of its reference documents, scripts, or assets. Returns the raw text content.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills."),
path: z.string().describe("Path relative to the skill directory, e.g. 'references/api.md' or 'scripts/build.sh'."),
},
implementation: safe_impl("read_skill_file", async ({ name, path: relPath }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const skillDir = path.dirname(target);
const full = await resolveSkillFile(skillDir, relPath as string);
const content = await readFile(full, 'utf-8');
return json({ ok: true, name, path: relPath, size: content.length, content });
}),
}),
];
return tools;
}
src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: any) => Promise<string>
): (params: T, ctx: any) => Promise<string> {
return async (params: T, ctx: any) => {
if (ctx.signal.aborted) {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
try {
return await fn(params, ctx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({
tool_error: true,
tool: name,
error: msg,
hint: "Read the error above, fix the parameter causing the issue, and retry the tool call.",
}, null, 2);
}
};
}
import { readFile, readdir } from "fs/promises";
import * as path from "path";
import * as os from "os";
const MAX_READ_BYTES = 64 * 1024;
function resolvePath(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return path.resolve(p);
}
function parseDescription(content: string): string {
const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!m) return "";
const dm = m[1].match(/description:\s*["']?([^"'\n]+)["']?/);
return dm ? dm[1].trim() : "";
}
async function scanSkills(root: string): Promise<Array<{ name: string; description: string; path: string }>> {
const results: Array<{ name: string; description: string; path: string }> = [];
async function walk(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(full);
} else if (e.isFile() && e.name === "SKILL.md") {
const rel = path.relative(root, full).replace(/\\/g, "/");
const name = rel.replace(/\/SKILL\.md$/, "");
const content = await readFile(full, "utf-8");
results.push({ name, description: parseDescription(content), path: full });
}
}
}
await walk(root);
return results;
}
async function findSkill(root: string, name: string): Promise<string> {
const skills = await scanSkills(root);
const n = name.trim();
const exact = skills.find((s) => s.name === n || s.name.endsWith("/" + n));
if (exact) return exact.path;
const fuzzy = skills.find((s) => s.name.toLowerCase().includes(n.toLowerCase()));
if (fuzzy) return fuzzy.path;
throw new Error(`Skill "${name}" not found. Use list_skills to see available skills.`);
}
// Claude Agent Skills spec: each skill directory may contain auxiliary
// files alongside SKILL.md, conventionally organized in references/,
// scripts/, and assets/ subdirectories (any file/subdir is allowed).
const LINKED_FILE_MAX_BYTES = 256 * 1024;
async function listSkillFiles(skillDir: string): Promise<Array<{ path: string; kind: "file" | "dir"; size?: number }>> {
const out: Array<{ path: string; kind: "file" | "dir"; size?: number }> = [];
async function walk(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
const rel = path.relative(skillDir, full).replace(/\\/g, "/");
if (e.isDirectory()) {
out.push({ path: rel, kind: "dir" });
await walk(full);
} else if (e.isFile()) {
let size = 0;
try { size = (await import("fs")).statSync(full).size; } catch { /* ignore */ }
out.push({ path: rel, kind: "file", size });
}
}
}
await walk(skillDir);
out.sort((a, b) => a.path.localeCompare(b.path));
return out;
}
async function resolveSkillFile(skillDir: string, relPath: string): Promise<string> {
const rel = relPath.replace(/\\/g, "/").trim();
if (rel.length === 0) throw new Error("'path' must be a non-empty relative path within the skill directory.");
if (rel.startsWith("/") || /^[a-zA-Z]:/.test(rel)) {
throw new Error("Absolute paths are not allowed — use a path relative to the skill directory.");
}
const full = path.resolve(skillDir, rel);
if (!full.startsWith(path.resolve(skillDir) + path.sep) && full !== path.resolve(skillDir)) {
throw new Error(`Path "${relPath}" escapes the skill directory.`);
}
const st = await (await import("fs")).promises.stat(full).catch(() => null);
if (!st || !st.isFile()) {
const files = await listSkillFiles(skillDir);
const listing = files.filter((f) => f.kind === "file").map((f) => f.path).join("\n ");
throw new Error(`File "${relPath}" not found in this skill. Available files:\n ${listing}`);
}
if (st.size > LINKED_FILE_MAX_BYTES) {
throw new Error(`File is ${st.size} bytes — too large to read (limit ${LINKED_FILE_MAX_BYTES}).`);
}
return full;
}
export async function toolsProvider(ctl: ToolsProviderController) {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const tools: Tool[] = [
tool({
name: "list_skills",
description: text`
List all available skills in the skills directory, with their names and descriptions. Use this first to discover what skills exist and which one matches the current task.
`,
parameters: {},
implementation: safe_impl("list_skills", async ({ }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const skills = await scanSkills(root);
return json({ ok: true, count: skills.length, skillsDir: root, skills });
}),
}),
tool({
name: "read_skill",
description: text`
Read the full content of a skill (its SKILL.md procedure). Use when you need the complete step-by-step instructions for a named skill. Returns the raw markdown.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills (e.g. 'software-development/lmstudio-plugin-authoring' or 'lmstudio-plugin-authoring')."),
},
implementation: safe_impl("read_skill", async ({ name }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const content = await readFile(target, 'utf-8');
if (content.length > MAX_READ_BYTES) {
throw new Error(`Skill is ${content.length} bytes — too large to read (limit ${MAX_READ_BYTES}).`);
}
const skillDir = path.dirname(target);
const linkedFiles = (await listSkillFiles(skillDir)).filter((f) => f.path !== "SKILL.md");
return json({
ok: true,
name,
path: target,
linkedFiles,
linkedFilesHint: linkedFiles.length
? "This skill ships auxiliary files (references/, scripts/, assets/). Use read_skill_file to load one when the SKILL.md points you to it — load only what you need."
: undefined,
content,
});
}),
}),
tool({
name: "apply_skill",
description: text`
Load a skill and apply it to a specific task. Returns the skill's full procedure framed as instructions to follow for the given task. Use when a task matches a skill's purpose and you intend to execute it.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills."),
task: z.string().describe("The task to accomplish using the skill's procedure."),
},
implementation: safe_impl("apply_skill", async ({ name, task }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const content = await readFile(target, 'utf-8');
if (content.length > MAX_READ_BYTES) {
throw new Error(`Skill is ${content.length} bytes — too large to read (limit ${MAX_READ_BYTES}).`);
}
return json({
ok: true,
name,
path: target,
task,
linkedFiles: (await listSkillFiles(path.dirname(target))).filter((f) => f.path !== "SKILL.md"),
instructions: `Follow this skill's procedure to accomplish the task: "${task}".\n\n${content}`,
});
}),
}),
tool({
name: "list_skill_files",
description: text`
List the auxiliary files shipped inside a skill's directory (references/, scripts/, assets/, etc.).
Use after reading a skill when the SKILL.md points to a reference document, script, or template you need.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills."),
},
implementation: safe_impl("list_skill_files", async ({ name }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const skillDir = path.dirname(target);
const files = (await listSkillFiles(skillDir)).filter((f) => f.path !== "SKILL.md");
return json({ ok: true, name, skillDir, files });
}),
}),
tool({
name: "read_skill_file",
description: text`
Read one auxiliary file from a skill's directory, e.g. 'references/api.md', 'scripts/build.sh', or 'assets/template.yaml'.
Use when a skill's SKILL.md directs you to one of its reference documents, scripts, or assets. Returns the raw text content.
`,
parameters: {
name: z.string().describe("The skill name as shown by list_skills."),
path: z.string().describe("Path relative to the skill directory, e.g. 'references/api.md' or 'scripts/build.sh'."),
},
implementation: safe_impl("read_skill_file", async ({ name, path: relPath }, ctx) => {
const root = resolvePath(cfg.get('skillsDir'));
const target = await findSkill(root, name);
const skillDir = path.dirname(target);
const full = await resolveSkillFile(skillDir, relPath as string);
const content = await readFile(full, 'utf-8');
return json({ ok: true, name, path: relPath, size: content.length, content });
}),
}),
];
return tools;
}