@wonderwhy-er/desktop-commander 0.2.41 β†’ 0.2.42

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.
package/README.md CHANGED
@@ -17,8 +17,6 @@ Work with code and text, run processes, and automate tasks, going far beyond oth
17
17
  <img width="380" height="200" src="https://glama.ai/mcp/servers/zempur9oh4/badge" alt="Desktop Commander MCP" />
18
18
  </a>
19
19
 
20
- ## πŸ‘‹ We’re hiring β€” come build with us: https://desktopcommander.app/careers/
21
-
22
20
  ## πŸ–₯️ Try the Desktop Commander App (Beta)
23
21
 
24
22
  **Want a better experience?** The Desktop Commander App gives you everything the MCP server does, plus:
@@ -1,4 +1,4 @@
1
- import { readFile, readMultipleFiles, writeFile, createDirectory, listDirectory, moveFile, getFileInfo, writePdf } from '../tools/filesystem.js';
1
+ import { readFile, readMultipleFiles, writeFile, createDirectory, listDirectory, moveFile, getFileInfo, writePdf, getDefaultEditorMetadata } from '../tools/filesystem.js';
2
2
  import { withTimeout } from '../utils/withTimeout.js';
3
3
  import { createErrorResponse } from '../error-handlers.js';
4
4
  import { configManager } from '../config-manager.js';
@@ -99,6 +99,8 @@ export async function handleReadFile(args) {
99
99
  fileName: path.basename(resolvedFilePath),
100
100
  filePath: resolvedFilePath,
101
101
  fileType: 'unsupported',
102
+ sourceTool: 'read_file',
103
+ ...await getDefaultEditorMetadata(resolvedFilePath),
102
104
  content: pdfContent
103
105
  .filter((item) => item.type === "text")
104
106
  .map((item) => item.text)
@@ -108,8 +110,9 @@ export async function handleReadFile(args) {
108
110
  }
109
111
  // Handle image files
110
112
  if (fileResult.metadata?.isImage) {
111
- // For image files, keep content payload text-only for broad host compatibility.
112
- // The preview widget reads image bytes from structuredContent.
113
+ // Return the image bytes in the MCP content array so the host model can
114
+ // actually see the image. structuredContent additionally carries the bytes
115
+ // for the preview widget to render.
113
116
  const imageData = typeof fileResult.content === 'string'
114
117
  ? fileResult.content
115
118
  : fileResult.content.toString('base64');
@@ -119,12 +122,19 @@ export async function handleReadFile(args) {
119
122
  {
120
123
  type: "text",
121
124
  text: imageSummary
125
+ },
126
+ {
127
+ type: "image",
128
+ data: imageData,
129
+ mimeType: fileResult.mimeType
122
130
  }
123
131
  ],
124
132
  structuredContent: {
125
133
  fileName: path.basename(resolvedFilePath),
126
134
  filePath: resolvedFilePath,
127
135
  fileType: 'image',
136
+ sourceTool: 'read_file',
137
+ ...await getDefaultEditorMetadata(resolvedFilePath),
128
138
  content: imageData,
129
139
  imageData,
130
140
  mimeType: fileResult.mimeType
@@ -145,6 +155,8 @@ export async function handleReadFile(args) {
145
155
  fileName: path.basename(resolvedFilePath),
146
156
  filePath: resolvedFilePath,
147
157
  fileType,
158
+ sourceTool: 'read_file',
159
+ ...await getDefaultEditorMetadata(resolvedFilePath),
148
160
  content: textContent,
149
161
  },
150
162
  };
@@ -252,6 +264,8 @@ export async function handleWriteFile(args) {
252
264
  fileName: path.basename(resolvedWritePath),
253
265
  filePath: resolvedWritePath,
254
266
  fileType: resolvePreviewFileType(resolvedWritePath),
267
+ sourceTool: 'write_file',
268
+ ...await getDefaultEditorMetadata(resolvedWritePath),
255
269
  },
256
270
  };
257
271
  }
