@wonderwhy-er/desktop-commander 0.2.40 → 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.
Files changed (35) hide show
  1. package/README.md +4 -4
  2. package/dist/handlers/filesystem-handlers.js +28 -3
  3. package/dist/server.d.ts +2 -1
  4. package/dist/server.js +50 -13
  5. package/dist/setup-claude-server.js +56 -50
  6. package/dist/terminal-manager.js +46 -0
  7. package/dist/tools/edit.js +7 -1
  8. package/dist/tools/filesystem.d.ts +5 -0
  9. package/dist/tools/filesystem.js +91 -14
  10. package/dist/tools/pdf/markdown.d.ts +13 -0
  11. package/dist/tools/pdf/markdown.js +93 -29
  12. package/dist/track-installation.js +57 -38
  13. package/dist/types.d.ts +4 -0
  14. package/dist/ui/contracts.d.ts +1 -1
  15. package/dist/ui/contracts.js +4 -1
  16. package/dist/ui/file-preview/preview-runtime.js +114 -116
  17. package/dist/ui/file-preview/src/app.js +19 -22
  18. package/dist/ui/file-preview/src/directory-controller.js +9 -2
  19. package/dist/ui/file-preview/src/file-type-handlers.js +20 -9
  20. package/dist/ui/file-preview/src/host/external-actions.d.ts +0 -11
  21. package/dist/ui/file-preview/src/host/external-actions.js +0 -39
  22. package/dist/ui/file-preview/src/payload-utils.js +10 -1
  23. package/dist/uninstall-claude-server.js +54 -47
  24. package/dist/utils/ab-test.d.ts +4 -0
  25. package/dist/utils/ab-test.js +6 -0
  26. package/dist/utils/capture.d.ts +10 -2
  27. package/dist/utils/capture.js +80 -54
  28. package/dist/utils/feature-flags.d.ts +3 -0
  29. package/dist/utils/feature-flags.js +34 -5
  30. package/dist/utils/files/excel.js +26 -5
  31. package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
  32. package/dist/utils/mcp-ui-ab-test.js +62 -0
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +2 -1
@@ -11,7 +11,7 @@ import { attachDirectoryHandlers } from './directory-controller.js';
11
11
  import { buildDocumentLayout } from './document-layout.js';
12
12
  import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js';
13
13
  import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js';
14
- import { buildOpenInEditorCommand, buildOpenInFolderCommand, detectDefaultMarkdownEditor, renderMarkdownEditorAppIcon } from './host/external-actions.js';
14
+ import { buildOpenInEditorCommand, buildOpenInFolderCommand, renderMarkdownEditorAppIcon } from './host/external-actions.js';
15
15
  import { attachSelectionContext } from './host/selection-context.js';
16
16
  import { createMarkdownController } from './markdown/controller.js';
17
17
  import { createConflictDialogController, renderConflictDialogMarkup, } from './markdown/conflict-dialog.js';
@@ -40,7 +40,9 @@ let inlinePayloadBeforeFullscreen;
40
40
  let directoryBackPayload;
41
41
  let selectionAbortController = null;
42
42
  const markdownEditorAppCache = new Map();
43
- const markdownEditorAppPending = new Set();
43
+ function getTelemetryToolName(payload) {
44
+ return typeof payload?.sourceTool === 'string' ? payload.sourceTool : 'read_file';
45
+ }
44
46
  async function callToolIfReady(name, args) {
45
47
  return rpcCallTool ? rpcCallTool(name, args) : undefined;
46
48
  }
