Skip to main content
Glama
manager.ts11.1 kB
/** * Shortcut Manager - Handles discovery, validation, and execution of macOS shortcuts */ import { exec } from 'child_process'; import { promisify } from 'util'; import { Shortcut, ShortcutInfo, ShortcutFilter, ExecutionRequest, ExecutionResult, ShortcutCategory, InputType, OutputType, CacheEntry, CacheStats } from '../types/shortcuts.js'; import { ShortcutConfig } from '../types/config.js'; import { Logger } from '../utils/logger.js'; import { ShortcutNotFoundError, ExecutionError, TimeoutError } from '../types/errors.js'; const execAsync = promisify(exec); export class ShortcutManager { private config: ShortcutConfig; private logger: Logger; private shortcutsCache: Map<string, CacheEntry<Shortcut[]>> = new Map(); private infoCache: Map<string, CacheEntry<ShortcutInfo>> = new Map(); private cacheStats = { hits: 0, misses: 0 }; constructor(config: ShortcutConfig, logger: Logger) { this.config = config; this.logger = logger; } async initialize(): Promise<void> { this.logger.info('Initializing Shortcut Manager...'); // Check if shortcuts CLI is available try { await execAsync('which shortcuts'); this.logger.info('macOS shortcuts CLI found'); } catch (error) { throw new Error('macOS shortcuts CLI not found. Please ensure you are running on macOS 12+ with Shortcuts app installed.'); } // Warm up cache with initial shortcut list if (this.config.enableCache) { try { await this.discoverShortcuts(); this.logger.info('Shortcut cache warmed up'); } catch (error) { this.logger.warn('Failed to warm up cache:', error); } } } async listShortcuts(filter?: ShortcutFilter): Promise<Shortcut[]> { const cacheKey = this.getCacheKey('shortcuts', filter); // Check cache first if (this.config.enableCache) { const cached = this.shortcutsCache.get(cacheKey); if (cached && !this.isCacheExpired(cached)) { this.cacheStats.hits++; this.logger.debug('Cache hit for shortcuts list'); return this.applyFilter(cached.data, filter); } } this.cacheStats.misses++; const shortcuts = await this.discoverShortcuts(); // Cache the results if (this.config.enableCache) { this.shortcutsCache.set(cacheKey, { data: shortcuts, timestamp: Date.now(), ttl: this.config.cacheTimeout }); } return this.applyFilter(shortcuts, filter); } async getShortcutInfo(name: string): Promise<ShortcutInfo | null> { const cacheKey = `info_${name}`; // Check cache first if (this.config.enableCache) { const cached = this.infoCache.get(cacheKey); if (cached && !this.isCacheExpired(cached)) { this.cacheStats.hits++; this.logger.debug('Cache hit for shortcut info:', { name }); return cached.data; } } this.cacheStats.misses++; try { // Get basic info from shortcuts list const shortcuts = await this.listShortcuts(); const shortcut = shortcuts.find(s => s.name === name); if (!shortcut) { return null; } // Get detailed info const detailedInfo = await this.getDetailedShortcutInfo(name); const info: ShortcutInfo = { ...shortcut, ...detailedInfo }; // Cache the result if (this.config.enableCache) { this.infoCache.set(cacheKey, { data: info, timestamp: Date.now(), ttl: this.config.cacheTimeout }); } return info; } catch (error) { this.logger.error('Failed to get shortcut info:', { name, error }); return null; } } async executeShortcut(request: ExecutionRequest): Promise<ExecutionResult> { const { name, input, timeout = this.config.defaultTimeout } = request; const startTime = Date.now(); this.logger.info('Executing shortcut:', { name, hasInput: !!input, timeout }); try { // Validate shortcut exists const info = await this.getShortcutInfo(name); if (!info) { throw new ShortcutNotFoundError(`Shortcut "${name}" not found`); } // Build command let command = `shortcuts run "${name}"`; if (input !== undefined && input !== null) { const inputStr = this.prepareInput(input); command += ` -i "${inputStr}"`; } this.logger.debug('Executing command:', { command }); // Execute with timeout const result = await this.executeWithTimeout(command, timeout); const executionTime = Date.now() - startTime; this.logger.info('Shortcut executed successfully:', { name, executionTime, outputLength: result.length }); return { success: true, output: this.parseOutput(result), executionTime, logs: [], metadata: { shortcutName: name, startTime: new Date(startTime), endTime: new Date(), inputSize: input ? JSON.stringify(input).length : 0, outputSize: result.length, warningsCount: 0, errorsCount: 0 } }; } catch (error) { const executionTime = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('Shortcut execution failed:', { name, error: errorMessage, executionTime }); return { success: false, error: errorMessage, executionTime, logs: [], metadata: { shortcutName: name, startTime: new Date(startTime), endTime: new Date(), inputSize: input ? JSON.stringify(input).length : 0, outputSize: 0, warningsCount: 0, errorsCount: 1 } }; } } private async discoverShortcuts(): Promise<Shortcut[]> { try { const { stdout } = await execAsync('shortcuts list'); const lines = stdout.trim().split('\n').filter(line => line.trim()); const shortcuts: Shortcut[] = []; for (const line of lines) { const name = line.trim(); if (name) { const description = await this.getShortcutDescription(name); shortcuts.push({ name, description: description || `macOS shortcut: ${name}`, category: this.categorizeShortcut(name), inputTypes: [InputType.TEXT], // Default, could be enhanced outputType: OutputType.TEXT, icon: '⚡', lastModified: new Date() }); } } this.logger.debug('Discovered shortcuts:', { count: shortcuts.length }); return shortcuts; } catch (error) { this.logger.error('Failed to discover shortcuts:', error); throw new ExecutionError('Failed to list shortcuts'); } } private async getShortcutDescription(name: string): Promise<string | undefined> { try { // This would require additional AppleScript or other methods // For now, return a generic description return `macOS shortcut: ${name}`; } catch { return undefined; } } private async getDetailedShortcutInfo(_name: string): Promise<Partial<ShortcutInfo>> { // For now, return basic info // This could be enhanced with AppleScript to get more details return { actionCount: 1, size: 1024, isSystemShortcut: false }; } private categorizeShortcut(name: string): ShortcutCategory { const lowerName = name.toLowerCase(); if (lowerName.includes('email') || lowerName.includes('message') || lowerName.includes('call')) { return ShortcutCategory.COMMUNICATION; } if (lowerName.includes('photo') || lowerName.includes('video') || lowerName.includes('music')) { return ShortcutCategory.MEDIA; } if (lowerName.includes('note') || lowerName.includes('task') || lowerName.includes('calendar')) { return ShortcutCategory.PRODUCTIVITY; } if (lowerName.includes('system') || lowerName.includes('setting')) { return ShortcutCategory.SYSTEM; } return ShortcutCategory.UTILITIES; } private applyFilter(shortcuts: Shortcut[], filter?: ShortcutFilter): Shortcut[] { if (!filter) return shortcuts; let filtered = shortcuts; if (filter.category) { filtered = filtered.filter(s => s.category === filter.category); } if (filter.search) { const searchLower = filter.search.toLowerCase(); filtered = filtered.filter(s => s.name.toLowerCase().includes(searchLower) || (s.description && s.description.toLowerCase().includes(searchLower)) ); } if (filter.limit && filter.limit > 0) { filtered = filtered.slice(0, filter.limit); } return filtered; } private prepareInput(input: any): string { if (typeof input === 'string') { return input.replace(/"/g, '\\"'); } return JSON.stringify(input).replace(/"/g, '\\"'); } private parseOutput(output: string): any { // Try to parse as JSON first try { return JSON.parse(output); } catch { // Return as plain text return output.trim(); } } private async executeWithTimeout(command: string, timeoutMs: number): Promise<string> { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new TimeoutError(`Execution timeout after ${timeoutMs}ms`)); }, timeoutMs); execAsync(command) .then(({ stdout, stderr }) => { clearTimeout(timer); if (stderr && stderr.trim()) { this.logger.warn('Command stderr:', stderr); } resolve(stdout); }) .catch(error => { clearTimeout(timer); reject(new ExecutionError(`Command failed: ${error.message}`)); }); }); } private getCacheKey(type: string, filter?: ShortcutFilter): string { if (!filter) return type; const parts = [type]; if (filter.category) parts.push(`cat:${filter.category}`); if (filter.search) parts.push(`search:${filter.search}`); if (filter.limit) parts.push(`limit:${filter.limit}`); return parts.join('|'); } private isCacheExpired(entry: CacheEntry<any>): boolean { return Date.now() - entry.timestamp > entry.ttl; } getCacheStats(): CacheStats { const totalRequests = this.cacheStats.hits + this.cacheStats.misses; return { size: this.shortcutsCache.size + this.infoCache.size, hits: this.cacheStats.hits, misses: this.cacheStats.misses, hitRate: totalRequests > 0 ? this.cacheStats.hits / totalRequests : 0 }; } clearCache(): void { this.shortcutsCache.clear(); this.infoCache.clear(); this.cacheStats = { hits: 0, misses: 0 }; this.logger.info('Cache cleared'); } async cleanup(): Promise<void> { this.clearCache(); this.logger.info('Shortcut Manager cleaned up'); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aezizhu/shortcut-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server