@@ -293,6 +307,11 @@ export async function handleListDirectory(args) {
293
307
  fileName: path.basename(resolvedPath),
294
308
  filePath: resolvedPath,
295
309
  fileType: 'directory',
310
+ sourceTool: 'list_directory',
311
+ // Carry the listing in structuredContent too. Chat reads the text
312
+ // content array, but structuredContent-only consumers (e.g. Cowork)
313
+ // render from here and would otherwise show an empty directory.
314
+ content: resultText,
296
315
  },
297
316
  };
298
317
  }
package/dist/server.d.ts CHANGED
@@ -38,4 +38,5 @@ declare let currentClient: {
38
38
  name: string;
39
39
  version: string;
40
40
  };
41
- export { currentClient };
41
+ declare let currentCallIsRemote: boolean;
42
+ export { currentClient, currentCallIsRemote };
package/dist/server.js CHANGED
@@ -21,8 +21,9 @@ import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
21
21
  import { VERSION } from './version.js';
22
22
  import { capture, capture_call_tool } from "./utils/capture.js";
23
23
  import { logToStderr, logger } from './utils/logger.js';
24
- import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI } from './ui/contracts.js';
24
+ import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI, } from './ui/contracts.js';
25
25
  import { listUiResources, readUiResource } from './ui/resources.js';
26
+ import { shouldShowMcpUiPreviews } from './utils/mcp-ui-ab-test.js';
26
27
  // Store startup messages to send after initialization
27
28
  const deferredMessages = [];
28
29
  function deferLog(level, message) {
@@ -70,6 +71,21 @@ server.setRequestHandler(ListPromptsRequestSchema, async () => {
70
71
  });
71
72
  // Store current client info (simple variable)
72
73
  let currentClient = { name: 'uninitialized', version: 'uninitialized' };
74
+ // Tracks whether the in-flight tool call originated from a remote device.
75
+ // Mirrors the module-level `currentClient` pattern so that telemetry events
76
+ // emitted deeper inside tool handlers (e.g. server_start_process,
77
+ // server_read_file) can be attributed to the remote path. The CallTool
78
+ // handler sets this on every call (true when _meta.remote is present,
79
+ // false otherwise) so the flag never leaks from a remote call to a
80
+ // subsequent local call.
81
+ let currentCallIsRemote = false;
82
+ /**
83
+ * Set whether the current tool call is from a remote device.
84
+ * Called once per tool call by the CallTool handler.
85
+ */
86
+ function setCurrentCallIsRemote(isRemote) {
87
+ currentCallIsRemote = isRemote;
88
+ }
73
89
  /**
74
90
  * Unified way to update client information
75
91
  */
@@ -131,7 +147,7 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
131
147
  }
132
148
  });
133
149
  // Export current client info for access by other modules
134
- export { currentClient };
150
+ export { currentClient, currentCallIsRemote };
135
151
  deferLog('info', 'Setting up request handlers...');
