@kernel.chat/kbot 3.71.0 → 3.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,800 @@
1
+ // kbot iPhone Tools — Control iPhone from macOS via native Continuity ecosystem
2
+ //
3
+ // Tools: phone_status, phone_message, phone_notify, phone_shortcut,
4
+ // phone_shortcuts_list, phone_call, phone_airdrop, phone_clipboard,
5
+ // phone_find, phone_focus
6
+ //
7
+ // No jailbreak, no third-party apps. Uses:
8
+ // - system_profiler SPBluetoothDataType (device detection)
9
+ // - AppleScript / Messages.app (iMessage)
10
+ // - macOS Shortcuts CLI (iOS Shortcuts via Handoff)
11
+ // - Universal Clipboard (pbcopy/pbpaste via Handoff)
12
+ // - FaceTime (tel:// protocol)
13
+ // - Finder AirDrop sharing
14
+ // - Find My app
15
+ //
16
+ // Requires: macOS, iPhone on same Apple ID, Handoff enabled
17
+ import { execSync } from 'node:child_process';
18
+ import { existsSync, statSync } from 'node:fs';
19
+ import { registerTool } from './index.js';
20
+ const platform = process.platform;
21
+ // ── Helpers ────────────────────────────────────────────────────
22
+ /** Escape a string for safe use inside AppleScript double quotes */
23
+ function escapeAppleScript(s) {
24
+ return s.replace(/[\x00-\x1f\x7f]/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
25
+ }
26
+ /** Run an AppleScript string via osascript, return stdout */
27
+ function osascript(script, timeout = 10_000) {
28
+ // Use -e for each line to handle multi-line scripts
29
+ const lines = script.split('\n').filter(l => l.trim());
30
+ const args = lines.map(l => `-e '${l.replace(/'/g, "'\\''")}'`).join(' ');
31
+ return execSync(`osascript ${args}`, {
32
+ encoding: 'utf-8',
33
+ timeout,
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ }).trim();
36
+ }
37
+ /** Run a shell command, return stdout or throw */
38
+ function shell(cmd, timeout = 30_000) {
39
+ return execSync(cmd, {
40
+ encoding: 'utf-8',
41
+ timeout,
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ }).trim();
44
+ }
45
+ /** Validate phone number format (basic) */
46
+ function isValidPhoneOrEmail(contact) {
47
+ // Phone: digits, spaces, dashes, parens, plus sign
48
+ const phonePattern = /^[+\d\s\-().]{7,20}$/;
49
+ // Email: basic pattern
50
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
51
+ return phonePattern.test(contact) || emailPattern.test(contact);
52
+ }
53
+ /** Strip a phone number to digits only (with leading +) */
54
+ function normalizePhone(phone) {
55
+ const stripped = phone.replace(/[^\d+]/g, '');
56
+ return stripped;
57
+ }
58
+ /** Guard: macOS only */
59
+ function requireMacOS() {
60
+ if (platform !== 'darwin') {
61
+ return 'Error: iPhone tools require macOS with Continuity. This system is not macOS.';
62
+ }
63
+ return null;
64
+ }
65
+ // ── Tool Registration ──────────────────────────────────────────
66
+ export function registerIPhoneTools() {
67
+ // ── 1. phone_status ──────────────────────────────────────────
68
+ registerTool({
69
+ name: 'phone_status',
70
+ description: 'Get iPhone connection status via Bluetooth and Continuity. ' +
71
+ 'Returns whether an iPhone is connected, its name, and battery level if available. ' +
72
+ 'Uses system_profiler SPBluetoothDataType and ioreg.',
73
+ parameters: {},
74
+ tier: 'free',
75
+ async execute() {
76
+ const macErr = requireMacOS();
77
+ if (macErr)
78
+ return macErr;
79
+ const result = ['iPhone Status:'];
80
+ // Check Bluetooth for connected iPhone
81
+ try {
82
+ const btData = shell('system_profiler SPBluetoothDataType 2>/dev/null', 15_000);
83
+ // Look for iPhone entries — they appear under "Connected:" or in device list
84
+ const lines = btData.split('\n');
85
+ let iphoneName = '';
86
+ let iphoneConnected = false;
87
+ let iphoneBattery = '';
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i];
90
+ // iPhone device entries typically have "iPhone" in the name
91
+ if (/iphone/i.test(line) && !line.includes('SPBluetooth')) {
92
+ iphoneName = line.replace(/:/g, '').trim();
93
+ // Look at subsequent lines for connection state and battery
94
+ for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) {
95
+ const sub = lines[j];
96
+ if (/connected:\s*yes/i.test(sub))
97
+ iphoneConnected = true;
98
+ if (/battery\s*level/i.test(sub)) {
99
+ const match = sub.match(/(\d+)%?/);
100
+ if (match)
101
+ iphoneBattery = match[1] + '%';
102
+ }
103
+ // Stop at next device entry (non-indented line with colon)
104
+ if (j > i + 1 && /^\s{0,8}\S.*:$/.test(sub))
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ // Also check via ioreg for battery info from paired devices
110
+ if (!iphoneBattery) {
111
+ try {
112
+ const ioreg = shell('ioreg -r -c AppleDeviceManagementHIDEventService 2>/dev/null | grep -i "BatteryPercent\\|Product"', 10_000);
113
+ const battMatch = ioreg.match(/"BatteryPercent"\s*=\s*(\d+)/);
114
+ if (battMatch)
115
+ iphoneBattery = battMatch[1] + '%';
116
+ }
117
+ catch { /* battery info may not be available */ }
118
+ }
119
+ // Check Continuity/Handoff status
120
+ let handoffActive = false;
121
+ try {
122
+ const handoff = shell('defaults read com.apple.sharingd DiscoverableMode 2>/dev/null');
123
+ handoffActive = handoff.includes('Contacts Only') || handoff.includes('Everyone');
124
+ }
125
+ catch {
126
+ // Sharing daemon prefs may not be readable
127
+ handoffActive = true; // assume enabled if we can't read
128
+ }
129
+ if (iphoneName) {
130
+ result.push(` Device: ${iphoneName}`);
131
+ result.push(` Connected (Bluetooth): ${iphoneConnected ? 'Yes' : 'No (paired but not connected)'}`);
132
+ if (iphoneBattery)
133
+ result.push(` Battery: ${iphoneBattery}`);
134
+ }
135
+ else {
136
+ result.push(' No iPhone found in Bluetooth devices.');
137
+ result.push(' Ensure your iPhone is paired via Bluetooth and on the same Apple ID.');
138
+ }
139
+ result.push(` Handoff/Continuity: ${handoffActive ? 'Enabled' : 'Unknown'}`);
140
+ // Check if Universal Clipboard might work (same iCloud account)
141
+ try {
142
+ shell('defaults read MobileMeAccounts Accounts 2>/dev/null');
143
+ result.push(' iCloud: Signed in (Universal Clipboard available)');
144
+ }
145
+ catch {
146
+ result.push(' iCloud: Could not verify sign-in status');
147
+ }
148
+ }
149
+ catch (err) {
150
+ result.push(` Error querying Bluetooth: ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ return result.join('\n');
153
+ },
154
+ });
155
+ // ── 2. phone_message ─────────────────────────────────────────
156
+ registerTool({
157
+ name: 'phone_message',
158
+ description: 'Send an iMessage from macOS Messages app. The message is sent via the ' +
159
+ 'Messages app which syncs with iPhone via iCloud. ' +
160
+ 'Recipient can be a phone number or email address.',
161
+ parameters: {
162
+ to: {
163
+ type: 'string',
164
+ description: 'Recipient phone number (e.g., "+15551234567") or iMessage email',
165
+ required: true,
166
+ },
167
+ message: {
168
+ type: 'string',
169
+ description: 'Message text to send',
170
+ required: true,
171
+ },
172
+ },
173
+ tier: 'free',
174
+ async execute(args) {
175
+ const macErr = requireMacOS();
176
+ if (macErr)
177
+ return macErr;
178
+ const to = String(args.to).trim();
179
+ const message = String(args.message);
180
+ if (!to)
181
+ return 'Error: "to" parameter is required (phone number or email).';
182
+ if (!message)
183
+ return 'Error: "message" parameter is required.';
184
+ if (!isValidPhoneOrEmail(to)) {
185
+ return `Error: "${to}" doesn't look like a valid phone number or email address.`;
186
+ }
187
+ const escapedTo = escapeAppleScript(to);
188
+ const escapedMsg = escapeAppleScript(message);
189
+ const script = [
190
+ 'tell application "Messages"',
191
+ ` set targetService to 1st account whose service type = iMessage`,
192
+ ` set targetBuddy to participant "${escapedTo}" of targetService`,
193
+ ` send "${escapedMsg}" to targetBuddy`,
194
+ 'end tell',
195
+ ].join('\n');
196
+ try {
197
+ osascript(script, 15_000);
198
+ return `iMessage sent to ${to}: "${message.length > 80 ? message.slice(0, 80) + '...' : message}"`;
199
+ }
200
+ catch (err) {
201
+ const errMsg = err instanceof Error ? err.message : String(err);
202
+ // Common errors
203
+ if (errMsg.includes('not found') || errMsg.includes('Can\'t get')) {
204
+ return `Error: Could not find iMessage recipient "${to}". Ensure they are reachable via iMessage.`;
205
+ }
206
+ if (errMsg.includes('not allowed') || errMsg.includes('permission')) {
207
+ return 'Error: Messages app access denied. Grant Automation permission in System Settings > Privacy & Security > Automation.';
208
+ }
209
+ return `Error sending iMessage: ${errMsg}`;
210
+ }
211
+ },
212
+ });
213
+ // ── 3. phone_notify ──────────────────────────────────────────
214
+ registerTool({
215
+ name: 'phone_notify',
216
+ description: 'Read recent macOS notifications from the Notification Center database. ' +
217
+ 'Shows notifications that have been mirrored from iPhone via Continuity ' +
218
+ 'as well as local macOS notifications. Returns app, title, body, and timestamp.',
219
+ parameters: {
220
+ limit: {
221
+ type: 'number',
222
+ description: 'Max notifications to return (default: 20)',
223
+ required: false,
224
+ default: 20,
225
+ },
226
+ app: {
227
+ type: 'string',
228
+ description: 'Filter by app name (optional, case-insensitive substring match)',
229
+ required: false,
230
+ },
231
+ },
232
+ tier: 'free',
233
+ async execute(args) {
234
+ const macErr = requireMacOS();
235
+ if (macErr)
236
+ return macErr;
237
+ const limit = Math.min(Number(args.limit) || 20, 100);
238
+ const appFilter = args.app ? String(args.app).toLowerCase() : '';
239
+ // The Notification Center DB location
240
+ const dbPaths = [
241
+ `${process.env.HOME}/Library/Group Containers/group.com.apple.usernoted/db2/db`,
242
+ `${process.env.HOME}/Library/Application Support/NotificationCenter/db2/db`,
243
+ ];
244
+ let dbPath = '';
245
+ for (const p of dbPaths) {
246
+ if (existsSync(p)) {
247
+ dbPath = p;
248
+ break;
249
+ }
250
+ }
251
+ if (!dbPath) {
252
+ return 'Error: Notification Center database not found. This may require Full Disk Access for the terminal app in System Settings > Privacy & Security > Full Disk Access.';
253
+ }
254
+ try {
255
+ // Query the notification database
256
+ // The schema varies by macOS version; we try a common query
257
+ const query = `SELECT
258
+ app_id,
259
+ COALESCE(title, '') as title,
260
+ COALESCE(subtitle, '') as subtitle,
261
+ COALESCE(body, '') as body,
262
+ delivered_date
263
+ FROM record
264
+ ORDER BY delivered_date DESC
265
+ LIMIT ${limit * 2};`;
266
+ const raw = shell(`sqlite3 -json "${dbPath}" "${query.replace(/"/g, '\\"')}" 2>/dev/null`, 10_000);
267
+ if (!raw || raw === '[]') {
268
+ // Try alternative table/column names for different macOS versions
269
+ const altQuery = `SELECT
270
+ bundleid as app_id,
271
+ COALESCE(json_extract(data, '$.title'), '') as title,
272
+ '' as subtitle,
273
+ COALESCE(json_extract(data, '$.body'), '') as body,
274
+ date_delivered as delivered_date
275
+ FROM notifications
276
+ ORDER BY date_delivered DESC
277
+ LIMIT ${limit * 2};`;
278
+ try {
279
+ const altRaw = shell(`sqlite3 -json "${dbPath}" "${altQuery.replace(/"/g, '\\"')}" 2>/dev/null`, 10_000);
280
+ if (altRaw && altRaw !== '[]') {
281
+ return formatNotifications(altRaw, appFilter, limit);
282
+ }
283
+ }
284
+ catch { /* try another approach */ }
285
+ // Fallback: just list tables so we can diagnose
286
+ try {
287
+ const tables = shell(`sqlite3 "${dbPath}" ".tables" 2>/dev/null`);
288
+ return `No notifications found with standard queries. DB tables: ${tables}\nThis database schema may differ on your macOS version. Grant Full Disk Access to your terminal app if needed.`;
289
+ }
290
+ catch {
291
+ return 'Error: Could not read notification database. Grant Full Disk Access to your terminal app in System Settings > Privacy & Security.';
292
+ }
293
+ }
294
+ return formatNotifications(raw, appFilter, limit);
295
+ }
296
+ catch (err) {
297
+ const errMsg = err instanceof Error ? err.message : String(err);
298
+ if (errMsg.includes('permission') || errMsg.includes('unable to open')) {
299
+ return 'Error: Cannot access Notification Center database. Grant Full Disk Access to your terminal app in System Settings > Privacy & Security > Full Disk Access.';
300
+ }
301
+ return `Error reading notifications: ${errMsg}`;
302
+ }
303
+ },
304
+ });
305
+ // ── 4. phone_shortcut ────────────────────────────────────────
306
+ registerTool({
307
+ name: 'phone_shortcut',
308
+ description: 'Run a Shortcut by name via the macOS `shortcuts` CLI. ' +
309
+ 'Shortcuts synced from iPhone are available here. ' +
310
+ 'Can pass text input and returns the shortcut output.',
311
+ parameters: {
312
+ name: {
313
+ type: 'string',
314
+ description: 'Name of the Shortcut to run (exact name as shown in Shortcuts app)',
315
+ required: true,
316
+ },
317
+ input: {
318
+ type: 'string',
319
+ description: 'Optional text input to pass to the shortcut',
320
+ required: false,
321
+ },
322
+ },
323
+ tier: 'free',
324
+ timeout: 60_000, // shortcuts can take a while
325
+ async execute(args) {
326
+ const macErr = requireMacOS();
327
+ if (macErr)
328
+ return macErr;
329
+ const name = String(args.name).trim();
330
+ if (!name)
331
+ return 'Error: Shortcut name is required.';
332
+ // Verify the shortcuts command exists
333
+ try {
334
+ shell('which shortcuts', 5_000);
335
+ }
336
+ catch {
337
+ return 'Error: `shortcuts` command not found. Requires macOS 12 Monterey or later.';
338
+ }
339
+ const escapedName = name.replace(/"/g, '\\"').replace(/\$/g, '\\$');
340
+ let cmd = `shortcuts run "${escapedName}"`;
341
+ if (args.input) {
342
+ const input = String(args.input);
343
+ // Pipe input via stdin
344
+ const escapedInput = input.replace(/"/g, '\\"').replace(/\$/g, '\\$');
345
+ cmd = `echo "${escapedInput}" | shortcuts run "${escapedName}"`;
346
+ }
347
+ try {
348
+ const output = shell(cmd, 60_000);
349
+ return output
350
+ ? `Shortcut "${name}" output:\n${output}`
351
+ : `Shortcut "${name}" ran successfully (no text output).`;
352
+ }
353
+ catch (err) {
354
+ const errMsg = err instanceof Error ? err.message : String(err);
355
+ if (errMsg.includes('couldn\'t find') || errMsg.includes('No shortcut')) {
356
+ return `Error: Shortcut "${name}" not found. Use phone_shortcuts_list to see available shortcuts.`;
357
+ }
358
+ return `Error running shortcut "${name}": ${errMsg}`;
359
+ }
360
+ },
361
+ });
362
+ // ── 5. phone_shortcuts_list ──────────────────────────────────
363
+ registerTool({
364
+ name: 'phone_shortcuts_list',
365
+ description: 'List all available Shortcuts via the macOS `shortcuts list` command. ' +
366
+ 'Includes shortcuts synced from iPhone via iCloud.',
367
+ parameters: {},
368
+ tier: 'free',
369
+ async execute() {
370
+ const macErr = requireMacOS();
371
+ if (macErr)
372
+ return macErr;
373
+ try {
374
+ shell('which shortcuts', 5_000);
375
+ }
376
+ catch {
377
+ return 'Error: `shortcuts` command not found. Requires macOS 12 Monterey or later.';
378
+ }
379
+ try {
380
+ const output = shell('shortcuts list', 15_000);
381
+ if (!output)
382
+ return 'No shortcuts found. Create shortcuts in the Shortcuts app.';
383
+ const shortcuts = output.split('\n').filter(s => s.trim());
384
+ return `Available Shortcuts (${shortcuts.length}):\n${shortcuts.map(s => ` - ${s}`).join('\n')}`;
385
+ }
386
+ catch (err) {
387
+ return `Error listing shortcuts: ${err instanceof Error ? err.message : String(err)}`;
388
+ }
389
+ },
390
+ });
391
+ // ── 6. phone_call ────────────────────────────────────────────
392
+ registerTool({
393
+ name: 'phone_call',
394
+ description: 'Initiate a FaceTime audio call to a phone number. ' +
395
+ 'Uses the tel:// protocol which routes through iPhone via Continuity. ' +
396
+ 'The call must be manually accepted on the Mac or iPhone.',
397
+ parameters: {
398
+ number: {
399
+ type: 'string',
400
+ description: 'Phone number to call (e.g., "+15551234567")',
401
+ required: true,
402
+ },
403
+ },
404
+ tier: 'free',
405
+ async execute(args) {
406
+ const macErr = requireMacOS();
407
+ if (macErr)
408
+ return macErr;
409
+ const number = String(args.number).trim();
410
+ if (!number)
411
+ return 'Error: Phone number is required.';
412
+ if (!/^[+\d\s\-().]{7,20}$/.test(number)) {
413
+ return `Error: "${number}" doesn't look like a valid phone number.`;
414
+ }
415
+ const normalized = normalizePhone(number);
416
+ try {
417
+ // Use open location with tel:// protocol
418
+ // This triggers FaceTime / iPhone Continuity call
419
+ shell(`open "tel://${normalized}"`, 10_000);
420
+ return `Initiating call to ${number}. Accept the call on your Mac or iPhone.`;
421
+ }
422
+ catch (err) {
423
+ return `Error initiating call: ${err instanceof Error ? err.message : String(err)}`;
424
+ }
425
+ },
426
+ });
427
+ // ── 7. phone_airdrop ────────────────────────────────────────
428
+ registerTool({
429
+ name: 'phone_airdrop',
430
+ description: 'Send a file to iPhone via AirDrop. Opens the macOS sharing sheet with ' +
431
+ 'AirDrop pre-selected. Requires AirDrop enabled on both Mac and iPhone. ' +
432
+ 'You will need to accept the transfer on your iPhone.',
433
+ parameters: {
434
+ file_path: {
435
+ type: 'string',
436
+ description: 'Absolute path to the file to send via AirDrop',
437
+ required: true,
438
+ },
439
+ },
440
+ tier: 'free',
441
+ async execute(args) {
442
+ const macErr = requireMacOS();
443
+ if (macErr)
444
+ return macErr;
445
+ const filePath = String(args.file_path).trim();
446
+ if (!filePath)
447
+ return 'Error: file_path is required.';
448
+ if (!existsSync(filePath)) {
449
+ return `Error: File not found: ${filePath}`;
450
+ }
451
+ try {
452
+ const stats = statSync(filePath);
453
+ if (stats.isDirectory()) {
454
+ return 'Error: Cannot AirDrop a directory. Specify a file path.';
455
+ }
456
+ }
457
+ catch {
458
+ return `Error: Cannot access file: ${filePath}`;
459
+ }
460
+ const escapedPath = escapeAppleScript(filePath);
461
+ // Open AirDrop sharing via NSSharingService AppleScript
462
+ const script = [
463
+ 'use framework "Foundation"',
464
+ 'use framework "AppKit"',
465
+ 'use scripting additions',
466
+ '',
467
+ `set filePath to POSIX file "${escapedPath}"`,
468
+ 'set shareItems to {filePath as alias}',
469
+ '',
470
+ 'tell application "Finder"',
471
+ ' activate',
472
+ ` set theFile to POSIX file "${escapedPath}" as alias`,
473
+ 'end tell',
474
+ '',
475
+ '-- Open AirDrop window in Finder',
476
+ 'tell application "Finder"',
477
+ ' if not (exists window "AirDrop") then',
478
+ ' tell application "System Events" to keystroke "R" using {command down, shift down}',
479
+ ' end if',
480
+ ' activate',
481
+ 'end tell',
482
+ ].join('\n');
483
+ try {
484
+ // Method 1: Open Finder with the file selected, then trigger sharing
485
+ // The most reliable approach is to use `open` to reveal in Finder + AirDrop
486
+ shell(`open -R "${filePath.replace(/"/g, '\\"')}"`, 5_000);
487
+ // Open AirDrop window
488
+ osascript([
489
+ 'tell application "Finder"',
490
+ ' activate',
491
+ ' if not (exists window "AirDrop") then',
492
+ ' tell application "System Events"',
493
+ ' keystroke "R" using {command down, shift down}',
494
+ ' end tell',
495
+ ' end if',
496
+ 'end tell',
497
+ ].join('\n'), 10_000);
498
+ const fileName = filePath.split('/').pop() || filePath;
499
+ return `AirDrop: Opened Finder with "${fileName}" and AirDrop window. Drag the file to your iPhone in the AirDrop window, or use Finder > Share > AirDrop.`;
500
+ }
501
+ catch (err) {
502
+ return `Error setting up AirDrop: ${err instanceof Error ? err.message : String(err)}`;
503
+ }
504
+ },
505
+ });
506
+ // ── 8. phone_clipboard ───────────────────────────────────────
507
+ registerTool({
508
+ name: 'phone_clipboard',
509
+ description: 'Read or write the Universal Clipboard shared between Mac and iPhone via Handoff. ' +
510
+ 'When you write to the Mac clipboard, it becomes available on the iPhone (and vice versa) ' +
511
+ 'within a few seconds if both devices are on the same Apple ID with Handoff enabled.',
512
+ parameters: {
513
+ action: {
514
+ type: 'string',
515
+ description: '"read" to get clipboard contents, "write" to set clipboard contents',
516
+ required: true,
517
+ },
518
+ text: {
519
+ type: 'string',
520
+ description: 'Text to write to clipboard (required for "write" action)',
521
+ required: false,
522
+ },
523
+ },
524
+ tier: 'free',
525
+ async execute(args) {
526
+ const macErr = requireMacOS();
527
+ if (macErr)
528
+ return macErr;
529
+ const action = String(args.action).toLowerCase().trim();
530
+ if (action === 'read') {
531
+ try {
532
+ const content = shell('pbpaste', 5_000);
533
+ if (!content)
534
+ return 'Clipboard is empty.';
535
+ // Truncate very long clipboard content
536
+ if (content.length > 10_000) {
537
+ return `Clipboard (${content.length} chars, truncated):\n${content.slice(0, 10_000)}\n\n[... truncated ${content.length - 10_000} chars]`;
538
+ }
539
+ return `Clipboard contents:\n${content}`;
540
+ }
541
+ catch (err) {
542
+ return `Error reading clipboard: ${err instanceof Error ? err.message : String(err)}`;
543
+ }
544
+ }
545
+ if (action === 'write') {
546
+ const text = args.text != null ? String(args.text) : '';
547
+ if (!text)
548
+ return 'Error: "text" parameter is required for write action.';
549
+ try {
550
+ // Use printf to handle special characters safely, pipe to pbcopy
551
+ execSync('pbcopy', {
552
+ input: text,
553
+ encoding: 'utf-8',
554
+ timeout: 5_000,
555
+ stdio: ['pipe', 'pipe', 'pipe'],
556
+ });
557
+ return `Clipboard updated (${text.length} chars). It will sync to your iPhone via Universal Clipboard within a few seconds if Handoff is enabled.`;
558
+ }
559
+ catch (err) {
560
+ return `Error writing to clipboard: ${err instanceof Error ? err.message : String(err)}`;
561
+ }
562
+ }
563
+ return 'Error: action must be "read" or "write".';
564
+ },
565
+ });
566
+ // ── 9. phone_find ────────────────────────────────────────────
567
+ registerTool({
568
+ name: 'phone_find',
569
+ description: 'Open the Find My app to locate your iPhone. ' +
570
+ 'Can also trigger a sound on the iPhone to help find it nearby.',
571
+ parameters: {
572
+ action: {
573
+ type: 'string',
574
+ description: '"locate" to open Find My app (default), "sound" to play a sound on iPhone via Find My',
575
+ required: false,
576
+ default: 'locate',
577
+ },
578
+ },
579
+ tier: 'free',
580
+ async execute(args) {
581
+ const macErr = requireMacOS();
582
+ if (macErr)
583
+ return macErr;
584
+ const action = String(args.action || 'locate').toLowerCase().trim();
585
+ try {
586
+ if (action === 'sound' || action === 'play_sound') {
587
+ // Open Find My and navigate to play sound
588
+ shell('open -a "Find My"', 5_000);
589
+ // Wait for app to open, then try to navigate to Devices tab
590
+ await new Promise(r => setTimeout(r, 1500));
591
+ try {
592
+ osascript([
593
+ 'tell application "Find My" to activate',
594
+ 'delay 1',
595
+ 'tell application "System Events"',
596
+ ' tell process "Find My"',
597
+ ' -- Click Devices tab',
598
+ ' try',
599
+ ' click radio button "Devices" of radio group 1 of toolbar 1 of window 1',
600
+ ' end try',
601
+ ' end tell',
602
+ 'end tell',
603
+ ].join('\n'), 15_000);
604
+ }
605
+ catch { /* best effort UI interaction */ }
606
+ return 'Find My opened on Devices tab. Select your iPhone and click "Play Sound" to locate it.';
607
+ }
608
+ // Default: just open Find My
609
+ shell('open -a "Find My"', 5_000);
610
+ return 'Find My app opened. Your iPhone location will be shown on the map if Location Services is enabled.';
611
+ }
612
+ catch (err) {
613
+ return `Error opening Find My: ${err instanceof Error ? err.message : String(err)}`;
614
+ }
615
+ },
616
+ });
617
+ // ── 10. phone_focus ──────────────────────────────────────────
618
+ registerTool({
619
+ name: 'phone_focus',
620
+ description: 'Check or set Focus mode (Do Not Disturb, etc.) via Shortcuts. ' +
621
+ 'Focus modes sync between Mac and iPhone when "Share Across Devices" is enabled. ' +
622
+ 'To toggle a Focus mode, you need a Shortcut that sets it (create in Shortcuts app).',
623
+ parameters: {
624
+ action: {
625
+ type: 'string',
626
+ description: '"status" to check current Focus mode, "set" to run a Focus-toggling Shortcut',
627
+ required: true,
628
+ },
629
+ shortcut_name: {
630
+ type: 'string',
631
+ description: 'Name of the Shortcut that toggles the desired Focus mode (required for "set" action). Create a Shortcut with the "Set Focus" action first.',
632
+ required: false,
633
+ },
634
+ },
635
+ tier: 'free',
636
+ async execute(args) {
637
+ const macErr = requireMacOS();
638
+ if (macErr)
639
+ return macErr;
640
+ const action = String(args.action).toLowerCase().trim();
641
+ if (action === 'status') {
642
+ try {
643
+ // Check DND / Focus status via defaults or assertions file
644
+ // macOS stores Focus state in the assertions daemon
645
+ const dndStatus = shell('plutil -extract dnd_prefs xml1 -o - ~/Library/Preferences/com.apple.ncprefs.plist 2>/dev/null | grep -c "true" 2>/dev/null || echo "0"', 5_000);
646
+ const isDnd = dndStatus.trim() !== '0';
647
+ // Also check via notification center preferences
648
+ let focusName = 'None';
649
+ try {
650
+ const assertionsRaw = shell('defaults read com.apple.controlcenter "NSStatusItem Visible FocusModes" 2>/dev/null', 5_000);
651
+ if (assertionsRaw.includes('1'))
652
+ focusName = 'Active (check Control Center for details)';
653
+ }
654
+ catch { /* may not be available */ }
655
+ // Try to get more specific focus info
656
+ try {
657
+ const focusConfig = shell('defaults read ~/Library/DoNotDisturb/DB/Assertions/v1/storeAssertionRecords 2>/dev/null', 5_000);
658
+ if (focusConfig && focusConfig !== '(\n)') {
659
+ focusName = 'Active';
660
+ }
661
+ }
662
+ catch { /* may not be accessible */ }
663
+ const result = [
664
+ 'Focus Mode Status:',
665
+ ` Do Not Disturb: ${isDnd ? 'ON' : 'OFF'}`,
666
+ ` Active Focus: ${focusName}`,
667
+ '',
668
+ 'Note: Focus modes sync across devices when "Share Across Devices" is enabled in Settings > Focus.',
669
+ 'To toggle a specific Focus mode, create a Shortcut with the "Set Focus" action and use phone_focus with action="set".',
670
+ ];
671
+ return result.join('\n');
672
+ }
673
+ catch (err) {
674
+ return `Error checking Focus status: ${err instanceof Error ? err.message : String(err)}`;
675
+ }
676
+ }
677
+ if (action === 'set' || action === 'toggle') {
678
+ const shortcutName = args.shortcut_name ? String(args.shortcut_name).trim() : '';
679
+ if (!shortcutName) {
680
+ return 'Error: shortcut_name is required for the "set" action.\n\nTo use this tool:\n1. Open the Shortcuts app\n2. Create a new Shortcut\n3. Add the "Set Focus" action\n4. Configure it for your desired Focus mode (e.g., "Do Not Disturb ON")\n5. Name it something like "DND On" or "Focus Work"\n6. Then run: phone_focus action="set" shortcut_name="DND On"';
681
+ }
682
+ try {
683
+ shell('which shortcuts', 5_000);
684
+ }
685
+ catch {
686
+ return 'Error: `shortcuts` command not found. Requires macOS 12 Monterey or later.';
687
+ }
688
+ try {
689
+ const escapedName = shortcutName.replace(/"/g, '\\"').replace(/\$/g, '\\$');
690
+ shell(`shortcuts run "${escapedName}"`, 30_000);
691
+ return `Focus shortcut "${shortcutName}" executed. The Focus mode change will sync to your iPhone if "Share Across Devices" is enabled.`;
692
+ }
693
+ catch (err) {
694
+ const errMsg = err instanceof Error ? err.message : String(err);
695
+ if (errMsg.includes('couldn\'t find') || errMsg.includes('No shortcut')) {
696
+ return `Error: Shortcut "${shortcutName}" not found. Use phone_shortcuts_list to see available shortcuts.`;
697
+ }
698
+ return `Error running Focus shortcut: ${errMsg}`;
699
+ }
700
+ }
701
+ return 'Error: action must be "status" or "set".';
702
+ },
703
+ });
704
+ }
705
+ // ── Helper: format notification results ────────────────────────
706
+ function formatNotifications(raw, appFilter, limit) {
707
+ try {
708
+ const notifications = JSON.parse(raw);
709
+ let filtered = notifications;
710
+ if (appFilter) {
711
+ filtered = notifications.filter(n => (n.app_id || '').toLowerCase().includes(appFilter));
712
+ }
713
+ const sliced = filtered.slice(0, limit);
714
+ if (sliced.length === 0) {
715
+ return appFilter
716
+ ? `No notifications found matching "${appFilter}".`
717
+ : 'No recent notifications found.';
718
+ }
719
+ const lines = [`Recent Notifications (${sliced.length}${appFilter ? ` matching "${appFilter}"` : ''}):\n`];
720
+ for (const n of sliced) {
721
+ // Format the app bundle ID into a readable name
722
+ const appName = formatAppId(n.app_id || 'unknown');
723
+ const title = n.title || '(no title)';
724
+ const body = n.body || '';
725
+ const subtitle = n.subtitle || '';
726
+ // Format timestamp — Notification Center uses Core Data timestamp
727
+ // (seconds since 2001-01-01) or Unix timestamp depending on macOS version
728
+ let timeStr = '';
729
+ if (n.delivered_date) {
730
+ const ts = Number(n.delivered_date);
731
+ if (ts > 1e9) {
732
+ // Likely Unix timestamp in seconds
733
+ timeStr = new Date(ts * 1000).toLocaleString();
734
+ }
735
+ else if (ts > 0) {
736
+ // Core Data timestamp (seconds since 2001-01-01)
737
+ const coreDataEpoch = new Date('2001-01-01T00:00:00Z').getTime();
738
+ timeStr = new Date(coreDataEpoch + ts * 1000).toLocaleString();
739
+ }
740
+ }
741
+ lines.push(` [${appName}]${timeStr ? ' ' + timeStr : ''}`);
742
+ lines.push(` ${title}${subtitle ? ' — ' + subtitle : ''}`);
743
+ if (body)
744
+ lines.push(` ${body.slice(0, 200)}${body.length > 200 ? '...' : ''}`);
745
+ lines.push('');
746
+ }
747
+ return lines.join('\n');
748
+ }
749
+ catch {
750
+ return `Raw notification data:\n${raw.slice(0, 5000)}`;
751
+ }
752
+ }
753
+ /** Convert a bundle ID like "com.apple.MobileSMS" to a readable name */
754
+ function formatAppId(bundleId) {
755
+ const knownApps = {
756
+ 'com.apple.MobileSMS': 'Messages',
757
+ 'com.apple.mobilephone': 'Phone',
758
+ 'com.apple.mobilemail': 'Mail',
759
+ 'com.apple.mobilecal': 'Calendar',
760
+ 'com.apple.reminders': 'Reminders',
761
+ 'com.apple.facetime': 'FaceTime',
762
+ 'com.apple.Maps': 'Maps',
763
+ 'com.apple.weather': 'Weather',
764
+ 'com.apple.news': 'News',
765
+ 'com.apple.Health': 'Health',
766
+ 'com.apple.Fitness': 'Fitness',
767
+ 'com.apple.mobileslideshow': 'Photos',
768
+ 'com.apple.camera': 'Camera',
769
+ 'com.apple.AppStore': 'App Store',
770
+ 'com.apple.iBooks': 'Books',
771
+ 'com.apple.podcasts': 'Podcasts',
772
+ 'com.apple.Music': 'Music',
773
+ 'com.apple.tv': 'TV',
774
+ 'com.apple.findmy': 'Find My',
775
+ 'com.apple.shortcuts': 'Shortcuts',
776
+ 'com.apple.Preferences': 'Settings',
777
+ 'com.apple.dt.Xcode': 'Xcode',
778
+ 'com.apple.Safari': 'Safari',
779
+ 'com.apple.finder': 'Finder',
780
+ 'com.slack.Slack': 'Slack',
781
+ 'com.tinyspeck.slackmacgap': 'Slack',
782
+ 'com.hnc.Discord': 'Discord',
783
+ 'com.spotify.client': 'Spotify',
784
+ 'us.zoom.xos': 'Zoom',
785
+ 'com.google.Chrome': 'Chrome',
786
+ 'com.microsoft.teams2': 'Teams',
787
+ 'com.whatsapp.WhatsApp': 'WhatsApp',
788
+ 'net.whatsapp.WhatsApp': 'WhatsApp',
789
+ 'com.facebook.Messenger': 'Messenger',
790
+ 'ph.telegra.Telegraph': 'Telegram',
791
+ 'org.telegram.Telegram': 'Telegram',
792
+ 'com.instagram.Instagram': 'Instagram',
793
+ };
794
+ if (knownApps[bundleId])
795
+ return knownApps[bundleId];
796
+ // Extract last component as fallback
797
+ const parts = bundleId.split('.');
798
+ return parts[parts.length - 1] || bundleId;
799
+ }
800
+ //# sourceMappingURL=iphone.js.map