src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { execFile } from "node:child_process";
import { copyFile, mkdir, readdir, rename, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { extname, isAbsolute, join, relative, resolve } from "node:path";
import { z } from "zod";
interface OsaScriptResult {
stderr: string;
stdout: string;
}
interface OrganizableFile {
destinationPath: string;
name: string;
sourcePath: string;
}
type CollisionPolicy = "overwrite" | "rename" | "skip";
type FileMatchKind =
| "all"
| "archives"
| "audio"
| "documents"
| "images"
| "pdfs"
| "videos";
type OrganizeOperation = "copy" | "move";
const automationDeniedPatterns = [
"not authorized to send apple events",
"not allowed to send apple events",
"automation",
"privacy",
];
const noIMessagePatterns = [
"can't get service",
"can’t get service",
"invalid index",
"missing value",
];
const accessibilityDeniedPatterns = [
"assistive access",
"accessibility",
"not allowed to control system events",
];
const maxOrganizedFileCount = 500;
const imageExtensions = new Set([
".avif",
".bmp",
".gif",
".heic",
".jpeg",
".jpg",
".png",
".svg",
".tif",
".tiff",
".webp",
]);
const videoExtensions = new Set([".avi", ".m4v", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"]);
const audioExtensions = new Set([".aac", ".aiff", ".flac", ".m4a", ".mp3", ".ogg", ".wav"]);
const documentExtensions = new Set([
".csv",
".doc",
".docx",
".md",
".numbers",
".pages",
".ppt",
".pptx",
".rtf",
".txt",
".xls",
".xlsx",
]);
const archiveExtensions = new Set([".7z", ".bz2", ".dmg", ".gz", ".rar", ".tar", ".tgz", ".zip"]);
const checkIMessageStatusScript = `
on run argv
tell application "/System/Applications/Messages.app"
set imessageServices to services whose service type = iMessage
return (count of imessageServices) as text
end tell
end run
`;
const sendIMessageScript = `
on run argv
set targetAddress to item 1 of argv
set messageText to item 2 of argv
tell application "/System/Applications/Messages.app"
set targetService to 1st service whose service type = iMessage
set targetBuddy to buddy targetAddress of targetService
send messageText to targetBuddy
end tell
end run
`;
const createAppleNoteScript = `
on pasteText(theText)
set the clipboard to theText
delay 0.05
tell application "System Events"
keystroke "v" using {command down}
end tell
delay 0.05
end pasteText
on applyChecklistFormat()
tell application "System Events"
tell process "Notes"
try
click menu item "Checklist" of menu "Format" of menu bar 1
on error
try
click menu item "Make Checklist" of menu "Format" of menu bar 1
on error
keystroke "l" using {command down, shift down}
end try
end try
end tell
end tell
delay 0.15
end applyChecklistFormat
on makeChecklistText(argv)
set checklistText to ""
repeat with itemIndex from 4 to count of argv
if checklistText is "" then
set checklistText to item itemIndex of argv
else
set checklistText to checklistText & linefeed & item itemIndex of argv
end if
end repeat
return checklistText
end makeChecklistText
on run argv
set noteTitle to item 1 of argv
set noteBodyHtml to item 2 of argv
set shouldOpenSeparately to (item 3 of argv is "true")
set previousClipboard to the clipboard
if (count of argv) > 3 then
tell application "System Events" to count processes
end if
try
tell application "/System/Applications/Notes.app"
set targetFolder to default folder of default account
set newNote to make new note at targetFolder with properties {name:noteTitle, body:noteBodyHtml}
set noteId to id of newNote
activate
show newNote separately shouldOpenSeparately
end tell
if (count of argv) > 3 then
delay 1
tell application "System Events"
tell process "Notes"
set frontmost to true
delay 0.3
key code 125 using {command down}
if noteBodyHtml is not "" then
key code 36
end if
delay 0.2
end tell
end tell
my applyChecklistFormat()
my pasteText(my makeChecklistText(argv))
end if
set the clipboard to previousClipboard
on error errorMessage number errorNumber
set the clipboard to previousClipboard
error errorMessage number errorNumber
end try
return noteId
end run
`;
function normalizeErrorText(error: unknown, stderr: string): string {
const errorMessage = error instanceof Error ? error.message : String(error);
return `${errorMessage}\n${stderr}`.trim();
}
function explainOsaScriptFailure(errorText: string): string {
const lowered = errorText.toLowerCase();
if (automationDeniedPatterns.some(pattern => lowered.includes(pattern))) {
return text`
Error: macOS denied Automation access. Allow the app running this plugin to control Messages
in System Settings > Privacy & Security > Automation, then try again.
`;
}
if (noIMessagePatterns.some(pattern => lowered.includes(pattern))) {
return "Error: Messages does not expose an active iMessage service. Sign into Messages and enable iMessage on this Mac.";
}
return `Error: osascript failed.\n${errorText}`;
}
function explainAppleNoteFailure(errorText: string): string {
const lowered = errorText.toLowerCase();
if (accessibilityDeniedPatterns.some(pattern => lowered.includes(pattern))) {
return text`
Error: macOS denied Accessibility access. Allow the app running this plugin to control
System Events in System Settings > Privacy & Security > Accessibility, then try again.
`;
}
if (automationDeniedPatterns.some(pattern => lowered.includes(pattern))) {
return text`
Error: macOS denied Automation access. Allow the app running this plugin to control Notes and
System Events in System Settings > Privacy & Security > Automation, then try again.
`;
}
return `Error: Notes automation failed.\n${errorText}`;
}
async function runOsaScript(
script: string,
args: string[],
signal: AbortSignal,
): Promise<OsaScriptResult> {
return await new Promise<OsaScriptResult>((resolvePromise, rejectPromise) => {
let settled = false;
const childProcess = execFile(
"osascript",
["-e", script, "--", ...args],
{
maxBuffer: 1024 * 1024,
timeout: 30_000,
},
(error, stdout, stderr) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", abortListener);
if (error !== null) {
rejectPromise(new Error(normalizeErrorText(error, stderr)));
return;
}
resolvePromise({
stderr: stderr.trim(),
stdout: stdout.trim(),
});
},
);
const abortListener = () => {
if (settled) {
return;
}
settled = true;
childProcess.kill();
rejectPromise(signal.reason instanceof Error ? signal.reason : new Error("Aborted."));
};
if (signal.aborted) {
abortListener();
return;
}
signal.addEventListener("abort", abortListener, { once: true });
});
}
async function runOsaScriptForTool(
script: string,
args: string[],
signal: AbortSignal,
explainFailure: (errorText: string) => string = explainOsaScriptFailure,
): Promise<{ error: string; ok: false } | { ok: true; result: OsaScriptResult }> {
try {
return { ok: true, result: await runOsaScript(script, args, signal) };
} catch (error) {
return { ok: false, error: explainFailure(error instanceof Error ? error.message : String(error)) };
}
}
function assertMacOS(): string | null {
if (process.platform !== "darwin") {
return "Error: this tool only works on macOS.";
}
return null;
}
function escapeHtml(value: string): string {
return value
.replace(/&/gu, "&")
.replace(/</gu, "<")
.replace(/>/gu, ">")
.replace(/"/gu, """);
}
function buildNoteBodyHtml(body: string | undefined): string {
if (body === undefined || body.trim().length === 0) {
return "";
}
const bodyHtml = escapeHtml(body).replace(/\r\n|\r|\n/gu, "<br>");
return `<div>${bodyHtml}</div>`;
}
function expandMacPath(path: string): string {
if (path === "~") {
return homedir();
}
if (path.startsWith("~/")) {
return join(homedir(), path.slice(2));
}
return path;
}
function resolveMacPath(path: string): string {
const expandedPath = expandMacPath(path.trim());
if (expandedPath.length === 0) {
throw new Error("Path must not be empty.");
}
return isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(homedir(), expandedPath);
}
function isInDirectory(parentPath: string, childPath: string): boolean {
const relativePath = relative(parentPath, childPath);
return relativePath.length === 0 || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function getExtensionsForKind(matchKind: FileMatchKind): Set<string> | null {
switch (matchKind) {
case "all":
return null;
case "archives":
return archiveExtensions;
case "audio":
return audioExtensions;
case "documents":
return documentExtensions;
case "images":
return imageExtensions;
case "pdfs":
return new Set([".pdf"]);
case "videos":
return videoExtensions;
}
}
function fileMatches({
extension,
extensions,
matchKind,
name,
nameContains,
}: {
extension: string;
extensions: Array<string> | undefined;
matchKind: FileMatchKind;
name: string;
nameContains: string | undefined;
}): boolean {
const loweredName = name.toLowerCase();
if (nameContains !== undefined && !loweredName.includes(nameContains.toLowerCase())) {
return false;
}
const normalizedExtensions = extensions
?.map(item => item.toLowerCase().trim())
.filter(item => item.length > 0)
.map(item => (item.startsWith(".") ? item : `.${item}`));
if (normalizedExtensions !== undefined && normalizedExtensions.length > 0) {
return normalizedExtensions.includes(extension);
}
const kindExtensions = getExtensionsForKind(matchKind);
return kindExtensions === null || kindExtensions.has(extension);
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
return false;
}
throw error;
}
}
async function buildDestinationPath({
collisionPolicy,
destinationDirectory,
fileName,
reservedDestinationPaths,
}: {
collisionPolicy: CollisionPolicy;
destinationDirectory: string;
fileName: string;
reservedDestinationPaths: Set<string>;
}): Promise<string | null> {
const initialPath = join(destinationDirectory, fileName);
if (!reservedDestinationPaths.has(initialPath) && (collisionPolicy === "overwrite" || !(await pathExists(initialPath)))) {
return initialPath;
}
if (collisionPolicy === "skip") {
return null;
}
const extension = extname(fileName);
const stem = extension.length === 0 ? fileName : fileName.slice(0, -extension.length);
for (let index = 1; index <= 999; index += 1) {
const candidatePath = join(destinationDirectory, `${stem} ${index}${extension}`);
if (!reservedDestinationPaths.has(candidatePath) && !(await pathExists(candidatePath))) {
return candidatePath;
}
}
throw new Error(`Could not find an available renamed destination for ${fileName}.`);
}
async function collectOrganizableFiles({
collisionPolicy,
destinationDirectory,
extensions,
includeHidden,
matchKind,
maxFiles,
nameContains,
recursive,
signal,
sourceDirectory,
}: {
collisionPolicy: CollisionPolicy;
destinationDirectory: string;
extensions: Array<string> | undefined;
includeHidden: boolean;
matchKind: FileMatchKind;
maxFiles: number;
nameContains: string | undefined;
recursive: boolean;
signal: AbortSignal;
sourceDirectory: string;
}): Promise<{ files: Array<OrganizableFile>; skippedCollisions: Array<string>; truncated: boolean }> {
const files: Array<OrganizableFile> = [];
const reservedDestinationPaths = new Set<string>();
const skippedCollisions: Array<string> = [];
const directoriesToVisit = [sourceDirectory];
let truncated = false;
while (directoriesToVisit.length > 0) {
signal.throwIfAborted();
const currentDirectory = directoriesToVisit.shift()!;
const entries = await readdir(currentDirectory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
signal.throwIfAborted();
if (files.length >= maxFiles) {
truncated = true;
break;
}
if (!includeHidden && entry.name.startsWith(".")) {
continue;
}
const sourcePath = join(currentDirectory, entry.name);
if (sourcePath === destinationDirectory || isInDirectory(destinationDirectory, sourcePath)) {
continue;
}
if (entry.isDirectory()) {
if (recursive) {
directoriesToVisit.push(sourcePath);
}
continue;
}
if (!entry.isFile()) {
continue;
}
const extension = extname(entry.name).toLowerCase();
if (
!fileMatches({
extension,
extensions,
matchKind,
name: entry.name,
nameContains,
})
) {
continue;
}
const destinationPath = await buildDestinationPath({
collisionPolicy,
destinationDirectory,
fileName: entry.name,
reservedDestinationPaths,
});
if (destinationPath === null) {
skippedCollisions.push(sourcePath);
continue;
}
reservedDestinationPaths.add(destinationPath);
files.push({
destinationPath,
name: entry.name,
sourcePath,
});
}
}
return { files, skippedCollisions, truncated };
}
export async function toolsProvider(_ctl: ToolsProviderController) {
const tools: Tool[] = [];
const checkIMessageStatusTool = tool({
name: "check_imessage_status",
description: text`
Check whether the local Mac's Messages app exposes an active iMessage service. This does not
send a message, but macOS may still require Automation permission because the tool controls
Messages through AppleScript.
`,
parameters: {},
implementation: async (_params, { signal, status }) => {
const platformError = assertMacOS();
if (platformError !== null) {
return platformError;
}
signal.throwIfAborted();
status("Checking Messages iMessage service");
const scriptResult = await runOsaScriptForTool(checkIMessageStatusScript, [], signal);
if (!scriptResult.ok) {
return scriptResult.error;
}
const serviceCount = Number.parseInt(scriptResult.result.stdout, 10);
return {
imessage_service_count: Number.isFinite(serviceCount) ? serviceCount : null,
ready: Number.isFinite(serviceCount) && serviceCount > 0,
note:
Number.isFinite(serviceCount) && serviceCount > 0
? "Messages exposes at least one iMessage service."
: "No active iMessage service found in Messages.",
};
},
});
tools.push(checkIMessageStatusTool);
const sendIMessageTool = tool({
name: "send_imessage",
description: text`
Send an iMessage through this Mac's Messages app using AppleScript automation.
Requirements: this Mac must be signed into Messages with iMessage enabled, and macOS must
allow the app running this plugin to control Messages. This sends as the Apple Account already
configured in Messages; it cannot spoof another sender. Delivery is not guaranteed.
For safety, confirm_send must be true. If confirm_send is false, the tool returns a preview
and does not send anything.
`,
parameters: {
recipient: z
.string()
.min(1)
.max(200)
.describe("Phone number or Apple Account email address for the recipient."),
message: z
.string()
.min(1)
.max(5000)
.describe("Message body to send exactly as provided."),
confirm_send: z
.boolean()
.describe("Must be true to actually send. False returns a non-sending preview."),
},
implementation: async ({ recipient, message, confirm_send }, { signal, status }) => {
const platformError = assertMacOS();
if (platformError !== null) {
return platformError;
}
signal.throwIfAborted();
if (!confirm_send) {
return {
sent: false,
recipient,
message,
note: "Preview only. Set confirm_send=true to send.",
};
}
status(`Sending iMessage to ${recipient}`);
const scriptResult = await runOsaScriptForTool(sendIMessageScript, [recipient, message], signal);
if (!scriptResult.ok) {
return scriptResult.error;
}
return {
sent: true,
recipient,
message,
note: "Messages accepted the send command. Delivery is not guaranteed.",
};
},
});
tools.push(sendIMessageTool);
const createAppleNoteTool = tool({
name: "create_apple_note",
description: text`
Create and open a new Apple Notes note. The note is created in the default Notes account and
folder. Provide checklist_items to create a real Apple Notes checklist with circular checkboxes.
Checklist creation uses Notes plus System Events UI automation, so macOS may require
Automation and Accessibility permissions. If no checklist_items are provided, the tool creates
a normal note from the title and optional body.
`,
parameters: {
title: z.string().min(1).max(200).describe("Title for the new note."),
body: z
.string()
.max(10000)
.optional()
.describe("Optional plain-text body placed before checklist items."),
checklist_items: z
.array(z.string().min(1).max(500))
.max(100)
.optional()
.describe("Optional checklist items to add as Apple Notes checklist rows."),
open_separately: z
.boolean()
.optional()
.describe("Open the new note in a separate Notes window. Default: true."),
},
implementation: async (
{ title, body, checklist_items, open_separately },
{ signal, status },
) => {
const platformError = assertMacOS();
if (platformError !== null) {
return platformError;
}
signal.throwIfAborted();
const normalizedChecklistItems =
checklist_items
?.map(item => item.replace(/\r\n|\r|\n/gu, " ").trim())
.filter(item => item.length > 0) ?? [];
const noteBodyHtml = buildNoteBodyHtml(body);
status(
normalizedChecklistItems.length > 0
? "Creating Apple Notes checklist"
: "Creating Apple note",
);
const scriptResult = await runOsaScriptForTool(
createAppleNoteScript,
[title, noteBodyHtml, String(open_separately ?? true), ...normalizedChecklistItems],
signal,
explainAppleNoteFailure,
);
if (!scriptResult.ok) {
return scriptResult.error;
}
return {
created: true,
note_id: scriptResult.result.stdout,
title,
checklist_item_count: normalizedChecklistItems.length,
opened: true,
};
},
});
tools.push(createAppleNoteTool);
const organizeFilesTool = tool({
name: "organize_files",
description: text`
Organize files on this Mac by moving or copying matching files from one directory into a
destination directory. The destination directory is created automatically if it does not
exist. This is intended for local demos such as moving image files from ~/Desktop into a new
folder.
Paths may be absolute or use ~. Relative paths resolve from the current user's home folder.
The tool never runs shell commands.
`,
parameters: {
source_directory: z
.string()
.min(1)
.describe("Directory to scan, for example ~/Desktop."),
destination_directory: z
.string()
.min(1)
.describe("Directory to create and move/copy matched files into."),
match_kind: z
.enum(["all", "archives", "audio", "documents", "images", "pdfs", "videos"])
.optional()
.describe("Preset file type filter. Default: images."),
extensions: z
.array(z.string().min(1))
.optional()
.describe("Optional extension filter such as ['png', '.jpg']; overrides match_kind."),
name_contains: z
.string()
.optional()
.describe("Optional case-insensitive filename substring filter."),
operation: z.enum(["copy", "move"]).optional().describe("Default: move."),
recursive: z.boolean().optional().describe("Scan subdirectories. Default: false."),
include_hidden: z.boolean().optional().describe("Include dotfiles. Default: false."),
collision_policy: z
.enum(["overwrite", "rename", "skip"])
.optional()
.describe("What to do when a destination filename exists. Default: rename."),
max_files: z
.number()
.int()
.min(1)
.max(maxOrganizedFileCount)
.optional()
.describe(`Maximum number of files to organize. Default: ${maxOrganizedFileCount}.`),
},
implementation: async (
{
source_directory,
destination_directory,
match_kind,
extensions,
name_contains,
operation,
recursive,
include_hidden,
collision_policy,
max_files,
},
{ signal, status },
) => {
const platformError = assertMacOS();
if (platformError !== null) {
return platformError;
}
signal.throwIfAborted();
const sourceDirectory = resolveMacPath(source_directory);
const destinationDirectory = resolveMacPath(destination_directory);
const sourceStats = await stat(sourceDirectory);
if (!sourceStats.isDirectory()) {
return "Error: source_directory must be a directory.";
}
if (sourceDirectory === destinationDirectory) {
return "Error: source_directory and destination_directory must be different.";
}
await mkdir(destinationDirectory, { recursive: true });
const destinationStats = await stat(destinationDirectory);
if (!destinationStats.isDirectory()) {
return "Error: destination_directory exists but is not a directory.";
}
const resolvedOperation: OrganizeOperation = operation ?? "move";
const resolvedCollisionPolicy: CollisionPolicy = collision_policy ?? "rename";
const resolvedMatchKind: FileMatchKind = match_kind ?? "images";
const resolvedMaxFiles = max_files ?? maxOrganizedFileCount;
status(`Finding ${resolvedMatchKind} files`);
const { files, skippedCollisions, truncated } = await collectOrganizableFiles({
collisionPolicy: resolvedCollisionPolicy,
destinationDirectory,
extensions,
includeHidden: include_hidden ?? false,
matchKind: resolvedMatchKind,
maxFiles: resolvedMaxFiles,
nameContains: name_contains,
recursive: recursive ?? false,
signal,
sourceDirectory,
});
const movedOrCopied: Array<{ from: string; to: string }> = [];
const failed: Array<{ error: string; from: string; to: string }> = [];
for (const file of files) {
signal.throwIfAborted();
status(`${resolvedOperation === "move" ? "Moving" : "Copying"} ${file.name}`);
try {
if (resolvedOperation === "move") {
await rename(file.sourcePath, file.destinationPath);
} else {
await copyFile(file.sourcePath, file.destinationPath);
}
movedOrCopied.push({ from: file.sourcePath, to: file.destinationPath });
} catch (error) {
failed.push({
error: error instanceof Error ? error.message : String(error),
from: file.sourcePath,
to: file.destinationPath,
});
}
}
return {
operation: resolvedOperation,
source_directory: sourceDirectory,
destination_directory: destinationDirectory,
match_kind: resolvedMatchKind,
matched_count: files.length,
completed_count: movedOrCopied.length,
skipped_collision_count: skippedCollisions.length,
failed_count: failed.length,
truncated,
files: movedOrCopied,
skipped_collisions: skippedCollisions,
failed,
};
},
});
tools.push(organizeFilesTool);
return tools;
}