136
152
  /**
137
153
  * Check if a tool should be included based on current client
@@ -150,6 +166,7 @@ function shouldIncludeTool(toolName) {
150
166
  server.setRequestHandler(ListToolsRequestSchema, async () => {
151
167
  try {
152
168
  // logToStderr('debug', 'Generating tools list...');
169
+ const showMcpUiPreviews = await shouldShowMcpUiPreviews();
153
170
  // Build complete tools array
154
171
  const allTools = [
155
172
  // Configuration tools
@@ -169,7 +186,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
169
186
  - systemInfo (operating system and environment details)
170
187
  ${CMD_PREFIX_DESCRIPTION}`,
171
188
  inputSchema: zodToJsonSchema(GetConfigArgsSchema),
172
- _meta: buildUiToolMeta(CONFIG_EDITOR_RESOURCE_URI, true),
189
+ _meta: buildUiToolMeta(CONFIG_EDITOR_RESOURCE_URI, true, showMcpUiPreviews),
173
190
  annotations: {
174
191
  title: "Get Configuration",
175
192
  readOnlyHint: true,
@@ -262,7 +279,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
262
279
  ${PATH_GUIDANCE}
263
280
  ${CMD_PREFIX_DESCRIPTION}`,
264
281
  inputSchema: zodToJsonSchema(ReadFileArgsSchema),
265
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
282
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
266
283
  annotations: {
267
284
  title: "Read File or URL",
268
285
  readOnlyHint: true,
@@ -330,7 +347,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
330
347
  ${PATH_GUIDANCE}
331
348
  ${CMD_PREFIX_DESCRIPTION}`,
332
349
  inputSchema: zodToJsonSchema(WriteFileArgsSchema),
333
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
350
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
334
351
  annotations: {
335
352
  title: "Write File",
336
353
  readOnlyHint: false,
@@ -451,7 +468,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
451
468
  ${PATH_GUIDANCE}
452
469
  ${CMD_PREFIX_DESCRIPTION}`,
453
470
  inputSchema: zodToJsonSchema(ListDirectoryArgsSchema),
454
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
471
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
455
472
  annotations: {
456
473
  title: "List Directory Contents",
457
474
  readOnlyHint: true,
@@ -710,7 +727,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
710
727
  ${PATH_GUIDANCE}
711
728
  ${CMD_PREFIX_DESCRIPTION}`,
712
729
  inputSchema: zodToJsonSchema(EditBlockArgsSchema),
713
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
730
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
714
731
  annotations: {
715
732
  title: "Edit Block",
716
733
  readOnlyHint: false,
@@ -1091,13 +1108,21 @@ import * as handlers from './handlers/index.js';
1091
1108
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1092
1109
  const { name, arguments: args } = request.params;
1093
1110
  const startTime = Date.now();
1111
+ // Hoisted above the try so the finally block can read them when emitting the
1112
+ // server_call_tool completion event (duration + status), even on the crash path.
1113
+ let telemetryData = { tool_name: name };
1114
+ let result;
1115
+ let isError = false;
1094
1116
  try {
1095
- // Prepare telemetry data - add config key for set_config_value
1096
- const telemetryData = { tool_name: name };
1097
- // Extract metadata from _meta field if present
1117
+ // telemetryData declared above; extract metadata from _meta field if present
1098
1118
  const metadata = request.params._meta;
1119
+ // Reset remote attribution for every call so a prior remote call never
1120
+ // leaks its flag onto a subsequent local call. Set to true only when
1121
+ // this call carries the remote marker in _meta.
1122
+ const isRemoteCall = !!(metadata && typeof metadata === 'object' && metadata.remote);
1123
+ setCurrentCallIsRemote(isRemoteCall);
1099
1124
  if (metadata && typeof metadata === 'object') {
1100
- // add remote flag if present (convert to string for GA4)
1125
+ // add remote flag if present (convert to string for telemetry)
1101
1126
  if (metadata.remote) {
1102
1127
  telemetryData.remote = String(metadata.remote);
1103
1128
  }
@@ -1124,11 +1149,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1124
1149
  telemetryData.prompt_id = promptArgs.promptId;
1125
1150
  }
1126
1151
  }
1127
- capture_call_tool('server_call_tool', telemetryData);
1128
1152
  // Track tool call
1129
1153
  trackToolCall(name, args);
1130
1154
  // Using a more structured approach with dedicated handlers
1131
- let result;
1155
+ // (result is declared above so the finally block can read execution status)
1132
1156
  switch (name) {
1133
1157
  // Config tools
1134
1158
  case "get_config":
@@ -1320,6 +1344,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1320
1344
  }
1321
1345
  // Add tool call to history (exclude only get_recent_tool_calls to prevent recursion)
1322
1346
  const duration = Date.now() - startTime;
1347
+ isError = !!result.isError;
1323
1348
  const EXCLUDED_TOOLS = [
1324
1349
  'get_recent_tool_calls',
1325
1350
  'track_ui_event'
@@ -1413,6 +1438,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1413
1438
  return result;
1414
1439
  }
1415
1440
  catch (error) {
1441
+ isError = true;
1416
1442
  const errorMessage = error instanceof Error ? error.message : String(error);
1417
1443
  // Track the failure
1418
1444
  await usageTracker.trackFailure(name);
@@ -1424,6 +1450,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1424
1450
  isError: true,
1425
1451
  };
1426
1452
  }
1453
+ finally {
1454
+ // Single tool-call telemetry event, fired AFTER execution so it can carry
1455
+ // timing. In a finally so it still fires on the hard-crash path (the catch
1456
+ // above). Only missed if a tool never returns or throws (a true hang).
1457
+ capture_call_tool('server_call_tool', {
1458
+ ...telemetryData,
1459
+ duration_ms: Date.now() - startTime,
1460
+ is_error: String(isError),
1461
+ });
1462
+ }
1427
1463
  });
1428
1464
  // Add no-op handlers so Visual Studio initialization succeeds
1429
1465
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
@@ -10,10 +10,9 @@ import { version as nodeVersion } from 'process';
10
10
  import * as https from 'https';
11
11
  import { randomUUID } from 'crypto';
12
12
 
13
- // Google Analytics configuration
14
- const GA_MEASUREMENT_ID = 'G-NGGDNL0K4L'; // Replace with your GA4 Measurement ID
15
- const GA_API_SECRET = '5M0mC--2S_6t94m8WrI60A'; // Replace with your GA4 API Secre
16
- const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
13
+ // Telemetry proxy configuration
14
+ const TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';
15
+ const TELEMETRY_PROXY_FALLBACK_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
17
16
 
18
17
  // Generate a unique anonymous ID using UUID - consistent with privacy policy
19
18
  let uniqueUserId = 'unknown';
@@ -302,11 +301,6 @@ async function enhancedGetTrackingProperties(additionalProps = {}) {
302
301
  async function trackEvent(eventName, additionalProps = {}) {
303
302
  const trackingStep = addSetupStep(`track_event_${eventName}`);
304
303
 
305
- if (!GA_MEASUREMENT_ID || !GA_API_SECRET) {
306
- updateSetupStep(trackingStep, 'skipped', new Error('GA not configured'));
307
- return;
308
- }
309
-
310
304
  // Add retry capability
311
305
  const maxRetries = 2;
312
306
  let attempt = 0;
@@ -319,7 +313,7 @@ async function trackEvent(eventName, additionalProps = {}) {
319
313
  // Get enriched properties
320
314
  const eventProperties = await enhancedGetTrackingProperties(additionalProps);
321
315
 
322
- // Prepare GA4 payload
316
+ // Prepare telemetry payload
323
317
  const payload = {
324
318
  client_id: uniqueUserId,
325
319
  non_personalized_ads: false,
@@ -330,7 +324,7 @@ async function trackEvent(eventName, additionalProps = {}) {
330
324
  }]
331
325
  };
332
326
 
333
- // Send to Google Analytics
327
+ // Send to telemetry proxy
334
328
  const postData = JSON.stringify(payload);
335
329
 
336
330
  const options = {
@@ -341,44 +335,8 @@ async function trackEvent(eventName, additionalProps = {}) {
341
335
  }
342
336
  };
343
337
 
344
- const result = await new Promise((resolve, reject) => {
345
- const req = https.request(GA_BASE_URL, options);
346
-
347
- // Set timeout to prevent blocking
348
- const timeoutId = setTimeout(() => {
349
- req.destroy();
350
- reject(new Error('Request timeout'));
351
- }, 5000); // Increased timeout to 5 seconds
352
-
353
- req.on('error', (error) => {
354
- clearTimeout(timeoutId);
355
- reject(error);
356
- });
357
-
358
- req.on('response', (res) => {
359
- clearTimeout(timeoutId);
360
- let data = '';
361
-
362
- res.on('data', (chunk) => {
363
- data += chunk;
364
- });
365
-
366
- res.on('error', (error) => {
367
- reject(error);
368
- });
369
-
370
- res.on('end', () => {
371
- if (res.statusCode >= 200 && res.statusCode < 300) {
372
- resolve({ success: true, data });
373
- } else {
374
- reject(new Error(`HTTP error ${res.statusCode}: ${data}`));
375
- }
376
- });
377
- });
378
-
379
- req.write(postData);
380
- req.end();
381
- });
338
+ const result = await postTelemetryPayload(postData, options);
339
+ if (!result.success) throw new Error('Telemetry proxy request failed');
382
340
 
383
341
  updateSetupStep(trackingStep, 'completed');
384
342
  return result;
@@ -397,6 +355,54 @@ async function trackEvent(eventName, additionalProps = {}) {
397
355
  return false;
398
356
  }
399
357
 
358
+ async function postTelemetryPayload(postData, options) {
359
+ for (const endpoint of [TELEMETRY_PROXY_URL, TELEMETRY_PROXY_FALLBACK_URL]) {
360
+ const result = await new Promise((resolve) => {
361
+ let settled = false;
362
+ let timeoutId;
363
+ const finish = (result) => {
364
+ if (settled) return;
365
+ settled = true;
366
+ clearTimeout(timeoutId);
367
+ resolve(result);
368
+ };
369
+ const req = https.request(endpoint, options);
370
+
371
+ timeoutId = setTimeout(() => {
372
+ req.destroy();
373
+ finish({ success: false, data: '' });
374
+ }, 5000);
375
+
376
+ req.on('error', () => {
377
+ finish({ success: false, data: '' });
378
+ });
379
+
380
+ req.on('response', (res) => {
381
+ let data = '';
382
+ res.on('data', (chunk) => {
383
+ data += chunk;
384
+ });
385
+ res.on('error', () => {
386
+ finish({ success: false, data: '' });
387
+ });
388
+ res.on('end', () => {
389
+ finish({ success: res.statusCode >= 200 && res.statusCode < 300, data });
390
+ });
391
+ res.on('close', () => {
392
+ finish({ success: false, data: '' });
393
+ });
394
+ });
395
+
396
+ req.write(postData);
397
+ req.end();
398
+ });
399
+
400
+ if (result.success) return result;
401
+ }
402
+
403
+ return { success: false, data: '' };
404
+ }
405
+
400
406
  // Ensure tracking completes before process exits
401
407
  async function ensureTrackingCompleted(eventName, additionalProps = {}, timeoutMs = 6000) {
402
408
  return new Promise(async (resolve) => {
@@ -933,4 +939,4 @@ if (process.argv.length >= 2 && process.argv[1] === fileURLToPath(import.meta.ur
933
939
  process.exit(1);
934
940
  }, 1000);
935
941
  });
936
- }
942
+ }
@@ -4,6 +4,34 @@ import { DEFAULT_COMMAND_TIMEOUT } from './config.js';
4
4
  import { configManager } from './config-manager.js';
5
5
  import { capture } from "./utils/capture.js";
6
6
  import { analyzeProcessState } from './utils/process-detection.js';
7
+ /**
8
+ * Standard Windows PATHEXT value, used to repair a corrupted PATHEXT before
9
+ * spawning child shells.
10
+ *
11
+ * On some Windows Claude Desktop / DXT launches the server process inherits a
12
+ * broken PATHEXT (observed as ".CPL" only). Because we build the child env from
13
+ * { ...process.env }, that broken value would propagate into every spawned
14
+ * shell, stripping ".EXE" and breaking resolution of git / node / python / rg /
15
+ * etc. (and even full-path .exe invocations under PowerShell). See issue #481.
16
+ */
17
+ const STANDARD_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC';
18
+ /**
19
+ * Return a healthy PATHEXT for spawned Windows shells.
20
+ * - Unset -> use the standard list.
21
+ * - Missing ".EXE" -> corrupted; merge the standard list with whatever was
22
+ * present (preserves any extra extensions, order-stable).
23
+ * - Otherwise -> leave the inherited value untouched.
24
+ */
25
+ function getRepairedPathExt() {
26
+ const current = process.env.PATHEXT;
27
+ if (!current)
28
+ return STANDARD_PATHEXT;
29
+ const exts = current.split(';').map(e => e.trim().toUpperCase()).filter(Boolean);
30
+ if (!exts.includes('.EXE')) {
31
+ return [...new Set([...STANDARD_PATHEXT.split(';'), ...exts])].join(';');
32
+ }
33
+ return current;
34
+ }
7
35
  /**
8
36
  * Get the appropriate spawn configuration for a given shell
9
37
  * This handles login shell flags for different shell types
@@ -39,6 +67,7 @@ function getShellSpawnArgs(shellPath, command) {
39
67
  return {
40
68
  executable: shellPath,
41
69
  args: ['/c', command],
70
+ windowsVerbatim: true,
42
71
  useShellOption: false
43
72
  };
44
73
  }
@@ -143,6 +172,23 @@ export class TerminalManager {
143
172
  windowsHide: true // Prevent visible console windows on Windows
144
173
  };
145
174
  }
175
+ // Repair PATHEXT on Windows before spawning. On some Windows DXT launches
176
+ // the server process inherits a corrupted PATHEXT (e.g. ".CPL"), which we
177
+ // would otherwise propagate via { ...process.env } and break command
178
+ // resolution (git, node, python, rg, ...) in the spawned shell. See #481.
179
+ if (process.platform === 'win32' && spawnOptions.env) {
180
+ spawnOptions.env.PATHEXT = getRepairedPathExt();
181
+ }
182
+ // On Windows, when we invoke cmd.exe directly and pass the user's command as a
183
+ // single argument, Node/libuv applies MSVCRT-style quoting that escapes embedded
184
+ // double quotes as \" . cmd.exe does not understand that escaping, so any command
185
+ // containing quotes (e.g. a quoted path with spaces like "C:\Program Files\app.exe")
186
+ // is corrupted before the shell ever parses it. Passing arguments verbatim lets
187
+ // cmd handle its own quoting. Scoped to shells that set windowsVerbatim (cmd only)
188
+ // because PowerShell/pwsh have different quote rules and must NOT use verbatim.
189
+ if (process.platform === 'win32' && spawnConfig.windowsVerbatim) {
190
+ spawnOptions.windowsVerbatimArguments = true;
191
+ }
146
192
  // Spawn the process with appropriate arguments
147
193
  const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);
148
194
  let output = '';
@@ -14,7 +14,7 @@
14
14
  * 2. Make this file a thin dispatch layer that routes to appropriate FileHandler
15
15
  * 3. Unify the editRange() signature to handle both text search/replace and structured edits
16
16
  */
