Forked from brdcastro/maestro
"use strict";
/**
* @file macosTools.ts
* macOS integration tools: AppleScript bridge, screenshot + vision, Spotlight search.
*
* These tools give the LLM direct interaction with the operating system,
* enabling it to control apps, see the screen, and search files system-wide.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.createMacOSTools = createMacOSTools;
const sdk_1 = require("@lmstudio/sdk");
const zod_1 = require("zod");
const child_process_1 = require("child_process");
const util_1 = require("util");
const path_1 = require("path");
const os_1 = require("os");
const promises_1 = require("fs/promises");
const crypto_1 = require("crypto");
const shared_1 = require("./shared");
const errorCodes_1 = require("./errorCodes");
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
/**
* Resolve absolute paths for macOS system binaries that live under /usr/sbin.
* The LM Studio plugin process does not always include /usr/sbin in PATH, which
* makes `execFile("screencapture", ...)` fail with ENOENT. Hardcoding the
* canonical macOS path avoids that ā the binary is a fixed OS component.
*/
const SCREENCAPTURE_BIN = "/usr/sbin/screencapture";
/** Patterns blocked in AppleScript for safety */
const APPLESCRIPT_BLOCKED = [
"do shell script",
'system events" to keystroke',
"delete every",
"rm -rf", "rm -f",
];
/** Patterns blocked in JXA for safety */
const JXA_BLOCKED = [
"doshellscript", "do shell script",
"$.system", "objc.import",
];
/**
* Detect if an AppleScript was truncated by output token limits.
* Returns a reason string if truncation is detected, or null if OK.
*/
function detectAppleScriptTruncation(script) {
const trimmed = script.trim();
// Count tell/end tell balance
const tellCount = (trimmed.match(/\btell\b/gi) || []).length;
const endTellCount = (trimmed.match(/\bend tell\b/gi) || []).length;
if (tellCount > 0 && tellCount > endTellCount + 1) {
return `unbalanced tell/end tell: ${tellCount} tell vs ${endTellCount} end tell`;
}
// Check for string that was cut mid-quote
const quoteCount = (trimmed.match(/"/g) || []).length;
if (quoteCount % 2 !== 0) {
return "unclosed string literal (odd number of quotes)";
}
// Ends abruptly mid-token (no newline or common ending)
if (trimmed.length > 200 && /[a-zA-Z,{]\s*$/.test(trimmed) &&
!trimmed.endsWith("end tell") && !trimmed.endsWith("end repeat") &&
!trimmed.endsWith("end if") && !trimmed.endsWith("end try")) {
return "script ends abruptly without proper closure";
}
return null;
}
/**
* Detect if a JXA script was truncated.
*/
function detectJXATruncation(script) {
const trimmed = script.trim();
const opens = (trimmed.match(/\{/g) || []).length;
const closes = (trimmed.match(/\}/g) || []).length;
if (opens > 0 && opens > closes + 1) {
return `unbalanced braces: ${opens} { vs ${closes} }`;
}
const parens = (trimmed.match(/\(/g) || []).length - (trimmed.match(/\)/g) || []).length;
if (parens > 1) {
return "unbalanced parentheses";
}
const quoteCount = (trimmed.match(/"/g) || []).length;
if (quoteCount % 2 !== 0) {
return "unclosed string literal";
}
return null;
}
/** When an AppleScript fails with a Keynote-related error, suggest build_keynote. */
function keynoteHint(errorMsg) {
const lower = errorMsg.toLowerCase();
if (lower.includes("keynote") || lower.includes("presentation") || lower.includes("slide")) {
return ("\n\nš” TIP: Do NOT write raw AppleScript for Keynote. " +
"Use the `build_keynote` tool instead ā it generates correct AppleScript internally. " +
"Pass your slides as structured data: [{title, body, image_path, notes}].");
}
return "";
}
function createMacOSTools(config, limits) {
if (process.platform !== "darwin")
return []; // macOS only
const tools = [];
const MAX_OUTPUT = limits?.maxOutput ?? 4_000;
// āā AppleScript Bridge āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
tools.push((0, sdk_1.tool)({
name: "run_applescript",
description: (0, sdk_1.text) `
Execute AppleScript code on macOS via osascript.
Use this to control any macOS application: Finder, Notes, Mail, Safari,
Music, Spotify, System Settings, Calendar, Reminders, and more.
ā ļø IMPORTANT: For complex scripts (>30 lines or scripts that embed large text),
use script_file instead of script to avoid output truncation:
1. Save the script to a .applescript file with save_file
2. Call run_applescript with script_file="/path/to/script.applescript"
This completely avoids truncation issues.
Examples:
- Open an app: 'tell application "Finder" to activate'
- Get frontmost app: 'tell application "System Events" to get name of first process whose frontmost is true'
- Create a note: 'tell application "Notes" to make new note at folder "Notes" with properties {name:"Title", body:"Content"}'
- Read clipboard: 'the clipboard'
- Move a file: 'tell application "Finder" to move file "doc.pdf" of desktop to folder "Documents" of home'
Returns the script output as text, or an error if the script fails.
`,
parameters: {
script: zod_1.z.string().optional().describe("AppleScript code to execute (inline). For long scripts, use script_file instead."),
script_file: zod_1.z.string().optional().describe("Path to an .applescript or .scpt file to execute. Use this for complex/long scripts to avoid truncation."),
timeout_seconds: zod_1.z.number().optional().describe("Timeout in seconds (default: 30, max: 120)."),
},
implementation: (0, shared_1.createSafeToolImplementation)(async ({ script, script_file, timeout_seconds }) => {
const timeout = Math.min(timeout_seconds || 30, 120) * 1000;
// Determine execution mode
if (!script && !script_file) {
return (0, errorCodes_1.toolError)(errorCodes_1.MISSING_PARAM, "Either 'script' or 'script_file' must be provided.");
}
// āā File-based execution āā
if (script_file) {
try {
await (0, promises_1.stat)(script_file);
}
catch {
return (0, errorCodes_1.toolError)(errorCodes_1.FILE_NOT_FOUND, `Script file not found: ${script_file}`);
}
// Read and validate file content
const fileContent = await (0, promises_1.readFile)(script_file, "utf-8");
const fileLower = fileContent.toLowerCase();
for (const b of APPLESCRIPT_BLOCKED) {
if (fileLower.includes(b)) {
return (0, errorCodes_1.toolError)(errorCodes_1.BLOCKED_PATTERN, `Blocked: Script file contains "${b}" which is not allowed for safety.`);
}
}
try {
const { stdout, stderr } = await execFileAsync("osascript", [script_file], {
timeout,
maxBuffer: 1024 * 1024,
});
const output = stdout.trim();
const truncated = output.length > MAX_OUTPUT;
return {
success: true,
output: truncated
? output.substring(0, MAX_OUTPUT) + `\n... (truncated, ${output.length} chars total)`
: output || "(no output)",
...(stderr.trim() ? { warnings: stderr.trim() } : {}),
};
}
catch (e) {
const msg = e.message || String(e);
if (msg.includes("ETIMEDOUT") || msg.includes("killed")) {
return (0, errorCodes_1.toolError)(errorCodes_1.TIMEOUT, `AppleScript timed out after ${(timeout / 1000)}s.`);
}
return (0, errorCodes_1.toolError)(errorCodes_1.APPLESCRIPT_FAILED, `AppleScript failed: ${msg.substring(0, 500)}`, keynoteHint(msg) || undefined);
}
}
// āā Inline execution āā
const inlineScript = script;
// Safety: block dangerous operations
const lower = inlineScript.toLowerCase();
for (const b of APPLESCRIPT_BLOCKED) {
if (lower.includes(b)) {
return (0, errorCodes_1.toolError)(errorCodes_1.BLOCKED_PATTERN, `Blocked: AppleScript containing "${b}" is not allowed for safety. Use the appropriate tool instead (execute_command for shell, etc.).`);
}
}
// Truncation detection: warn if script looks incomplete
const truncationWarning = detectAppleScriptTruncation(inlineScript);
if (truncationWarning) {
return (0, errorCodes_1.toolError)(errorCodes_1.SCRIPT_TRUNCATED, `Script appears truncated (${truncationWarning}). Your output was likely cut off.`, `Use script_file instead: save the script with save_file, then call run_applescript with script_file="/path/to/script.applescript".`);
}
try {
const { stdout, stderr } = await execFileAsync("osascript", ["-e", inlineScript], {
timeout,
maxBuffer: 1024 * 1024, // 1MB
});
const output = stdout.trim();
const truncated = output.length > MAX_OUTPUT;
return {
success: true,
output: truncated
? output.substring(0, MAX_OUTPUT) + `\n... (truncated, ${output.length} chars total)`
: output || "(no output)",
...(stderr.trim() ? { warnings: stderr.trim() } : {}),
};
}
catch (e) {
const msg = e.message || String(e);
if (msg.includes("ETIMEDOUT") || msg.includes("killed")) {
return (0, errorCodes_1.toolError)(errorCodes_1.TIMEOUT, `AppleScript timed out after ${(timeout / 1000)}s.`);
}
return (0, errorCodes_1.toolError)(errorCodes_1.APPLESCRIPT_FAILED, `AppleScript failed: ${msg.substring(0, 500)}`, keynoteHint(msg) || undefined);
}
}, config.allowAppleScript, "run_applescript"),
}));
// āā JXA (JavaScript for Automation) ā more powerful alternative āāāā
tools.push((0, sdk_1.tool)({
name: "run_jxa",
description: (0, sdk_1.text) `
Execute JavaScript for Automation (JXA) code on macOS.
JXA is Apple's JavaScript-based automation language ā same power as AppleScript
but with modern JavaScript syntax. Useful for complex automation tasks.
ā ļø For long scripts, use script_file: save with save_file first, then pass the path.
Examples:
- Get frontmost app: 'Application("System Events").processes.whose({frontmost: true})[0].name()'
- List desktop files: 'Application("Finder").desktop.files().map(f => f.name())'
- Get Safari URL: 'Application("Safari").windows[0].currentTab.url()'
- System info: 'Application("System Events").currentUser().name()'
`,
parameters: {
script: zod_1.z.string().optional().describe("JXA code to execute (inline). For long scripts, use script_file instead."),
script_file: zod_1.z.string().optional().describe("Path to a .js file to execute as JXA. Use for complex/long scripts."),
timeout_seconds: zod_1.z.number().optional().describe("Timeout in seconds (default: 30, max: 120)."),
},
implementation: (0, shared_1.createSafeToolImplementation)(async ({ script, script_file, timeout_seconds }) => {
const timeout = Math.min(timeout_seconds || 30, 120) * 1000;
if (!script && !script_file) {
return (0, errorCodes_1.toolError)(errorCodes_1.MISSING_PARAM, "Either 'script' or 'script_file' must be provided.");
}
// āā File-based execution āā
if (script_file) {
try {
await (0, promises_1.stat)(script_file);
}
catch {
return (0, errorCodes_1.toolError)(errorCodes_1.FILE_NOT_FOUND, `Script file not found: ${script_file}`);
}
const fileContent = await (0, promises_1.readFile)(script_file, "utf-8");
const fileLower = fileContent.toLowerCase();
for (const b of JXA_BLOCKED) {
if (fileLower.includes(b)) {
return (0, errorCodes_1.toolError)(errorCodes_1.BLOCKED_PATTERN, `Blocked: Script file contains "${b}" which is not allowed for safety.`);
}
}
try {
const { stdout, stderr } = await execFileAsync("osascript", ["-l", "JavaScript", script_file], {
timeout,
maxBuffer: 1024 * 1024,
});
const output = stdout.trim();
const truncated = output.length > MAX_OUTPUT;
return {
success: true,
output: truncated
? output.substring(0, MAX_OUTPUT) + `\n... (truncated, ${output.length} chars total)`
: output || "(no output)",
...(stderr.trim() ? { warnings: stderr.trim() } : {}),
};
}
catch (e) {
const msg = e.message || String(e);
return (0, errorCodes_1.toolError)(errorCodes_1.JXA_FAILED, `JXA failed: ${msg.substring(0, 500)}`);
}
}
// āā Inline execution āā
const inlineScript = script;
const lower = inlineScript.toLowerCase();
for (const b of JXA_BLOCKED) {
if (lower.includes(b)) {
return (0, errorCodes_1.toolError)(errorCodes_1.BLOCKED_PATTERN, `Blocked: JXA with "${b}" is not allowed for safety.`);
}
}
// Truncation detection
const truncationWarning = detectJXATruncation(inlineScript);
if (truncationWarning) {
return (0, errorCodes_1.toolError)(errorCodes_1.SCRIPT_TRUNCATED, `Script appears truncated (${truncationWarning}).`, `Use script_file instead: save the script with save_file, then call run_jxa with script_file="/path/to/script.js".`);
}
try {
const { stdout, stderr } = await execFileAsync("osascript", ["-l", "JavaScript", "-e", inlineScript], {
timeout,
maxBuffer: 1024 * 1024,
});
const output = stdout.trim();
const truncated = output.length > MAX_OUTPUT;
return {
success: true,
output: truncated
? output.substring(0, MAX_OUTPUT) + `\n... (truncated, ${output.length} chars total)`
: output || "(no output)",
...(stderr.trim() ? { warnings: stderr.trim() } : {}),
};
}
catch (e) {
const msg = e.message || String(e);
return (0, errorCodes_1.toolError)(errorCodes_1.JXA_FAILED, `JXA failed: ${msg.substring(0, 500)}`);
}
}, config.allowAppleScript, "run_jxa"),
}));
// āā Screenshot + Vision āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
tools.push((0, sdk_1.tool)({
name: "take_screenshot",
description: (0, sdk_1.text) `
Capture a screenshot of the macOS screen and return it as a base64 image
for visual analysis. Use it to read error messages, check UI state, or
verify visual output.
COST: each screenshot adds ~10-25K tokens to the conversation and those
tokens REPLAY in every later turn. Budget yourself 1-2 screenshots per
task. Prefer mode='window' or mode='region' over full screen ā smaller
capture = far fewer tokens. To verify a preview_html result, rely on
the preview window the user can already see; don't screenshot just to
look at it yourself.
Modes:
- Full screen (default): entire display ā most expensive, use sparingly
- Window: a specific app window (by app name) ā preferred default
- Region: a specific rectangle (x, y, width, height) ā cheapest
`,
parameters: {
mode: zod_1.z.enum(["screen", "window", "region"]).optional().describe("Capture mode (default: screen). Prefer 'window' or 'region' over 'screen' to save tokens."),
app_name: zod_1.z.string().optional().describe("App name for window capture (e.g. 'Safari', 'Terminal')."),
region: zod_1.z.object({
x: zod_1.z.number(),
y: zod_1.z.number(),
width: zod_1.z.number(),
height: zod_1.z.number(),
}).optional().describe("Region coordinates for region capture."),
max_dimension: zod_1.z.number().optional().describe("Max width/height in pixels (default: 768). Use 512 for general UI checks; 1024+ only when reading small text. Higher values multiply token cost roughly with area."),
},
implementation: (0, shared_1.createSafeToolImplementation)(async ({ mode = "screen", app_name, region, max_dimension }) => {
const maxDim = Math.min(max_dimension || 768, 2048);
const tmpDir = (0, path_1.join)((0, os_1.tmpdir)(), `maestro-ss-${(0, crypto_1.randomBytes)(6).toString("hex")}`);
await (0, promises_1.mkdir)(tmpDir, { recursive: true });
const rawPath = (0, path_1.join)(tmpDir, "raw.png");
const resizedPath = (0, path_1.join)(tmpDir, "resized.jpg");
try {
// 1. Capture screenshot using macOS screencapture
const captureArgs = [];
if (mode === "window" && app_name) {
// Bring app to front, then capture frontmost window
try {
await execFileAsync("osascript", ["-e",
`tell application "${app_name}" to activate`,
]);
// Small delay for window to come to front
await new Promise(r => setTimeout(r, 500));
}
catch { }
captureArgs.push("-l", await getWindowId(app_name), rawPath);
}
else if (mode === "region" && region) {
captureArgs.push("-R", `${region.x},${region.y},${region.width},${region.height}`, rawPath);
}
else {
// Full screen, no cursor, no sound
captureArgs.push("-x", rawPath);
}
await execFileAsync(SCREENCAPTURE_BIN, captureArgs, { timeout: 10000 });
// 2. Resize with ffmpeg (reuses existing infrastructure) or sips.
// JPEG quality kept LOW on purpose ā these screenshots are for the
// model to read, not for archival. Lower quality = fewer base64
// bytes = fewer tokens replayed in every subsequent turn.
try {
const { getFfmpegPath } = await Promise.resolve().then(() => __importStar(require("../media/ffmpegPath")));
const ffmpeg = await getFfmpegPath();
await execFileAsync(ffmpeg, [
"-i", rawPath,
"-vf", `scale='min(${maxDim},iw)':'min(${maxDim},ih)':force_original_aspect_ratio=decrease`,
"-q:v", "10", "-y", resizedPath, // mjpeg q:v 10 ā JPEG quality 45
]);
}
catch {
// Fallback: use macOS sips (always available, no ffmpeg needed)
await execFileAsync("sips", [
"--resampleHeightWidthMax", String(maxDim),
"--setProperty", "format", "jpeg",
"--setProperty", "formatOptions", "45",
rawPath, "--out", resizedPath,
]);
}
const buf = await (0, promises_1.readFile)(resizedPath);
const dataUri = `data:image/jpeg;base64,${buf.toString("base64")}`;
return {
success: true,
image: dataUri,
mode,
...(app_name ? { app: app_name } : {}),
bytes: buf.byteLength,
note: "Screenshot captured. Analyze the image above. NOTE: this image will replay in every later turn ā extract what you need now and avoid taking another unless strictly necessary.",
};
}
catch (e) {
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `Screenshot failed: ${e.message || String(e)}`);
}
finally {
await (0, promises_1.rm)(tmpDir, { recursive: true, force: true }).catch(() => { });
}
}, config.allowScreenshot, "take_screenshot"),
}));
// āā Spotlight Search āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
tools.push((0, sdk_1.tool)({
name: "spotlight_search",
description: (0, sdk_1.text) `
Search files across the entire macOS system using Spotlight (mdfind).
Much faster than manual file search ā uses the system index.
Supports:
- Natural text search: 'quarterly report 2024'
- File name search: '-name "*.pdf"'
- Kind filter: 'kind:pdf budget' or 'kind:image sunset'
- Date filter: 'date:2024-01' (files modified in Jan 2024)
- App filter: 'kind:application Chrome'
Common kind values: pdf, image, movie, audio, presentation, spreadsheet,
document, folder, application, email, message, contact, bookmark, font.
`,
parameters: {
query: zod_1.z.string().describe("Spotlight search query."),
directory: zod_1.z.string().optional().describe("Limit search to a specific directory (e.g. ~/Documents)."),
limit: zod_1.z.number().optional().describe("Max results to return (default: 20, max: 50)."),
name_only: zod_1.z.boolean().optional().describe("If true, search file names only (-name flag). Default: false (content + metadata)."),
},
implementation: async ({ query, directory, limit, name_only }) => {
const maxResults = Math.min(limit || 20, 50);
try {
const args = [];
if (name_only) {
args.push("-name", query);
}
else {
args.push(query);
}
if (directory) {
// Expand ~ to home directory
const dir = directory.replace(/^~/, process.env.HOME || "");
args.push("-onlyin", dir);
}
const { stdout } = await execFileAsync("mdfind", args, {
timeout: 15000,
maxBuffer: 1024 * 1024,
});
const allPaths = stdout.trim().split("\n").filter(Boolean);
const results = allPaths.slice(0, maxResults);
// Get basic metadata ā limit concurrency to 8 to avoid process storm
const CONCURRENCY = 8;
const detailed = [];
for (let i = 0; i < results.length; i += CONCURRENCY) {
const batch = results.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(batch.map(async (filePath) => {
try {
const { stdout: mdls } = await execFileAsync("mdls", [
"-name", "kMDItemFSSize",
"-name", "kMDItemContentType",
"-name", "kMDItemFSContentChangeDate",
"-raw", "-nullMarker", "",
filePath,
], { timeout: 3000 });
const [size, type, date] = mdls.split("\0").map(s => s.trim());
return {
path: filePath,
...(size && size !== "(null)" ? { size_bytes: parseInt(size) || undefined } : {}),
...(type && type !== "(null)" ? { type } : {}),
...(date && date !== "(null)" ? { modified: date } : {}),
};
}
catch {
return { path: filePath };
}
}));
detailed.push(...batchResults);
}
return {
query,
total_found: allPaths.length,
showing: detailed.length,
results: detailed,
...(allPaths.length > maxResults ? {
note: `Showing ${maxResults} of ${allPaths.length} results. Increase 'limit' or narrow your query.`,
} : {}),
};
}
catch (e) {
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `Spotlight search failed: ${e.message || String(e)}`);
}
},
}));
// āā Active App / Window Info āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
tools.push((0, sdk_1.tool)({
name: "get_active_app",
description: (0, sdk_1.text) `
Get information about the currently active (frontmost) macOS application,
including its name, window title, and position. Useful for understanding
the user's current context.
`,
parameters: {},
implementation: async () => {
try {
const script = `
tell application "System Events"
set frontApp to first process whose frontmost is true
set appName to name of frontApp
set winNames to name of every window of frontApp
return appName & "|||" & (winNames as text)
end tell
`;
const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 5000 });
const [appName, windowTitles] = stdout.trim().split("|||");
return {
app: appName?.trim() || "unknown",
windows: windowTitles?.trim() ? windowTitles.split(", ").map(w => w.trim()) : [],
};
}
catch (e) {
return (0, errorCodes_1.toolError)(errorCodes_1.EXEC_FAILED, `Failed to get active app: ${e.message || String(e)}`);
}
},
}));
// āā Build Keynote Presentation āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
tools.push((0, sdk_1.tool)({
name: "build_keynote",
description: (0, sdk_1.text) `
Build a Keynote presentation from structured data. This tool generates
correct AppleScript internally ā you don't need to write any AppleScript.
Provide an array of slides, each with a title, optional body text, and
optional image path. The tool creates or populates a Keynote presentation.
Use this instead of run_applescript when building presentations.
For complex presentations, read your content with read_file first,
then pass the structured slides array.
`,
parameters: {
slides: zod_1.z.array(zod_1.z.object({
title: zod_1.z.string().describe("Slide title text."),
body: zod_1.z.string().optional().describe("Slide body text (can be long)."),
image_path: zod_1.z.string().optional().describe("Absolute path to an image file to add to the slide."),
notes: zod_1.z.string().optional().describe("Presenter notes for this slide."),
})).describe("Array of slides to create."),
use_open_presentation: zod_1.z.boolean().optional().describe("If true, use the first open Keynote document. If false (default), create a new one."),
theme: zod_1.z.string().optional().describe("Keynote theme name (default: 'Basic White'). Only used when creating new presentations."),
},
implementation: (0, shared_1.createSafeToolImplementation)(async ({ slides, use_open_presentation, theme }) => {
if (!slides || slides.length === 0) {
return (0, errorCodes_1.toolError)(errorCodes_1.NO_SLIDES, "At least one slide is required.");
}
if (slides.length > 100) {
return (0, errorCodes_1.toolError)(errorCodes_1.SLIDES_LIMIT, "Maximum 100 slides per call.");
}
// Build the AppleScript
const themeName = theme || "Basic White";
const scriptLines = [
'on run',
' tell application "Keynote"',
' activate',
];
if (use_open_presentation) {
scriptLines.push(' if (count of documents) is 0 then', ' return "Error: No Keynote presentation is open."', ' end if', ' set targetDoc to document 1', ' -- Remove the default blank slide if presentation is empty', ' if (count of slides of targetDoc) is 1 then', ' try', ' set firstSlideText to object text of default title item of slide 1 of targetDoc', ' if firstSlideText is "" or firstSlideText is missing value then', ' delete slide 1 of targetDoc', ' end if', ' on error', ' -- Keep it', ' end try', ' end if');
}
else {
scriptLines.push(` set targetDoc to make new document with properties {document theme:theme "${themeName}"}`, ' -- Remove the default blank slide', ' try', ' delete slide 1 of targetDoc', ' end try');
}
// Add each slide
for (let i = 0; i < slides.length; i++) {
const slide = slides[i];
const titleEsc = escapeAppleScript(slide.title);
const bodyEsc = slide.body ? escapeAppleScript(slide.body) : null;
scriptLines.push('');
scriptLines.push(` -- Slide ${i + 1}`);
if (bodyEsc) {
// Use Title & Body layout
scriptLines.push(` set newSlide to make new slide at end of targetDoc with properties {base layout:slide layout "Title & Body" of theme "${themeName}" of targetDoc}`);
}
else {
// Use Title Only layout
scriptLines.push(` set newSlide to make new slide at end of targetDoc with properties {base layout:slide layout "Title - Center" of theme "${themeName}" of targetDoc}`);
}
// Set title
scriptLines.push(` set object text of default title item of newSlide to "${titleEsc}"`);
// Set body
if (bodyEsc) {
scriptLines.push(` set object text of default body item of newSlide to "${bodyEsc}"`);
}
// Add image
if (slide.image_path) {
const imgEsc = escapeAppleScript(slide.image_path);
scriptLines.push(' try', ` set imgFile to POSIX file "${imgEsc}"`, ` make new image at end of newSlide with properties {file:imgFile, position:{600, 100}, width:350, height:450}`, ' on error errMsg', ` -- Image failed: will continue without it`, ' end try');
}
// Set notes
if (slide.notes) {
const notesEsc = escapeAppleScript(slide.notes);
scriptLines.push(` set presenter notes of newSlide to "${notesEsc}"`);
}
}
scriptLines.push('', ` return "Success: Created ${slides.length} slides in Keynote."`, ' end tell', 'end run');
const script = scriptLines.join('\n');
// Save to temp file and execute (avoids truncation)
const tmpFile = (0, path_1.join)((0, os_1.tmpdir)(), `maestro-keynote-${(0, crypto_1.randomBytes)(6).toString("hex")}.applescript`);
try {
await (0, promises_1.writeFile)(tmpFile, script, "utf-8");
const { stdout, stderr } = await execFileAsync("osascript", [tmpFile], {
timeout: 120000, // 2 min for large presentations
maxBuffer: 1024 * 1024,
});
const output = stdout.trim();
return {
success: true,
output: output || "(no output)",
slides_created: slides.length,
...(stderr.trim() ? { warnings: stderr.trim() } : {}),
};
}
catch (e) {
const msg = e.message || String(e);
// Try fallback with simpler layout names
if (msg.includes("slide layout") || msg.includes("theme")) {
try {
// Retry with simpler approach: no explicit layout
const fallbackLines = buildFallbackScript(slides, use_open_presentation ?? false);
await (0, promises_1.writeFile)(tmpFile, fallbackLines, "utf-8");
const { stdout, stderr } = await execFileAsync("osascript", [tmpFile], {
timeout: 120000,
maxBuffer: 1024 * 1024,
});
return {
success: true,
output: stdout.trim() || "(no output)",
slides_created: slides.length,
note: "Used fallback layout (theme-specific layouts not available).",
...(stderr.trim() ? { warnings: stderr.trim() } : {}),
};
}
catch (e2) {
return (0, errorCodes_1.toolError)(errorCodes_1.KEYNOTE_FAILED, `Keynote automation failed: ${(e2.message || String(e2)).substring(0, 500)}`);
}
}
return (0, errorCodes_1.toolError)(errorCodes_1.KEYNOTE_FAILED, `Keynote automation failed: ${msg.substring(0, 500)}`);
}
finally {
await (0, promises_1.rm)(tmpFile, { force: true }).catch(() => { });
}
}, config.allowAppleScript, "build_keynote"),
}));
return tools;
}
/** Escape a string for AppleScript double-quoted string literal */
function escapeAppleScript(s) {
return s
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}
/** Fallback script using default slide creation without explicit layout names */
function buildFallbackScript(slides, useOpen) {
const lines = [
'on run',
' tell application "Keynote"',
' activate',
];
if (useOpen) {
lines.push(' if (count of documents) is 0 then return "Error: No Keynote presentation open."', ' set targetDoc to document 1');
}
else {
lines.push(' set targetDoc to make new document', ' try', ' delete slide 1 of targetDoc', ' end try');
}
for (let i = 0; i < slides.length; i++) {
const slide = slides[i];
const titleEsc = escapeAppleScript(slide.title);
lines.push(` set newSlide to make new slide at end of targetDoc`);
lines.push(' tell newSlide');
lines.push(' try');
lines.push(` set object text of default title item to "${titleEsc}"`);
lines.push(' end try');
if (slide.body) {
const bodyEsc = escapeAppleScript(slide.body);
lines.push(' try');
lines.push(` set object text of default body item to "${bodyEsc}"`);
lines.push(' on error');
lines.push(` make new text item with properties {object text:"${bodyEsc}"}`);
lines.push(' end try');
}
if (slide.image_path) {
const imgEsc = escapeAppleScript(slide.image_path);
lines.push(' try');
lines.push(` set imgFile to POSIX file "${imgEsc}"`);
lines.push(` make new image with properties {file:imgFile, position:{600, 100}, width:350, height:450}`);
lines.push(' end try');
}
if (slide.notes) {
const notesEsc = escapeAppleScript(slide.notes);
lines.push(` set presenter notes to "${notesEsc}"`);
}
lines.push(' end tell');
}
lines.push(` return "Success: Created ${slides.length} slides."`);
lines.push(' end tell');
lines.push('end run');
return lines.join('\n');
}
/**
* Get the window ID of an app's frontmost window (for screencapture -l).
*/
async function getWindowId(appName) {
try {
const script = `
tell application "System Events"
set appProc to first process whose name is "${appName}"
set winId to id of first window of appProc
return winId as text
end tell
`;
const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 5000 });
return stdout.trim();
}
catch {
// Fallback: just capture the frontmost window
throw new Error(`Could not find window for "${appName}". Use mode "screen" instead.`);
}
}
//# sourceMappingURL=macosTools.js.map