src / toolsProvider.ts
import { text, tool, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { z } from "zod";
import simpleGit, { type SimpleGit } from "simple-git";
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: ToolCallContext) => Promise<string>
): (params: T, ctx: ToolCallContext) => Promise<string> {
return async (params: T, ctx: ToolCallContext) => {
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 }, null, 2);
}
};
}
function getGit(repoPath: string): SimpleGit {
return simpleGit(repoPath || process.cwd());
}
function truncateLines(text: string, maxLines: number): { text: string; truncated: boolean; totalLines: number } {
const lines = text.split("\n");
const truncated = lines.length > maxLines;
return {
text: truncated ? lines.slice(0, maxLines).join("\n") + `\n[... truncated — ${lines.length} total lines, showing first ${maxLines}]` : text,
truncated,
totalLines: lines.length,
};
}
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const repo = () => cfg.get("repoPath").trim() || process.cwd();
return [
tool({
name: "git_status",
description: text`
Show the current working tree status of a git repository.
Returns: current branch, staged changes, unstaged changes, untracked files,
whether the branch is ahead/behind its remote.
Call before git_diff to understand what has changed overall.
`,
parameters: {
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = use plugin default."),
},
implementation: safe_impl("git_status", async ({ repo_path }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status("Reading git status…");
const [status, branch] = await Promise.all([
git.status(),
git.branch(),
]);
return json({
branch: status.current,
tracking: status.tracking,
ahead: status.ahead,
behind: status.behind,
clean: status.isClean(),
staged: {
added: status.created,
modified: status.staged,
deleted: status.deleted,
renamed: status.renamed.map(r => ({ from: r.from, to: r.to })),
},
unstaged: {
modified: status.modified,
deleted: status.deleted,
conflicted: status.conflicted,
},
untracked: status.not_added,
allBranches: branch.all.slice(0, 20),
});
}),
}),
tool({
name: "git_log",
description: text`
Show recent commit history for a git repository.
Returns: hash, author, date, message, files changed per commit.
Use to understand what work has been done recently, who made changes, or find a commit hash.
Combine with git_show to inspect a specific commit in detail.
`,
parameters: {
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
limit: z.coerce.number().int().min(1).max(200).default(0).describe("Number of commits (0 = use plugin default)."),
branch: z.string().default("").describe("Branch or ref to log. Blank = current branch."),
file_path: z.string().default("").describe("Filter to commits that touched this file path."),
author: z.string().default("").describe("Filter by author name or email."),
since: z.string().default("").describe("Show commits after this date (e.g. '2 weeks ago', '2024-01-01')."),
search: z.string().default("").describe("Filter commits whose message matches this string."),
},
implementation: safe_impl("git_log", async ({ repo_path, limit, branch, file_path, author, since, search }, ctx) => {
const git = getGit(repo_path || repo());
const n = limit > 0 ? limit : cfg.get("maxLogEntries");
ctx.status(`Fetching last ${n} commits…`);
const options: Record<string, string | number> = { "--pretty": "format:%H|%an|%ae|%ai|%s", "-n": n };
if (author) options["--author"] = author;
if (since) options["--since"] = since;
if (search) options["--grep"] = search;
const args: string[] = [];
if (branch) args.push(branch);
if (file_path) args.push("--", file_path);
const raw = await git.log({ ...options, ...(args.length ? { "--": args } : {}) } as any);
const commits = raw.all.map((c: any) => ({
hash: c.hash,
shortHash: c.hash.slice(0, 8),
author: c.author_name,
email: c.author_email,
date: c.date,
message: c.message,
}));
return json({ total: commits.length, commits });
}),
}),
tool({
name: "git_show",
description: text`
Show full details of a specific commit: message, author, date, and the full diff.
Use after git_log to inspect what a commit actually changed.
Provide the full or short commit hash.
`,
parameters: {
hash: z.string().describe("Commit hash (full or short, e.g. 'abc1234' or 'HEAD' or 'HEAD~2')"),
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
},
implementation: safe_impl("git_show", async ({ hash, repo_path }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status(`Loading commit ${hash}…`);
const raw = await git.show(["--stat", "--patch", hash]);
const maxLines = cfg.get("maxDiffLines");
const { text: output, truncated, totalLines } = truncateLines(raw, maxLines);
return json({ hash, output, truncated, totalLines });
}),
}),
tool({
name: "git_diff",
description: text`
Show the diff between two refs, or the current working tree diff.
Useful for: reviewing staged changes before commit, comparing branches,
understanding what changed between two versions.
Examples:
- git_diff() — unstaged working tree changes
- git_diff(staged=true) — staged (index) changes
- git_diff(from="main", to="feature-branch") — branch comparison
- git_diff(from="HEAD~3", to="HEAD") — last 3 commits
- git_diff(file_path="src/app.ts") — single file only
`,
parameters: {
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
from: z.string().default("").describe("Starting ref (commit hash, branch, tag). Blank = working tree."),
to: z.string().default("").describe("Ending ref. Blank = working tree."),
staged: z.boolean().default(false).describe("Show staged (index) changes. Equivalent to git diff --cached."),
file_path: z.string().default("").describe("Limit diff to this file path."),
},
implementation: safe_impl("git_diff", async ({ repo_path, from, to, staged, file_path }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status("Computing diff…");
const args: string[] = [];
if (staged) args.push("--cached");
if (from && to) {
args.push(from, to);
} else if (from) {
args.push(from);
}
if (file_path) args.push("--", file_path);
const raw = await git.diff(args);
const maxLines = cfg.get("maxDiffLines");
const { text: output, truncated, totalLines } = truncateLines(raw, maxLines);
return json({ output, truncated, totalLines });
}),
}),
tool({
name: "git_blame",
description: text`
Show who last changed each line of a file and in which commit.
Returns line number, author, commit hash, date, and line content.
Use to understand ownership and history of specific code sections.
`,
parameters: {
file_path: z.string().describe("Path to the file (relative to repo root or absolute)."),
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
from_line: z.coerce.number().int().min(1).default(1).describe("First line number to include (1-indexed)."),
to_line: z.coerce.number().int().min(1).default(100).describe("Last line number to include."),
},
implementation: safe_impl("git_blame", async ({ file_path, repo_path, from_line, to_line }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status(`Blaming ${file_path} lines ${from_line}-${to_line}…`);
const raw = await git.raw(["blame", "--porcelain", `-L${from_line},${to_line}`, "--", file_path]);
const lines: Array<{ line: number; hash: string; author: string; date: string; content: string }> = [];
let lineNum = from_line;
let currentHash = "";
let currentAuthor = "";
let currentDate = "";
for (const line of raw.split("\n")) {
if (/^[0-9a-f]{40}/.test(line)) {
currentHash = line.slice(0, 8);
} else if (line.startsWith("author ")) {
currentAuthor = line.slice(7);
} else if (line.startsWith("author-time ")) {
currentDate = new Date(parseInt(line.slice(12)) * 1000).toISOString().slice(0, 10);
} else if (line.startsWith("\t")) {
lines.push({ line: lineNum++, hash: currentHash, author: currentAuthor, date: currentDate, content: line.slice(1) });
}
}
return json({ file: file_path, fromLine: from_line, toLine: to_line, lines });
}),
}),
tool({
name: "git_branch",
description: text`
List branches in the repository with their last commit info.
Shows local branches, remote-tracking branches, and which is current.
Use to understand the branch structure before switching or merging.
`,
parameters: {
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
include_remote: z.boolean().default(true).describe("Include remote-tracking branches."),
},
implementation: safe_impl("git_branch", async ({ repo_path, include_remote }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status("Listing branches…");
const branches = await git.branch(include_remote ? ["-a", "-v"] : ["-v"]);
const result = Object.entries(branches.branches).map(([name, info]) => ({
name,
current: info.current,
hash: info.commit.slice(0, 8),
label: info.label,
}));
return json({ current: branches.current, total: result.length, branches: result });
}),
}),
tool({
name: "git_stash",
description: text`
List all stashes in the repository with their message and date.
Returns stash index, message, and the branch it was stashed on.
Use to recover forgotten stashed work or understand pending changes.
`,
parameters: {
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
},
implementation: safe_impl("git_stash", async ({ repo_path }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status("Reading stash list…");
const raw = await git.stashList();
const stashes = raw.all.map((s: any, i: number) => ({
index: i,
ref: `stash@{${i}}`,
message: s.message,
hash: s.hash?.slice(0, 8),
date: s.date,
}));
return json({ total: stashes.length, stashes });
}),
}),
tool({
name: "git_file_history",
description: text`
Show the commit history for a specific file — every commit that touched it.
Returns commits in reverse chronological order with hash, author, date, and message.
More focused than git_log when you want the evolution of a single file.
`,
parameters: {
file_path: z.string().describe("Path to the file (relative to repo root or absolute)."),
repo_path: z.string().default("").describe("Absolute path to the git repo. Blank = plugin default."),
limit: z.coerce.number().int().min(1).max(100).default(20).describe("Maximum number of commits."),
},
implementation: safe_impl("git_file_history", async ({ file_path, repo_path, limit }, ctx) => {
const git = getGit(repo_path || repo());
ctx.status(`Fetching history for ${file_path}…`);
const raw = await git.log({ file: file_path, maxCount: limit } as any);
const commits = raw.all.map((c: any) => ({
hash: c.hash.slice(0, 8),
author: c.author_name,
date: c.date,
message: c.message,
}));
return json({ file: file_path, total: commits.length, commits });
}),
}),
];
};