17
- import { writeFile, readFileInternal, validatePath } from './filesystem.js';
17
+ import { getDefaultEditorMetadata, writeFile, readFileInternal, validatePath } from './filesystem.js';
18
18
  import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js';
19
19
  import { capture } from '../utils/capture.js';
20
20
  import { createErrorResponse } from '../error-handlers.js';
@@ -180,6 +180,8 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
180
180
  fileName: path.basename(resolvedEditPath),
181
181
  filePath: resolvedEditPath,
182
182
  fileType: resolvePreviewFileType(resolvedEditPath),
183
+ sourceTool: 'edit_block',
184
+ ...await getDefaultEditorMetadata(resolvedEditPath),
183
185
  },
184
186
  };
185
187
  }
@@ -368,6 +370,8 @@ export async function handleEditBlock(args) {
368
370
  fileName: path.basename(resolvedRangePath),
369
371
  filePath: resolvedRangePath,
370
372
  fileType: resolvePreviewFileType(resolvedRangePath),
373
+ sourceTool: 'edit_block',
374
+ ...await getDefaultEditorMetadata(resolvedRangePath),
371
375
  },
372
376
  };
373
377
  }
@@ -403,6 +407,8 @@ export async function handleEditBlock(args) {
403
407
  fileName: path.basename(resolvedEditRangePath),
404
408
  filePath: resolvedEditRangePath,
405
409
  fileType: resolvePreviewFileType(resolvedEditRangePath),
410
+ sourceTool: 'edit_block',
411
+ ...await getDefaultEditorMetadata(resolvedEditRangePath),
406
412
  },
