@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.
- package/README.md +4 -4
- package/dist/handlers/filesystem-handlers.js +28 -3
- package/dist/server.d.ts +2 -1
- package/dist/server.js +50 -13
- package/dist/setup-claude-server.js +56 -50
- package/dist/terminal-manager.js +46 -0
- package/dist/tools/edit.js +7 -1
- package/dist/tools/filesystem.d.ts +5 -0
- package/dist/tools/filesystem.js +91 -14
- package/dist/tools/pdf/markdown.d.ts +13 -0
- package/dist/tools/pdf/markdown.js +93 -29
- package/dist/track-installation.js +57 -38
- package/dist/types.d.ts +4 -0
- package/dist/ui/contracts.d.ts +1 -1
- package/dist/ui/contracts.js +4 -1
- package/dist/ui/file-preview/preview-runtime.js +114 -116
- package/dist/ui/file-preview/src/app.js +19 -22
- package/dist/ui/file-preview/src/directory-controller.js +9 -2
- package/dist/ui/file-preview/src/file-type-handlers.js +20 -9
- package/dist/ui/file-preview/src/host/external-actions.d.ts +0 -11
- package/dist/ui/file-preview/src/host/external-actions.js +0 -39
- package/dist/ui/file-preview/src/payload-utils.js +10 -1
- package/dist/uninstall-claude-server.js +54 -47
- package/dist/utils/ab-test.d.ts +4 -0
- package/dist/utils/ab-test.js +6 -0
- package/dist/utils/capture.d.ts +10 -2
- package/dist/utils/capture.js +80 -54
- package/dist/utils/feature-flags.d.ts +3 -0
- package/dist/utils/feature-flags.js +34 -5
- package/dist/utils/files/excel.js +26 -5
- package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
- package/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/utils/capture.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { platform } from 'os';
|
|
2
2
|
import * as https from 'https';
|
|
3
3
|
import { configManager, isTelemetryDisabledValue } from '../config-manager.js';
|
|
4
|
-
import { currentClient } from '../server.js';
|
|
4
|
+
import { currentClient, currentCallIsRemote } from '../server.js';
|
|
5
5
|
let VERSION = 'unknown';
|
|
6
6
|
try {
|
|
7
7
|
const versionModule = await import('../version.js');
|
|
@@ -13,8 +13,26 @@ catch {
|
|
|
13
13
|
// Will be initialized when needed
|
|
14
14
|
let uniqueUserId = 'unknown';
|
|
15
15
|
// --- Telemetry Proxy (direct BigQuery ingestion) ---
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// TODO: Move proxy endpoints, auth header setup, request retry/fallback, and
|
|
17
|
+
// transport code into a dedicated telemetry utility once this migration lands.
|
|
18
|
+
// TODO(security): bearer token was removed, so this endpoint is now unauthenticated.
|
|
19
|
+
// Confirm the proxy enforces rate limiting / payload validation server-side,
|
|
20
|
+
// otherwise anyone can POST arbitrary events straight into BigQuery ingestion.
|
|
21
|
+
const TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';
|
|
22
|
+
const TELEMETRY_PROXY_FALLBACK_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
|
|
23
|
+
/**
|
|
24
|
+
* Hard kill-switch for telemetry via environment variable.
|
|
25
|
+
*
|
|
26
|
+
* Independent of the persisted `telemetryEnabled` config so that tests, CI and
|
|
27
|
+
* one-off runs can suppress all analytics without mutating the user's config.
|
|
28
|
+
* Set DESKTOP_COMMANDER_DISABLE_TELEMETRY to 1/true/yes/on to disable.
|
|
29
|
+
*/
|
|
30
|
+
export function isTelemetryDisabledByEnv() {
|
|
31
|
+
const raw = process.env.DESKTOP_COMMANDER_DISABLE_TELEMETRY;
|
|
32
|
+
if (!raw)
|
|
33
|
+
return false;
|
|
34
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* Sanitizes error objects to remove potentially sensitive information like file paths
|
|
20
38
|
* @param error Error object or string to sanitize
|
|
@@ -47,12 +65,19 @@ export function sanitizeError(error) {
|
|
|
47
65
|
};
|
|
48
66
|
}
|
|
49
67
|
/**
|
|
50
|
-
* Send an event to
|
|
68
|
+
* Send an event to telemetry
|
|
51
69
|
* @param event Event name
|
|
52
70
|
* @param properties Optional event properties
|
|
53
71
|
*/
|
|
72
|
+
// TODO(cleanup): captureBase is now dead code — no caller remains after the GA
|
|
73
|
+
// removal (only referenced in a comment). It still carries the full GA4-flavored
|
|
74
|
+
// send path. Remove it, or repurpose it as the shared proxy transport.
|
|
54
75
|
export const captureBase = async (captureURL, event, properties) => {
|
|
55
76
|
try {
|
|
77
|
+
// Env kill-switch takes precedence over config (tests/CI).
|
|
78
|
+
if (isTelemetryDisabledByEnv()) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
56
81
|
// Check if telemetry is enabled in config (defaults to true if not set)
|
|
57
82
|
const telemetryEnabled = await configManager.getValue('telemetryEnabled');
|
|
58
83
|
// If telemetry is explicitly disabled or GA credentials are missing, don't send
|
|
@@ -178,9 +203,13 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
178
203
|
const eventProperties = {
|
|
179
204
|
...baseProperties,
|
|
180
205
|
...clientContext,
|
|
206
|
+
// Attribute events to the remote path when the in-flight tool call
|
|
207
|
+
// came from a remote device. Placed before sanitizedProperties so an
|
|
208
|
+
// explicit `remote` passed by the caller (e.g. captureRemote) wins.
|
|
209
|
+
...(currentCallIsRemote ? { remote: String(true) } : {}),
|
|
181
210
|
...sanitizedProperties
|
|
182
211
|
};
|
|
183
|
-
// Prepare
|
|
212
|
+
// Prepare telemetry payload
|
|
184
213
|
const payload = {
|
|
185
214
|
client_id: uniqueUserId,
|
|
186
215
|
non_personalized_ads: false,
|
|
@@ -190,7 +219,7 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
190
219
|
params: eventProperties
|
|
191
220
|
}]
|
|
192
221
|
};
|
|
193
|
-
// Send data to
|
|
222
|
+
// Send data to telemetry endpoint
|
|
194
223
|
const postData = JSON.stringify(payload);
|
|
195
224
|
const options = {
|
|
196
225
|
method: 'POST',
|
|
@@ -209,7 +238,7 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
209
238
|
const success = res.statusCode === 200 || res.statusCode === 204;
|
|
210
239
|
if (!success) {
|
|
211
240
|
// Optional debug logging
|
|
212
|
-
// console.debug(`
|
|
241
|
+
// console.debug(`Telemetry tracking error: ${res.statusCode} ${data}`);
|
|
213
242
|
}
|
|
214
243
|
});
|
|
215
244
|
});
|
|
@@ -229,7 +258,7 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
229
258
|
}
|
|
230
259
|
};
|
|
231
260
|
/**
|
|
232
|
-
* Build the standard event properties used by
|
|
261
|
+
* Build the standard event properties used by the telemetry proxy.
|
|
233
262
|
* Extracted from captureBase so both paths get identical data.
|
|
234
263
|
*/
|
|
235
264
|
const buildEventProperties = async (properties) => {
|
|
@@ -325,16 +354,21 @@ const buildEventProperties = async (properties) => {
|
|
|
325
354
|
app_version: VERSION,
|
|
326
355
|
engagement_time_msec: "100",
|
|
327
356
|
...clientContext,
|
|
357
|
+
// Attribute events to the remote path when the in-flight tool call
|
|
358
|
+
// came from a remote device. Placed before sanitizedProperties so an
|
|
359
|
+
// explicit `remote` passed by the caller (e.g. captureRemote) wins.
|
|
360
|
+
...(currentCallIsRemote ? { remote: String(true) } : {}),
|
|
328
361
|
...sanitizedProperties,
|
|
329
362
|
};
|
|
330
363
|
};
|
|
331
364
|
/**
|
|
332
365
|
* Send event to the telemetry proxy (direct BigQuery ingestion).
|
|
333
|
-
*
|
|
334
|
-
* the 1M/day GA4 BigQuery export limit.
|
|
366
|
+
* Uses the custom domain first and retries the generated Cloud Run URL on failure.
|
|
335
367
|
*/
|
|
336
368
|
const sendToTelemetryProxy = async (event, eventProperties) => {
|
|
337
369
|
try {
|
|
370
|
+
if (isTelemetryDisabledByEnv())
|
|
371
|
+
return;
|
|
338
372
|
const telemetryEnabled = await configManager.getValue('telemetryEnabled');
|
|
339
373
|
if (isTelemetryDisabledValue(telemetryEnabled))
|
|
340
374
|
return;
|
|
@@ -346,7 +380,18 @@ const sendToTelemetryProxy = async (event, eventProperties) => {
|
|
|
346
380
|
params: eventProperties
|
|
347
381
|
}]
|
|
348
382
|
});
|
|
349
|
-
const
|
|
383
|
+
const sent = await postTelemetryPayload(TELEMETRY_PROXY_URL, payload);
|
|
384
|
+
if (!sent) {
|
|
385
|
+
await postTelemetryPayload(TELEMETRY_PROXY_FALLBACK_URL, payload);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Silent fail — telemetry should never break functionality
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const postTelemetryPayload = async (endpoint, payload) => {
|
|
393
|
+
return await new Promise((resolve) => {
|
|
394
|
+
const url = new URL(endpoint);
|
|
350
395
|
const options = {
|
|
351
396
|
hostname: url.hostname,
|
|
352
397
|
port: 443,
|
|
@@ -354,59 +399,40 @@ const sendToTelemetryProxy = async (event, eventProperties) => {
|
|
|
354
399
|
method: 'POST',
|
|
355
400
|
headers: {
|
|
356
401
|
'Content-Type': 'application/json',
|
|
357
|
-
'Authorization': `Bearer ${TELEMETRY_PROXY_TOKEN}`,
|
|
358
402
|
'Content-Length': Buffer.byteLength(payload)
|
|
359
403
|
}
|
|
360
404
|
};
|
|
361
405
|
const req = https.request(options, (res) => {
|
|
362
|
-
res.resume();
|
|
406
|
+
res.resume();
|
|
407
|
+
res.on('end', () => resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300));
|
|
408
|
+
});
|
|
409
|
+
req.on('error', () => resolve(false));
|
|
410
|
+
req.setTimeout(3000, () => {
|
|
411
|
+
req.destroy();
|
|
412
|
+
resolve(false);
|
|
363
413
|
});
|
|
364
|
-
req.on('error', () => { }); // silent fail
|
|
365
|
-
req.setTimeout(3000, () => req.destroy());
|
|
366
414
|
req.write(payload);
|
|
367
415
|
req.end();
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
// Silent fail — telemetry should never break functionality
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
export const capture_call_tool = async (event, properties) => {
|
|
374
|
-
// Old property (G-8L163XZ1CE) — keeps lower-volume tool events
|
|
375
|
-
const GA_OLD_ID = 'G-8L163XZ1CE';
|
|
376
|
-
const GA_OLD_SECRET = 'hNxh4TK2TnSy4oLZn4RwTA';
|
|
377
|
-
const GA_OLD_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_OLD_ID}&api_secret=${GA_OLD_SECRET}`;
|
|
378
|
-
// New property (dc_high_volume) — receives highest-volume tool events to avoid 1M/day BQ export limit
|
|
379
|
-
const GA_NEW_ID = 'G-ZDF1M5403Z';
|
|
380
|
-
const GA_NEW_SECRET = 'cUEilpa0SpWfc2UjblDtKQ';
|
|
381
|
-
const GA_NEW_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_NEW_ID}&api_secret=${GA_NEW_SECRET}`;
|
|
382
|
-
// Route highest-volume tools to new property, rest to old
|
|
383
|
-
const HIGH_VOLUME_TOOLS = ['start_process', 'track_ui_event'];
|
|
384
|
-
const toolName = properties?.tool_name ?? properties?.name;
|
|
385
|
-
const gaUrl = HIGH_VOLUME_TOOLS.includes(toolName) ? GA_NEW_URL : GA_OLD_URL;
|
|
386
|
-
// Build properties once, send to GA4 + telemetry proxy in parallel
|
|
387
|
-
const eventProperties = await buildEventProperties(properties);
|
|
388
|
-
await Promise.all([
|
|
389
|
-
captureBase(gaUrl, event, properties), // GA4 (routed by tool name)
|
|
390
|
-
sendToTelemetryProxy(event, eventProperties), // direct BigQuery (all events)
|
|
391
|
-
]);
|
|
416
|
+
});
|
|
392
417
|
};
|
|
418
|
+
// TODO(behavior): capture() is now fire-and-forget — every `await capture(...)`
|
|
419
|
+
// call site resolves before the network send completes. Fine for the long-running
|
|
420
|
+
// MCP server, but events fired right before process exit (e.g. opt-out, feedback)
|
|
421
|
+
// can be silently dropped. If we need delivery guarantees on short-lived paths,
|
|
422
|
+
// expose an awaitable variant or flush-before-exit hook.
|
|
393
423
|
export const capture = async (event, properties) => {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
};
|
|
404
|
-
export const capture_ui_event = async (event, properties) => {
|
|
405
|
-
const GA_MEASUREMENT_ID = 'G-MPFSWEGQ0T';
|
|
406
|
-
const GA_API_SECRET = 'BeK3uyAOQ6-TK6wnaDG2Ww';
|
|
407
|
-
const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
|
|
408
|
-
return await captureBase(GA_BASE_URL, event, properties);
|
|
424
|
+
void (async () => {
|
|
425
|
+
try {
|
|
426
|
+
const eventProperties = await buildEventProperties(properties);
|
|
427
|
+
await sendToTelemetryProxy(event, eventProperties);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Silent fail — telemetry should never break functionality
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
409
433
|
};
|
|
434
|
+
export const capture_call_tool = capture;
|
|
435
|
+
export const capture_ui_event = capture;
|
|
410
436
|
/**
|
|
411
437
|
* Wrapper for capture() that automatically adds remote flag for remote-device telemetry
|
|
412
438
|
* Also adds additional privacy filtering to remove sensitive identity information
|
|
@@ -33,6 +33,9 @@ declare class FeatureFlagManager {
|
|
|
33
33
|
* Wait for fresh flags to be fetched from network.
|
|
34
34
|
* Use this when you need to ensure flags are loaded before making decisions
|
|
35
35
|
* (e.g., A/B test assignments for new users who don't have a cache yet)
|
|
36
|
+
*
|
|
37
|
+
* Has a hard timeout to prevent blocking MCP startup if the fetch hangs.
|
|
38
|
+
* See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465
|
|
36
39
|
*/
|
|
37
40
|
waitForFreshFlags(): Promise<void>;
|
|
38
41
|
/**
|
|
@@ -93,10 +93,24 @@ class FeatureFlagManager {
|
|
|
93
93
|
* Wait for fresh flags to be fetched from network.
|
|
94
94
|
* Use this when you need to ensure flags are loaded before making decisions
|
|
95
95
|
* (e.g., A/B test assignments for new users who don't have a cache yet)
|
|
96
|
+
*
|
|
97
|
+
* Has a hard timeout to prevent blocking MCP startup if the fetch hangs.
|
|
98
|
+
* See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465
|
|
96
99
|
*/
|
|
97
100
|
async waitForFreshFlags() {
|
|
98
101
|
if (this.freshFetchPromise) {
|
|
99
|
-
|
|
102
|
+
let safetyTimeoutHandle;
|
|
103
|
+
try {
|
|
104
|
+
const safetyTimeout = new Promise((resolve) => {
|
|
105
|
+
safetyTimeoutHandle = setTimeout(resolve, 5000);
|
|
106
|
+
});
|
|
107
|
+
await Promise.race([this.freshFetchPromise, safetyTimeout]);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (safetyTimeoutHandle) {
|
|
111
|
+
clearTimeout(safetyTimeoutHandle);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
116
|
/**
|
|
@@ -127,17 +141,26 @@ class FeatureFlagManager {
|
|
|
127
141
|
* Fetch flags from remote URL
|
|
128
142
|
*/
|
|
129
143
|
async fetchFlags() {
|
|
144
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
147
|
+
let hardTimeoutHandle;
|
|
130
148
|
try {
|
|
131
149
|
// Don't log here - runs async and can interfere with MCP clients
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
// Use Promise.race as a hard timeout safety net.
|
|
151
|
+
// On some platforms (Windows + Node 24 / undici 7.x), AbortController.abort()
|
|
152
|
+
// fails to interrupt an in-progress TCP connect — the fetch hangs until the
|
|
153
|
+
// OS-level TCP timeout (~30s on Windows). Promise.race guarantees we reject
|
|
154
|
+
// at the JS level regardless of AbortController behavior.
|
|
155
|
+
// See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465
|
|
156
|
+
const fetchPromise = fetch(this.flagUrl, {
|
|
135
157
|
signal: controller.signal,
|
|
136
158
|
headers: {
|
|
137
159
|
'Cache-Control': 'no-cache',
|
|
138
160
|
}
|
|
139
161
|
});
|
|
140
|
-
|
|
162
|
+
const hardTimeout = new Promise((_, reject) => hardTimeoutHandle = setTimeout(() => reject(new Error('Feature flags fetch timed out')), FETCH_TIMEOUT_MS));
|
|
163
|
+
const response = await Promise.race([fetchPromise, hardTimeout]);
|
|
141
164
|
if (!response.ok) {
|
|
142
165
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
143
166
|
}
|
|
@@ -155,6 +178,12 @@ class FeatureFlagManager {
|
|
|
155
178
|
logger.debug('Failed to fetch feature flags:', error.message);
|
|
156
179
|
// Continue with cached values
|
|
157
180
|
}
|
|
181
|
+
finally {
|
|
182
|
+
clearTimeout(abortTimeout);
|
|
183
|
+
if (hardTimeoutHandle) {
|
|
184
|
+
clearTimeout(hardTimeoutHandle);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
158
187
|
}
|
|
159
188
|
/**
|
|
160
189
|
* Save flags to local cache
|
|
@@ -25,8 +25,10 @@ export class ExcelFileHandler {
|
|
|
25
25
|
const paginationInfo = totalRows > returnedRows
|
|
26
26
|
? `\n[Showing rows ${(options?.offset || 0) + 1}-${(options?.offset || 0) + returnedRows} of ${totalRows} total. Use offset/length to paginate.]`
|
|
27
27
|
: '';
|
|
28
|
+
const sheetHasSpace = /\s/.test(sheetName);
|
|
29
|
+
const exampleSheet = sheetHasSpace ? sheetName : 'Sheet1';
|
|
28
30
|
const content = `[Sheet: '${sheetName}' from ${path}]${paginationInfo}
|
|
29
|
-
[To MODIFY cells: use edit_block with range param, e.g., edit_block(path, {range: "
|
|
31
|
+
[To MODIFY cells: use edit_block with range param, e.g., edit_block(path, {range: "${exampleSheet}!E5", content: [[newValue]]}). read_file accepts the same range form, or pass sheet + range separately.]
|
|
30
32
|
|
|
31
33
|
${JSON.stringify(data)}`;
|
|
32
34
|
return {
|
|
@@ -260,6 +262,17 @@ ${JSON.stringify(data)}`;
|
|
|
260
262
|
if (workbook.worksheets.length === 0) {
|
|
261
263
|
return { sheetName: '', data: [], totalRows: 0, returnedRows: 0 };
|
|
262
264
|
}
|
|
265
|
+
// Accept range with embedded sheet prefix (parity with edit_block).
|
|
266
|
+
// E.g. range:"Sheet1!A1:B2" or "'My Sheet'!A1" — strip the sheet
|
|
267
|
+
// prefix and, when the caller did not pass an explicit sheet, use it.
|
|
268
|
+
let cellRangeOnly = range;
|
|
269
|
+
if (range && range.includes('!')) {
|
|
270
|
+
const [sheetFromRange, cellsFromRange] = this.parseRange(range);
|
|
271
|
+
cellRangeOnly = cellsFromRange ?? undefined;
|
|
272
|
+
if (sheetRef === undefined && sheetFromRange) {
|
|
273
|
+
sheetRef = sheetFromRange;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
263
276
|
// Find target worksheet
|
|
264
277
|
let worksheet;
|
|
265
278
|
let sheetName;
|
|
@@ -287,8 +300,8 @@ ${JSON.stringify(data)}`;
|
|
|
287
300
|
let endRow = worksheet.actualRowCount || 1;
|
|
288
301
|
let startCol = 1;
|
|
289
302
|
let endCol = worksheet.actualColumnCount || 1;
|
|
290
|
-
if (
|
|
291
|
-
const parsed = this.parseCellRange(
|
|
303
|
+
if (cellRangeOnly) {
|
|
304
|
+
const parsed = this.parseCellRange(cellRangeOnly);
|
|
292
305
|
startRow = parsed.startRow;
|
|
293
306
|
startCol = parsed.startCol;
|
|
294
307
|
if (parsed.endRow)
|
|
@@ -386,7 +399,14 @@ ${JSON.stringify(data)}`;
|
|
|
386
399
|
}
|
|
387
400
|
parseRange(range) {
|
|
388
401
|
if (range.includes('!')) {
|
|
389
|
-
const
|
|
402
|
+
const idx = range.indexOf('!');
|
|
403
|
+
let sheetName = range.slice(0, idx);
|
|
404
|
+
const cellRange = range.slice(idx + 1);
|
|
405
|
+
// Strip Excel-native single quotes around sheet names with spaces:
|
|
406
|
+
// 'My Sheet'!A1 → My Sheet, A1
|
|
407
|
+
if (sheetName.length >= 2 && sheetName.startsWith("'") && sheetName.endsWith("'")) {
|
|
408
|
+
sheetName = sheetName.slice(1, -1).replace(/''/g, "'");
|
|
409
|
+
}
|
|
390
410
|
return [sheetName, cellRange];
|
|
391
411
|
}
|
|
392
412
|
return [range, null];
|
|
@@ -395,7 +415,8 @@ ${JSON.stringify(data)}`;
|
|
|
395
415
|
// Parse A1 or A1:C10 format
|
|
396
416
|
const match = range.match(/^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$/i);
|
|
397
417
|
if (!match) {
|
|
398
|
-
throw new Error(`Invalid cell range: ${range}`
|
|
418
|
+
throw new Error(`Invalid cell range: "${range}". Expected forms: "A1", "A1:C10", or "SheetName!A1:C10" ` +
|
|
419
|
+
`(single-quote sheet names containing spaces: "'My Sheet'!A1:C10").`);
|
|
399
420
|
}
|
|
400
421
|
const startCol = this.columnToNumber(match[1]);
|
|
401
422
|
const startRow = parseInt(match[2], 10);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const MCP_UI_EXPERIMENT_NAME = "McpUiPreviews";
|
|
2
|
+
export declare const MCP_UI_SHOW_VARIANT = "showMCPUi";
|
|
3
|
+
export declare const MCP_UI_HIDE_VARIANT = "notShowMCPUi";
|
|
4
|
+
export interface McpUiPreviewDecisionDeps {
|
|
5
|
+
getExistingAssignment: () => Promise<unknown>;
|
|
6
|
+
isFirstRun: () => boolean;
|
|
7
|
+
wasLoadedFromCache: () => boolean;
|
|
8
|
+
waitForFreshFlags: () => Promise<void>;
|
|
9
|
+
getABTestVariant: (experimentName: string) => Promise<string | null>;
|
|
10
|
+
capture: (event: string, properties?: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveMcpUiPreviewDecision(deps: McpUiPreviewDecisionDeps): Promise<boolean>;
|
|
13
|
+
export declare function shouldShowMcpUiPreviews(): Promise<boolean>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { configManager } from '../config-manager.js';
|
|
2
|
+
import { getABTestVariant } from './ab-test.js';
|
|
3
|
+
import { capture } from './capture.js';
|
|
4
|
+
import { featureFlagManager } from './feature-flags.js';
|
|
5
|
+
export const MCP_UI_EXPERIMENT_NAME = 'McpUiPreviews';
|
|
6
|
+
export const MCP_UI_SHOW_VARIANT = 'showMCPUi';
|
|
7
|
+
export const MCP_UI_HIDE_VARIANT = 'notShowMCPUi';
|
|
8
|
+
function variantEnablesMcpUi(variant) {
|
|
9
|
+
if (variant === MCP_UI_HIDE_VARIANT)
|
|
10
|
+
return false;
|
|
11
|
+
if (variant === MCP_UI_SHOW_VARIANT)
|
|
12
|
+
return true;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export async function resolveMcpUiPreviewDecision(deps) {
|
|
16
|
+
try {
|
|
17
|
+
const existingAssignment = await deps.getExistingAssignment();
|
|
18
|
+
const existingDecision = variantEnablesMcpUi(existingAssignment);
|
|
19
|
+
if (existingDecision !== null) {
|
|
20
|
+
if (!deps.wasLoadedFromCache()) {
|
|
21
|
+
await deps.waitForFreshFlags();
|
|
22
|
+
}
|
|
23
|
+
const currentVariant = await deps.getABTestVariant(MCP_UI_EXPERIMENT_NAME);
|
|
24
|
+
return variantEnablesMcpUi(currentVariant) ?? existingDecision;
|
|
25
|
+
}
|
|
26
|
+
if (!deps.isFirstRun()) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (!deps.wasLoadedFromCache()) {
|
|
30
|
+
await deps.waitForFreshFlags();
|
|
31
|
+
}
|
|
32
|
+
const variant = await deps.getABTestVariant(MCP_UI_EXPERIMENT_NAME);
|
|
33
|
+
const decision = variantEnablesMcpUi(variant);
|
|
34
|
+
if (decision === null) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await deps.capture('server_mcp_ui_ab_decision', {
|
|
39
|
+
experiment: MCP_UI_EXPERIMENT_NAME,
|
|
40
|
+
variant,
|
|
41
|
+
mcp_ui_enabled: decision,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Telemetry must not change the assigned product experience.
|
|
46
|
+
}
|
|
47
|
+
return decision;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function shouldShowMcpUiPreviews() {
|
|
54
|
+
return resolveMcpUiPreviewDecision({
|
|
55
|
+
getExistingAssignment: () => configManager.getValue(`abTest_${MCP_UI_EXPERIMENT_NAME}`),
|
|
56
|
+
isFirstRun: () => configManager.isFirstRun(),
|
|
57
|
+
wasLoadedFromCache: () => featureFlagManager.wasLoadedFromCache(),
|
|
58
|
+
waitForFreshFlags: () => featureFlagManager.waitForFreshFlags(),
|
|
59
|
+
getABTestVariant,
|
|
60
|
+
capture,
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.2.
|
|
1
|
+
export declare const VERSION = "0.2.42";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.2.
|
|
1
|
+
export const VERSION = '0.2.42';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonderwhy-er/desktop-commander",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.42",
|
|
4
4
|
"description": "MCP server for terminal operations and file editing",
|
|
5
5
|
"mcpName": "io.github.wonderwhy-er/desktop-commander",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"prepare": "npm run build",
|
|
42
42
|
"clean": "shx rm -rf dist",
|
|
43
43
|
"test": "npm run build && node test/run-all-tests.js",
|
|
44
|
+
"test:integration": "npm run build && node test/integration/run-all-integration-tests.js",
|
|
44
45
|
"test:debug": "node --inspect test/run-all-tests.js",
|
|
45
46
|
"validate:tools": "npm run build && node scripts/validate-tools-sync.js",
|
|
46
47
|
"link:local": "npm run build && npm link",
|