src / toolsProvider.ts
src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import * as path from "path";
import { z } from "zod";
import type { Catalog } from "./catalog";
import { expandHome, globalConfigSchematics } from "./config";
import { TOOL_READ_CAP, TREE_MAX_DEPTH, type SkillFs, type SkillIndex } from "./types";
const SKIP_DIRS = new Set(["node_modules", ".git"]);
/** realpath(3) for a path that may not fully exist: resolve the deepest existing ancestor, re-attach the rest. */
async function realpathDeepest(fs: SkillFs, p: string): Promise<string> {
const rest: string[] = [];
let current = p;
for (;;) {
try {
const real = await fs.realpath(current);
return rest.length ? path.join(real, ...rest) : real;
} catch (e) {
const parent = path.dirname(current);
if (parent === current) throw e;
rest.unshift(path.basename(current));
current = parent;
}
}
}
/** Resolves `rel` inside `skillDir`, following symlinks, and throws if the real path leaves the dir. */
export async function resolveInside(fs: SkillFs, skillDir: string, rel: string): Promise<string> {
const realDir = await fs.realpath(skillDir);
const candidate = path.resolve(skillDir, rel); // absolute `rel` resolves to itself and is caught below
const real = await realpathDeepest(fs, candidate);
if (real !== realDir && !real.startsWith(realDir + path.sep)) throw new Error("path escapes the skill directory");
return real;
}
export function listSkills(index: SkillIndex, query?: string): string {
if (index.skills.length === 0) return `No skills found under ${index.root}.`;
const q = query?.trim().toLowerCase();
const matches = q
? index.skills.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
: index.skills;
if (matches.length === 0) return `No skills match "${query}".`;
return matches.map(s => `${s.name} — ${s.description} — ${s.dir}${s.note ? ` [${s.note}]` : ""}`).join("\n");
}
function isBinary(buf: Buffer): boolean {
return buf.subarray(0, 8192).includes(0);
}
export async function readSkillFile(fs: SkillFs, index: SkillIndex, skillName: string, rel = "SKILL.md"): Promise<string> {
const skill = index.byName.get(skillName);
if (!skill) return `Error: unknown skill "${skillName}". Use list_skills to see available names.`;
try {
const full = await resolveInside(fs, skill.dir, rel);
const buf = await fs.readFile(full);
if (isBinary(buf)) return `Error: ${rel} is a binary file.`;
const content = buf.toString("utf8");
if (content.length <= TOOL_READ_CAP) return content;
return `${content.slice(0, TOOL_READ_CAP)}\n[truncated: showing the first ${TOOL_READ_CAP} of ${content.length} characters]`;
} catch (e) {
return `Error: ${(e as Error).message}`;
}
}
export async function listSkillFiles(fs: SkillFs, index: SkillIndex, skillName: string, subdir = ""): Promise<string> {
const skill = index.byName.get(skillName);
if (!skill) return `Error: unknown skill "${skillName}". Use list_skills to see available names.`;
try {
const realSkillDir = await fs.realpath(skill.dir);
const start = await resolveInside(fs, skill.dir, subdir || ".");
const lines: string[] = [];
const walk = async (dir: string, depth: number) => {
const entries = (await fs.readdir(dir)).filter(e => !e.name.startsWith(".") && !SKIP_DIRS.has(e.name));
for (const e of entries) {
const full = path.join(dir, e.name);
const relPath = path.relative(realSkillDir, full);
if (e.isDirectory) {
if (depth >= TREE_MAX_DEPTH) lines.push(`${relPath}/ (dir)`);
else await walk(full, depth + 1);
} else if (e.isFile) {
const s = await fs.stat(full);
lines.push(`${relPath} (${s.size})`);
}
}
};
await walk(start, 1);
lines.sort();
return lines.length ? lines.join("\n") : `No files under ${subdir || "."}.`;
} catch (e) {
return `Error: ${(e as Error).message}`;
}
}
export function createToolsProvider(deps: { catalog: Catalog; fs: SkillFs }) {
return async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const root = expandHome(ctl.getGlobalPluginConfig(globalConfigSchematics).get("skillsRoot"));
const index = () => deps.catalog.load(root);
return [
tool({
name: "list_skills",
description: text`
Lists the agent skills installed under ${root}. Each line is "name — description — path".
Optional \`query\` filters by substring on name or description.
`,
parameters: { query: z.string().optional() },
implementation: async ({ query }) => listSkills(await index(), query),
}),
tool({
name: "read_skill_file",
description: text`
Reads a file inside a skill directory under ${root}. \`skill\` is the skill name from list_skills;
\`path\` is relative to the skill directory and defaults to SKILL.md. Use it to load a
skill's full instructions or its references/ and scripts/ files.
`,
parameters: { skill: z.string(), path: z.string().optional() },
implementation: async ({ skill, path: rel }) => readSkillFile(deps.fs, await index(), skill, rel ?? "SKILL.md"),
}),
tool({
name: "list_skill_files",
description: text`
Lists files inside a skill directory under ${root} recursively (depth ${TREE_MAX_DEPTH}), one per line with
size in bytes. Optional \`subdir\` narrows the listing.
`,
parameters: { skill: z.string(), subdir: z.string().optional() },
implementation: async ({ skill, subdir }) => listSkillFiles(deps.fs, await index(), skill, subdir),
}),
];
};
}
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import * as path from "path";
import { z } from "zod";
import type { Catalog } from "./catalog";
import { expandHome, globalConfigSchematics } from "./config";
import { TOOL_READ_CAP, TREE_MAX_DEPTH, type SkillFs, type SkillIndex } from "./types";
const SKIP_DIRS = new Set(["node_modules", ".git"]);
/** realpath(3) for a path that may not fully exist: resolve the deepest existing ancestor, re-attach the rest. */
async function realpathDeepest(fs: SkillFs, p: string): Promise<string> {
const rest: string[] = [];
let current = p;
for (;;) {
try {
const real = await fs.realpath(current);
return rest.length ? path.join(real, ...rest) : real;
} catch (e) {
const parent = path.dirname(current);
if (parent === current) throw e;
rest.unshift(path.basename(current));
current = parent;
}
}
}
/** Resolves `rel` inside `skillDir`, following symlinks, and throws if the real path leaves the dir. */
export async function resolveInside(fs: SkillFs, skillDir: string, rel: string): Promise<string> {
const realDir = await fs.realpath(skillDir);
const candidate = path.resolve(skillDir, rel); // absolute `rel` resolves to itself and is caught below
const real = await realpathDeepest(fs, candidate);
if (real !== realDir && !real.startsWith(realDir + path.sep)) throw new Error("path escapes the skill directory");
return real;
}
export function listSkills(index: SkillIndex, query?: string): string {
if (index.skills.length === 0) return `No skills found under ${index.root}.`;
const q = query?.trim().toLowerCase();
const matches = q
? index.skills.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
: index.skills;
if (matches.length === 0) return `No skills match "${query}".`;
return matches.map(s => `${s.name} — ${s.description} — ${s.dir}${s.note ? ` [${s.note}]` : ""}`).join("\n");
}
function isBinary(buf: Buffer): boolean {
return buf.subarray(0, 8192).includes(0);
}
export async function readSkillFile(fs: SkillFs, index: SkillIndex, skillName: string, rel = "SKILL.md"): Promise<string> {
const skill = index.byName.get(skillName);
if (!skill) return `Error: unknown skill "${skillName}". Use list_skills to see available names.`;
try {
const full = await resolveInside(fs, skill.dir, rel);
const buf = await fs.readFile(full);
if (isBinary(buf)) return `Error: ${rel} is a binary file.`;
const content = buf.toString("utf8");
if (content.length <= TOOL_READ_CAP) return content;
return `${content.slice(0, TOOL_READ_CAP)}\n[truncated: showing the first ${TOOL_READ_CAP} of ${content.length} characters]`;
} catch (e) {
return `Error: ${(e as Error).message}`;
}
}
export async function listSkillFiles(fs: SkillFs, index: SkillIndex, skillName: string, subdir = ""): Promise<string> {
const skill = index.byName.get(skillName);
if (!skill) return `Error: unknown skill "${skillName}". Use list_skills to see available names.`;
try {
const realSkillDir = await fs.realpath(skill.dir);
const start = await resolveInside(fs, skill.dir, subdir || ".");
const lines: string[] = [];
const walk = async (dir: string, depth: number) => {
const entries = (await fs.readdir(dir)).filter(e => !e.name.startsWith(".") && !SKIP_DIRS.has(e.name));
for (const e of entries) {
const full = path.join(dir, e.name);
const relPath = path.relative(realSkillDir, full);
if (e.isDirectory) {
if (depth >= TREE_MAX_DEPTH) lines.push(`${relPath}/ (dir)`);
else await walk(full, depth + 1);
} else if (e.isFile) {
const s = await fs.stat(full);
lines.push(`${relPath} (${s.size})`);
}
}
};
await walk(start, 1);
lines.sort();
return lines.length ? lines.join("\n") : `No files under ${subdir || "."}.`;
} catch (e) {
return `Error: ${(e as Error).message}`;
}
}
export function createToolsProvider(deps: { catalog: Catalog; fs: SkillFs }) {
return async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const root = expandHome(ctl.getGlobalPluginConfig(globalConfigSchematics).get("skillsRoot"));
const index = () => deps.catalog.load(root);
return [
tool({
name: "list_skills",
description: text`
Lists the agent skills installed under ${root}. Each line is "name — description — path".
Optional \`query\` filters by substring on name or description.
`,
parameters: { query: z.string().optional() },
implementation: async ({ query }) => listSkills(await index(), query),
}),
tool({
name: "read_skill_file",
description: text`
Reads a file inside a skill directory under ${root}. \`skill\` is the skill name from list_skills;
\`path\` is relative to the skill directory and defaults to SKILL.md. Use it to load a
skill's full instructions or its references/ and scripts/ files.
`,
parameters: { skill: z.string(), path: z.string().optional() },
implementation: async ({ skill, path: rel }) => readSkillFile(deps.fs, await index(), skill, rel ?? "SKILL.md"),
}),
tool({
name: "list_skill_files",
description: text`
Lists files inside a skill directory under ${root} recursively (depth ${TREE_MAX_DEPTH}), one per line with
size in bytes. Optional \`subdir\` narrows the listing.
`,
parameters: { skill: z.string(), subdir: z.string().optional() },
implementation: async ({ skill, subdir }) => listSkillFiles(deps.fs, await index(), skill, subdir),
}),
];
};
}