407
413
  };
408
414
  }
@@ -71,3 +71,8 @@ export declare function getFileInfo(filePath: string): Promise<Record<string, an
71
71
  * @param options Options for PDF generation or modification. For modification, can include `sourcePdf`.
72
72
  */
73
73
  export declare function writePdf(filePath: string, content: string | PdfOperations[], outputPath?: string, options?: any): Promise<void>;
74
+ type DefaultEditorMetadata = {
75
+ defaultEditorName?: string;
76
+ defaultEditorPath?: string;
77
+ };
78
+ export declare function getDefaultEditorMetadata(filePath: string): Promise<DefaultEditorMetadata>;
@@ -2,6 +2,8 @@ import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import os from 'os';
4
4
  import fetch from 'cross-fetch';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
5
7
  import { capture } from '../utils/capture.js';
6
8
  import { withTimeout } from '../utils/withTimeout.js';
7
9
  import { configManager } from '../config-manager.js';
@@ -887,3 +889,44 @@ export async function writePdf(filePath, content, outputPath, options = {}) {
887
889
  throw new Error('Invalid content type for writePdf. Expected string (markdown) or array of operations.');
888
890
  }
889
891
  }
892
+ const execFileAsync = promisify(execFile);
893
+ const DEFAULT_EDITOR_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
894
+ const defaultEditorCache = new Map();
895
+ function escapeAppleScriptString(value) {
896
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
897
+ }
898
+ export async function getDefaultEditorMetadata(filePath) {
899
+ if (os.platform() !== 'darwin') {
900
+ return {};
901
+ }
902
+ let cacheKey = '';
903
+ try {
904
+ const extension = path.extname(filePath).toLowerCase();
905
+ cacheKey = extension || path.basename(filePath).toLowerCase();
906
+ const cached = defaultEditorCache.get(cacheKey);
907
+ if (cached) {
908
+ if (!cached.expiresAt || cached.expiresAt > Date.now()) {
909
+ return cached.metadata;
910
+ }
911
+ defaultEditorCache.delete(cacheKey);
912
+ }
913
+ const script = `set appAlias to default application of (info for POSIX file "${escapeAppleScriptString(filePath)}")\nreturn (name of (info for appAlias)) & linefeed & POSIX path of appAlias`;
914
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 12000 });
915
+ const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
916
+ const defaultEditorName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
917
+ const defaultEditorPath = lines[lines.length - 1] ?? '';
918
+ if (defaultEditorName && defaultEditorPath.startsWith('/')) {
919
+ const metadata = { defaultEditorName, defaultEditorPath };
920
+ defaultEditorCache.set(cacheKey, { metadata });
921
+ return metadata;
922
+ }
923
+ defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
924
+ }
925
+ catch {
926
+ if (cacheKey) {
927
+ defaultEditorCache.set(cacheKey, { metadata: {}, expiresAt: Date.now() + DEFAULT_EDITOR_NEGATIVE_CACHE_MS });
928
+ }
929
+ // Generic UI fallback is good enough if detection fails.
930
+ }
931
+ return {};
932
+ }
@@ -1,5 +1,17 @@
1
1
  import type { PageRange } from './lib/pdf2md.js';
2
2
  import { PdfParseResult } from './lib/pdf2md.js';
3
+ interface CachedPuppeteerChrome {
4
+ executablePath: string;
5
+ }
6
+ /**
7
+ * Find Chrome in puppeteer's cache directory
8
+ * Returns the executable path if found, undefined otherwise
9
+ */
10
+ export declare function findPuppeteerChrome(cacheDir?: string): CachedPuppeteerChrome | undefined;
11
+ /**
12
+ * Remove stale Puppeteer Chrome builds while preserving the active build.
13
+ */
14
+ export declare function pruneOldPuppeteerChromeBuilds(activeExecutablePath: string, cacheDir?: string): Promise<void>;
3
15
  /**
4
16
  * Preemptively ensure Chrome is available for PDF generation.
5
17
  * Call this at server startup to trigger download in background if needed.
@@ -11,3 +23,4 @@ export declare function ensureChromeAvailable(): void;
11
23
  */
12
24
  export declare function parsePdfToMarkdown(source: string, pageNumbers?: number[] | PageRange): Promise<PdfParseResult>;
13
25
  export declare function parseMarkdownToPdf(markdown: string, options?: any): Promise<Buffer>;
26
+ export {};