UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

841 lines (838 loc) 32.3 kB
import { App } from '@modelcontextprotocol/ext-apps'; import { createToolBridge } from '../../shared/tool-bridge.js'; import { createCompactRowShellController } from '../../shared/tool-shell.js'; import { renderCompactRow } from '../../shared/compact-row.js'; import { escapeHtml } from '../../shared/escape-html.js'; import { createWidgetStateStorage } from '../../shared/widget-state.js'; import { connectWithSharedHostContext, isObjectRecord } from '../../shared/host-context.js'; import { createUiEventTracker } from '../../shared/ui-event-tracker.js'; import { createArrayModalController, renderArrayModalMarkup } from './array-modal.js'; import { CONFIG_FIELD_DEFINITIONS, isConfigFieldKey } from '../../../config-field-definitions.js'; let shellController; const CONFIG_EDITOR_COMPONENT = 'config_editor'; const GET_CONFIG_TOOL_NAME = 'get_config'; const MAX_TELEMETRY_MESSAGE_LENGTH = 180; function sanitizeTelemetryErrorMessage(message) { // Keep error signal useful while removing path-like data and bounding payload size. const collapsed = message.replace(/\s+/g, ' ').trim(); const withoutPaths = collapsed .replace(/(?:\/|\\)[\w\d_.\-/\\]+/g, '[PATH]') .replace(/[A-Za-z]:\\[\w\d_.\-/\\]+/g, '[PATH]'); if (withoutPaths.length === 0) { return 'Unknown error'; } if (withoutPaths.length <= MAX_TELEMETRY_MESSAGE_LENGTH) { return withoutPaths; } return `${withoutPaths.slice(0, MAX_TELEMETRY_MESSAGE_LENGTH - 3)}...`; } function buildConfigUpdateTelemetryParams(args) { const base = { config_key: args.configKey, value_type: args.valueType, }; if (args.errorStage) { base.error_stage = args.errorStage; } if (args.errorMessage) { base.error_message = sanitizeTelemetryErrorMessage(args.errorMessage); } return base; } function isConfigEditorPayload(value) { return isObjectRecord(value) && Array.isArray(value.entries); } function stringifyValueForInput(value) { if (Array.isArray(value)) { return value.map((item) => String(item)).join('\n'); } if (typeof value === 'string') { return value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } if (value === null) { return 'null'; } try { return JSON.stringify(value); } catch { return String(value); } } function formatKeyLabel(key) { return key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .trim() .split(/\s+/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function getConfigFieldMetadata(key) { if (!isConfigFieldKey(key)) { return undefined; } return { label: CONFIG_FIELD_DEFINITIONS[key].label, description: CONFIG_FIELD_DEFINITIONS[key].description, }; } function extractPayload(result) { if (!isObjectRecord(result)) { return null; } const structured = result.structuredContent; if (!isObjectRecord(structured) || !Array.isArray(structured.entries)) { return null; } const entries = structured.entries .filter((entry) => isObjectRecord(entry)) .filter((entry) => typeof entry.key === 'string') .map((entry) => { const key = entry.key; const metadata = getConfigFieldMetadata(key); return { key, label: metadata?.label, description: metadata?.description, value: entry.value, valueType: typeof entry.valueType === 'string' ? entry.valueType : 'string', editable: entry.editable !== false, }; }); return { config: isObjectRecord(structured.config) ? structured.config : undefined, uiHints: isObjectRecord(structured.uiHints) ? { availableShells: Array.isArray(structured.uiHints.availableShells) ? structured.uiHints.availableShells.filter((value) => typeof value === 'string') : undefined, } : undefined, entries, }; } function extractToolText(result) { if (!isObjectRecord(result) || !Array.isArray(result.content)) { return undefined; } for (const item of result.content) { if (!isObjectRecord(item)) { continue; } if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) { return item.text; } } return undefined; } function isToolErrorResult(result) { return isObjectRecord(result) && result.isError === true; } function parseDraftValue(rawValue, valueType) { if (valueType === 'string') { return { ok: true, value: rawValue.replace(/\r?\n/g, ' ') }; } if (valueType === 'number') { if (rawValue.trim() === '') { return { ok: false, message: 'Enter a valid number.' }; } const numeric = Number(rawValue); if (!Number.isFinite(numeric)) { return { ok: false, message: 'Enter a valid number.' }; } return { ok: true, value: numeric }; } if (valueType === 'boolean') { const normalized = rawValue.trim().toLowerCase(); if (normalized !== 'true' && normalized !== 'false') { return { ok: false, message: 'Boolean values must be true or false.' }; } return { ok: true, value: normalized === 'true' }; } if (valueType === 'null') { if (rawValue.trim().toLowerCase() !== 'null') { return { ok: false, message: 'Null values must be exactly null.' }; } return { ok: true, value: null }; } if (valueType === 'array') { const trimmed = rawValue.trim(); if (trimmed.length === 0) { return { ok: true, value: [] }; } if (!trimmed.startsWith('[')) { const items = rawValue .split(/\r?\n/) .map((item) => item.trim()) .filter((item) => item.length > 0); return { ok: true, value: items }; } try { const parsed = JSON.parse(trimmed); if (!Array.isArray(parsed)) { return { ok: false, message: 'Array values must be valid JSON arrays.' }; } if (!parsed.every((item) => typeof item === 'string')) { return { ok: false, message: 'Array values must contain only strings.' }; } return { ok: true, value: parsed }; } catch { return { ok: false, message: 'Array values must be valid JSON arrays.' }; } } return { ok: true, value: rawValue }; } function areConfigValuesEqual(left, right) { if (Object.is(left, right)) { return true; } if (Array.isArray(left) && Array.isArray(right)) { if (left.length !== right.length) { return false; } for (let index = 0; index < left.length; index += 1) { if (!Object.is(left[index], right[index])) { return false; } } return true; } return false; } function parseDraftArrayValues(draft) { const trimmed = draft.trim(); if (!trimmed) { return []; } if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { return parsed.filter((item) => typeof item === 'string'); } } catch { // fallback below } } return draft .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); } function getSettingSummary(entry) { if (entry.key !== 'blockedCommands' && entry.key !== 'allowedDirectories') { return null; } const values = Array.isArray(entry.value) ? entry.value.filter((item) => typeof item === 'string' && item.trim().length > 0) : []; const count = values.length; if (entry.key === 'blockedCommands') { return `${count} command${count === 1 ? '' : 's'} blocked`; } if (count === 0) { return 'All folders allowed (no restriction)'; } return `${count} folder${count === 1 ? '' : 's'} allowed`; } function getShellOptions(payload, currentShell) { const hintedShells = Array.isArray(payload?.uiHints?.availableShells) ? payload.uiHints.availableShells.filter((shell) => typeof shell === 'string' && shell.trim().length > 0) : []; const config = payload?.config; const systemInfo = isObjectRecord(config?.systemInfo) ? config.systemInfo : null; const isWindows = Boolean(systemInfo && systemInfo.isWindows === true); const isMacOS = Boolean(systemInfo && systemInfo.isMacOS === true); const baseOptions = hintedShells.length > 0 ? hintedShells : isWindows ? ['powershell.exe', 'pwsh.exe', 'cmd.exe', 'bash.exe'] : isMacOS ? ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/fish', 'zsh', 'bash', 'sh', 'fish'] : ['/bin/bash', '/bin/sh', '/usr/bin/fish', '/bin/zsh', 'bash', 'sh', 'fish', 'zsh']; const options = new Set(); for (const shell of baseOptions) { options.add(shell); } if (currentShell.trim().length > 0) { options.add(currentShell); } return [...options]; } export function createConfigEditorController(callTool, trackConfigUiEvent) { const state = { payload: null, selectedKey: null, draftValue: '', }; const getSelectedEntry = () => { if (!state.payload || !state.selectedKey) { return undefined; } return state.payload.entries.find((entry) => entry.key === state.selectedKey); }; const setPayload = (payload) => { state.payload = payload; if (!payload || payload.entries.length === 0) { state.selectedKey = null; state.draftValue = ''; return; } if (!state.selectedKey || !payload.entries.some((entry) => entry.key === state.selectedKey)) { state.selectedKey = payload.entries[0].key; } const current = getSelectedEntry(); state.draftValue = current ? stringifyValueForInput(current.value) : ''; }; const setSelection = (key) => { state.selectedKey = key; const selected = getSelectedEntry(); state.draftValue = selected ? stringifyValueForInput(selected.value) : ''; }; const setDraftValue = (value) => { state.draftValue = value; }; const apply = async () => { const selected = getSelectedEntry(); if (!selected) { return { ok: false, tooltip: { message: 'Select a config key before applying.', tone: 'error', }, }; } if (!selected.editable) { return { ok: false, tooltip: { message: `The selected key (${selected.key}) is read-only.`, tone: 'error', }, }; } const parsed = parseDraftValue(state.draftValue, selected.valueType); if (!parsed.ok) { return { ok: false, tooltip: { message: parsed.message, tone: 'error', }, }; } if (areConfigValuesEqual(parsed.value, selected.value)) { state.draftValue = stringifyValueForInput(selected.value); return { ok: false }; } try { const setResult = await callTool('set_config_value', { key: selected.key, value: parsed.value, origin: 'ui', }); if (isToolErrorResult(setResult)) { const errorMessage = extractToolText(setResult) ?? `Failed to update ${selected.key}.`; trackConfigUiEvent?.('config_update_failed', { tool_name: 'set_config_value', ...buildConfigUpdateTelemetryParams({ configKey: selected.key, valueType: selected.valueType, errorMessage, errorStage: 'set_config_value', }), }); return { ok: false, tooltip: { message: errorMessage, tone: 'error', }, }; } trackConfigUiEvent?.('config_update_success', { tool_name: 'set_config_value', ...buildConfigUpdateTelemetryParams({ configKey: selected.key, valueType: selected.valueType, }), }); const refreshed = await callTool('get_config', {}); if (isToolErrorResult(refreshed)) { const errorMessage = extractToolText(refreshed) ?? 'Value was updated but config refresh failed.'; return { ok: false, tooltip: { message: errorMessage, tone: 'error', }, }; } const refreshedPayload = extractPayload(refreshed); if (refreshedPayload) { setPayload(refreshedPayload); if (state.selectedKey === selected.key) { const updatedSelected = getSelectedEntry(); state.draftValue = updatedSelected ? stringifyValueForInput(updatedSelected.value) : state.draftValue; } } return { ok: true, }; } catch (error) { const errorMessage = `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`; trackConfigUiEvent?.('config_update_failed', { tool_name: 'set_config_value', ...buildConfigUpdateTelemetryParams({ configKey: selected.key, valueType: selected.valueType, errorMessage, errorStage: 'transport', }), }); return { ok: false, tooltip: { message: errorMessage, tone: 'error', }, }; } }; return { state, callTool, extractPayload, setPayload, setSelection, setDraftValue, apply, }; } function render(container, controller, chrome, hooks = {}) { shellController?.dispose(); shellController = undefined; const { state } = controller; const entries = state.payload?.entries ?? []; const shellClasses = [ 'shell', 'tool-shell', chrome.expanded ? 'expanded' : 'collapsed', 'config-shell', chrome.hideSummaryRow ? 'host-framed' : '', chrome.compact ? 'compact' : '', ].filter(Boolean).join(' '); const settingsHtml = entries.map((entry, index) => { const keyTitle = entry.label ?? formatKeyLabel(entry.key); const description = entry.description ?? ''; const summary = getSettingSummary(entry); let controlHtml; if (entry.key === 'defaultShell') { const currentShell = String(entry.value ?? ''); const shellOptions = getShellOptions(state.payload, currentShell); const isCustomShell = !shellOptions.includes(currentShell); const options = shellOptions .map((shell) => `<option value="${escapeHtml(shell)}" ${shell === currentShell ? 'selected' : ''}>${escapeHtml(shell)}</option>`) .join(''); controlHtml = ` <div class="setting-shell-control"> <select class="setting-inline-select" data-action="shell-select" data-key-index="${index}"> ${options} <option value="__custom__" ${isCustomShell ? 'selected' : ''}>Custom...</option> </select> <input class="setting-inline-input setting-shell-custom${isCustomShell ? '' : ' hidden'}" data-action="shell-custom" data-key-index="${index}" type="text" value="${escapeHtml(currentShell)}" placeholder="Type custom shell path"/> </div> `; } else if (entry.valueType === 'boolean') { const checked = String(entry.value) === 'true' ? 'checked' : ''; controlHtml = `<label class="setting-switch"><input type="checkbox" data-action="toggle-boolean" data-key-index="${index}" ${checked}/><span class="config-boolean-slider"></span></label>`; } else if (entry.valueType === 'number') { controlHtml = `<input class="setting-inline-input" data-action="edit-number" data-key-index="${index}" type="number" step="any" value="${escapeHtml(String(entry.value ?? ''))}"/>`; } else if (entry.valueType === 'array') { controlHtml = `<button class="setting-inline-action" data-action="open-array" data-key-index="${index}">Edit</button>`; } else if (entry.valueType === 'null') { controlHtml = `<span class="setting-inline-static">null</span>`; } else { controlHtml = `<input class="setting-inline-input" data-action="edit-string" data-key-index="${index}" type="text" value="${escapeHtml(String(entry.value ?? ''))}"/>`; } return ` <section class="setting-row"> <div class="setting-info"> <h3>${escapeHtml(keyTitle)}</h3> ${description ? `<p>${escapeHtml(description)}</p>` : ''} <p class="setting-summary${summary ? '' : ' hidden'}" data-setting-summary-key="${escapeHtml(entry.key)}">${summary ? escapeHtml(summary) : ''}</p> </div> <div class="setting-control">${controlHtml}</div> </section> `; }).join(''); container.innerHTML = ` <main id="tool-shell" class="${shellClasses}"> ${renderCompactRow({ id: 'compact-toggle', label: 'View config', filename: 'Desktop Commander', variant: 'ready', expandable: true, expanded: chrome.expanded, interactive: true })} <section class="panel config-card"> <div class="panel-content-wrapper"> <div class="settings-stack" aria-label="Desktop Commander settings">${settingsHtml}</div> </div> </section> ${renderArrayModalMarkup('List Setting')} </main> <div id="config-tooltip" class="config-tooltip" role="status" aria-live="polite" hidden></div> `; const toolShell = container.querySelector('#tool-shell'); const compactRow = container.querySelector('.compact-row--ready'); const getUpdatedEntryByKey = (key) => { return controller.state.payload?.entries.find((item) => item.key === key); }; const refreshSettingSummary = (key) => { const summaryElement = container.querySelector(`[data-setting-summary-key="${key}"]`); if (!summaryElement) { return; } const updatedEntry = getUpdatedEntryByKey(key); const summary = updatedEntry ? getSettingSummary(updatedEntry) : null; summaryElement.textContent = summary ?? ''; summaryElement.classList.toggle('hidden', !summary); }; const emitConfigChanged = (key, fallbackValue) => { const updatedEntry = getUpdatedEntryByKey(key); refreshSettingSummary(key); hooks.onConfigChanged?.({ key, value: updatedEntry ? updatedEntry.value : fallbackValue, }); }; const emitTooltip = (result) => { if (result.tooltip) { hooks.onTooltip?.(result.tooltip); } }; const arrayModal = createArrayModalController({ container, parseEntryItems: (entry) => parseDraftArrayValues(stringifyValueForInput(entry.value)), formatEntryTitle: (entry) => entry.label ?? formatKeyLabel(entry.key), onSave: async (changedKey, items) => { controller.setSelection(changedKey); controller.setDraftValue(items.join('\n')); const result = await controller.apply(); emitTooltip(result); if (result.ok) { emitConfigChanged(changedKey, items); } }, }); container.querySelectorAll('[data-action]').forEach((element) => { const target = element; const action = target.dataset.action; const keyIndex = Number(target.dataset.keyIndex); const entry = entries[keyIndex]; if (!entry) { return; } if (action === 'toggle-boolean') { target.addEventListener('change', async () => { const input = target; const previousChecked = input.checked; controller.setSelection(entry.key); controller.setDraftValue(input.checked ? 'true' : 'false'); const result = await controller.apply(); emitTooltip(result); const updatedEntry = getUpdatedEntryByKey(entry.key); if (updatedEntry && typeof updatedEntry.value === 'boolean') { input.checked = updatedEntry.value; } else if (!result.ok) { input.checked = !previousChecked; } if (result.ok) { emitConfigChanged(entry.key, input.checked); } }); return; } if (action === 'edit-number') { target.addEventListener('blur', async () => { const input = target; controller.setSelection(entry.key); controller.setDraftValue(input.value); const result = await controller.apply(); emitTooltip(result); const updatedEntry = getUpdatedEntryByKey(entry.key); input.value = String(updatedEntry?.value ?? controller.state.draftValue); if (result.ok) { emitConfigChanged(entry.key, input.value); } }); return; } if (action === 'shell-select') { target.addEventListener('change', async () => { const select = target; const customInput = container.querySelector(`input[data-action="shell-custom"][data-key-index="${keyIndex}"]`); if (select.value === '__custom__') { customInput?.classList.remove('hidden'); customInput?.focus(); return; } customInput?.classList.add('hidden'); controller.setSelection(entry.key); controller.setDraftValue(select.value); const result = await controller.apply(); emitTooltip(result); const updatedEntry = getUpdatedEntryByKey(entry.key); const shellValue = String(updatedEntry?.value ?? select.value); const shellCustomInput = container.querySelector(`input[data-action="shell-custom"][data-key-index="${keyIndex}"]`); if (shellCustomInput) { shellCustomInput.value = shellValue; } if (result.ok) { emitConfigChanged(entry.key, shellValue); } }); return; } if (action === 'shell-custom') { const commitCustomShell = async () => { const input = target; if (!input.value.trim()) { return; } controller.setSelection(entry.key); controller.setDraftValue(input.value.trim()); const result = await controller.apply(); emitTooltip(result); const updatedEntry = getUpdatedEntryByKey(entry.key); input.value = String(updatedEntry?.value ?? controller.state.draftValue); if (result.ok) { emitConfigChanged(entry.key, input.value); } }; target.addEventListener('blur', () => { void commitCustomShell(); }); target.addEventListener('keydown', (event) => { if (event.key !== 'Enter') { return; } event.preventDefault(); void commitCustomShell(); }); return; } if (action === 'edit-string') { target.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') { return; } event.preventDefault(); const input = target; controller.setSelection(entry.key); controller.setDraftValue(input.value.replace(/\r?\n/g, ' ')); const result = await controller.apply(); emitTooltip(result); const updatedEntry = getUpdatedEntryByKey(entry.key); input.value = String(updatedEntry?.value ?? controller.state.draftValue); if (result.ok) { emitConfigChanged(entry.key, input.value); } }); return; } if (action === 'open-array') { target.addEventListener('click', () => { const latestEntry = getUpdatedEntryByKey(entry.key) ?? entry; arrayModal.open(latestEntry); }); } }); shellController = createCompactRowShellController({ shell: toolShell, compactRow, initialExpanded: chrome.expanded, onToggle: (expanded) => { chrome.expanded = expanded; hooks.onExpandedChanged?.(expanded); }, }); } function markReady() { document.body.classList.add('dc-ready'); } export function bootstrapConfigEditorApp() { const container = document.getElementById('app'); if (!container) { return; } const bridge = createToolBridge(); const trackConfigUiEvent = createUiEventTracker((name, args) => bridge.callTool(name, args), { component: CONFIG_EDITOR_COMPONENT, baseParams: { origin: 'ui' }, }); const controller = createConfigEditorController((name, args) => bridge.callTool(name, args), trackConfigUiEvent); const widgetState = createWidgetStateStorage(isConfigEditorPayload); const chrome = { hideSummaryRow: false, compact: false, expanded: true, }; let configEditorShownEventSent = false; let quietContextSupported = true; let tooltipHideTimer = null; const clearTooltipTimer = () => { if (tooltipHideTimer !== null) { window.clearTimeout(tooltipHideTimer); tooltipHideTimer = null; } }; const showTooltip = (tooltip) => { const tooltipElement = container.querySelector('#config-tooltip'); if (!tooltipElement) { return; } clearTooltipTimer(); tooltipElement.className = `config-tooltip config-tooltip--${tooltip.tone}`; tooltipElement.textContent = tooltip.message; tooltipElement.hidden = false; tooltipHideTimer = window.setTimeout(() => { tooltipElement.hidden = true; tooltipElement.textContent = ''; tooltipElement.className = 'config-tooltip'; tooltipHideTimer = null; }, 2600); }; const syncModelContext = (reason, change) => { if (!quietContextSupported || !change) { return; } app.updateModelContext({ content: [{ type: 'text', text: `Updated ${change.key} to ${JSON.stringify(change.value)} (${reason}).` }], structuredContent: { reason, changedKey: change.key, changedValue: change.value, }, }).catch(() => { // Host may not support updateModelContext; avoid repeated failed calls. quietContextSupported = false; }); }; let renderFrameId = null; const scheduleRender = () => { if (renderFrameId !== null) { return; } renderFrameId = window.requestAnimationFrame(() => { renderFrameId = null; render(container, controller, chrome, { onConfigChanged: (change) => { if (controller.state.payload) { widgetState.write(controller.state.payload); } syncModelContext('widget-edit', change); }, onTooltip: showTooltip, onExpandedChanged: (expanded) => { trackConfigUiEvent(expanded ? 'expand' : 'collapse', { tool_name: GET_CONFIG_TOOL_NAME, expanded, }); }, }); markReady(); }); }; scheduleRender(); const app = new App({ name: 'Desktop Commander Config Editor', version: '1.0.0' }, {}, { autoResize: true }); app.onteardown = async () => { shellController?.dispose(); if (renderFrameId !== null) { window.cancelAnimationFrame(renderFrameId); renderFrameId = null; } clearTooltipTimer(); return {}; }; const applyPayload = (payload) => { controller.setPayload(payload); widgetState.write(payload); scheduleRender(); if (!configEditorShownEventSent) { configEditorShownEventSent = true; // One-shot impression event for get_config UI card visibility. trackConfigUiEvent('config_editor_shown', { tool_name: GET_CONFIG_TOOL_NAME, entry_count: payload.entries.length, }); } }; const refreshConfigFromServer = async () => { try { const result = await bridge.callTool('get_config', {}); const payload = controller.extractPayload(result); if (payload) { applyPayload(payload); } } catch { // Best-effort refresh only } }; app.ontoolresult = (result) => { const payload = controller.extractPayload(result); if (payload) { applyPayload(payload); } }; app.ontoolcancelled = (params) => { showTooltip({ message: params.reason ?? 'Tool was cancelled.', tone: 'error', }); }; void connectWithSharedHostContext({ app, chrome, onContextApplied: () => { // Config editor should default to expanded in all hosts unless the // host forces framed mode. if (!chrome.hideSummaryRow) { chrome.expanded = true; } scheduleRender(); }, onConnected: async () => { const cachedPayload = widgetState.read(); if (cachedPayload) { controller.setPayload(cachedPayload); } scheduleRender(); await refreshConfigFromServer(); }, }).catch(() => { scheduleRender(); window.setTimeout(() => { showTooltip({ message: 'Failed to connect to host.', tone: 'error', }); }, 0); }); window.addEventListener('beforeunload', () => { shellController?.dispose(); clearTooltipTimer(); }, { once: true }); }