# Directory Structure ``` ├── .gitignore ├── .npmignore ├── biome.json ├── CLAUDE.md ├── examples │ ├── terminal-detection.js │ └── tmux-client-tracking.sh ├── macos-notify-mcp-icon.svg ├── MacOSNotifyMCP │ ├── build-app.sh │ ├── MacOSNotifyMCP.app │ │ └── Contents │ │ ├── _CodeSignature │ │ │ └── CodeResources │ │ ├── Info.plist │ │ ├── MacOS │ │ │ └── MacOSNotifyMCP │ │ └── Resources │ │ └── MacOSNotifyMCP.icns │ ├── MacOSNotifyMCP.icns │ ├── main.swift │ └── notify-swift ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── cli-wrapper.cjs │ ├── cli.ts │ ├── index.ts │ ├── mcp-wrapper.cjs │ └── notifier.ts ├── test │ ├── cli.test.ts │ ├── index.test.ts │ └── notifier.test.ts ├── tsconfig.json └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # IDE 8 | .vscode/ 9 | .idea/ 10 | 11 | # OS 12 | .DS_Store 13 | 14 | # Logs 15 | *.log 16 | npm-debug.log* 17 | 18 | # Environment 19 | .env 20 | .env.local 21 | 22 | # TypeScript 23 | *.tsbuildinfo 24 | 25 | # Temporary files 26 | *.tmp 27 | tmp/ 28 | temp/ ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` 1 | # Source files 2 | src/ 3 | tsconfig.json 4 | 5 | # Development files 6 | .gitignore 7 | .git/ 8 | 9 | # Swift source files (but keep the built app) 10 | NotifyTmux/main.swift 11 | NotifyTmux/build-app.sh 12 | NotifyTmux/notify-swift 13 | 14 | # Old Deno files 15 | bin/ 16 | 17 | # IDE 18 | .vscode/ 19 | .idea/ 20 | 21 | # OS 22 | .DS_Store 23 | 24 | # Temporary 25 | *.tmp 26 | tmp/ 27 | temp/ 28 | 29 | # Keep NotifyTmux.app 30 | !NotifyTmux/NotifyTmux.app/ ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # macOS Notify MCP 2 | 3 | A Model Context Protocol (MCP) server for macOS notifications with tmux integration. This tool allows AI assistants like Claude to send native macOS notifications that can focus specific tmux sessions when clicked. 4 | 5 | ## Features 6 | 7 | - 🔔 Native macOS notifications using UserNotifications API 8 | - 🖱️ Clickable notifications that focus tmux sessions 9 | - 🎯 Direct navigation to specific tmux session, window, and pane 10 | - 🔊 Customizable notification sounds 11 | - 🚀 Support for multiple concurrent notifications 12 | - 🤖 MCP server for AI assistant integration 13 | - 🖥️ Terminal emulator detection (VSCode, Cursor, iTerm2, Terminal.app) 14 | 15 | ## Installation 16 | 17 | ### Prerequisites 18 | 19 | - macOS (required for notifications) 20 | - Node.js >= 18.0.0 21 | - tmux (optional, for tmux integration) 22 | 23 | ### Install from npm 24 | 25 | ```bash 26 | npm install -g macos-notify-mcp 27 | ``` 28 | 29 | ### Build from source 30 | 31 | ```bash 32 | git clone https://github.com/yuki-yano/macos-notify-mcp.git 33 | cd macos-notify-mcp 34 | npm install 35 | npm run build 36 | npm run build-app # Build the macOS app bundle (only needed for development) 37 | ``` 38 | 39 | ## Usage 40 | 41 | ### As MCP Server 42 | 43 | First, install the package globally: 44 | 45 | ```bash 46 | npm install -g macos-notify-mcp 47 | ``` 48 | 49 | #### Quick Setup with Claude Code 50 | 51 | Use the `claude mcp add` command: 52 | 53 | ```bash 54 | claude mcp add macos-notify -s user -- macos-notify-mcp 55 | ``` 56 | 57 | Then restart Claude Code. 58 | 59 | #### Manual Setup for Claude Desktop 60 | 61 | Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: 62 | 63 | ```json 64 | { 65 | "mcpServers": { 66 | "macos-notify": { 67 | "command": "macos-notify-mcp" 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | 74 | ### Available MCP Tools 75 | 76 | - `send_notification` - Send a macOS notification 77 | - `message` (required): Notification message 78 | - `title`: Notification title (default: "Claude Code") 79 | - `sound`: Notification sound (default: "Glass") 80 | - `session`: tmux session name 81 | - `window`: tmux window number 82 | - `pane`: tmux pane number 83 | - `useCurrent`: Use current tmux location 84 | 85 | - `list_tmux_sessions` - List available tmux sessions 86 | 87 | - `get_current_tmux_info` - Get current tmux session information 88 | 89 | ### As CLI Tool 90 | 91 | ```bash 92 | # Basic notification 93 | macos-notify-cli -m "Build completed" 94 | 95 | # With title 96 | macos-notify-cli -t "Development" -m "Tests passed" 97 | 98 | # With tmux integration 99 | macos-notify-cli -m "Task finished" -s my-session -w 1 -p 0 100 | 101 | # Use current tmux location 102 | macos-notify-cli -m "Check this pane" --current-tmux 103 | 104 | # Detect current terminal emulator 105 | macos-notify-cli --detect-terminal 106 | 107 | # List tmux sessions 108 | macos-notify-cli --list-sessions 109 | ``` 110 | 111 | ### Terminal Detection 112 | 113 | The tool automatically detects which terminal emulator you're using and uses this information when you click on notifications to focus the correct application. You can test terminal detection with: 114 | 115 | ```bash 116 | # Test terminal detection 117 | macos-notify-cli --detect-terminal 118 | ``` 119 | 120 | #### Supported Terminal Detection 121 | 122 | The tool detects terminals using various methods: 123 | 124 | 1. **Cursor**: Via `CURSOR_TRACE_ID` environment variable 125 | 2. **VSCode**: Via `VSCODE_IPC_HOOK_CLI` or `VSCODE_REMOTE` environment variables 126 | 3. **alacritty**: Via `ALACRITTY_WINDOW_ID` or `ALACRITTY_SOCKET` environment variables 127 | 4. **iTerm2**: Via `TERM_PROGRAM=iTerm.app` 128 | 5. **Terminal.app**: Via `TERM_PROGRAM=Apple_Terminal` 129 | 130 | #### Terminal Detection in tmux 131 | 132 | When running inside tmux, the tool attempts to detect which terminal emulator the active tmux client is using: 133 | 134 | 1. **Active Client Detection**: Identifies the most recently active tmux client 135 | 2. **TTY Process Analysis**: Traces processes using the client's TTY 136 | 3. **Environment Preservation**: Checks preserved environment variables 137 | 4. **Process Tree Fallback**: Analyzes the process tree as a last resort 138 | 139 | For advanced tmux client tracking, see `examples/tmux-client-tracking.sh`. 140 | 141 | ## How it Works 142 | 143 | 1. **Notification Delivery**: Uses a native macOS app bundle (MacOSNotifyMCP.app) to send UserNotifications API notifications 144 | 2. **Click Handling**: When a notification is clicked, the app activates the detected terminal emulator (VSCode, Cursor, iTerm2, alacritty, or Terminal.app) and switches to the specified tmux session 145 | 3. **Terminal Support**: Automatically detects and activates the correct terminal application 146 | 4. **Multiple Instances**: Each notification runs as a separate process, allowing multiple concurrent notifications 147 | 148 | ## Architecture 149 | 150 | The project consists of two main components: 151 | 152 | 1. **MCP Server/CLI** (TypeScript/Node.js) 153 | - Implements the Model Context Protocol 154 | - Provides a command-line interface 155 | - Manages tmux session detection and validation 156 | 157 | 2. **MacOSNotifyMCP.app** (Swift/macOS) 158 | - Native macOS application for notifications 159 | - Handles notification clicks to focus tmux sessions 160 | - Runs as a background process for each notification 161 | 162 | ## MacOSNotifyMCP.app 163 | 164 | The MacOSNotifyMCP.app is bundled with the npm package and is automatically available after installation. No additional setup is required. 165 | 166 | ## Troubleshooting 167 | 168 | ### Notifications not appearing 169 | 170 | 1. Check System Settings → Notifications → MacOSNotifyMCP 171 | 2. Ensure notifications are allowed 172 | 3. Run `macos-notify-mcp -m "test"` to verify 173 | 174 | ### tmux integration not working 175 | 176 | 1. Ensure tmux is installed and running 177 | 2. Check session names with `macos-notify-mcp --list-sessions` 178 | 3. Verify terminal app is supported (Alacritty, iTerm2, WezTerm, or Terminal) 179 | 180 | ## Development 181 | 182 | ```bash 183 | # Install dependencies 184 | npm install 185 | 186 | # Build TypeScript 187 | npm run build 188 | 189 | # Run in development 190 | npm run dev 191 | 192 | # Lint and format code 193 | npm run lint 194 | npm run format 195 | 196 | # Build macOS app (only if modifying Swift code) 197 | npm run build-app 198 | ``` 199 | 200 | ## License 201 | 202 | MIT 203 | 204 | ## Author 205 | 206 | Yuki Yano ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Model Context Protocol (MCP) server for macOS notifications with tmux integration. The project consists of: 8 | - A TypeScript-based MCP server and CLI tool 9 | - A native macOS Swift application (MacOSNotifyMCP.app) that handles notifications 10 | 11 | ## Key Commands 12 | 13 | ### Development 14 | ```bash 15 | npm install # Install dependencies 16 | npm run build # Build TypeScript to dist/ 17 | npm run dev # Run MCP server in watch mode 18 | npm run build-app # Build the macOS app bundle (MacOSNotifyMCP.app) 19 | ``` 20 | 21 | ### Linting & Formatting 22 | ```bash 23 | npm run lint # Run biome linter with auto-fix 24 | npm run format # Format code with biome 25 | npm run check # Check code without modifications 26 | ``` 27 | 28 | ### Testing 29 | ```bash 30 | npm test # Build and test CLI help output 31 | node dist/cli.js -m "test" --current-tmux # Test notification with current tmux session 32 | ``` 33 | 34 | ## Architecture 35 | 36 | ### Core Components 37 | 38 | 1. **MCP Server** (`src/index.ts`) 39 | - Implements Model Context Protocol server 40 | - Provides tools: `send_notification`, `list_tmux_sessions`, `get_current_tmux_info` 41 | - Uses StdioServerTransport for communication 42 | 43 | 2. **Notifier Core** (`src/notifier.ts`) 44 | - Main notification logic 45 | - Searches for MacOSNotifyMCP.app in multiple locations 46 | - Handles tmux session detection and validation 47 | - Uses `spawn` for subprocess management (not `exec`) 48 | 49 | 3. **CLI Interface** (`src/cli.js`) 50 | - Command-line tool for direct notification sending 51 | - Argument parsing for tmux session/window/pane 52 | - Session validation before sending notifications 53 | 54 | 4. **MacOSNotifyMCP.app** (`MacOSNotifyMCP/main.swift`) 55 | - Native macOS app using UserNotifications API 56 | - Handles notification clicks to focus tmux sessions 57 | - Runs as background process (no Dock icon) 58 | - Supports multiple concurrent notifications via `-n` flag 59 | 60 | ### Key Design Decisions 61 | 62 | 1. **Single Notification Method**: All notifications go through MacOSNotifyMCP.app (no osascript fallbacks) 63 | 2. **App Bundling**: MacOSNotifyMCP.app is included in the npm package, no post-install scripts 64 | 3. **App Discovery**: MacOSNotifyMCP.app is searched in order: 65 | - Package installation directory (primary) 66 | - Current working directory (development) 67 | 4. **Process Management**: Each notification spawns a new MacOSNotifyMCP.app instance 68 | 5. **Error Handling**: Commands use `spawn` to properly handle arguments with special characters 69 | 70 | ## Important Notes 71 | 72 | - The project uses ES modules (`"type": "module"` in package.json) 73 | - MacOSNotifyMCP.app is pre-built and included in the npm package 74 | - The app uses ad-hoc signing 75 | - Biome is configured for linting/formatting (config embedded in package.json) 76 | - tmux integration requires tmux to be installed and running 77 | - The app path is resolved from the npm package installation directory 78 | 79 | ## MCP Tools Usage 80 | 81 | When this project is installed as an MCP server, use these tools for notifications: 82 | 83 | ### Available MCP Tools 84 | 85 | 1. **send_notification** - Send macOS notifications 86 | - Required: `message` (string) 87 | - Optional: `title`, `sound`, `session`, `window`, `pane`, `useCurrent` 88 | - Example: "Send a notification with message 'Build completed'" 89 | 90 | 2. **list_tmux_sessions** - List available tmux sessions 91 | - No parameters required 92 | - Returns list of active tmux sessions 93 | 94 | 3. **get_current_tmux_info** - Get current tmux location 95 | - No parameters required 96 | - Returns current session, window, and pane 97 | 98 | ### Usage Examples 99 | 100 | When users ask about notifications or tmux, actively use these tools: 101 | 102 | - "Notify me when done" → Use `send_notification` with appropriate message 103 | - "Send notification to current tmux" → Use `send_notification` with `useCurrent: true` 104 | - "What tmux sessions are available?" → Use `list_tmux_sessions` 105 | - "Where am I in tmux?" → Use `get_current_tmux_info` 106 | 107 | ### Interactive Patterns 108 | 109 | When waiting for user input, always send a notification first: 110 | 111 | 1. **Before asking for confirmation**: 112 | ``` 113 | send_notification("Build complete. Waiting for deployment confirmation") 114 | // Then ask: "Deploy to production?" 115 | ``` 116 | 117 | 2. **When presenting options**: 118 | ``` 119 | send_notification("Multiple matches found. Please choose in terminal") 120 | // Then show options 121 | ``` 122 | 123 | 3. **On errors requiring user decision**: 124 | ``` 125 | send_notification("Error encountered. Need your input to proceed") 126 | // Then present error and options 127 | ``` 128 | 129 | ### Testing Commands 130 | 131 | For development testing, use these CLI commands: 132 | ```bash 133 | # Test notification directly 134 | node dist/cli.js -m "Test message" 135 | 136 | # Test with current tmux session 137 | node dist/cli.js -m "Test message" --current-tmux 138 | 139 | # List tmux sessions 140 | node dist/cli.js --list-sessions 141 | ``` ``` -------------------------------------------------------------------------------- /src/mcp-wrapper.cjs: -------------------------------------------------------------------------------- ``` 1 | #!/usr/bin/env node 2 | 3 | // CommonJS wrapper for ES module MCP server 4 | // This ensures compatibility with npm global installs 5 | 6 | async function main() { 7 | try { 8 | await import('./index.js') 9 | } catch (error) { 10 | console.error('Failed to start MCP server:', error) 11 | process.exit(1) 12 | } 13 | } 14 | 15 | main() ``` -------------------------------------------------------------------------------- /src/cli-wrapper.cjs: -------------------------------------------------------------------------------- ``` 1 | #!/usr/bin/env node 2 | 3 | // CommonJS wrapper for ES module CLI 4 | // This ensures compatibility with npm global installs 5 | 6 | async function main() { 7 | try { 8 | const { main } = await import('./cli.js') 9 | await main() 10 | } catch (error) { 11 | console.error('Failed to load CLI:', error) 12 | process.exit(1) 13 | } 14 | } 15 | 16 | main() ``` -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | coverage: { 8 | provider: 'v8', 9 | reporter: ['text', 'json', 'html'], 10 | exclude: [ 11 | 'node_modules/**', 12 | 'dist/**', 13 | 'MacOSNotifyMCP/**', 14 | 'test/**', 15 | '**/*.d.ts', 16 | '**/*.config.*', 17 | '**/mockData.ts', 18 | 'scripts/**', 19 | ], 20 | }, 21 | include: ['test/**/*.test.ts'], 22 | testTimeout: 10000, 23 | }, 24 | }) ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "lib": ["ES2022"], 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true, 16 | "resolveJsonModule": true, 17 | "allowSyntheticDefaultImports": true 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "dist"], 21 | "ts-node": { 22 | "compilerOptions": { 23 | "allowJs": true 24 | } 25 | } 26 | } ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", 3 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 4 | "linter": { 5 | "enabled": true, 6 | "rules": { 7 | "recommended": true, 8 | "style": { 9 | "noParameterAssign": "error", 10 | "useAsConstAssertion": "error", 11 | "useDefaultParameterLast": "error", 12 | "useEnumInitializers": "error", 13 | "useSelfClosingElements": "error", 14 | "useSingleVarDeclarator": "error", 15 | "noUnusedTemplateLiteral": "error", 16 | "useNumberNamespace": "error", 17 | "noInferrableTypes": "error", 18 | "noUselessElse": "error" 19 | } 20 | } 21 | }, 22 | "formatter": { 23 | "enabled": true, 24 | "formatWithErrors": false, 25 | "indentStyle": "space", 26 | "indentWidth": 2, 27 | "lineWidth": 80, 28 | "lineEnding": "lf" 29 | }, 30 | "javascript": { 31 | "formatter": { 32 | "quoteStyle": "single", 33 | "semicolons": "asNeeded", 34 | "trailingCommas": "all" 35 | } 36 | }, 37 | "files": { 38 | "includes": [ 39 | "**/src/**/*.ts", 40 | "**/bin/**/*", 41 | "!**/node_modules", 42 | "!**/dist", 43 | "!**/build", 44 | "!**/MacOSNotifyMCP" 45 | ] 46 | } 47 | } 48 | ``` -------------------------------------------------------------------------------- /examples/terminal-detection.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | import { TmuxNotifier } from '../dist/notifier.js' 4 | 5 | async function demonstrateTerminalDetection() { 6 | const notifier = new TmuxNotifier() 7 | 8 | console.log('Terminal Detection Example') 9 | console.log('==========================\n') 10 | 11 | // Show environment variables 12 | console.log('Environment variables:') 13 | console.log(` VSCODE_IPC_HOOK_CLI: ${process.env.VSCODE_IPC_HOOK_CLI ? 'Set' : 'Not set'}`) 14 | console.log(` TERM_PROGRAM: ${process.env.TERM_PROGRAM || 'Not set'}`) 15 | console.log(` TMUX: ${process.env.TMUX ? 'Set' : 'Not set'}\n`) 16 | 17 | // Detect terminal 18 | const terminal = await notifier.getTerminalEmulator() 19 | console.log(`Detected Terminal: ${terminal}\n`) 20 | 21 | // Send notification without terminal info 22 | console.log('Sending notification without terminal info...') 23 | await notifier.sendNotification({ 24 | message: 'Standard notification', 25 | title: 'Test' 26 | }) 27 | 28 | // Send notification with terminal info 29 | console.log('Sending notification with terminal info...') 30 | await notifier.sendNotification({ 31 | message: 'Notification with terminal detection', 32 | title: 'Test', 33 | includeTerminalInfo: true 34 | }) 35 | 36 | console.log('\nDone! Check your notifications.') 37 | } 38 | 39 | demonstrateTerminalDetection().catch(console.error) ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "macos-notify-mcp", 3 | "version": "0.0.8", 4 | "description": "MCP server for macOS notifications with tmux integration", 5 | "keywords": [ 6 | "mcp", 7 | "macos", 8 | "notification", 9 | "tmux", 10 | "claude" 11 | ], 12 | "author": "Yuki Yano", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/yuki-yano/macos-notify-mcp.git" 17 | }, 18 | "type": "module", 19 | "main": "dist/index.js", 20 | "types": "dist/index.d.ts", 21 | "bin": { 22 | "macos-notify-cli": "dist/cli-wrapper.cjs", 23 | "macos-notify-mcp": "dist/mcp-wrapper.cjs" 24 | }, 25 | "files": [ 26 | "dist", 27 | "MacOSNotifyMCP" 28 | ], 29 | "scripts": { 30 | "build": "tsc", 31 | "postbuild": "cp src/cli-wrapper.cjs dist/cli-wrapper.cjs && chmod +x dist/cli-wrapper.cjs && cp src/mcp-wrapper.cjs dist/mcp-wrapper.cjs && chmod +x dist/mcp-wrapper.cjs", 32 | "dev": "tsx watch src/index.ts", 33 | "start": "node dist/index.js", 34 | "test": "vitest", 35 | "test:ui": "vitest --ui", 36 | "test:coverage": "vitest --coverage", 37 | "test:watch": "vitest --watch", 38 | "cli:test": "npm run build && node dist/cli.js --help", 39 | "prepare": "npm run build", 40 | "build-app": "cd MacOSNotifyMCP && ./build-app.sh", 41 | "lint": "biome check --write", 42 | "format": "biome format --write", 43 | "check": "biome check" 44 | }, 45 | "dependencies": { 46 | "@modelcontextprotocol/sdk": "^1.0.4" 47 | }, 48 | "devDependencies": { 49 | "@biomejs/biome": "^2.0.0", 50 | "@types/node": "^22.15.32", 51 | "@vitest/coverage-v8": "^3.2.4", 52 | "tsx": "^4.19.2", 53 | "typescript": "^5.7.3", 54 | "vitest": "^3.2.4" 55 | }, 56 | "engines": { 57 | "node": ">=18.0.0" 58 | } 59 | } 60 | ``` -------------------------------------------------------------------------------- /MacOSNotifyMCP/build-app.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | # Create complete macOS app bundle 3 | 4 | APP_NAME="MacOSNotifyMCP" 5 | APP_DIR="${APP_NAME}.app" 6 | CONTENTS_DIR="${APP_DIR}/Contents" 7 | MACOS_DIR="${CONTENTS_DIR}/MacOS" 8 | RESOURCES_DIR="${CONTENTS_DIR}/Resources" 9 | 10 | # Create directory structure 11 | mkdir -p "${MACOS_DIR}" 12 | mkdir -p "${RESOURCES_DIR}" 13 | 14 | # Create Info.plist 15 | cat > "${CONTENTS_DIR}/Info.plist" << EOF 16 | <?xml version="1.0" encoding="UTF-8"?> 17 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 18 | <plist version="1.0"> 19 | <dict> 20 | <key>CFBundleIdentifier</key> 21 | <string>com.macos-notify-mcp.app</string> 22 | <key>CFBundleName</key> 23 | <string>MacOSNotifyMCP</string> 24 | <key>CFBundleDisplayName</key> 25 | <string>MacOSNotifyMCP</string> 26 | <key>CFBundleVersion</key> 27 | <string>1.0</string> 28 | <key>CFBundleShortVersionString</key> 29 | <string>1.0</string> 30 | <key>CFBundleExecutable</key> 31 | <string>MacOSNotifyMCP</string> 32 | <key>CFBundlePackageType</key> 33 | <string>APPL</string> 34 | <key>LSMinimumSystemVersion</key> 35 | <string>10.15</string> 36 | <key>NSUserNotificationAlertStyle</key> 37 | <string>alert</string> 38 | <key>CFBundleInfoDictionaryVersion</key> 39 | <string>6.0</string> 40 | <key>NSSupportsAutomaticTermination</key> 41 | <true/> 42 | <key>NSAppTransportSecurity</key> 43 | <dict> 44 | <key>NSAllowsArbitraryLoads</key> 45 | <true/> 46 | </dict> 47 | <key>CFBundleIconFile</key> 48 | <string>MacOSNotifyMCP</string> 49 | <key>LSUIElement</key> 50 | <true/> 51 | </dict> 52 | </plist> 53 | EOF 54 | 55 | # Copy icon file to Resources directory 56 | if [ -f "${APP_NAME}.icns" ]; then 57 | cp "${APP_NAME}.icns" "${RESOURCES_DIR}/" 58 | echo "Icon file copied: ${APP_NAME}.icns" 59 | else 60 | echo "Warning: Icon file ${APP_NAME}.icns not found" 61 | fi 62 | 63 | # Compile Swift binary and place it in the app 64 | swiftc -o "${MACOS_DIR}/${APP_NAME}" main.swift 65 | 66 | # Add signature to app (ad-hoc signing) 67 | echo "Signing app..." 68 | codesign --force --deep --sign - "${APP_DIR}" 69 | 70 | echo "App bundle created: ${APP_DIR}" 71 | echo "Usage: open ${APP_DIR} --args -m \"Test message\"" 72 | echo "Bundle ID: com.macos-notify-mcp.app" ``` -------------------------------------------------------------------------------- /macos-notify-mcp-icon.svg: -------------------------------------------------------------------------------- ``` 1 | <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> 2 | <!-- Background - macOS style rounded square --> 3 | <defs> 4 | <!-- Gradient for background --> 5 | <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%"> 6 | <stop offset="0%" style="stop-color:#007AFF;stop-opacity:1" /> 7 | <stop offset="100%" style="stop-color:#0051D5;stop-opacity:1" /> 8 | </linearGradient> 9 | 10 | <!-- Shadow filter --> 11 | <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%"> 12 | <feGaussianBlur in="SourceAlpha" stdDeviation="20"/> 13 | <feOffset dx="0" dy="10" result="offsetblur"/> 14 | <feFlood flood-color="#000000" flood-opacity="0.25"/> 15 | <feComposite in2="offsetblur" operator="in"/> 16 | <feMerge> 17 | <feMergeNode/> 18 | <feMergeNode in="SourceGraphic"/> 19 | </feMerge> 20 | </filter> 21 | 22 | <!-- Glow effect --> 23 | <filter id="glow"> 24 | <feGaussianBlur stdDeviation="4" result="coloredBlur"/> 25 | <feMerge> 26 | <feMergeNode in="coloredBlur"/> 27 | <feMergeNode in="SourceGraphic"/> 28 | </feMerge> 29 | </filter> 30 | </defs> 31 | 32 | <!-- App icon background --> 33 | <rect x="64" y="64" width="896" height="896" rx="200" ry="200" 34 | fill="url(#bgGradient)" filter="url(#shadow)"/> 35 | 36 | <!-- Main bell shape --> 37 | <g transform="translate(512, 440)"> 38 | <!-- Bell body --> 39 | <path d="M-180,-140 C-180,-260 -100,-340 0,-340 C100,-340 180,-260 180,-140 40 | L180,40 C180,80 160,100 160,100 L-160,100 C-160,100 -180,80 -180,40 Z" 41 | fill="#FFFFFF" opacity="0.95"/> 42 | 43 | <!-- Bell clapper --> 44 | <circle cx="0" cy="140" r="40" fill="#FFFFFF" opacity="0.95"/> 45 | 46 | <!-- Notification indicator --> 47 | <circle cx="140" cy="-120" r="60" fill="#FF3B30" filter="url(#glow)"/> 48 | </g> 49 | 50 | <!-- Terminal/Code brackets to represent tmux --> 51 | <g transform="translate(512, 720)"> 52 | <!-- Left bracket --> 53 | <path d="M-240,-80 L-200,-80 L-200,-60 L-220,-60 L-220,60 L-200,60 L-200,80 L-240,80 L-240,-80 Z" 54 | fill="#FFFFFF" opacity="0.7"/> 55 | 56 | <!-- Right bracket --> 57 | <path d="M240,-80 L200,-80 L200,-60 L220,-60 L220,60 L200,60 L200,80 L240,80 L240,-80 Z" 58 | fill="#FFFFFF" opacity="0.7"/> 59 | 60 | <!-- Underscore cursor --> 61 | <rect x="-40" y="40" width="80" height="20" rx="10" fill="#00FF00" opacity="0.9"/> 62 | </g> 63 | 64 | <!-- AI circuit dots --> 65 | <g transform="translate(512, 512)"> 66 | <!-- Connection lines --> 67 | <circle cx="-320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/> 68 | <circle cx="320" cy="0" r="20" fill="#00D4FF" opacity="0.6"/> 69 | <circle cx="0" cy="-320" r="20" fill="#00D4FF" opacity="0.6"/> 70 | <circle cx="0" cy="320" r="20" fill="#00D4FF" opacity="0.6"/> 71 | 72 | <!-- Diagonal dots --> 73 | <circle cx="-220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/> 74 | <circle cx="220" cy="-220" r="15" fill="#00D4FF" opacity="0.4"/> 75 | <circle cx="-220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/> 76 | <circle cx="220" cy="220" r="15" fill="#00D4FF" opacity="0.4"/> 77 | </g> 78 | </svg> ``` -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { TmuxNotifier } from './notifier.js' 4 | 5 | interface CliOptions { 6 | message: string 7 | title?: string 8 | sound?: string 9 | session?: string 10 | window?: string 11 | pane?: string 12 | } 13 | 14 | export async function main() { 15 | const notifier = new TmuxNotifier() 16 | 17 | // Parse command line arguments 18 | const args = process.argv.slice(2) 19 | 20 | if (args.includes('--help') || args.includes('-h')) { 21 | console.log(` 22 | Usage: 23 | macos-notify-cli [options] 24 | 25 | Options: 26 | -m, --message <text> Notification message (required) 27 | -t, --title <text> Notification title (default: git repository name) 28 | -s, --session <name> tmux session name 29 | -w, --window <number> tmux window number 30 | -p, --pane <number> tmux pane number 31 | --sound <name> Notification sound (default: "Glass") 32 | --current-tmux Use current tmux location 33 | --list-sessions List available tmux sessions 34 | --detect-terminal Detect and display the current terminal emulator 35 | -h, --help Show this help message 36 | 37 | Examples: 38 | # Basic notification 39 | macos-notify-cli -m "Build completed" 40 | 41 | # Navigate to specific session 42 | macos-notify-cli -m "Tests passed" -s development -w 1 -p 0 43 | 44 | # Use current tmux location 45 | macos-notify-cli -m "Task finished" --current-tmux 46 | `) 47 | process.exit(0) 48 | } 49 | 50 | if (args.includes('--list-sessions')) { 51 | const sessions = await notifier.listSessions() 52 | console.log('Available tmux sessions:') 53 | sessions.forEach((s) => console.log(` ${s}`)) 54 | process.exit(0) 55 | } 56 | 57 | if (args.includes('--detect-terminal')) { 58 | const terminal = await notifier.getTerminalEmulator() 59 | console.log(`Detected terminal: ${terminal}`) 60 | process.exit(0) 61 | } 62 | 63 | // Parse arguments 64 | const options: CliOptions = { 65 | message: '', 66 | } 67 | 68 | for (let i = 0; i < args.length; i++) { 69 | switch (args[i]) { 70 | case '-m': 71 | case '--message': 72 | options.message = args[++i] 73 | break 74 | case '-t': 75 | case '--title': 76 | options.title = args[++i] 77 | break 78 | case '-s': 79 | case '--session': 80 | options.session = args[++i] 81 | break 82 | case '-w': 83 | case '--window': 84 | options.window = args[++i] 85 | break 86 | case '-p': 87 | case '--pane': 88 | options.pane = args[++i] 89 | break 90 | case '--sound': 91 | options.sound = args[++i] 92 | break 93 | case '--current-tmux': { 94 | const current = await notifier.getCurrentTmuxInfo() 95 | if (current) { 96 | options.session = current.session 97 | options.window = current.window 98 | options.pane = current.pane 99 | } else { 100 | console.error('Error: Not in a tmux session') 101 | process.exit(1) 102 | } 103 | break 104 | } 105 | } 106 | } 107 | 108 | if (!options.message) { 109 | console.error('Error: Message is required (-m option)') 110 | process.exit(1) 111 | } 112 | 113 | // Check if session exists 114 | if (options.session) { 115 | const exists = await notifier.sessionExists(options.session) 116 | if (!exists) { 117 | console.error(`Error: Session '${options.session}' does not exist`) 118 | const sessions = await notifier.listSessions() 119 | if (sessions.length > 0) { 120 | console.log('\nAvailable sessions:') 121 | sessions.forEach((s) => console.log(` ${s}`)) 122 | } 123 | process.exit(1) 124 | } 125 | } 126 | 127 | // Send notification 128 | try { 129 | await notifier.sendNotification(options) 130 | console.log('Notification sent successfully') 131 | process.exit(0) 132 | } catch (error) { 133 | console.error('Failed to send notification:', error) 134 | process.exit(1) 135 | } 136 | } 137 | 138 | // Export for testing (already exported as function declaration) 139 | 140 | // Only run if called directly (not when imported as a module) 141 | if (import.meta.url === `file://${process.argv[1]}`) { 142 | main().catch((error) => { 143 | console.error('Unexpected error:', error) 144 | process.exit(1) 145 | }) 146 | } 147 | ``` -------------------------------------------------------------------------------- /examples/tmux-client-tracking.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # tmux client tracking example 4 | # This script demonstrates how to track which terminal emulator is attached to tmux 5 | 6 | # Method 1: Setup tmux hooks to track client information 7 | setup_tmux_hooks() { 8 | # Track when clients attach 9 | tmux set-hook -g client-attached 'run-shell "echo \"Client attached: #{client_tty} at $(date)\" >> ~/.tmux-client.log"' 10 | 11 | # Track when clients change sessions 12 | tmux set-hook -g client-session-changed 'run-shell "echo \"Client #{client_tty} switched to #{session_name} at $(date)\" >> ~/.tmux-client.log"' 13 | 14 | # Track client activity 15 | tmux set-hook -g after-select-pane 'run-shell "echo \"Pane selected by #{client_tty} in #{session_name}:#{window_index}.#{pane_index} at $(date)\" >> ~/.tmux-client.log"' 16 | } 17 | 18 | # Method 2: Get terminal info from active client 19 | get_active_client_terminal() { 20 | # Get the most recently active client 21 | local active_client=$(tmux list-clients -F '#{client_tty}:#{client_activity}' | sort -t: -k2 -nr | head -1 | cut -d: -f1) 22 | 23 | if [ -n "$active_client" ]; then 24 | echo "Active client TTY: $active_client" 25 | 26 | # Try to find the terminal process 27 | if command -v lsof >/dev/null 2>&1; then 28 | echo "Processes using TTY:" 29 | lsof "$active_client" 2>/dev/null | grep -E '(Cursor|Code|iTerm|Terminal|alacritty)' | head -5 30 | fi 31 | 32 | # Alternative: Check parent processes 33 | local pids=$(ps aux | grep "$active_client" | grep -v grep | awk '{print $2}') 34 | for pid in $pids; do 35 | if [ -f "/proc/$pid/environ" ]; then 36 | echo "Environment for PID $pid:" 37 | tr '\0' '\n' < "/proc/$pid/environ" | grep -E '(TERM_PROGRAM|CURSOR_TRACE_ID|VSCODE_IPC_HOOK_CLI|ALACRITTY)' 38 | fi 39 | done 40 | fi 41 | } 42 | 43 | # Method 3: Store client info in tmux environment 44 | track_client_terminal() { 45 | # This would be called when Claude Code starts 46 | local terminal_type="Unknown" 47 | 48 | # Detect terminal type 49 | if [ -n "$CURSOR_TRACE_ID" ]; then 50 | terminal_type="Cursor" 51 | elif [ -n "$VSCODE_IPC_HOOK_CLI" ]; then 52 | terminal_type="VSCode" 53 | elif [ "$TERM_PROGRAM" = "iTerm.app" ]; then 54 | terminal_type="iTerm2" 55 | elif [ "$TERM_PROGRAM" = "Apple_Terminal" ]; then 56 | terminal_type="Terminal" 57 | elif [ -n "$ALACRITTY_WINDOW_ID" ]; then 58 | terminal_type="alacritty" 59 | fi 60 | 61 | # Store in tmux environment 62 | tmux set-environment -g "CLAUDE_CODE_TERMINAL_${TMUX_PANE}" "$terminal_type" 63 | tmux set-environment -g "CLAUDE_CODE_STARTED_$(date +%s)" "$terminal_type:$TMUX_PANE" 64 | } 65 | 66 | # Method 4: Real-time client detection 67 | detect_current_client() { 68 | # Get current pane 69 | local current_pane=$(tmux display-message -p '#{pane_id}') 70 | 71 | # Find which client is currently viewing this pane 72 | tmux list-clients -F '#{client_tty}:#{client_session}:#{session_id}:#{window_id}:#{pane_id}' | while IFS=: read -r tty session session_id window_id pane_id; do 73 | # Check if this client is viewing our pane 74 | local client_pane=$(tmux display-message -t "$tty" -p '#{pane_id}' 2>/dev/null) 75 | if [ "$client_pane" = "$current_pane" ]; then 76 | echo "Client $tty is viewing this pane" 77 | # Now detect terminal from this TTY 78 | fi 79 | done 80 | } 81 | 82 | # Example usage 83 | case "${1:-}" in 84 | "setup") 85 | setup_tmux_hooks 86 | echo "tmux hooks configured" 87 | ;; 88 | "track") 89 | track_client_terminal 90 | echo "Terminal tracked: $(tmux show-environment -g | grep CLAUDE_CODE_TERMINAL)" 91 | ;; 92 | "detect") 93 | get_active_client_terminal 94 | ;; 95 | "current") 96 | detect_current_client 97 | ;; 98 | *) 99 | echo "Usage: $0 {setup|track|detect|current}" 100 | echo " setup - Configure tmux hooks for client tracking" 101 | echo " track - Track current terminal in tmux environment" 102 | echo " detect - Detect active client's terminal" 103 | echo " current - Detect which client is viewing current pane" 104 | ;; 105 | esac ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { readFileSync } from 'node:fs' 2 | import { dirname, join } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 5 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 6 | import { 7 | CallToolRequestSchema, 8 | ListToolsRequestSchema, 9 | } from '@modelcontextprotocol/sdk/types.js' 10 | import { TmuxNotifier } from './notifier.js' 11 | 12 | interface NotificationOptions { 13 | message: string 14 | title?: string 15 | sound?: string 16 | session?: string 17 | window?: string 18 | pane?: string 19 | } 20 | 21 | // Get version from package.json 22 | const __dirname = dirname(fileURLToPath(import.meta.url)) 23 | const packageJson = JSON.parse( 24 | readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'), 25 | ) 26 | 27 | const server = new Server( 28 | { 29 | name: 'macos-notify-mcp', 30 | version: packageJson.version, 31 | }, 32 | { 33 | capabilities: { 34 | tools: {}, 35 | }, 36 | }, 37 | ) 38 | 39 | const notifier = new TmuxNotifier() 40 | 41 | // List available tools 42 | server.setRequestHandler(ListToolsRequestSchema, async () => { 43 | return { 44 | tools: [ 45 | { 46 | name: 'send_notification', 47 | description: 'Send a macOS notification with optional tmux integration', 48 | inputSchema: { 49 | type: 'object', 50 | properties: { 51 | message: { 52 | type: 'string', 53 | description: 'The notification message', 54 | }, 55 | title: { 56 | type: 'string', 57 | description: 'The notification title (default: "Claude Code")', 58 | }, 59 | sound: { 60 | type: 'string', 61 | description: 'The notification sound (default: "Glass")', 62 | }, 63 | session: { 64 | type: 'string', 65 | description: 'tmux session name', 66 | }, 67 | window: { 68 | type: 'string', 69 | description: 'tmux window number', 70 | }, 71 | pane: { 72 | type: 'string', 73 | description: 'tmux pane number', 74 | }, 75 | useCurrent: { 76 | type: 'boolean', 77 | description: 'Use current tmux location', 78 | }, 79 | }, 80 | required: ['message'], 81 | }, 82 | }, 83 | { 84 | name: 'list_tmux_sessions', 85 | description: 'List available tmux sessions', 86 | inputSchema: { 87 | type: 'object', 88 | properties: {}, 89 | }, 90 | }, 91 | { 92 | name: 'get_current_tmux_info', 93 | description: 'Get current tmux session information', 94 | inputSchema: { 95 | type: 'object', 96 | properties: {}, 97 | }, 98 | }, 99 | ], 100 | } 101 | }) 102 | 103 | // Handle tool calls 104 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 105 | const { name, arguments: args } = request.params 106 | 107 | if (!args) { 108 | throw new Error('No arguments provided') 109 | } 110 | 111 | try { 112 | switch (name) { 113 | case 'send_notification': { 114 | // Safely extract properties from args 115 | const notificationArgs = args as Record<string, unknown> 116 | 117 | // Validate message is provided 118 | if (!notificationArgs.message) { 119 | throw new Error('Message is required') 120 | } 121 | 122 | const options: NotificationOptions = { 123 | message: String(notificationArgs.message), 124 | title: notificationArgs.title 125 | ? String(notificationArgs.title) 126 | : undefined, 127 | sound: notificationArgs.sound 128 | ? String(notificationArgs.sound) 129 | : undefined, 130 | } 131 | 132 | if (notificationArgs.useCurrent) { 133 | const current = await notifier.getCurrentTmuxInfo() 134 | if (current) { 135 | options.session = current.session 136 | options.window = current.window 137 | options.pane = current.pane 138 | } 139 | } else { 140 | if (notificationArgs.session) 141 | options.session = String(notificationArgs.session) 142 | if (notificationArgs.window) 143 | options.window = String(notificationArgs.window) 144 | if (notificationArgs.pane) 145 | options.pane = String(notificationArgs.pane) 146 | } 147 | 148 | // Validate session if specified 149 | if (options.session) { 150 | const exists = await notifier.sessionExists(options.session) 151 | if (!exists) { 152 | const sessions = await notifier.listSessions() 153 | return { 154 | content: [ 155 | { 156 | type: 'text', 157 | text: `Error: Session '${options.session}' does not exist. Available sessions: ${sessions.join(', ')}`, 158 | }, 159 | ], 160 | } 161 | } 162 | } 163 | 164 | await notifier.sendNotification(options) 165 | 166 | return { 167 | content: [ 168 | { 169 | type: 'text', 170 | text: `Notification sent: "${options.message}"${options.session ? ` (tmux: ${options.session})` : ''}`, 171 | }, 172 | ], 173 | } 174 | } 175 | 176 | case 'list_tmux_sessions': { 177 | const sessions = await notifier.listSessions() 178 | return { 179 | content: [ 180 | { 181 | type: 'text', 182 | text: 183 | sessions.length > 0 184 | ? `Available tmux sessions:\n${sessions.map((s) => `- ${s}`).join('\n')}` 185 | : 'No tmux sessions found', 186 | }, 187 | ], 188 | } 189 | } 190 | 191 | case 'get_current_tmux_info': { 192 | const info = await notifier.getCurrentTmuxInfo() 193 | if (info) { 194 | return { 195 | content: [ 196 | { 197 | type: 'text', 198 | text: `Current tmux location:\n- Session: ${info.session}\n- Window: ${info.window}\n- Pane: ${info.pane}`, 199 | }, 200 | ], 201 | } 202 | } 203 | return { 204 | content: [ 205 | { 206 | type: 'text', 207 | text: 'Not in a tmux session', 208 | }, 209 | ], 210 | } 211 | } 212 | 213 | default: 214 | throw new Error(`Unknown tool: ${name}`) 215 | } 216 | } catch (error) { 217 | return { 218 | content: [ 219 | { 220 | type: 'text', 221 | text: `Error: ${error instanceof Error ? error.message : String(error)}`, 222 | }, 223 | ], 224 | } 225 | } 226 | }) 227 | 228 | // Start the server 229 | async function main() { 230 | const transport = new StdioServerTransport() 231 | await server.connect(transport) 232 | console.error('macOS Notify MCP server started') 233 | } 234 | 235 | main().catch((error) => { 236 | console.error('Server error:', error) 237 | process.exit(1) 238 | }) 239 | ``` -------------------------------------------------------------------------------- /test/cli.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 | 3 | // Mock the notifier module before importing cli 4 | let mockNotifier: any 5 | 6 | vi.mock('../src/notifier.js', () => ({ 7 | TmuxNotifier: vi.fn(() => mockNotifier), 8 | })) 9 | 10 | describe('CLI', () => { 11 | let originalArgv: string[] 12 | let originalExit: typeof process.exit 13 | let exitCode: number | undefined 14 | let consoleLogSpy: ReturnType<typeof vi.spyOn> 15 | let consoleErrorSpy: ReturnType<typeof vi.spyOn> 16 | 17 | beforeEach(() => { 18 | // Save original values 19 | originalArgv = process.argv 20 | originalExit = process.exit 21 | 22 | // Mock process.exit 23 | exitCode = undefined 24 | process.exit = ((code?: number) => { 25 | exitCode = code 26 | throw new Error(`Process.exit(${code})`) 27 | }) as never 28 | 29 | // Mock console methods 30 | consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) 31 | consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) 32 | 33 | // Reset module cache 34 | vi.resetModules() 35 | 36 | // Create mock notifier 37 | mockNotifier = { 38 | sendNotification: vi.fn().mockResolvedValue(undefined), 39 | listSessions: vi.fn().mockResolvedValue(['session1', 'session2']), 40 | sessionExists: vi.fn().mockResolvedValue(true), 41 | getCurrentTmuxInfo: vi 42 | .fn() 43 | .mockResolvedValue({ session: 'current', window: '1', pane: '0' }), 44 | } 45 | }) 46 | 47 | afterEach(() => { 48 | // Restore original values 49 | process.argv = originalArgv 50 | process.exit = originalExit 51 | consoleLogSpy.mockRestore() 52 | consoleErrorSpy.mockRestore() 53 | vi.clearAllMocks() 54 | }) 55 | 56 | async function runCli() { 57 | // Import fresh copy and get main function 58 | const cliModule = await import('../src/cli.js') 59 | const main = (cliModule as any).main || cliModule.default 60 | 61 | if (typeof main === 'function') { 62 | try { 63 | await main() 64 | } catch (e: any) { 65 | if (!e.message.startsWith('Process.exit')) { 66 | throw e 67 | } 68 | } 69 | } 70 | } 71 | 72 | describe('--help flag', () => { 73 | it('should display help message with --help', async () => { 74 | process.argv = ['node', 'cli.js', '--help'] 75 | 76 | await runCli() 77 | 78 | expect(consoleLogSpy).toHaveBeenCalled() 79 | const output = consoleLogSpy.mock.calls.join('\n') 80 | expect(output).toContain('Usage:') 81 | expect(output).toContain('Options:') 82 | expect(output).toContain('--help') 83 | expect(output).toContain('--list-sessions') 84 | expect(exitCode).toBe(0) 85 | }) 86 | 87 | it('should display help message with -h', async () => { 88 | process.argv = ['node', 'cli.js', '-h'] 89 | 90 | await runCli() 91 | 92 | expect(consoleLogSpy).toHaveBeenCalled() 93 | expect(exitCode).toBe(0) 94 | }) 95 | }) 96 | 97 | describe('--list-sessions', () => { 98 | it('should list tmux sessions', async () => { 99 | process.argv = ['node', 'cli.js', '--list-sessions'] 100 | 101 | await runCli() 102 | 103 | expect(mockNotifier.listSessions).toHaveBeenCalled() 104 | expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:') 105 | expect(consoleLogSpy).toHaveBeenCalledWith(' session1') 106 | expect(consoleLogSpy).toHaveBeenCalledWith(' session2') 107 | expect(exitCode).toBe(0) 108 | }) 109 | 110 | it('should handle no sessions gracefully', async () => { 111 | mockNotifier.listSessions.mockResolvedValue([]) 112 | process.argv = ['node', 'cli.js', '--list-sessions'] 113 | 114 | await runCli() 115 | 116 | expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:') 117 | expect(exitCode).toBe(0) 118 | }) 119 | }) 120 | 121 | describe('notification sending', () => { 122 | it.skip('should send basic notification with message', async () => { 123 | process.argv = ['node', 'cli.js', '-m', 'Hello World'] 124 | 125 | await runCli() 126 | 127 | // Check what errors were logged 128 | if (consoleErrorSpy.mock.calls.length > 0) { 129 | console.log('Console errors found:', consoleErrorSpy.mock.calls) 130 | } 131 | 132 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 133 | message: 'Hello World', 134 | }) 135 | expect(consoleLogSpy).toHaveBeenCalledWith('Notification sent successfully') 136 | expect(consoleErrorSpy).not.toHaveBeenCalled() 137 | expect(exitCode).toBe(0) 138 | }) 139 | 140 | it('should send notification with title', async () => { 141 | process.argv = [ 142 | 'node', 143 | 'cli.js', 144 | '-m', 145 | 'Test message', 146 | '-t', 147 | 'Test Title', 148 | ] 149 | 150 | await runCli() 151 | 152 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 153 | message: 'Test message', 154 | title: 'Test Title', 155 | }) 156 | }) 157 | 158 | it('should send notification with sound', async () => { 159 | process.argv = [ 160 | 'node', 161 | 'cli.js', 162 | '-m', 163 | 'Alert!', 164 | '--sound', 165 | 'Glass', 166 | ] 167 | 168 | await runCli() 169 | 170 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 171 | message: 'Alert!', 172 | sound: 'Glass', 173 | }) 174 | }) 175 | 176 | it('should send notification to specific tmux location', async () => { 177 | process.argv = [ 178 | 'node', 179 | 'cli.js', 180 | '-m', 181 | 'Tmux notification', 182 | '-s', 183 | 'my-session', 184 | '-w', 185 | '2', 186 | '-p', 187 | '1', 188 | ] 189 | 190 | await runCli() 191 | 192 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 193 | message: 'Tmux notification', 194 | session: 'my-session', 195 | window: '2', 196 | pane: '1', 197 | }) 198 | }) 199 | 200 | it('should use current tmux location with --current-tmux flag', async () => { 201 | process.argv = ['node', 'cli.js', '-m', 'Current location', '--current-tmux'] 202 | 203 | await runCli() 204 | 205 | expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled() 206 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 207 | message: 'Current location', 208 | session: 'current', 209 | window: '1', 210 | pane: '0', 211 | }) 212 | }) 213 | 214 | it('should handle missing current tmux info', async () => { 215 | mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null) 216 | process.argv = ['node', 'cli.js', '-m', 'Test', '--current-tmux'] 217 | 218 | await runCli() 219 | 220 | // When --current returns null, it should exit with error 221 | expect(exitCode).toBe(1) 222 | expect(mockNotifier.sendNotification).not.toHaveBeenCalled() 223 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Not in a tmux session') 224 | }) 225 | 226 | it('should validate session exists', async () => { 227 | mockNotifier.sessionExists.mockResolvedValue(false) 228 | process.argv = [ 229 | 'node', 230 | 'cli.js', 231 | '-m', 232 | 'Test', 233 | '-s', 234 | 'nonexistent', 235 | ] 236 | 237 | await runCli() 238 | 239 | expect(mockNotifier.sessionExists).toHaveBeenCalledWith('nonexistent') 240 | expect(consoleErrorSpy).toHaveBeenCalledWith( 241 | "Error: Session 'nonexistent' does not exist", 242 | ) 243 | expect(exitCode).toBe(1) 244 | }) 245 | }) 246 | 247 | describe('argument parsing', () => { 248 | it('should handle long form arguments', async () => { 249 | process.argv = [ 250 | 'node', 251 | 'cli.js', 252 | '--message', 253 | 'Long form', 254 | '--title', 255 | 'Title', 256 | '--session', 257 | 'session1', 258 | '--window', 259 | '1', 260 | '--pane', 261 | '0', 262 | ] 263 | 264 | await runCli() 265 | 266 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 267 | message: 'Long form', 268 | title: 'Title', 269 | session: 'session1', 270 | window: '1', 271 | pane: '0', 272 | }) 273 | }) 274 | 275 | it('should error when no message provided', async () => { 276 | process.argv = ['node', 'cli.js'] 277 | 278 | await runCli() 279 | 280 | expect(consoleErrorSpy).toHaveBeenCalledWith( 281 | 'Error: Message is required (-m option)', 282 | ) 283 | expect(exitCode).toBe(1) 284 | }) 285 | 286 | it('should handle spaces in arguments', async () => { 287 | process.argv = [ 288 | 'node', 289 | 'cli.js', 290 | '-m', 291 | 'Message with spaces', 292 | '-t', 293 | 'Title with spaces', 294 | ] 295 | 296 | await runCli() 297 | 298 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 299 | message: 'Message with spaces', 300 | title: 'Title with spaces', 301 | }) 302 | }) 303 | }) 304 | 305 | describe('error handling', () => { 306 | it('should handle notification sending errors', async () => { 307 | const error = new Error('Failed to send notification') 308 | mockNotifier.sendNotification.mockRejectedValue(error) 309 | process.argv = ['node', 'cli.js', '-m', 'Test'] 310 | 311 | await runCli() 312 | 313 | expect(consoleErrorSpy).toHaveBeenCalledWith( 314 | 'Failed to send notification:', 315 | error, 316 | ) 317 | expect(exitCode).toBe(1) 318 | }) 319 | 320 | it('should handle list sessions errors', async () => { 321 | // The notifier catches errors and returns empty array 322 | mockNotifier.listSessions.mockResolvedValue([]) 323 | process.argv = ['node', 'cli.js', '--list-sessions'] 324 | 325 | await runCli() 326 | 327 | expect(consoleLogSpy).toHaveBeenCalledWith('Available tmux sessions:') 328 | expect(exitCode).toBe(0) 329 | }) 330 | }) 331 | }) ``` -------------------------------------------------------------------------------- /MacOSNotifyMCP/main.swift: -------------------------------------------------------------------------------- ```swift 1 | #!/usr/bin/env swift 2 | 3 | import Foundation 4 | import UserNotifications 5 | import Cocoa 6 | 7 | class MacOSNotifyMCP: NSObject, UNUserNotificationCenterDelegate { 8 | private let center = UNUserNotificationCenter.current() 9 | 10 | override init() { 11 | super.init() 12 | center.delegate = self 13 | } 14 | 15 | func requestPermissionAndSendNotification( 16 | title: String, 17 | message: String, 18 | sound: String = "default", 19 | session: String? = nil, 20 | window: String? = nil, 21 | pane: String? = nil, 22 | terminal: String? = nil 23 | ) { 24 | center.requestAuthorization(options: [.alert, .sound]) { granted, error in 25 | if granted { 26 | self.sendNotification( 27 | title: title, 28 | message: message, 29 | sound: sound, 30 | session: session, 31 | window: window, 32 | pane: pane, 33 | terminal: terminal 34 | ) 35 | } else { 36 | print("Notification permission denied") 37 | exit(1) 38 | } 39 | } 40 | } 41 | 42 | private func sendNotification( 43 | title: String, 44 | message: String, 45 | sound: String, 46 | session: String?, 47 | window: String?, 48 | pane: String?, 49 | terminal: String? 50 | ) { 51 | let content = UNMutableNotificationContent() 52 | content.title = title 53 | content.body = message 54 | if sound == "default" { 55 | content.sound = .default 56 | } else { 57 | content.sound = UNNotificationSound(named: UNNotificationSoundName(sound + ".aiff")) 58 | } 59 | 60 | // tmux情報とターミナル情報をuserInfoに格納 61 | var userInfo: [String: Any] = [:] 62 | if let session = session { 63 | userInfo["session"] = session 64 | if let window = window { 65 | userInfo["window"] = window 66 | } 67 | if let pane = pane { 68 | userInfo["pane"] = pane 69 | } 70 | } 71 | if let terminal = terminal { 72 | userInfo["terminal"] = terminal 73 | } 74 | content.userInfo = userInfo 75 | 76 | let request = UNNotificationRequest( 77 | identifier: UUID().uuidString, 78 | content: content, 79 | trigger: nil 80 | ) 81 | 82 | center.add(request) { error in 83 | if let error = error { 84 | print("Notification error: \(error)") 85 | exit(1) 86 | } 87 | print("Notification sent") 88 | } 89 | } 90 | 91 | // Handle notification click 92 | func userNotificationCenter( 93 | _ center: UNUserNotificationCenter, 94 | didReceive response: UNNotificationResponse, 95 | withCompletionHandler completionHandler: @escaping () -> Void 96 | ) { 97 | let userInfo = response.notification.request.content.userInfo 98 | let terminal = userInfo["terminal"] as? String 99 | 100 | if let session = userInfo["session"] as? String { 101 | focusToTmux( 102 | session: session, 103 | window: userInfo["window"] as? String, 104 | pane: userInfo["pane"] as? String, 105 | terminal: terminal 106 | ) 107 | } else if let terminal = terminal { 108 | // tmuxセッションがない場合でもターミナルをアクティブ化 109 | activateTerminal(preferredTerminal: terminal) 110 | } 111 | 112 | completionHandler() 113 | 114 | // Exit after handling click 115 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 116 | NSApplication.shared.terminate(nil) 117 | } 118 | } 119 | 120 | private func focusToTmux(session: String, window: String?, pane: String?, terminal: String?) { 121 | // Activate terminal 122 | activateTerminal(preferredTerminal: terminal) 123 | 124 | // Execute tmux commands 125 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 126 | let tmuxPath = self.findTmuxPath() 127 | guard !tmuxPath.isEmpty else { return } 128 | 129 | // Switch to session 130 | var tmuxTarget = session 131 | if let window = window { 132 | tmuxTarget += ":\(window)" 133 | if let pane = pane { 134 | tmuxTarget += ".\(pane)" 135 | } 136 | } 137 | 138 | self.runCommand(tmuxPath, args: ["switch-client", "-t", tmuxTarget]) 139 | } 140 | } 141 | 142 | private func activateTerminal(preferredTerminal: String? = nil) { 143 | // ターミナルタイプからアプリケーション名へのマッピング 144 | let terminalMap: [String: String] = [ 145 | "VSCode": "Visual Studio Code", 146 | "Cursor": "Cursor", 147 | "iTerm2": "iTerm2", 148 | "Terminal": "Terminal", 149 | "alacritty": "Alacritty" 150 | ] 151 | 152 | // 検出されたターミナルを優先的に使用 153 | if let preferred = preferredTerminal, 154 | let appName = terminalMap[preferred] { 155 | if isAppRunning(appName) { 156 | runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(appName)\" to activate"]) 157 | return 158 | } 159 | } 160 | 161 | // フォールバック: 実行中のターミナルを探す 162 | let terminals = ["Alacritty", "iTerm2", "WezTerm", "Terminal", "Visual Studio Code", "Cursor"] 163 | 164 | for terminal in terminals { 165 | if isAppRunning(terminal) { 166 | runCommand("/usr/bin/osascript", args: ["-e", "tell application \"\(terminal)\" to activate"]) 167 | return 168 | } 169 | } 170 | 171 | // Default to Terminal.app 172 | runCommand("/usr/bin/osascript", args: ["-e", "tell application \"Terminal\" to activate"]) 173 | } 174 | 175 | private func isAppRunning(_ appName: String) -> Bool { 176 | let task = Process() 177 | task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") 178 | task.arguments = ["-f", appName] 179 | task.standardOutput = Pipe() 180 | 181 | do { 182 | try task.run() 183 | task.waitUntilExit() 184 | return task.terminationStatus == 0 185 | } catch { 186 | return false 187 | } 188 | } 189 | 190 | private func findTmuxPath() -> String { 191 | let paths = ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] 192 | 193 | for path in paths { 194 | if FileManager.default.fileExists(atPath: path) { 195 | return path 196 | } 197 | } 198 | 199 | // Search using which 200 | let task = Process() 201 | task.executableURL = URL(fileURLWithPath: "/usr/bin/which") 202 | task.arguments = ["tmux"] 203 | let pipe = Pipe() 204 | task.standardOutput = pipe 205 | 206 | do { 207 | try task.run() 208 | task.waitUntilExit() 209 | 210 | if task.terminationStatus == 0 { 211 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 212 | if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { 213 | return output 214 | } 215 | } 216 | } catch {} 217 | 218 | return "" 219 | } 220 | 221 | @discardableResult 222 | private func runCommand(_ path: String, args: [String]) -> Bool { 223 | let task = Process() 224 | task.executableURL = URL(fileURLWithPath: path) 225 | task.arguments = args 226 | 227 | do { 228 | try task.run() 229 | task.waitUntilExit() 230 | return task.terminationStatus == 0 231 | } catch { 232 | return false 233 | } 234 | } 235 | } 236 | 237 | // Main process 238 | let app = NSApplication.shared 239 | app.setActivationPolicy(.accessory) // Run in background (no Dock icon, but can show notification icons) 240 | 241 | // Parse arguments 242 | var title = "Claude Code" 243 | var message = "" 244 | var session: String? 245 | var window: String? 246 | var pane: String? 247 | var sound = "default" 248 | var terminal: String? 249 | 250 | var i = 1 251 | let args = CommandLine.arguments 252 | while i < args.count { 253 | switch args[i] { 254 | case "-t", "--title": 255 | if i + 1 < args.count { 256 | title = args[i + 1] 257 | i += 1 258 | } 259 | case "-m", "--message": 260 | if i + 1 < args.count { 261 | message = args[i + 1] 262 | i += 1 263 | } 264 | case "-s", "--session": 265 | if i + 1 < args.count { 266 | session = args[i + 1] 267 | i += 1 268 | } 269 | case "-w", "--window": 270 | if i + 1 < args.count { 271 | window = args[i + 1] 272 | i += 1 273 | } 274 | case "-p", "--pane": 275 | if i + 1 < args.count { 276 | pane = args[i + 1] 277 | i += 1 278 | } 279 | case "--sound": 280 | if i + 1 < args.count { 281 | sound = args[i + 1] 282 | i += 1 283 | } 284 | case "--terminal": 285 | if i + 1 < args.count { 286 | terminal = args[i + 1] 287 | i += 1 288 | } 289 | case "-h", "--help": 290 | print(""" 291 | Usage: 292 | MacOSNotifyMCP [options] 293 | 294 | Options: 295 | -t, --title <text> Notification title (default: "Claude Code") 296 | -m, --message <text> Notification message (required) 297 | -s, --session <name> tmux session name 298 | -w, --window <number> tmux window number 299 | -p, --pane <number> tmux pane number 300 | --sound <name> Notification sound (default: "default") 301 | --terminal <type> Terminal type (VSCode, Cursor, iTerm2, etc.) 302 | 303 | Examples: 304 | MacOSNotifyMCP -m "Build completed" 305 | MacOSNotifyMCP -t "Build" -m "Success" -s development -w 1 -p 0 306 | """) 307 | exit(0) 308 | default: 309 | break 310 | } 311 | i += 1 312 | } 313 | 314 | // Message is required 315 | if message.isEmpty { 316 | print("Error: Message is required (-m option)") 317 | exit(1) 318 | } 319 | 320 | // Create MacOSNotifyMCP instance and send notification 321 | let notifier = MacOSNotifyMCP() 322 | 323 | // Send notification and wait in RunLoop 324 | notifier.requestPermissionAndSendNotification( 325 | title: title, 326 | message: message, 327 | sound: sound, 328 | session: session, 329 | window: window, 330 | pane: pane, 331 | terminal: terminal 332 | ) 333 | 334 | // Run the app 335 | app.run() ``` -------------------------------------------------------------------------------- /test/notifier.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | afterEach, 3 | beforeEach, 4 | describe, 5 | expect, 6 | it, 7 | type Mock, 8 | vi, 9 | } from 'vitest' 10 | import { TmuxNotifier } from '../src/notifier' 11 | import type { ChildProcess } from 'node:child_process' 12 | 13 | // Mock modules 14 | vi.mock('node:child_process') 15 | vi.mock('node:fs') 16 | vi.mock('node:url', () => ({ 17 | fileURLToPath: vi.fn(() => '/mocked/path/notifier.js'), 18 | })) 19 | 20 | describe('TmuxNotifier', () => { 21 | let notifier: TmuxNotifier 22 | let mockSpawn: Mock 23 | let mockExistsSync: Mock 24 | 25 | beforeEach(async () => { 26 | // Reset mocks 27 | vi.clearAllMocks() 28 | 29 | // Get mocked functions 30 | const childProcess = await import('node:child_process') 31 | const fs = await import('node:fs') 32 | mockSpawn = childProcess.spawn as unknown as Mock 33 | mockExistsSync = fs.existsSync as unknown as Mock 34 | 35 | // Default mock implementations 36 | mockExistsSync.mockReturnValue(true) 37 | }) 38 | 39 | afterEach(() => { 40 | vi.restoreAllMocks() 41 | }) 42 | 43 | describe('constructor', () => { 44 | it('should use custom app path when provided', () => { 45 | const customPath = '/custom/path/to/MacOSNotifyMCP.app' 46 | notifier = new TmuxNotifier(customPath) 47 | expect(notifier['appPath']).toBe(customPath) 48 | }) 49 | 50 | it('should find app in default locations when no custom path provided', () => { 51 | mockExistsSync.mockImplementation((path: string) => { 52 | return path.includes('MacOSNotifyMCP/MacOSNotifyMCP.app') 53 | }) 54 | 55 | notifier = new TmuxNotifier() 56 | expect(notifier['appPath']).toContain('MacOSNotifyMCP.app') 57 | }) 58 | 59 | it('should handle missing app gracefully', () => { 60 | mockExistsSync.mockReturnValue(false) 61 | notifier = new TmuxNotifier() 62 | // Should default to first possible path even if it doesn't exist 63 | expect(notifier['appPath']).toContain('MacOSNotifyMCP.app') 64 | }) 65 | }) 66 | 67 | describe('runCommand', () => { 68 | beforeEach(() => { 69 | notifier = new TmuxNotifier('/test/app/path') 70 | }) 71 | 72 | it('should execute command successfully', async () => { 73 | const mockProcess = createMockProcess() 74 | mockSpawn.mockReturnValue(mockProcess) 75 | 76 | const result = notifier['runCommand']('echo', ['hello']) 77 | 78 | // Simulate successful execution 79 | mockProcess.stdout.emit('data', Buffer.from('hello')) 80 | mockProcess.emit('close', 0) 81 | 82 | expect(await result).toBe('hello') 83 | expect(mockSpawn).toHaveBeenCalledWith('echo', ['hello']) 84 | }) 85 | 86 | it('should handle command failure with non-zero exit code', async () => { 87 | const mockProcess = createMockProcess() 88 | mockSpawn.mockReturnValue(mockProcess) 89 | 90 | const promise = notifier['runCommand']('false', []) 91 | 92 | // Simulate failure 93 | mockProcess.stderr.emit('data', Buffer.from('Command failed')) 94 | mockProcess.emit('close', 1) 95 | 96 | await expect(promise).rejects.toThrow('Command failed: false') 97 | }) 98 | 99 | it('should handle spawn errors', async () => { 100 | const mockProcess = createMockProcess() 101 | mockSpawn.mockReturnValue(mockProcess) 102 | 103 | const promise = notifier['runCommand']('nonexistent', []) 104 | 105 | // Simulate spawn error 106 | mockProcess.emit('error', new Error('spawn ENOENT')) 107 | 108 | await expect(promise).rejects.toThrow('spawn ENOENT') 109 | }) 110 | 111 | it('should accumulate stdout and stderr data', async () => { 112 | const mockProcess = createMockProcess() 113 | mockSpawn.mockReturnValue(mockProcess) 114 | 115 | const result = notifier['runCommand']('test', []) 116 | 117 | // Emit multiple data chunks 118 | mockProcess.stdout.emit('data', Buffer.from('Hello ')) 119 | mockProcess.stdout.emit('data', Buffer.from('World')) 120 | mockProcess.stderr.emit('data', Buffer.from('Warning')) 121 | mockProcess.emit('close', 0) 122 | 123 | expect(await result).toBe('Hello World') 124 | }) 125 | }) 126 | 127 | describe('getCurrentTmuxInfo', () => { 128 | beforeEach(() => { 129 | notifier = new TmuxNotifier('/test/app/path') 130 | }) 131 | 132 | it('should return current tmux session info', async () => { 133 | const runCommandSpy = vi 134 | .spyOn(notifier as any, 'runCommand') 135 | .mockResolvedValueOnce('my-session') 136 | .mockResolvedValueOnce('1') 137 | .mockResolvedValueOnce('0') 138 | 139 | const result = await notifier.getCurrentTmuxInfo() 140 | 141 | expect(result).toEqual({ 142 | session: 'my-session', 143 | window: '1', 144 | pane: '0', 145 | }) 146 | expect(runCommandSpy).toHaveBeenCalledTimes(3) 147 | }) 148 | 149 | it('should return null when not in tmux session', async () => { 150 | const runCommandSpy = vi 151 | .spyOn(notifier as any, 'runCommand') 152 | .mockRejectedValue(new Error('not in tmux')) 153 | 154 | const result = await notifier.getCurrentTmuxInfo() 155 | 156 | expect(result).toBeNull() 157 | expect(runCommandSpy).toHaveBeenCalledTimes(1) 158 | }) 159 | }) 160 | 161 | describe('listSessions', () => { 162 | beforeEach(() => { 163 | notifier = new TmuxNotifier('/test/app/path') 164 | }) 165 | 166 | it('should return list of tmux sessions', async () => { 167 | const runCommandSpy = vi 168 | .spyOn(notifier as any, 'runCommand') 169 | .mockResolvedValue('session1\nsession2\nsession3\n') 170 | 171 | const result = await notifier.listSessions() 172 | 173 | expect(result).toEqual(['session1', 'session2', 'session3']) 174 | expect(runCommandSpy).toHaveBeenCalledWith('tmux', [ 175 | 'list-sessions', 176 | '-F', 177 | '#{session_name}', 178 | ]) 179 | }) 180 | 181 | it('should return empty array when no sessions exist', async () => { 182 | const runCommandSpy = vi 183 | .spyOn(notifier as any, 'runCommand') 184 | .mockRejectedValue(new Error('no sessions')) 185 | 186 | const result = await notifier.listSessions() 187 | 188 | expect(result).toEqual([]) 189 | }) 190 | 191 | it('should filter out empty lines', async () => { 192 | const runCommandSpy = vi 193 | .spyOn(notifier as any, 'runCommand') 194 | .mockResolvedValue('session1\n\nsession2\n\n') 195 | 196 | const result = await notifier.listSessions() 197 | 198 | expect(result).toEqual(['session1', 'session2']) 199 | }) 200 | }) 201 | 202 | describe('sessionExists', () => { 203 | beforeEach(() => { 204 | notifier = new TmuxNotifier('/test/app/path') 205 | }) 206 | 207 | it('should return true when session exists', async () => { 208 | const listSessionsSpy = vi 209 | .spyOn(notifier, 'listSessions') 210 | .mockResolvedValue(['session1', 'session2', 'my-session']) 211 | 212 | const result = await notifier.sessionExists('my-session') 213 | 214 | expect(result).toBe(true) 215 | expect(listSessionsSpy).toHaveBeenCalled() 216 | }) 217 | 218 | it('should return false when session does not exist', async () => { 219 | const listSessionsSpy = vi 220 | .spyOn(notifier, 'listSessions') 221 | .mockResolvedValue(['session1', 'session2']) 222 | 223 | const result = await notifier.sessionExists('nonexistent') 224 | 225 | expect(result).toBe(false) 226 | }) 227 | 228 | it('should handle empty session list', async () => { 229 | const listSessionsSpy = vi 230 | .spyOn(notifier, 'listSessions') 231 | .mockResolvedValue([]) 232 | 233 | const result = await notifier.sessionExists('any-session') 234 | 235 | expect(result).toBe(false) 236 | }) 237 | }) 238 | 239 | describe('sendNotification', () => { 240 | beforeEach(() => { 241 | notifier = new TmuxNotifier('/test/app/path') 242 | }) 243 | 244 | it('should send basic notification with message only', async () => { 245 | const runCommandSpy = vi 246 | .spyOn(notifier as any, 'runCommand') 247 | .mockResolvedValue('') 248 | 249 | await notifier.sendNotification({ message: 'Hello World' }) 250 | 251 | expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [ 252 | '-n', 253 | '/test/app/path', 254 | '--args', 255 | '-t', 256 | 'macos-notify-mcp', 257 | '-m', 258 | 'Hello World', 259 | '--sound', 260 | 'Glass', 261 | ]) 262 | }) 263 | 264 | it('should send notification with all options', async () => { 265 | const runCommandSpy = vi 266 | .spyOn(notifier as any, 'runCommand') 267 | .mockResolvedValue('') 268 | 269 | await notifier.sendNotification({ 270 | message: 'Test message', 271 | title: 'Test Title', 272 | sound: 'Glass', 273 | session: 'my-session', 274 | window: '2', 275 | pane: '1', 276 | }) 277 | 278 | expect(runCommandSpy).toHaveBeenCalledWith('/usr/bin/open', [ 279 | '-n', 280 | '/test/app/path', 281 | '--args', 282 | '-t', 283 | 'Test Title', 284 | '-m', 285 | 'Test message', 286 | '--sound', 287 | 'Glass', 288 | '-s', 289 | 'my-session', 290 | '-w', 291 | '2', 292 | '-p', 293 | '1', 294 | ]) 295 | }) 296 | 297 | it('should handle empty app path', async () => { 298 | notifier = new TmuxNotifier() 299 | notifier['appPath'] = '' 300 | 301 | await expect( 302 | notifier.sendNotification({ message: 'Test' }), 303 | ).rejects.toThrow('MacOSNotifyMCP.app not found') 304 | }) 305 | 306 | it('should escape special characters in arguments', async () => { 307 | const runCommandSpy = vi 308 | .spyOn(notifier as any, 'runCommand') 309 | .mockResolvedValue('') 310 | 311 | await notifier.sendNotification({ 312 | message: 'Message with "quotes"', 313 | title: "Title with 'apostrophes'", 314 | }) 315 | 316 | const call = runCommandSpy.mock.calls[0] 317 | expect(call[1]).toContain('Message with "quotes"') 318 | expect(call[1]).toContain('-t') 319 | expect(call[1]).toContain("Title with 'apostrophes'") 320 | }) 321 | 322 | it('should omit undefined optional parameters', async () => { 323 | const runCommandSpy = vi 324 | .spyOn(notifier as any, 'runCommand') 325 | .mockResolvedValue('') 326 | 327 | await notifier.sendNotification({ 328 | message: 'Simple message', 329 | title: 'Title', 330 | // Other options undefined 331 | }) 332 | 333 | const args = runCommandSpy.mock.calls[0][1] 334 | expect(args).toEqual([ 335 | '-n', 336 | '/test/app/path', 337 | '--args', 338 | '-t', 339 | 'Title', 340 | '-m', 341 | 'Simple message', 342 | '--sound', 343 | 'Glass', 344 | ]) 345 | expect(args).not.toContain('-s') 346 | expect(args).not.toContain('-w') 347 | expect(args).not.toContain('-p') 348 | }) 349 | }) 350 | }) 351 | 352 | // Helper function to create a mock child process 353 | function createMockProcess(): Partial<ChildProcess> { 354 | const EventEmitter = require('node:events').EventEmitter 355 | const stdout = new EventEmitter() 356 | const stderr = new EventEmitter() 357 | const process = new EventEmitter() 358 | 359 | return Object.assign(process, { 360 | stdout, 361 | stderr, 362 | stdin: { 363 | end: vi.fn(), 364 | }, 365 | kill: vi.fn(), 366 | pid: 12345, 367 | }) as unknown as ChildProcess 368 | } ``` -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 | import { 3 | CallToolRequestSchema, 4 | ListToolsRequestSchema, 5 | } from '@modelcontextprotocol/sdk/types.js' 6 | 7 | describe('MCP Server', () => { 8 | let mockNotifier: any 9 | let mockServer: any 10 | let handlers: Map<string, any> 11 | 12 | beforeEach(() => { 13 | // Reset module cache 14 | vi.resetModules() 15 | 16 | // Create handlers map 17 | handlers = new Map() 18 | 19 | // Create mock notifier 20 | mockNotifier = { 21 | sendNotification: vi.fn().mockResolvedValue(undefined), 22 | listSessions: vi.fn().mockResolvedValue(['session1', 'session2']), 23 | sessionExists: vi.fn().mockResolvedValue(true), 24 | getCurrentTmuxInfo: vi 25 | .fn() 26 | .mockResolvedValue({ session: 'current', window: '1', pane: '0' }), 27 | } 28 | 29 | // Mock the notifier module 30 | vi.doMock('../src/notifier', () => ({ 31 | TmuxNotifier: vi.fn(() => mockNotifier), 32 | })) 33 | 34 | // Create mock server 35 | mockServer = { 36 | name: 'macos-notify-mcp', 37 | version: '0.1.0', 38 | setRequestHandler: vi.fn((schema: any, handler: any) => { 39 | handlers.set(schema, handler) 40 | }), 41 | connect: vi.fn(), 42 | } 43 | 44 | // Mock MCP SDK 45 | vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({ 46 | Server: vi.fn(() => mockServer), 47 | })) 48 | 49 | vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ 50 | StdioServerTransport: vi.fn(() => ({ 51 | start: vi.fn(), 52 | close: vi.fn(), 53 | })), 54 | })) 55 | }) 56 | 57 | afterEach(() => { 58 | vi.clearAllMocks() 59 | }) 60 | 61 | async function loadServer() { 62 | await import('../src/index') 63 | return { handlers } 64 | } 65 | 66 | describe('Tool Registration', () => { 67 | it('should register all tools on initialization', async () => { 68 | const { handlers } = await loadServer() 69 | 70 | const listToolsHandler = handlers.get(ListToolsRequestSchema) 71 | expect(listToolsHandler).toBeDefined() 72 | 73 | const response = await listToolsHandler({ method: 'tools/list' }) 74 | 75 | expect(response.tools).toHaveLength(3) 76 | 77 | const toolNames = response.tools.map((tool: any) => tool.name) 78 | expect(toolNames).toContain('send_notification') 79 | expect(toolNames).toContain('list_tmux_sessions') 80 | expect(toolNames).toContain('get_current_tmux_info') 81 | }) 82 | 83 | it('should provide correct schema for send_notification tool', async () => { 84 | const { handlers } = await loadServer() 85 | 86 | const listToolsHandler = handlers.get(ListToolsRequestSchema) 87 | const response = await listToolsHandler({ method: 'tools/list' }) 88 | 89 | const sendNotificationTool = response.tools.find( 90 | (tool: any) => tool.name === 'send_notification', 91 | ) 92 | 93 | expect(sendNotificationTool).toBeDefined() 94 | expect(sendNotificationTool.description).toContain( 95 | 'Send a macOS notification', 96 | ) 97 | expect(sendNotificationTool.inputSchema.type).toBe('object') 98 | expect(sendNotificationTool.inputSchema.required).toContain('message') 99 | expect( 100 | sendNotificationTool.inputSchema.properties.message, 101 | ).toBeDefined() 102 | expect(sendNotificationTool.inputSchema.properties.title).toBeDefined() 103 | expect(sendNotificationTool.inputSchema.properties.sound).toBeDefined() 104 | }) 105 | }) 106 | 107 | describe('Tool Execution', () => { 108 | let callToolHandler: any 109 | 110 | beforeEach(async () => { 111 | const { handlers } = await loadServer() 112 | callToolHandler = handlers.get(CallToolRequestSchema) 113 | }) 114 | 115 | describe('send_notification', () => { 116 | it('should send notification with message only', async () => { 117 | const request = { 118 | method: 'tools/call', 119 | params: { 120 | name: 'send_notification', 121 | arguments: { 122 | message: 'Test notification', 123 | }, 124 | }, 125 | } 126 | 127 | const response = await callToolHandler(request) 128 | 129 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 130 | message: 'Test notification', 131 | }) 132 | expect(response.content).toHaveLength(1) 133 | expect(response.content[0].type).toBe('text') 134 | expect(response.content[0].text).toBe('Notification sent: "Test notification"') 135 | }) 136 | 137 | it('should send notification with all parameters', async () => { 138 | const request = { 139 | method: 'tools/call', 140 | params: { 141 | name: 'send_notification', 142 | arguments: { 143 | message: 'Full notification', 144 | title: 'Important', 145 | sound: 'Glass', 146 | session: 'work', 147 | window: '2', 148 | pane: '1', 149 | }, 150 | }, 151 | } 152 | 153 | const response = await callToolHandler(request) 154 | 155 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 156 | message: 'Full notification', 157 | title: 'Important', 158 | sound: 'Glass', 159 | session: 'work', 160 | window: '2', 161 | pane: '1', 162 | }) 163 | expect(response.content[0].text).toBe('Notification sent: "Full notification" (tmux: work)') 164 | }) 165 | 166 | it('should handle missing required message parameter', async () => { 167 | const request = { 168 | method: 'tools/call', 169 | params: { 170 | name: 'send_notification', 171 | arguments: { 172 | title: 'No message', 173 | }, 174 | }, 175 | } 176 | 177 | const response = await callToolHandler(request) 178 | expect(response.content[0].text).toBe('Error: Message is required') 179 | }) 180 | 181 | it('should handle notification sending errors', async () => { 182 | mockNotifier.sendNotification.mockRejectedValue( 183 | new Error('Failed to send'), 184 | ) 185 | 186 | const request = { 187 | method: 'tools/call', 188 | params: { 189 | name: 'send_notification', 190 | arguments: { 191 | message: 'Will fail', 192 | }, 193 | }, 194 | } 195 | 196 | const response = await callToolHandler(request) 197 | expect(response.content[0].text).toBe('Error: Failed to send') 198 | }) 199 | 200 | it('should convert non-string parameters to strings', async () => { 201 | const request = { 202 | method: 'tools/call', 203 | params: { 204 | name: 'send_notification', 205 | arguments: { 206 | message: 123, 207 | title: true, 208 | window: 456, 209 | pane: null, 210 | } as any, 211 | }, 212 | } 213 | 214 | const response = await callToolHandler(request) 215 | 216 | expect(mockNotifier.sendNotification).toHaveBeenCalledWith({ 217 | message: '123', 218 | title: 'true', 219 | window: '456', 220 | // pane is not included because null is filtered out 221 | }) 222 | }) 223 | }) 224 | 225 | describe('list_tmux_sessions', () => { 226 | it('should list tmux sessions', async () => { 227 | const request = { 228 | method: 'tools/call', 229 | params: { 230 | name: 'list_tmux_sessions', 231 | arguments: {}, 232 | }, 233 | } 234 | 235 | const response = await callToolHandler(request) 236 | 237 | expect(mockNotifier.listSessions).toHaveBeenCalled() 238 | expect(response.content).toHaveLength(1) 239 | expect(response.content[0].type).toBe('text') 240 | expect(response.content[0].text).toContain('session1') 241 | expect(response.content[0].text).toContain('session2') 242 | }) 243 | 244 | it('should handle empty session list', async () => { 245 | mockNotifier.listSessions.mockResolvedValue([]) 246 | 247 | const request = { 248 | method: 'tools/call', 249 | params: { 250 | name: 'list_tmux_sessions', 251 | arguments: {}, 252 | }, 253 | } 254 | 255 | const response = await callToolHandler(request) 256 | 257 | expect(response.content[0].text).toBe('No tmux sessions found') 258 | }) 259 | 260 | it('should handle errors when listing sessions', async () => { 261 | mockNotifier.listSessions.mockRejectedValue( 262 | new Error('Tmux not available'), 263 | ) 264 | 265 | const request = { 266 | method: 'tools/call', 267 | params: { 268 | name: 'list_tmux_sessions', 269 | arguments: {}, 270 | }, 271 | } 272 | 273 | const response = await callToolHandler(request) 274 | expect(response.content[0].text).toBe('Error: Tmux not available') 275 | }) 276 | }) 277 | 278 | describe('get_current_tmux_info', () => { 279 | it('should get current tmux info', async () => { 280 | const request = { 281 | method: 'tools/call', 282 | params: { 283 | name: 'get_current_tmux_info', 284 | arguments: {}, 285 | }, 286 | } 287 | 288 | const response = await callToolHandler(request) 289 | 290 | expect(mockNotifier.getCurrentTmuxInfo).toHaveBeenCalled() 291 | expect(response.content).toHaveLength(1) 292 | expect(response.content[0].type).toBe('text') 293 | const text = response.content[0].text 294 | expect(text).toContain('Session: current') 295 | expect(text).toContain('Window: 1') 296 | expect(text).toContain('Pane: 0') 297 | }) 298 | 299 | it('should handle when not in tmux session', async () => { 300 | mockNotifier.getCurrentTmuxInfo.mockResolvedValue(null) 301 | 302 | const request = { 303 | method: 'tools/call', 304 | params: { 305 | name: 'get_current_tmux_info', 306 | arguments: {}, 307 | }, 308 | } 309 | 310 | const response = await callToolHandler(request) 311 | 312 | expect(response.content[0].text).toBe('Not in a tmux session') 313 | }) 314 | 315 | it('should handle errors when getting tmux info', async () => { 316 | mockNotifier.getCurrentTmuxInfo.mockRejectedValue( 317 | new Error('Tmux error'), 318 | ) 319 | 320 | const request = { 321 | method: 'tools/call', 322 | params: { 323 | name: 'get_current_tmux_info', 324 | arguments: {}, 325 | }, 326 | } 327 | 328 | const response = await callToolHandler(request) 329 | expect(response.content[0].text).toBe('Error: Tmux error') 330 | }) 331 | }) 332 | 333 | describe('unknown tool', () => { 334 | it('should handle unknown tool name', async () => { 335 | const request = { 336 | method: 'tools/call', 337 | params: { 338 | name: 'unknown_tool', 339 | arguments: {}, 340 | }, 341 | } 342 | 343 | const response = await callToolHandler(request) 344 | expect(response.content[0].text).toBe('Error: Unknown tool: unknown_tool') 345 | }) 346 | }) 347 | }) 348 | 349 | describe('Server Lifecycle', () => { 350 | it('should create server with correct configuration', async () => { 351 | await loadServer() 352 | 353 | const { Server } = await import('@modelcontextprotocol/sdk/server/index.js') 354 | expect(Server).toHaveBeenCalledWith({ 355 | name: 'macos-notify-mcp', 356 | version: expect.any(String), 357 | }, expect.any(Object)) 358 | }) 359 | 360 | it('should connect transport', async () => { 361 | await loadServer() 362 | 363 | const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js') 364 | expect(StdioServerTransport).toHaveBeenCalled() 365 | }) 366 | 367 | it('should set up error handling', async () => { 368 | const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) 369 | 370 | await loadServer() 371 | 372 | // Simulate an error event 373 | const errorHandler = mockServer.onerror || mockServer.setRequestHandler.mock.calls.find( 374 | (call: any) => call[0] === 'error' 375 | )?.[1] 376 | 377 | if (errorHandler) { 378 | const testError = new Error('Test error') 379 | errorHandler(testError) 380 | expect(consoleErrorSpy).toHaveBeenCalledWith('Server error:', testError) 381 | } 382 | 383 | consoleErrorSpy.mockRestore() 384 | }) 385 | }) 386 | }) ``` -------------------------------------------------------------------------------- /src/notifier.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { spawn } from 'node:child_process' 2 | import { existsSync } from 'node:fs' 3 | import { dirname, join } from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | interface NotificationOptions { 7 | title?: string 8 | message: string 9 | sound?: string 10 | session?: string 11 | window?: string 12 | pane?: string 13 | } 14 | 15 | interface TmuxInfo { 16 | session: string 17 | window: string 18 | pane: string 19 | } 20 | 21 | interface CommandError extends Error { 22 | code?: number 23 | stderr?: string 24 | stdout?: string 25 | } 26 | 27 | export type TerminalType = 28 | | 'VSCode' 29 | | 'Cursor' 30 | | 'iTerm2' 31 | | 'Terminal' 32 | | 'alacritty' 33 | | 'Unknown' 34 | 35 | export class TmuxNotifier { 36 | private appPath = '' 37 | private defaultTitle = 'macos-notify-mcp' 38 | 39 | constructor(customAppPath?: string) { 40 | if (customAppPath) { 41 | this.appPath = customAppPath 42 | } else { 43 | // Try multiple locations 44 | const possiblePaths = [ 45 | // Relative to the package installation (primary location) 46 | join( 47 | dirname(fileURLToPath(import.meta.url)), 48 | '..', 49 | 'MacOSNotifyMCP', 50 | 'MacOSNotifyMCP.app', 51 | ), 52 | // Development path 53 | join(process.cwd(), 'MacOSNotifyMCP', 'MacOSNotifyMCP.app'), 54 | ] 55 | 56 | // Find the first existing path 57 | for (const path of possiblePaths) { 58 | if (existsSync(path)) { 59 | this.appPath = path 60 | break 61 | } 62 | } 63 | 64 | // Default to package-relative path 65 | if (!this.appPath) { 66 | this.appPath = possiblePaths[0] 67 | } 68 | } 69 | 70 | // Get repository name as default title 71 | this.initializeDefaultTitle() 72 | } 73 | 74 | /** 75 | * Initialize default title from git repository name 76 | */ 77 | private async initializeDefaultTitle(): Promise<void> { 78 | try { 79 | const repoName = await this.getGitRepoName() 80 | if (repoName) { 81 | this.defaultTitle = repoName 82 | } 83 | } catch (_error) { 84 | // Keep default title if git command fails 85 | } 86 | } 87 | 88 | /** 89 | * Get the active tmux client information 90 | */ 91 | private async getActiveClientInfo(): Promise<{ 92 | tty: string 93 | session: string 94 | activity: string 95 | } | null> { 96 | try { 97 | // Get list of all clients attached to the current session 98 | const currentSession = process.env.TMUX_PANE 99 | ? await this.runCommand('tmux', [ 100 | 'display-message', 101 | '-p', 102 | '#{session_name}', 103 | ]) 104 | : null 105 | 106 | if (!currentSession) return null 107 | 108 | // Get all clients attached to this session 109 | const clientsOutput = await this.runCommand('tmux', [ 110 | 'list-clients', 111 | '-t', 112 | currentSession.trim(), 113 | '-F', 114 | '#{client_tty}|#{client_session}|#{client_activity}', 115 | ]) 116 | 117 | const clients = clientsOutput 118 | .trim() 119 | .split('\n') 120 | .filter(Boolean) 121 | .map((line) => { 122 | const [tty, session, activity] = line.split('|') 123 | return { tty, session, activity: Number(activity) } 124 | }) 125 | 126 | // Get the most recently active client 127 | const activeClient = clients.reduce((prev, curr) => 128 | curr.activity > prev.activity ? curr : prev, 129 | ) 130 | 131 | return { 132 | tty: activeClient.tty, 133 | session: activeClient.session, 134 | activity: activeClient.activity.toString(), 135 | } 136 | } catch (_error) { 137 | return null 138 | } 139 | } 140 | 141 | /** 142 | * Detect terminal emulator from client TTY 143 | */ 144 | private async detectTerminalFromClient( 145 | clientTty: string, 146 | ): Promise<TerminalType> { 147 | try { 148 | // Find processes using this TTY 149 | const lsofOutput = await this.runCommand('lsof', [clientTty]) 150 | const lines = lsofOutput.trim().split('\n').slice(1) // Skip header 151 | 152 | for (const line of lines) { 153 | const parts = line.split(/\s+/) 154 | if (parts.length < 2) continue 155 | 156 | const pid = parts[1] 157 | // Get process info 158 | const psOutput = await this.runCommand('ps', ['-p', pid, '-o', 'comm=']) 159 | const command = psOutput.trim() 160 | 161 | // Check for known terminal emulators 162 | if (command.includes('Cursor')) return 'Cursor' 163 | if (command.includes('Code')) return 'VSCode' 164 | if (command.includes('iTerm2')) return 'iTerm2' 165 | if (command.includes('Terminal')) return 'Terminal' 166 | if (command.includes('alacritty')) return 'alacritty' 167 | } 168 | } catch (_error) { 169 | // lsof might fail, continue with other methods 170 | } 171 | 172 | return 'Unknown' 173 | } 174 | 175 | /** 176 | * Detect the parent terminal emulator 177 | */ 178 | private async detectTerminalEmulator(): Promise<TerminalType> { 179 | // 1. Check for Cursor via CURSOR_TRACE_ID 180 | if (process.env.CURSOR_TRACE_ID) { 181 | return 'Cursor' 182 | } 183 | 184 | // 2. Check for VSCode/Cursor via VSCODE_IPC_HOOK_CLI 185 | if (process.env.VSCODE_IPC_HOOK_CLI) { 186 | // Check if it's Cursor by looking for cursor-specific paths 187 | if (process.env.VSCODE_IPC_HOOK_CLI.includes('Cursor')) { 188 | return 'Cursor' 189 | } 190 | return 'VSCode' 191 | } 192 | 193 | // 3. Check for VSCode Remote (for tmux attached from VSCode) 194 | if (process.env.VSCODE_REMOTE || process.env.VSCODE_PID) { 195 | return 'VSCode' 196 | } 197 | 198 | // 4. Check for alacritty via specific environment variables 199 | if (process.env.ALACRITTY_WINDOW_ID || process.env.ALACRITTY_SOCKET) { 200 | return 'alacritty' 201 | } 202 | 203 | // 5. Check TERM_PROGRAM for iTerm2 and Terminal.app 204 | if (process.env.TERM_PROGRAM) { 205 | if (process.env.TERM_PROGRAM === 'iTerm.app') { 206 | return 'iTerm2' 207 | } 208 | if (process.env.TERM_PROGRAM === 'Apple_Terminal') { 209 | return 'Terminal' 210 | } 211 | if (process.env.TERM_PROGRAM === 'alacritty') { 212 | return 'alacritty' 213 | } 214 | } 215 | 216 | // 6. If we're in tmux, try to detect the active client's terminal 217 | if (process.env.TMUX) { 218 | try { 219 | // Get active client info 220 | const clientInfo = await this.getActiveClientInfo() 221 | if (clientInfo) { 222 | const detectedTerminal = await this.detectTerminalFromClient( 223 | clientInfo.tty, 224 | ) 225 | if (detectedTerminal !== 'Unknown') { 226 | return detectedTerminal 227 | } 228 | } 229 | 230 | // Fallback: Get the tmux client's terminal info 231 | const clientTerm = await this.runCommand('tmux', [ 232 | 'display-message', 233 | '-p', 234 | '#{client_termname}', 235 | ]) 236 | 237 | // Check for specific terminal indicators in the client termname 238 | if (clientTerm.includes('iterm') || clientTerm.includes('iTerm')) { 239 | return 'iTerm2' 240 | } 241 | if (clientTerm.includes('Apple_Terminal')) { 242 | return 'Terminal' 243 | } 244 | 245 | // Also check tmux client environment variables 246 | try { 247 | const clientEnv = await this.runCommand('tmux', [ 248 | 'show-environment', 249 | '-g', 250 | 'TERM_PROGRAM', 251 | ]) 252 | if (clientEnv.includes('TERM_PROGRAM=iTerm.app')) { 253 | return 'iTerm2' 254 | } 255 | if (clientEnv.includes('TERM_PROGRAM=Apple_Terminal')) { 256 | return 'Terminal' 257 | } 258 | } catch (_) { 259 | // Ignore if show-environment fails 260 | } 261 | } catch (_) { 262 | // Ignore tmux command failures 263 | } 264 | } 265 | 266 | // 3. Fallback: Check process tree 267 | try { 268 | // Get the parent process ID chain 269 | let currentPid = process.pid 270 | const maxDepth = 10 // Prevent infinite loops 271 | 272 | for (let i = 0; i < maxDepth; i++) { 273 | // Get parent process info using ps command 274 | const psOutput = await this.runCommand('ps', [ 275 | '-p', 276 | currentPid.toString(), 277 | '-o', 278 | 'ppid=,comm=', 279 | ]) 280 | 281 | const [ppidStr, command] = psOutput.trim().split(/\s+/, 2) 282 | const ppid = Number.parseInt(ppidStr) 283 | 284 | if (!ppid || ppid === 1) { 285 | break // Reached init process 286 | } 287 | 288 | // Check if the command matches known terminal emulators 289 | if (command) { 290 | if (command.includes('Cursor')) { 291 | return 'Cursor' 292 | } 293 | if (command.includes('Code') || command.includes('code-insiders')) { 294 | return 'VSCode' 295 | } 296 | if (command.includes('iTerm2')) { 297 | return 'iTerm2' 298 | } 299 | if (command.includes('Terminal')) { 300 | return 'Terminal' 301 | } 302 | } 303 | 304 | currentPid = ppid 305 | } 306 | } catch (_error) { 307 | // Ignore errors in process tree detection 308 | } 309 | 310 | return 'Unknown' 311 | } 312 | 313 | /** 314 | * Get git repository name from current directory 315 | */ 316 | private async getGitRepoName(): Promise<string | null> { 317 | try { 318 | // Get the remote URL 319 | const remoteUrl = ( 320 | await this.runCommand('git', ['config', '--get', 'remote.origin.url']) 321 | ).trim() 322 | 323 | if (!remoteUrl) { 324 | // If no remote, try to get the directory name of the git root 325 | const gitRoot = ( 326 | await this.runCommand('git', ['rev-parse', '--show-toplevel']) 327 | ).trim() 328 | return gitRoot.split('/').pop() || null 329 | } 330 | 331 | // Extract repo name from URL 332 | // Handle both HTTPS and SSH formats 333 | // https://github.com/user/repo.git 334 | // [email protected]:user/repo.git 335 | const match = remoteUrl.match(/[/:]([\w-]+)\/([\w-]+?)(\.git)?$/) 336 | if (match) { 337 | return match[2] 338 | } 339 | 340 | // Fallback to directory name 341 | const gitRoot = ( 342 | await this.runCommand('git', ['rev-parse', '--show-toplevel']) 343 | ).trim() 344 | return gitRoot.split('/').pop() || null 345 | } catch (_error) { 346 | return null 347 | } 348 | } 349 | 350 | /** 351 | * Run a command and return the output 352 | */ 353 | private async runCommand(command: string, args: string[]): Promise<string> { 354 | return new Promise((resolve, reject) => { 355 | const proc = spawn(command, args) 356 | let stdout = '' 357 | let stderr = '' 358 | 359 | proc.stdout.on('data', (data) => { 360 | stdout += data.toString() 361 | }) 362 | 363 | proc.stderr.on('data', (data) => { 364 | stderr += data.toString() 365 | }) 366 | 367 | proc.on('close', (code) => { 368 | if (code === 0) { 369 | resolve(stdout) 370 | } else { 371 | const error = new Error( 372 | `Command failed: ${command} ${args.join(' ')}\n${stderr}`, 373 | ) as CommandError 374 | error.code = code ?? undefined 375 | error.stderr = stderr 376 | error.stdout = stdout 377 | reject(error) 378 | } 379 | }) 380 | 381 | proc.on('error', reject) 382 | }) 383 | } 384 | 385 | /** 386 | * Get current tmux session info 387 | */ 388 | async getCurrentTmuxInfo(): Promise<TmuxInfo | null> { 389 | try { 390 | const session = ( 391 | await this.runCommand('tmux', [ 392 | 'display-message', 393 | '-p', 394 | '#{session_name}', 395 | ]) 396 | ).trim() 397 | const window = ( 398 | await this.runCommand('tmux', [ 399 | 'display-message', 400 | '-p', 401 | '#{window_index}', 402 | ]) 403 | ).trim() 404 | const pane = ( 405 | await this.runCommand('tmux', [ 406 | 'display-message', 407 | '-p', 408 | '#{pane_index}', 409 | ]) 410 | ).trim() 411 | 412 | return { session, window, pane } 413 | } catch (_error) { 414 | return null 415 | } 416 | } 417 | 418 | /** 419 | * List tmux sessions 420 | */ 421 | async listSessions(): Promise<string[]> { 422 | try { 423 | const output = await this.runCommand('tmux', [ 424 | 'list-sessions', 425 | '-F', 426 | '#{session_name}', 427 | ]) 428 | return output.trim().split('\n').filter(Boolean) 429 | } catch (_error) { 430 | return [] 431 | } 432 | } 433 | 434 | /** 435 | * Check if a session exists 436 | */ 437 | async sessionExists(session: string): Promise<boolean> { 438 | const sessions = await this.listSessions() 439 | return sessions.includes(session) 440 | } 441 | 442 | /** 443 | * Get the detected terminal emulator type 444 | */ 445 | async getTerminalEmulator(): Promise<TerminalType> { 446 | return this.detectTerminalEmulator() 447 | } 448 | 449 | /** 450 | * Send notification 451 | */ 452 | async sendNotification(options: NotificationOptions): Promise<void> { 453 | const { 454 | title = this.defaultTitle, 455 | message, 456 | sound = 'Glass', 457 | session, 458 | window, 459 | pane, 460 | } = options 461 | 462 | // Check if app path is valid 463 | if (!this.appPath) { 464 | throw new Error('MacOSNotifyMCP.app not found') 465 | } 466 | 467 | // Always detect terminal emulator to pass to notification app 468 | const terminal = await this.detectTerminalEmulator() 469 | 470 | // Use MacOSNotifyMCP.app for notifications 471 | const args = [ 472 | '-n', 473 | this.appPath, 474 | '--args', 475 | '-t', 476 | title, 477 | '-m', 478 | message, 479 | '--sound', 480 | sound, 481 | '--terminal', 482 | terminal, 483 | ] 484 | 485 | if (session) { 486 | args.push('-s', session) 487 | if (window !== undefined && window !== '') { 488 | args.push('-w', window) 489 | } 490 | if (pane !== undefined && pane !== '') { 491 | args.push('-p', pane) 492 | } 493 | } 494 | 495 | await this.runCommand('/usr/bin/open', args) 496 | } 497 | } 498 | ```