@@ -147,7 +149,10 @@ async function readAndResolvePayload(payload, onReady) {
147
149
  try {
148
150
  const freshPayload = await markdownController.readPayload(payload.filePath);
149
151
  if (freshPayload) {
150
- onReady(freshPayload);
152
+ onReady({
153
+ ...freshPayload,
154
+ sourceTool: payload.sourceTool ?? freshPayload.sourceTool,
155
+ });
151
156
  if (freshPayload.fileType === 'markdown') {
152
157
  void markdownController.refreshFromDisk(freshPayload);
153
158
  }
@@ -212,21 +217,15 @@ export function renderApp(container, payload, htmlMode = 'rendered', expandedSta
212
217
  const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({
213
218
  availableDisplayModes: getAvailableDisplayModes(),
214
219
  }).canFullscreen;
220
+ if (payload.fileType === 'markdown' && payload.defaultEditorName) {
221
+ markdownEditorAppCache.set(payload.filePath, {
222
+ appName: payload.defaultEditorName,
223
+ appPath: payload.defaultEditorPath,
224
+ });
225
+ }
215
226
  const defaultMarkdownEditor = payload.fileType === 'markdown'
216
227
  ? markdownEditorAppCache.get(payload.filePath)
217
228
  : undefined;
218
- if (payload.fileType === 'markdown' && !defaultMarkdownEditor) {
219
- void detectDefaultMarkdownEditor({
220
- filePath: payload.filePath,
221
- editorAppCache: markdownEditorAppCache,
222
- editorAppPending: markdownEditorAppPending,
223
- callTool: callToolIfReady,
224
- extractToolText,
225
- onDetected: () => {
226
- rerenderCurrent?.();
227
- },
228
- });
229
- }
230
229
  const layout = buildDocumentLayout({
231
230
  payload,
232
231
  body,
@@ -415,9 +414,12 @@ export function bootstrapApp() {
415
414
  const result = await app.requestDisplayMode({ mode });
416
415
  return typeof result.mode === 'string' ? result.mode : null;
417
416
  };
418
- trackUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
417
+ const filePreviewUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
419
418
  component: 'file_preview',
420
- baseParams: { tool_name: 'read_file' },
419
+ });
420
+ trackUiEvent = (event, params = {}) => filePreviewUiEvent(event, {
421
+ tool_name: getTelemetryToolName(currentPayload ?? hostPayload),
422
+ ...params,
421
423
  });
422
424
  app.ontoolinput = (params) => {
423
425
  const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined;
@@ -548,11 +550,6 @@ export function bootstrapApp() {
548
550
  onConnected: () => {
549
551
  currentHostContext = app.getHostContext();
550
552
  pendingCachedPayload = widgetState.read() ?? undefined;
551
- window.setTimeout(() => {
552
- if (!initialStateResolved) {
553
- resolveInitialState(undefined, 'Preview unavailable after page refresh. Switch threads or re-run the tool.');
554
- }
555
- }, 8000);
556
553
  },
557
554
  }).catch(() => {
558
555
  renderStatusState(container, 'Failed to connect to host.');
@@ -5,7 +5,7 @@ function parseDirectoryEntries(content) {
5
5
  const hintLines = [];
6
6
  const entryLines = [];
7
7
  for (const line of lines) {
8
- if (/^\[(DIR|FILE|DENIED|WARNING)\]/.test(line.trim())) {
8
+ if (/^\[(DIR|FILE|DENIED|NOT_FOUND|WARNING)\]/.test(line.trim())) {
9
9
  entryLines.push(line.trim());
10
10
  }
11
11
  else if (entryLines.length === 0) {
@@ -25,6 +25,7 @@ function parseDirectoryEntries(content) {
25
25
  fullPath: dirName,
26
26
  isDir: false,
27
27
  isDenied: false,
28
+ isNotFound: false,
28
29
  isWarning: true,
29
30
  warningText: msg,
30
31
  depth: parts.length,
@@ -33,13 +34,15 @@ function parseDirectoryEntries(content) {
33
34
  }
34
35
  const isDir = line.startsWith('[DIR]');
35
36
  const isDenied = line.startsWith('[DENIED]');
36
- const name = line.replace(/^\[(DIR|FILE|DENIED)\]\s*/, '');
37
+ const isNotFound = line.startsWith('[NOT_FOUND]');
38
+ const name = line.replace(/^\[(DIR|FILE|DENIED|NOT_FOUND)\]\s*/, '');
37
39
  const parts = name.replace(/\\/g, '/').split('/');
38
40
  flat.push({
39
41
  name,
40
42
  fullPath: name,
41
43
  isDir,
42
44
  isDenied,
45
+ isNotFound,
43
46
  isWarning: false,
44
47
  warningText: '',
45
48
  depth: parts.length - 1,
@@ -53,6 +56,7 @@ function parseDirectoryEntries(content) {
53
56
  name: baseName,
54
57
  isDir: item.isDir,
55
58
  isDenied: item.isDenied,
59
+ isNotFound: item.isNotFound,
56
60
  isWarning: item.isWarning,
57
61
  warningText: item.warningText,
58
62
  children: [],
@@ -85,6 +89,9 @@ function renderDirTree(entries, rootPath) {
85
89
  if (item.isDenied) {
86
90
  return `<div class="dir-entry"><span class="dir-icon">🚫</span> <span class="dir-name-denied">${escapeHtml(item.name)}</span></div>`;
87
91
  }
92
+ if (item.isNotFound) {
93
+ return `<div class="dir-entry"><span class="dir-icon">❓</span> <span class="dir-name-denied">${escapeHtml(item.name)}</span></div>`;
94
+ }
88
95
  if (item.isDir) {
89
96
  const hasChildren = item.children.length > 0;
90
97
  const chevron = `<span class="dir-chevron${hasChildren ? ' expanded' : ''}">${hasChildren ? '▼' : '▶'}</span>`;
@@ -74,15 +74,26 @@ const handlerRegistry = {
74
74
  },
75
75
  },
76
76
  unsupported: {
77
- getCapabilities: () => ({
78
- supportsPreview: false,
79
- canCopy: false,
80
- canOpenInFolder: true,
81
- }),
82
- renderBody: () => ({
83
- notice: 'Preview is not available for this file type.',
84
- html: '<div class="panel-content source-content"></div>',
85
- }),
77
+ getCapabilities: (payload) => {
78
+ const hasRawContent = stripReadStatusLine(payload.content).trim().length > 0;
79
+ return {
80
+ supportsPreview: hasRawContent,
81
+ canCopy: hasRawContent,
82
+ canOpenInFolder: !isLikelyUrl(payload.filePath),
83
+ };
84
+ },
85
+ renderBody: ({ payload }) => {
86
+ const rawContent = stripReadStatusLine(payload.content);
87
+ if (rawContent.trim().length === 0) {
88
+ return {
89
+ notice: 'Preview is not available for this file type.',
90
+ html: '<div class="panel-content source-content"></div>',
91
+ };
92
+ }
93
+ return {
94
+ html: `<div class="panel-content source-content">${renderRawFallback(rawContent)}</div>`,
95
+ };
96
+ },
86
97
  },
87
98
  };
88
99
  export function getFileTypeCapabilities(payload) {
@@ -5,15 +5,4 @@ export declare function buildOpenInEditorCommand(filePath: string, isLikelyUrl:
5
5
  appName: string;
6
6
  appPath?: string;
7
7
  }>): string | undefined;
8
- export declare function detectDefaultMarkdownEditor(options: {
9
- filePath: string;
10
- editorAppCache: Map<string, {
11
- appName: string;
12
- appPath?: string;
13
- }>;
14
- editorAppPending: Set<string>;
15
- callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown | undefined>;
16
- extractToolText: (value: unknown) => string | undefined;
17
- onDetected?: () => void;
18
- }): Promise<void>;
19
8
  export declare function renderMarkdownEditorAppIcon(): string;
@@ -50,45 +50,6 @@ export function buildOpenInEditorCommand(filePath, isLikelyUrl, editorAppCache)
50
50
  }
51
51
  return `xdg-open ${shellQuote(trimmedPath)}`;
52
52
  }
53
- export async function detectDefaultMarkdownEditor(options) {
54
- const trimmedPath = options.filePath.trim();
55
- if (!trimmedPath || options.editorAppCache.has(trimmedPath) || options.editorAppPending.has(trimmedPath)) {
56
- return;
57
- }
58
- const userAgent = navigator.userAgent.toLowerCase();
59
- if (!userAgent.includes('mac')) {
60
- return;
61
- }
62
- options.editorAppPending.add(trimmedPath);
63
- try {
64
- const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}")
65
- return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`;
66
- const detectResult = await options.callTool?.('start_process', {
67
- command: detectCommand,
68
- timeout_ms: 12000,
69
- });
70
- const text = options.extractToolText(detectResult) ?? '';
71
- if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) {
72
- return;
73
- }
74
- const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
75
- const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
76
- const appPath = lines[lines.length - 1] ?? '';
77
- if (appName && appPath.startsWith('/')) {
78
- options.editorAppCache.set(trimmedPath, {
79
- appName,
80
- appPath,
81
- });
82
- options.onDetected?.();
83
- }
84
- }
85
- catch {
86
- // Fall back to generic editor label.
87
- }
88
- finally {
89
- options.editorAppPending.delete(trimmedPath);
90
- }
91
- }
92
53
  export function renderMarkdownEditorAppIcon() {
93
54
  return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg>';
94
55
  }
@@ -41,6 +41,12 @@ export function extractToolText(value) {
41
41
  }
42
42
  return undefined;
43
43
  }
44
+ function extractStructuredContentText(value) {
45
+ if (!isObjectRecord(value)) {
46
+ return undefined;
47
+ }
48
+ return typeof value.content === 'string' ? value.content : undefined;
49
+ }
44
50
  export function extractRenderPayload(value) {
45
51
  if (!isObjectRecord(value)) {
46
52
  return undefined;
@@ -52,7 +58,10 @@ export function extractRenderPayload(value) {
52
58
  : null;
53
59
  if (!meta)
54
60
  return undefined;
55
- const text = extractToolText(value) ?? extractToolText(value.structuredContent) ?? '';
61
+ const text = extractStructuredContentText(value.structuredContent)
62
+ ?? extractToolText(value)
63
+ ?? extractToolText(value.structuredContent)
64
+ ?? '';
56
65
  return buildRenderPayload(meta, text);
57
66
  }
58
67
  export function assertSuccessfulEditBlockResult(result) {
@@ -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 Secret
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
  // Read clientId and telemetry settings from existing config
19
18
  let uniqueUserId = 'unknown';
@@ -222,11 +221,6 @@ async function trackEvent(eventName, additionalProps = {}) {
222
221
  return true; // Return success since this is expected behavior
223
222
  }
224
223
 
225
- if (!GA_MEASUREMENT_ID || !GA_API_SECRET) {
226
- updateUninstallStep(trackingStep, 'skipped', new Error('GA not configured'));
227
- return;
228
- }
229
-
230
224
  const maxRetries = 2;
231
225
  let attempt = 0;
232
226
  let lastError = null;
@@ -257,43 +251,8 @@ async function trackEvent(eventName, additionalProps = {}) {
257
251
  }
258
252
  };
259
253
 
260
- const result = await new Promise((resolve, reject) => {
261
- const req = https.request(GA_BASE_URL, options);
262
-
263
- const timeoutId = setTimeout(() => {
264
- req.destroy();
265
- reject(new Error('Request timeout'));
266
- }, 5000);
267
-
268
- req.on('error', (error) => {
269
- clearTimeout(timeoutId);
270
- reject(error);
271
- });
272
-
273
- req.on('response', (res) => {
274
- clearTimeout(timeoutId);
275
- let data = '';
276
-
277
- res.on('data', (chunk) => {
278
- data += chunk;
279
- });
280
-
281
- res.on('error', (error) => {
282
- reject(error);
283
- });
284
-
285
- res.on('end', () => {
286
- if (res.statusCode >= 200 && res.statusCode < 300) {
287
- resolve({ success: true, data });
288
- } else {
289
- reject(new Error(`HTTP error ${res.statusCode}: ${data}`));
290
- }
291
- });
292
- });
293
-
294
- req.write(postData);
295
- req.end();
296
- });
254
+ const result = await postTelemetryPayload(postData, options);
255
+ if (!result.success) throw new Error('Telemetry proxy request failed');
297
256
 
298
257
  updateUninstallStep(trackingStep, 'completed');
299
258
  return result;
@@ -309,6 +268,54 @@ async function trackEvent(eventName, additionalProps = {}) {
309
268
  updateUninstallStep(trackingStep, 'failed', lastError);
310
269
  return false;
311
270
  }
271
+
272
+ async function postTelemetryPayload(postData, options) {
273
+ for (const endpoint of [TELEMETRY_PROXY_URL, TELEMETRY_PROXY_FALLBACK_URL]) {
274
+ const result = await new Promise((resolve) => {
275
+ let settled = false;
276
+ let timeoutId;
277
+ const finish = (result) => {
278
+ if (settled) return;
279
+ settled = true;
280
+ clearTimeout(timeoutId);
281
+ resolve(result);
282
+ };
283
+ const req = https.request(endpoint, options);
284
+
285
+ timeoutId = setTimeout(() => {
286
+ req.destroy();
287
+ finish({ success: false, data: '' });
288
+ }, 5000);
289
+
290
+ req.on('error', () => {
291
+ finish({ success: false, data: '' });
292
+ });
293
+
294
+ req.on('response', (res) => {
295
+ let data = '';
296
+ res.on('data', (chunk) => {
297
+ data += chunk;
298
+ });
299
+ res.on('error', () => {
300
+ finish({ success: false, data: '' });
301
+ });
302
+ res.on('end', () => {
303
+ finish({ success: res.statusCode >= 200 && res.statusCode < 300, data });
304
+ });
305
+ res.on('close', () => {
306
+ finish({ success: false, data: '' });
307
+ });
308
+ });
309
+
310
+ req.write(postData);
311
+ req.end();
312
+ });
313
+
314
+ if (result.success) return result;
315
+ }
316
+
317
+ return { success: false, data: '' };
318
+ }
312
319
  // Ensure tracking completes before process exits
313
320
  async function ensureTrackingCompleted(eventName, additionalProps = {}, timeoutMs = 6000) {
314
321
  return new Promise(async (resolve) => {
@@ -750,4 +757,4 @@ if (process.argv.length >= 2 && process.argv[1] === fileURLToPath(import.meta.ur
750
757
  logToFile(`Fatal error: ${error}`, true);
751
758
  process.exit(1);
752
759
  });
753
- }
760
+ }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Get the exact assigned variant for a named experiment.
3
+ */
4
+ export declare function getABTestVariant(experimentName: string): Promise<string | null>;
1
5
  /**
2
6
  * Check if a feature (variant name) is enabled for current user
3
7
  */
@@ -57,6 +57,12 @@ async function getVariant(experimentName) {
57
57
  variantCache[experimentName] = variant;
58
58
  return variant;
59
59
  }
60
+ /**
61
+ * Get the exact assigned variant for a named experiment.
62
+ */
63
+ export async function getABTestVariant(experimentName) {
64
+ return getVariant(experimentName);
65
+ }
60
66
  /**
61
67
  * Check if a feature (variant name) is enabled for current user
62
68
  */
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Hard kill-switch for telemetry via environment variable.
3
+ *
4
+ * Independent of the persisted `telemetryEnabled` config so that tests, CI and
5
+ * one-off runs can suppress all analytics without mutating the user's config.
6
+ * Set DESKTOP_COMMANDER_DISABLE_TELEMETRY to 1/true/yes/on to disable.
7
+ */
8
+ export declare function isTelemetryDisabledByEnv(): boolean;
1
9
  /**
2
10
  * Sanitizes error objects to remove potentially sensitive information like file paths
3
11
  * @param error Error object or string to sanitize
@@ -8,13 +16,13 @@ export declare function sanitizeError(error: any): {
8
16
  code?: string;
9
17
  };
10
18
  /**
11
- * Send an event to Google Analytics
19
+ * Send an event to telemetry
12
20
  * @param event Event name
13
21
  * @param properties Optional event properties
14
22
  */
15
23
  export declare const captureBase: (captureURL: string, event: string, properties?: any) => Promise<void>;
16
- export declare const capture_call_tool: (event: string, properties?: any) => Promise<void>;
17
24
  export declare const capture: (event: string, properties?: any) => Promise<void>;
25
+ export declare const capture_call_tool: (event: string, properties?: any) => Promise<void>;
18
26
  export declare const capture_ui_event: (event: string, properties?: any) => Promise<void>;
19
27
  /**
20
28
  * Wrapper for capture() that automatically adds remote flag for remote-device telemetry