@dollhousemcp/mcp-server
Version:
DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.
1,338 lines (1,137 loc) • 86.8 kB
JavaScript
/**
* DollhouseMCP Console — Setup Tab
*
* OS detection, platform tab switching, install method toggle,
* auto-install via API, copy-to-clipboard for install configs.
*/
(() => {
'use strict';
// ── Config builders ────────────────────────────────────────────────────
const PKG = '@dollhousemcp/mcp-server';
const HOOKS_DIR = '~/.dollhouse/hooks';
const HOOK_BASE_SCRIPT_PATH = `${HOOKS_DIR}/pretooluse-dollhouse.sh`;
const HOOK_CONTRACT_DOC_URL = 'https://github.com/DollhouseMCP/mcp-server/blob/main/docs/architecture/permission-hook-platform-contracts.md';
// Keep hook entrypoints and output expectations in sync with
// docs/architecture/permission-hook-platform-contracts.md.
/** Platform registry — drives config generation AND panel rendering */
const PLATFORMS = [
// Claude Desktop & Claude Code panels are handwritten in HTML (unique structure)
{ id: 'claude-desktop', rootKey: 'mcpServers' },
{ id: 'claude-code', rootKey: 'mcpServers', cli: 'claude', hookCommand: `bash ${HOOK_BASE_SCRIPT_PATH}`, hookConfigPath: '<code>~/.claude/settings.json</code>' },
// These panels are generated from this data by renderGeneratedPanels()
{ id: 'cursor', rootKey: 'mcpServers', installClient: 'cursor', openClient: 'cursor', configPath: '<code>.cursor/mcp.json</code> in your project, or <code>~/.cursor/mcp.json</code> for all projects', hint: 'Or configure via Settings > MCP Servers in the Cursor UI.', hookCommand: `bash ${HOOKS_DIR}/pretooluse-cursor.sh`, hookConfigPath: '<code>.cursor/hooks.json</code> in your project, or <code>~/.cursor/hooks.json</code> for all projects' },
{ id: 'vscode', rootKey: 'servers', installClient: 'vscode', configPath: '<code>.vscode/mcp.json</code> in your workspace', hint: 'VS Code uses <code>"servers"</code>, not <code>"mcpServers"</code>.', hookCommand: `bash ${HOOKS_DIR}/pretooluse-vscode.sh`, hookConfigPath: '<code>~/.copilot/hooks/dollhouse-permissions.json</code> plus <code>chat.hookFilesLocations</code> in VS Code user settings' },
{ id: 'codex', rootKey: 'mcpServers', installClient: 'codex', openClient: 'codex', cli: 'codex', toml: true, tomlPath: '<code>~/.codex/config.toml</code> (Codex uses TOML, not JSON)', hookCommand: `bash ${HOOKS_DIR}/pretooluse-codex.sh`, hookConfigPath: '<code>~/.codex/hooks.json</code> and <code>~/.codex/config.toml</code>' },
{ id: 'gemini', rootKey: 'mcpServers', installClient: 'gemini-cli', openClient: 'gemini-cli', cli: 'gemini', configPath: '<code>~/.gemini/settings.json</code> or <code>.gemini/settings.json</code> in your project', hookCommand: `bash ${HOOKS_DIR}/pretooluse-gemini.sh`, hookConfigPath: '<code>~/.gemini/settings.json</code> or <code>.gemini/settings.json</code> in your project' },
{ id: 'windsurf', rootKey: 'mcpServers', installClient: 'windsurf', openClient: 'windsurf', configPath: '<code>~/.codeium/windsurf/mcp_config.json</code>', hint: 'Or click the MCPs icon in the Cascade panel > Configure.', hookCommand: `bash ${HOOKS_DIR}/pretooluse-windsurf.sh`, hookConfigPath: '<code>~/.codeium/windsurf/hooks.json</code> or <code>.windsurf/hooks.json</code> in your project' },
{ id: 'cline', rootKey: 'mcpServers', installClient: 'cline', openClient: 'cline', configPath: 'Cline stores MCP servers in <code>cline_mcp_settings.json</code> inside its extension settings. Use Configure Now or open the file directly.' },
{ id: 'lmstudio', rootKey: 'mcpServers', installClient: 'lmstudio', openClient: 'lmstudio', configPath: '<code>~/.lmstudio/mcp.json</code> (or open via Program tab > Install > Edit mcp.json)', hint: 'Restart LM Studio after saving.' },
];
const HOOK_BASE_SCRIPT = `#!/bin/bash
# pretooluse-dollhouse.sh — shared hook bridge for DollhouseMCP
RUN_DIR="$HOME/.dollhouse/run"
PORT_FILE="$RUN_DIR/permission-server.port"
HOOK_PLATFORM="\${DOLLHOUSE_HOOK_PLATFORM:-claude_code}"
read_port_from_file() {
local file_path="$1"
local port_value
[[ -f "$file_path" ]] || return 1
port_value=$(cat "$file_path" 2>/dev/null)
[[ "$port_value" =~ ^[0-9]+$ ]] || return 1
printf '%s\\n' "$port_value"
}
restore_latest_port_file() {
local port_value="$1"
[[ "$port_value" =~ ^[0-9]+$ ]] || return 1
mkdir -p "$RUN_DIR" 2>/dev/null || return 1
printf '%s' "$port_value" > "$PORT_FILE" 2>/dev/null || return 1
}
find_latest_live_pid_port_file() {
local candidate
local file_name
local pid
while IFS= read -r candidate; do
[[ -e "$candidate" ]] || continue
file_name="\${candidate##*/}"
pid="\${file_name#permission-server-}"
pid="\${pid%.port}"
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
printf '%s\\n' "$candidate"
return 0
fi
done < <(ls -1t "$RUN_DIR"/permission-server-*.port 2>/dev/null || true)
return 1
}
resolve_permission_port() {
local candidate_file
local port_value
if port_value=$(read_port_from_file "$PORT_FILE"); then
printf '%s\\n' "$port_value"
return 0
fi
candidate_file=$(find_latest_live_pid_port_file) || return 1
port_value=$(read_port_from_file "$candidate_file") || return 1
restore_latest_port_file "$port_value" >/dev/null 2>&1 || true
printf '%s\\n' "$port_value"
}
PORT=$(resolve_permission_port) || exit 0
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .toolName // .tool // .name // empty' 2>/dev/null)
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // .toolInput // .input // {}' 2>/dev/null)
[[ -n "$TOOL_NAME" ]] || exit 0
PAYLOAD=$(jq -cn \\
--arg tool_name "$TOOL_NAME" \\
--arg platform "$HOOK_PLATFORM" \\
--arg session_id "\${DOLLHOUSE_SESSION_ID:-}" \\
--argjson input "$TOOL_INPUT" \\
'{ tool_name: $tool_name, input: $input, platform: $platform }
+ (if ($session_id | length) > 0 then { session_id: $session_id } else {} end)')
RESPONSE=$(curl -s --max-time 5 -X POST "http://127.0.0.1:$PORT/api/evaluate_permission" \\
-H "Content-Type: application/json" \\
-d "$PAYLOAD" 2>/dev/null)
[[ -n "$RESPONSE" ]] && echo "$RESPONSE"
exit 0`;
const buildHookWrapperScript = (platform) => `#!/bin/bash
# pretooluse-${platform}.sh — manual hook wrapper for DollhouseMCP
export DOLLHOUSE_HOOK_PLATFORM="${platform}"
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
exec bash "$SCRIPT_DIR/pretooluse-dollhouse.sh"`;
const CLAUDE_CODE_HOOK_SETTINGS = `{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ${HOOK_BASE_SCRIPT_PATH}"
}
]
}
]
}
}`;
const GEMINI_HOOK_SETTINGS = `{
"hooks": {
"BeforeTool": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "bash ${HOOKS_DIR}/pretooluse-gemini.sh"
}
]
}
]
}
}`;
const CODEX_HOOK_SETTINGS = `{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ${HOOKS_DIR}/pretooluse-codex.sh",
"statusMessage": "Checking Bash permissions"
}
]
}
]
}
}`;
const CURSOR_HOOK_SETTINGS = `{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"command": "bash ${HOOKS_DIR}/pretooluse-cursor.sh",
"matcher": ".*"
}
]
}
}`;
const VSCODE_HOOK_SETTINGS = `{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ${HOOKS_DIR}/pretooluse-vscode.sh"
}
]
}
]
}
}`;
const VSCODE_HOOK_LOCATIONS_SETTINGS = `{
"chat.hookFilesLocations": {
"~/.copilot/hooks": true
}
}`;
const WINDSURF_HOOK_SETTINGS = `{
"hooks": {
"pre_run_command": [
{
"type": "command",
"command": "bash ${HOOKS_DIR}/pretooluse-windsurf.sh"
}
],
"pre_mcp_tool_use": [
{
"type": "command",
"command": "bash ${HOOKS_DIR}/pretooluse-windsurf.sh"
}
]
}
}`;
const CODEX_HOOK_FEATURES_TOML = `[features]
codex_hooks = true`;
/** Build a JSON config block for a given npx command string */
function jsonConfig(rootKey, npxCmd) {
const parts = npxCmd.split(' ');
const obj = {};
obj[rootKey] = { dollhousemcp: { command: parts[0], args: parts.slice(1) } };
return { code: JSON.stringify(obj, null, 2), copyText: JSON.stringify(obj) };
}
/** Build npx command string for a version tag */
const npxCmd = (tag) => `npx -y ${PKG}@${tag}`;
/** Build all platform configs for a given pinned version */
function buildConfigs(version, channel = 'latest') {
const ch = channel;
const result = {};
for (const { id, rootKey, cli, toml } of PLATFORMS) {
const entry = {
npx: cli
? { code: `${cli} mcp add dollhousemcp -- ${npxCmd(ch)}`, isTerminal: true }
: jsonConfig(rootKey, npxCmd(ch)),
global: cli
? { code: `${cli} mcp add dollhousemcp -- ${npxCmd(version)}`, isTerminal: true }
: jsonConfig(rootKey, npxCmd(version)),
};
if (cli) {
entry.npxJson = jsonConfig(rootKey, npxCmd(ch));
entry.globalJson = jsonConfig(rootKey, npxCmd(version));
}
if (toml) {
const tomlBlock = (tag) => `[mcp_servers.dollhousemcp]\ncommand = "npx"\nargs = ["-y", "${PKG}@${tag}"]`;
entry.npxToml = { code: tomlBlock(ch) };
entry.globalToml = { code: tomlBlock(version) };
}
result[id] = entry;
}
return result;
}
// ── Channel constants ────────────────────────────────────────────────
const CHANNELS = {
STABLE: 'latest',
RC: 'rc',
BETA: 'beta',
};
const VALID_CHANNELS = new Set(Object.values(CHANNELS));
const DEFAULT_CHANNEL = CHANNELS.STABLE;
/** Validate and normalize a channel value. Falls back to stable if invalid. */
const normalizeChannel = (ch) => VALID_CHANNELS.has(ch) ? ch : DEFAULT_CHANNEL;
// Start with a placeholder version, update once we fetch from server
let pinnedVersion = 'latest';
let currentChannel = DEFAULT_CHANNEL;
let configs = buildConfigs(pinnedVersion, currentChannel);
// ── Current method state ──────────────────────────────────────────────
let currentMethod = 'npx';
// ── OS detection ──────────────────────────────────────────────────────
const detectOS = () => {
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return 'macos';
if (/Win/i.test(ua)) return 'windows';
return 'linux';
};
// ── Highlight current OS in path lists ────────────────────────────────
const highlightOSPaths = (os) => {
const labels = { macos: 'macOS', windows: 'Windows', linux: 'Linux' };
const label = labels[os];
if (!label) return;
document.querySelectorAll('.setup-paths li').forEach((li) => {
const strong = li.querySelector('strong');
if (strong && strong.textContent.trim().replace(':', '') === label) {
li.classList.add('is-current');
}
});
document.querySelectorAll('.setup-os-path').forEach((el) => {
const osPath = el.dataset[os];
if (osPath) el.textContent = osPath;
});
};
// ── Method toggle ─────────────────────────────────────────────────────
const initMethodToggle = () => {
const toggle = document.getElementById('setup-method-toggle');
if (!toggle) return;
const buttons = toggle.querySelectorAll('.setup-method-btn');
// Cache DOM elements queried on every toggle click
const prereq = document.getElementById('setup-pinned-prereq');
const mcpbSection = document.getElementById('setup-mcpb-section');
const channelToggle = document.getElementById('setup-channel-toggle');
const handleToggle = (btn) => {
const method = btn.dataset.method;
if (!method || method === currentMethod) return;
currentMethod = method;
buttons.forEach((b) => {
b.classList.toggle('is-active', b.dataset.method === method);
b.setAttribute('aria-pressed', b.dataset.method === method ? 'true' : 'false');
});
if (prereq) prereq.hidden = method !== 'global';
if (mcpbSection) mcpbSection.hidden = method !== 'global';
if (channelToggle) channelToggle.hidden = method !== 'npx';
updateAllConfigs(method === 'permissions' ? 'npx' : method);
updateInstallButtonLabels();
updateSetupModeSections();
updateDetectionState();
};
buttons.forEach((btn) => {
btn.addEventListener('click', () => handleToggle(btn));
});
// Sync initial visibility — if the browser restored a non-default
// active button (e.g. pinned was selected before reload), apply
// the hidden state now without waiting for a click.
if (channelToggle) channelToggle.hidden = currentMethod !== 'npx';
};
// ── Channel selector ──────────────────────────────────────────────────
/** User-facing descriptions for each release channel, shown below the selector. */
const CHANNEL_HINTS = {
latest: 'Recommended for most users.',
rc: 'Preview of the next stable release. May have minor issues.',
beta: 'Cutting-edge features. May be unstable.',
};
const initChannelSelector = () => {
const select = document.getElementById('setup-channel-select');
const hint = document.getElementById('setup-channel-hint');
if (!select) return;
select.addEventListener('change', () => {
currentChannel = normalizeChannel(select.value);
if (hint) hint.textContent = CHANNEL_HINTS[currentChannel] || '';
configs = buildConfigs(pinnedVersion, currentChannel);
updateAllConfigs(currentMethod);
// Clear is-success/is-match state so buttons can be re-evaluated
document.querySelectorAll('.setup-install-btn').forEach((btn) => {
btn.classList.remove('is-success', 'is-match');
btn.disabled = false;
});
document.querySelectorAll('.setup-install-status').forEach((s) => {
s.textContent = '';
s.className = 'setup-install-status';
});
updateInstallButtonLabels();
updateDetectionState();
});
};
/** Rewrite code blocks and copy-text for the selected method */
const updateAllConfigs = (method) => {
for (const [platformKey, platformConfigs] of Object.entries(configs)) {
const panel = document.getElementById('setup-panel-' + platformKey);
if (!panel) continue;
const codeBlocks = Array.from(panel.querySelectorAll('.setup-code-block'));
let blockIdx = 0;
// Primary (terminal command or JSON config) — first code block
const primary = platformConfigs[method];
if (primary && codeBlocks[blockIdx]) {
updateCodeBlock(codeBlocks[blockIdx], primary);
blockIdx++;
}
// Secondary JSON (e.g., claude-code has terminal + JSON manual config)
const jsonKey = method + 'Json';
if (platformConfigs[jsonKey] && codeBlocks[blockIdx]) {
updateCodeBlock(codeBlocks[blockIdx], platformConfigs[jsonKey]);
blockIdx++;
}
// Tertiary (TOML for Codex)
const tomlKey = method + 'Toml';
if (platformConfigs[tomlKey] && codeBlocks[blockIdx]) {
updateCodeBlock(codeBlocks[blockIdx], platformConfigs[tomlKey]);
}
}
};
const updateSetupModeSections = () => {
document.querySelectorAll('[data-setup-modes]').forEach((section) => {
const modes = (section.dataset.setupModes || '')
.split(/\s+/)
.filter(Boolean);
section.hidden = !modes.includes(currentMethod);
});
const permissionsIntro = document.getElementById('setup-permissions-intro');
if (permissionsIntro) {
permissionsIntro.hidden = currentMethod !== 'permissions';
}
document.querySelectorAll('.setup-installed-notice').forEach((notice) => {
notice.hidden = currentMethod === 'permissions';
});
};
/** Update a single code block's displayed code and copy button */
const updateCodeBlock = (block, config) => {
if (!block || !config) return;
const pre = block.querySelector('pre code');
const copyBtn = block.querySelector('.setup-copy-btn');
if (pre) pre.textContent = config.code;
if (copyBtn) copyBtn.dataset.copyText = config.copyText || config.code;
};
// ── Platform tab switching ────────────────────────────────────────────
const initPlatformTabs = () => {
const nav = document.getElementById('setup-platforms');
if (!nav) return;
const tabs = nav.querySelectorAll('[role="tab"]');
const container = nav.parentElement;
const panels = container.querySelectorAll('[role="tabpanel"]');
const activate = (tab) => {
const targetId = tab.getAttribute('aria-controls');
if (!targetId) return;
tabs.forEach((t) => {
t.classList.remove('is-active');
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
});
panels.forEach((p) => {
p.classList.remove('is-active');
p.hidden = true;
});
tab.classList.add('is-active');
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
const panel = container.querySelector('#' + targetId);
if (panel) {
panel.classList.add('is-active');
panel.hidden = false;
}
};
tabs.forEach((tab, i) => {
tab.addEventListener('click', () => activate(tab));
tab.setAttribute('tabindex', tab.classList.contains('is-active') ? '0' : '-1');
});
// Keyboard navigation: arrow keys cycle through platform tabs
nav.addEventListener('keydown', (e) => {
const tabArr = Array.from(tabs);
const current = tabArr.findIndex(t => t.classList.contains('is-active'));
let next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (current + 1) % tabArr.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (current - 1 + tabArr.length) % tabArr.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = tabArr.length - 1;
}
if (next >= 0) {
e.preventDefault();
activate(tabArr[next]);
tabArr[next].focus();
}
});
};
// ── Copy buttons ──────────────────────────────────────────────────────
const initCopyButtons = () => {
// Use event delegation so dynamically updated copy-text works
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.setup-copy-btn');
if (!btn) return;
const text = btn.dataset.copyText;
if (!text) return;
const original = btn.textContent;
try {
let copyText = text;
try {
const parsed = JSON.parse(text);
copyText = JSON.stringify(parsed, null, 2);
} catch {
// Not JSON — copy as-is
}
await navigator.clipboard.writeText(copyText);
btn.textContent = 'Copied';
btn.dataset.copied = '';
} catch {
btn.textContent = 'Failed';
}
setTimeout(() => {
btn.textContent = original;
delete btn.dataset.copied;
}, 1400);
});
};
// ── Update install button labels based on method ────────────────────────
const updateInstallButtonLabels = () => {
const isPinned = currentMethod === 'global' && pinnedVersion && pinnedVersion !== 'latest';
// Update Install buttons
const channelLabel = currentChannel === 'latest' ? '' : ` (${currentChannel})`;
document.querySelectorAll('.setup-install-btn').forEach((btn) => {
if (btn.classList.contains('is-success') || btn.classList.contains('is-match')) return;
btn.textContent = isPinned ? `Configure v${pinnedVersion}` : `Configure Now${channelLabel}`;
});
// Update auto-install badges and descriptions
document.querySelectorAll('.setup-method-badge').forEach((badge) => {
badge.textContent = isPinned ? 'pinned version' : 'auto-updating';
});
document.querySelectorAll('.setup-method-desc').forEach((desc) => {
if (isPinned) {
desc.textContent = `Installs DollhouseMCP v${pinnedVersion}. This version will not auto-update.`;
} else {
// Restore text reflecting the selected channel.
// Use textContent for dynamic values to prevent DOM XSS (CodeQL: DOM text reinterpreted as HTML).
const panel = desc.closest('.setup-panel');
const safeChannel = normalizeChannel(currentChannel);
if (panel?.id === 'setup-panel-claude-desktop') {
desc.textContent = '';
desc.append(
`Pulls the ${safeChannel} version of DollhouseMCP on every startup. Uses `,
);
const code = document.createElement('code');
code.textContent = `npx @${safeChannel}`;
desc.append(code);
desc.append(' under the hood. Restart Claude Desktop after.');
} else if (panel?.id === 'setup-panel-claude-code') {
desc.textContent = `Adds DollhouseMCP to Claude Code, pulling the ${safeChannel} version on every startup.`;
}
}
});
};
// ── Install buttons ────────────────────────────────────────────────────
/** Format an install error for display. Detects channel-specific 404s. */
const formatInstallError = (err) => {
const msg = err.message || 'Installation failed';
const isChannelError = currentChannel !== DEFAULT_CHANNEL &&
(msg.includes('404') || msg.includes('Not Found') || msg.includes('not found'));
return isChannelError
? `No ${currentChannel} release is published yet. Switch to Stable or try again later.`
: `${msg}. Try the manual config below.`;
};
const buildInstallPayload = (client) => {
const payload = { client };
if (currentMethod === 'global' && pinnedVersion && pinnedVersion !== 'latest') {
payload.version = pinnedVersion;
} else if (currentChannel !== 'latest') {
payload.channel = currentChannel;
}
return payload;
};
const applyInstallSuccessState = (btn, status, data, verified) => {
btn.textContent = 'Installed';
btn.classList.remove('is-loading');
btn.classList.add('is-success');
if (!status) return;
if (data.hookInstall?.supported && !data.hookInstall?.configured && data.hookInstall?.assetsPrepared) {
status.textContent = 'Configured MCP server. Dollhouse hook assets were also prepared; finish manual permission setup in Permissions & Security.';
} else {
status.textContent = verified
? 'Verified — config written. Restart the application to activate.'
: 'Restart the application to activate.';
}
status.classList.add('is-success');
};
/** Handle Configure Now button click */
const handleInstallClick = async (btn) => {
const client = btn.dataset.installClient;
if (!client) return;
const status = document.querySelector(`[data-install-status="${client}"]`);
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Configuring...';
btn.classList.add('is-loading');
if (status) {
status.textContent = '';
status.className = 'setup-install-status';
}
try {
const res = await DollhouseAuth.apiFetch('/api/setup/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildInstallPayload(client)),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Installation failed');
btn.textContent = 'Verifying...';
// Verify the install by re-detecting — confirms config was written
// and re-renders the current config display with the new values.
await fetchDetection();
updateDetectionState();
const verified = detectedConfigs[clientToPlatformReverse[client]]?.installed;
applyInstallSuccessState(btn, status, data, verified);
// Show the completion banner after any successful install
showCompletionBanner(client);
} catch (err) {
btn.textContent = originalText;
btn.disabled = false;
btn.classList.remove('is-loading');
if (status) {
status.textContent = formatInstallError(err);
status.classList.add('is-error');
}
}
};
const updatePermissionInstallButton = (btn, detected) => {
if (!btn || btn.classList.contains('is-success')) return;
if (detected?.hookNeedsRepair) {
btn.textContent = 'Repair hooks';
btn.disabled = false;
btn.classList.remove('is-match');
return;
}
if (detected?.hookInstalled) {
btn.textContent = 'Permissions enabled';
btn.disabled = true;
btn.classList.add('is-match');
return;
}
btn.textContent = 'Configure Now';
btn.disabled = false;
btn.classList.remove('is-match');
};
const handlePermissionInstallClick = async (btn) => {
const client = btn.dataset.permissionInstallClient;
if (!client) return;
const status = document.querySelector(`[data-permission-install-status="${client}"]`);
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Configuring...';
btn.classList.add('is-loading');
if (status) {
status.textContent = '';
status.className = 'setup-install-status';
}
try {
const res = await DollhouseAuth.apiFetch('/api/setup/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Installation failed');
await fetchDetection();
updateDetectionState();
btn.textContent = 'Permissions enabled';
btn.classList.remove('is-loading');
btn.classList.add('is-success');
if (status) {
status.textContent = data.hookInstall?.message || 'Permissions are enabled. Restart the client if it is already running.';
status.classList.add('is-success');
}
} catch (err) {
btn.textContent = originalText;
btn.disabled = false;
btn.classList.remove('is-loading');
if (status) {
status.textContent = formatInstallError(err);
status.classList.add('is-error');
}
}
};
// ── Completion banner ────────────────────────────────────────────────
/** Friendly display names for install clients */
const CLIENT_DISPLAY_NAMES = {
'claude-desktop': 'Claude Desktop',
'claude-code': 'Claude Code',
'cursor': 'Cursor',
'vscode': 'VS Code',
'codex': 'Codex',
'gemini-cli': 'Gemini CLI',
'windsurf': 'Windsurf',
'cline': 'Cline',
'lmstudio': 'LM Studio',
};
/** Track which clients have been successfully installed this session */
const installedClients = [];
/**
* Show or update the completion banner after successful install.
* Tracks all installed clients and updates the banner text to reflect
* every client that was configured in this session.
*/
const showCompletionBanner = (client) => {
const clientName = CLIENT_DISPLAY_NAMES[client] || client;
// Track this client (avoid duplicates from re-installs)
if (!installedClients.includes(clientName)) {
installedClients.push(clientName);
}
const clientList = installedClients.length === 1
? `<strong>${installedClients[0]}</strong>`
: installedClients.slice(0, -1).map(c => `<strong>${c}</strong>`).join(', ')
+ ` and <strong>${installedClients.at(-1)}</strong>`;
const restartList = installedClients.length === 1
? `Restart <strong>${installedClients[0]}</strong> to activate DollhouseMCP`
: `Restart any of your configured clients to activate DollhouseMCP`;
const configuredWord = installedClients.length === 1 ? 'has' : 'have';
const bannerHTML = `
<div class="setup-completion-icon">✓</div>
<h3>You're all set!</h3>
<p>${clientList} ${configuredWord} been configured with DollhouseMCP.</p>
<div class="setup-completion-steps">
<div class="setup-completion-step">
<span class="setup-completion-step-num">1</span>
<span>Close this browser tab</span>
</div>
<div class="setup-completion-step">
<span class="setup-completion-step-num">2</span>
<span>${restartList}</span>
</div>
<div class="setup-completion-step">
<span class="setup-completion-step-num">3</span>
<span>Start a conversation and ask: <em>"What DollhouseMCP tools do you have?"</em></span>
</div>
</div>
<p class="setup-completion-terminal-hint">In the terminal, type <code>q</code> to exit the installer.</p>
`;
const existing = document.getElementById('setup-completion-banner');
if (existing) {
// Update the existing banner with the new client list
existing.innerHTML = bannerHTML;
return;
}
// First install — create and insert the banner
const banner = document.createElement('div');
banner.id = 'setup-completion-banner';
banner.className = 'setup-completion-banner';
banner.innerHTML = bannerHTML;
const setupContent = document.querySelector('.setup-content');
const heroSection = setupContent?.querySelector('.setup-hero');
if (setupContent && heroSection) {
heroSection.after(banner);
} else if (setupContent) {
setupContent.prepend(banner);
}
banner.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const initInstallButtons = () => {
document.querySelectorAll('.setup-install-btn').forEach((btn) => {
btn.addEventListener('click', () => handleInstallClick(btn));
// Link button to its status message for accessibility
const client = btn.dataset.installClient;
const status = document.querySelector(`[data-install-status="${client}"]`);
if (status) {
const statusId = `install-status-${client}`;
status.id = statusId;
btn.setAttribute('aria-describedby', statusId);
}
});
};
const initPermissionInstallButtons = () => {
document.querySelectorAll('.setup-permission-install-btn').forEach((btn) => {
btn.addEventListener('click', () => handlePermissionInstallClick(btn));
const client = btn.dataset.permissionInstallClient;
const status = document.querySelector(`[data-permission-install-status="${client}"]`);
if (status) {
const statusId = `permission-install-status-${client}`;
status.id = statusId;
btn.setAttribute('aria-describedby', statusId);
}
});
};
// ── Open config file buttons ───────────────────────────────────────────
/** Handle Open config file button click */
const handleOpenClick = async (btn) => {
const client = btn.dataset.openClient;
if (!client) return;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Opening...';
const resetBtn = () => {
btn.textContent = originalText;
btn.disabled = false;
};
try {
const res = await DollhouseAuth.apiFetch('/api/setup/open-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Could not open file');
btn.textContent = 'Opened';
setTimeout(resetBtn, 2000);
} catch {
btn.textContent = 'Failed';
setTimeout(resetBtn, 2000);
}
};
const initOpenButtons = () => {
document.querySelectorAll('.setup-open-btn').forEach((btn) => {
btn.addEventListener('click', () => handleOpenClick(btn));
});
};
// ── Fetch version and update pinned configs ────────────────────────────
const fetchVersion = async () => {
try {
const res = await DollhouseAuth.apiFetch('/api/setup/version');
if (!res.ok) return;
const data = await res.json();
// Use latest from GitHub if available, otherwise running version
pinnedVersion = data.latest?.version || data.running?.version || pinnedVersion;
if (pinnedVersion === 'latest') return;
// Rebuild configs with real version and current channel
configs = buildConfigs(pinnedVersion, currentChannel);
// Update prereq section
const versionLabel = document.getElementById('pinned-version-label');
if (versionLabel) versionLabel.textContent = `v${pinnedVersion}`;
// Update global install command
const globalCmd = document.getElementById('pinned-global-cmd');
const globalCopy = document.getElementById('pinned-global-copy');
if (globalCmd) globalCmd.textContent = `npm install -g ${PKG}@${pinnedVersion}`;
if (globalCopy) globalCopy.dataset.copyText = `npm install -g ${PKG}@${pinnedVersion}`;
// Update local install command
const localCmd = document.getElementById('pinned-local-cmd');
const localCopy = document.getElementById('pinned-local-copy');
if (localCmd) localCmd.textContent = `mkdir -p ~/mcp-servers && cd ~/mcp-servers\nnpm install ${PKG}@${pinnedVersion}`;
if (localCopy) localCopy.dataset.copyText = `mkdir -p ~/mcp-servers && cd ~/mcp-servers && npm install ${PKG}@${pinnedVersion}`;
// Update .mcpb download button version label
const mcpbVersion = document.getElementById('pinned-mcpb-version');
if (mcpbVersion) mcpbVersion.textContent = `(v${pinnedVersion})`;
// If currently showing pinned method, refresh all config snippets
if (currentMethod === 'global') {
updateAllConfigs('global');
}
} catch {
// Offline or no API — keep defaults
}
};
// ── Detect existing installations ──────────────────────────────────────
// Map from detect API client IDs to platform panel IDs (and reverse for install verification)
const clientToPlatform = {
'claude': 'claude-desktop',
'claude-code': 'claude-code',
'cursor': 'cursor',
'cline': 'cline',
'windsurf': 'windsurf',
'lmstudio': 'lmstudio',
'gemini-cli': 'gemini',
'codex': 'codex',
};
// Reverse map: installClient ID → platform panel ID (for install verification)
const clientToPlatformReverse = {};
for (const [detectId, platformId] of Object.entries(clientToPlatform)) {
// Also map installClient IDs that differ from detect IDs
clientToPlatformReverse[detectId] = platformId;
}
// Stored detection results — keyed by platform panel ID
let detectedConfigs = {};
/**
* Compare the detected config against what the current method would install.
* Returns true if command + args match (ignoring env vars and extra keys).
*/
const configsMatch = (platformId, method) => {
const detected = detectedConfigs[platformId];
if (!detected?.installed || !detected?.currentConfig) return false;
const current = detected.currentConfig;
// Get the generated config for this platform + method
const platformConfigs = configs[platformId];
if (!platformConfigs) return false;
const generated = platformConfigs[method];
if (!generated || generated.isTerminal) {
// For terminal-command platforms, compare via the JSON config instead
const jsonKey = method + 'Json';
const jsonConfig = platformConfigs[jsonKey];
if (!jsonConfig) return false;
return compareJsonConfig(current, jsonConfig);
}
return compareJsonConfig(current, generated);
};
/** Compare a detected config object against a generated config block.
* Matches on command + package reference. Ignores flags like -y and
* extra keys like env, type, etc. — those don't change the server version. */
const compareJsonConfig = (current, generated) => {
try {
const genText = generated.copyText || generated.code;
const genParsed = JSON.parse(genText);
const genServer = genParsed.mcpServers?.dollhousemcp || genParsed.servers?.dollhousemcp;
if (!genServer) return false;
// Command must match
if (current.command !== genServer.command) return false;
// Extract the package reference from args (the @dollhousemcp/... part)
const getPkgRef = (args) => (args || []).find(a => a.includes('@dollhousemcp/'));
const currentPkg = getPkgRef(current.args);
const genPkg = getPkgRef(genServer.args);
return currentPkg === genPkg;
} catch {
return false;
}
};
/**
* Update all detection notices and button states based on current method.
* Called on init and whenever the method toggle changes.
*/
const updateDetectionState = () => {
const platformIds = new Set(['claude-desktop', ...PLATFORMS.map((platform) => platform.id)]);
for (const platformId of platformIds) {
updatePlatformDetectionState(platformId);
}
};
const PERMISSION_SUPPORT_MATRIX = {
'claude-desktop': {
label: 'Claude Desktop',
supportLevel: 'unsupported',
statusTag: 'coming soon',
badgeClass: 'unsupported',
limitation: 'Claude Desktop can host DollhouseMCP, but this release does not ship a native permissions setup flow for it yet.',
},
'claude-code': {
label: 'Claude Code',
supportLevel: 'full_native',
statusTag: 'claude code',
badgeClass: 'verified',
configPath: '<code>~/.claude/settings.json</code>',
scriptPath: HOOK_BASE_SCRIPT_PATH,
settingsBlock: CLAUDE_CODE_HOOK_SETTINGS,
limitation: 'Claude Code has the full native permission-hook path in this release.',
},
gemini: {
label: 'Gemini CLI',
supportLevel: 'partial_native',
statusTag: 'allow / deny',
badgeClass: 'manual',
configPath: '<code>~/.gemini/settings.json</code> or <code>.gemini/settings.json</code> in your project',
scriptPath: `${HOOKS_DIR}/pretooluse-gemini.sh`,
settingsBlock: GEMINI_HOOK_SETTINGS,
limitation: 'Gemini CLI exposes native BeforeTool hooks, but it does not support an ask/confirm response path. Confirmation-style policies currently degrade to deny.',
},
cursor: {
label: 'Cursor',
supportLevel: 'partial_native',
statusTag: 'native hooks',
badgeClass: 'manual',
configPath: '<code>.cursor/hooks.json</code> in your project, or <code>~/.cursor/hooks.json</code> for all projects',
scriptPath: `${HOOKS_DIR}/pretooluse-cursor.sh`,
settingsBlock: CURSOR_HOOK_SETTINGS,
limitation: 'Cursor exposes native hooks, but its permission handling still needs broader runtime verification across allow and ask decisions.',
},
vscode: {
label: 'VS Code',
supportLevel: 'partial_native',
statusTag: 'native hooks',
badgeClass: 'manual',
configPath: '<code>~/.copilot/hooks/dollhouse-permissions.json</code> and VS Code user settings',
scriptPath: `${HOOKS_DIR}/pretooluse-vscode.sh`,
settingsBlock: VSCODE_HOOK_SETTINGS,
featureBlock: VSCODE_HOOK_LOCATIONS_SETTINGS,
featureHeading: '2. Enable <code>~/.copilot/hooks</code> in VS Code user settings',
featureCopyLabel: 'Copy VS Code hookFilesLocations settings',
limitation: 'VS Code exposes native PreToolUse hooks, but it ignores matcher values and uses tool names that differ from Claude Code. This adapter normalizes the common built-in tools we know about.',
},
windsurf: {
label: 'Windsurf',
supportLevel: 'partial_native',
statusTag: 'allow / deny',
badgeClass: 'manual',
configPath: '<code>~/.codeium/windsurf/hooks.json</code> or <code>.windsurf/hooks.json</code> in your project',
scriptPath: `${HOOKS_DIR}/pretooluse-windsurf.sh`,
settingsBlock: WINDSURF_HOOK_SETTINGS,
limitation: 'Windsurf exposes native pre-run and pre-MCP hooks, but they are binary allow-or-block hooks. Confirmation-style policies currently degrade to block.',
},
codex: {
label: 'Codex',
supportLevel: 'partial_native',
statusTag: 'bash only',
badgeClass: 'manual',
configPath: '<code>~/.codex/hooks.json</code> and <code>~/.codex/config.toml</code>',
scriptPath: `${HOOKS_DIR}/pretooluse-codex.sh`,
settingsBlock: CODEX_HOOK_SETTINGS,
featureBlock: CODEX_HOOK_FEATURES_TOML,
limitation: 'Codex currently only supports native PreToolUse hooks for Bash, so this turns on Bash permission guardrails only.',
},
cline: {
label: 'Cline',
supportLevel: 'mcp_only',
statusTag: 'mcp only',
badgeClass: 'manual',
limitation: 'Cline MCP setup is supported here, but native permission-hook automation is still incomplete in this release.',
},
lmstudio: {
label: 'LM Studio',
supportLevel: 'mcp_only',
statusTag: 'mcp only',
badgeClass: 'manual',
limitation: 'LM Studio MCP setup is supported here, but permission enforcement currently relies on LM Studio built-in confirmations or a future fallback adapter.',
},
};
const getPermissionSupport = (platformId, detected) => {
if (detected?.support) {
return {
...PERMISSION_SUPPORT_MATRIX[platformId],
supportLevel: detected.support.level || PERMISSION_SUPPORT_MATRIX[platformId]?.supportLevel,
};
}
return PERMISSION_SUPPORT_MATRIX[platformId];
};
const getHookRepairStatusCopy = (support, detected) => {
if (detected?.hookNeedsRepair) {
return {
tone: 'warning',
titleText: `${support.label} hook files need repair.`,
messageText: 'DollhouseMCP detected stale local hook assets. Use Configure Now below to rewrite them, or reload the local server so the automatic repair pass can run again.',
};
}
if (detected?.hookAutoRepaired) {
return {
tone: 'info',
titleText: `${support.label} hook files were refreshed automatically.`,
messageText: 'The installed local hook assets were updated to match this release. Restart the client if it is already running.',
};
}
return null;
};
const getFullNativePermissionStatusCopy = (support, detected) => {
const repairCopy = getHookRepairStatusCopy(support, detected);
if (repairCopy) return repairCopy;
if (detected?.hookInstalled) {
return {
tone: 'info',
titleText: `${support.label} permission enforcement is enabled.`,
messageText: 'No further changes are needed here unless you want to reinstall the hook settings.',
};
}
if (detected?.installed) {
return {
tone: 'warning',
titleText: `${support.label} is connected for this client.`,
messageText: `DollhouseMCP is configured as an MCP server. Use Configure Now below to also install the ${support.label} permission hook.`,
};
}
return {
tone: 'info',
titleText: `${support.label} permissions are not configured yet.`,
messageText: `First connect DollhouseMCP using Auto-updating or Pinned version, then use Configure Now below to install the ${support.label} permission hook.`,
};
};
const getPartialPermissionStatusCopy = (support, detected) => {
const activationLabel = support.label === 'Codex' ? 'Bash guardrails' : 'permission hooks';
const repairCopy = getHookRepairStatusCopy(support, detected);
if (repairCopy) return repairCopy;
if (detected?.hookInstalled) {
return {
tone: 'info',
titleText: `${support.label} ${activationLabel} are enabled.`,
messageText: support.limitation,
};
}
if (detected?.installed) {
return {
tone: 'warning',
titleText: `${support.label} is connected for this client.`,
messageText: `DollhouseMCP is configured as an MCP server. Use Configure Now below to turn on ${support.label}'s native ${activationLabel}.`,
};
}
return {
tone: 'info',
titleText: `${support.label} ${activationLabel} are not configured yet.`,
messageText: `First connect DollhouseMCP using Auto-updating or Pinned version, then use Configure Now below to install ${support.label}'s native ${activationLabel}.`,
};
};
const getMcpOnlyPermissionStatusCopy = (support, detected) => {
if (detected?.installed) {
return {
tone: 'warning',
titleText: `${support.label} is connected for this client.`,
messageText: `${support.limitation} This release keeps that client in the MCP and fallback lane for now.`,
};
}
return {
tone: 'info',
titleText: `${support.label} supports MCP setup in this release.`,
messageText: `${support.limitation} Use Auto-updating or Pinned version above to connect DollhouseMCP first.`,
};
};
const getManualPermissionStatusCopy = (support, detected) => {
const repairCopy = getHookRepairStatusCopy(support, detected);
if (repairCopy) return repairCopy;
if (detected?.hookAssetsPrepared) {
return {
tone: 'info',
titleText: 'Hook bridge files are already prepared for this client.',
messageText: 'Finish the client-specific hook registration below to turn on permission enforcement.',
};
}
if (detected?.installed) {
return {
tone: 'warning',
titleText: 'DollhouseMCP is connected for this client.',
messageText: 'DollhouseMCP is configured here, but permission enforcement is separate. Use the manual hook steps below to turn it on for this client.',
};
}
return {
tone: 'info',
titleText: 'Manual permissions setup is available for this client.',
messageText: 'Use the steps below if you want to turn on permission enforcement for this client manually.',
};
};
const getUnsupportedPermissionStatusCopy = (support, detected) => ({
tone: detected?.installed ? 'warning' : 'neutral',
titleText: `Permissions & security tools are unavailable for ${support.label} right now.`,
messageText: detected?.installed
? 'DollhouseMCP is connected for this client, but this release does not include a supported permissions setup flow here yet.'
: support.limitation,
});
const getPermissionStatusCopy = (platformId, detected) => {
const support = getPermissionSupport(platformId, detected);
if (!support) {
return getUnsupportedPermissionStatusCopy({
label: 'this client',
limitation: 'This release does not include a supported permissions setup flow for this client yet.',
}, detected);
}
if (support.supportLevel === 'full_native') {
return getFullNativePermissionStatusCopy(support, detected);
}
if (support.supportLevel === 'partial_native') {
return getPartialPermissionStatusCopy(support, detected);
}
if (support.supportLevel === 'mcp_only') {
return getMcpOnlyPermissionStatusCopy(support, detected);
}
if (support.supportLevel === 'manual') {
return getManualPermissionStatusCopy(support, detected);
}
return getUnsupportedPermissionStatusCopy(support, detected);
};
const updatePermissionStatus = (panel, platformId, detected) => {
const status = panel?.querySelector('.setup-permission-status');
if (!status) return;
const title = status.querySelector('.setup-permission-status-title');
const message = status.querySelector('.setup-permission-status-msg');
const { tone, titleText, messageText } = getPermissionStatusCopy(platformId, detected);
status.dataset.state = tone;
if (title) title.textContent = titleText;
if (message) message.textContent = messageText;
};
/** Update notice, badge, button, AND current config display for a single platform */
const updatePlatformDetectionState = (platformId) => {
const detected = detectedConfigs[platformId];
const panel = document.getElementById('setup-panel-' + platformId);
const tabBtn = document.getElementById('setup-tab-' + platformId);
updatePermissionStatus(panel, platformId, detected);
updatePermissionInstallButton(panel?.querySelector('.setup-permission-install-btn'), detected);
if (!detected?.installed) return;
const matches = configsMatch(platformId, currentMethod);
updateDetectionNotice(panel?.querySelector('.setup-installed-notice'), matches);
updateDetectionBadge(tabBtn?.querySelector('.setup-tab-badge'), matches);
updateDetectionButton(panel?.querySelector('.setup-install-btn'), matches);
// Refresh the "Current config" code block with the latest detected config
if (detected.currentConfig && panel) {
const codeEl = panel.querySelector('.setup-installed-notice pre code');
if (codeEl) codeEl.textContent = JSON.stringify(detected.currentConfig, null, 2);
}
};
const updateDetectionNotice = (notice, matches) => {
if (!notice) return;
notice.className = matches ? 'setup-installed-notice is-match' : 'setup-installed-notice';
const strong = notice.querySelector('strong');
const msg = notice.querySelector('.setup-notice-msg');
if (strong) strong.textContent = matches
? 'DollhouseMCP is configured and matches these settings.'
: 'DollhouseMCP is already configured for this client.';
if (msg) msg.textContent = matches
? 'No changes would be made.'
: 'Installing will overwrite the existing configuration.';
};
const updateDetectionBadge = (badge, matches) => {
if (!badge) return;
badge.className = matches ? 'setup-tab-badge is-match' : 'setup-tab-badge';
badge.textContent = matches ? 'configured' : 'installed';
};
const updateDetectionButton = (installBtn, matches) => {
if (!installBtn || ins