From 5b252ee1d5bc26a1b201bfcce1c97a60614251fc Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 26 Oct 2026 10:27:56 +0000 Subject: [PATCH] feat(mcp): add dcd mcp install, and lead the docs with the standalone binary dcd mcp install [claude-code|claude-desktop|cursor|vscode] adds the server to an AI tool: through claude mcp add or code --add-mcp, or by merging an entry into the Claude Desktop / Cursor JSON config. Entries start dcd by full path, since desktop apps don't see the shell's PATH. The install scripts point at dcd mcp install and print the full path. The README leads with dcd mcp install, then full-path configs for each client (with VS Code's own format), with npx as the fallback. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + README.md | 37 +++++-- install.ps1 | 3 + install.sh | 9 +- src/commands/mcp.ts | 193 +++++++++++++++++++++++++++++++++- src/utils/mcp-install.ts | 171 ++++++++++++++++++++++++++++++ test/unit/mcp-install.test.ts | 109 +++++++++++++++++++ 7 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 src/utils/mcp-install.ts create mode 100644 test/unit/mcp-install.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3cef23c..1c6b63f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ Top-level `defineCommand` in `src/index.ts` wires thirteen subcommands (`cloud`, **`dcd mcp` (the bridge).** A second stdio entry point, `src/mcp/bridge.ts`, run by the `dcd mcp` command. It is a low-level `Server` that routes between two MCP clients: the `dcd_*` tools from the same in-process `createServer()` (over an in-memory transport), and the device tools proxied from the hosted `/mcp`, so those are defined and contract-tested once, in the dcd API. It adds the inputs only a local process can serve: `install_app` takes `app_path` (uploaded with `uploadLocalApp`) and `run` takes `path` (a file is sent as `yaml`, a directory is uploaded as a flow bundle). Credentials are the CLI's (`getContext`): the hosted `/mcp` accepts an API key or a `dcd login` session with `x-dcd-org`. One connection is shared, and its `fetch` re-reads the auth headers from `getContext()` on every request, so a renewed session is picked up without remaking the connection (which would close it under a `run` still going). If the hosted endpoint can't be reached, it serves the `dcd_*` tools only and says why on stderr. Read-only mode keeps only device tools annotated `readOnlyHint`. `dcd-mcp` itself is unchanged. +**`dcd mcp install [client]`** (`src/utils/mcp-install.ts`) adds the server to Claude Code (`claude mcp add --scope user`), VS Code (`code --add-mcp`), or Claude Desktop / Cursor (merges an entry into their JSON config, and refuses a file that isn't plain JSON rather than rewrite it). Entries always start dcd by **full path**, since desktop apps opened from the Dock don't read the shell rc that puts `~/.dcd/bin` on PATH: the standalone binary is `process.execPath`; an npm install is node (`stableNodePath`, the PATH entry rather than Homebrew's versioned Cellar path) plus `argv[1]` as started, not resolved. It refuses to run from source or from the npx cache. Note citty runs a parent's `run` **after** its subcommand, so `mcpCommand.run` returns early when a positional is present — without that, `dcd mcp install` would go on to start a server. + ## Contributing Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release): diff --git a/README.md b/README.md index b5a4cc5..ddb1fcb 100644 --- a/README.md +++ b/README.md @@ -50,26 +50,51 @@ There are three ways to connect: `dcd mcp` connects to the hosted server and passes its device tools through. It adds what only a local process can do: `install_app` takes `app_path` (a build on your machine, uploaded first) and `run` takes `path` (a flow file, or a directory uploaded as a bundle). It also serves the `dcd_*` tools below. -Claude Code: +**Set it up with `dcd mcp install`.** Install the CLI (see the top of this page), sign in, then add the server to your AI tool: ```sh-session -$ claude mcp add devicecloud -- npx -y @devicecloud.dev/dcd mcp +$ dcd login +$ dcd mcp install cursor # or claude-code, claude-desktop, vscode ``` -Other clients: +Leave out the client name to pick from a list. Add `--read-only` for a server that only offers tools that change nothing, or `--print` to see what would be added without changing anything. For Claude Code and VS Code it runs `claude mcp add` and `code --add-mcp`. For Claude Desktop and Cursor it adds an entry to their JSON config file and keeps everything else in it. Restart the app afterwards. + +**Setting it up by hand.** Start dcd by its **full path**. Desktop apps such as Claude Desktop and Cursor are usually opened from the Dock or Start menu, so they don't see the PATH your shell sets up, and a bare `dcd` gives "command not found". The installer prints the path at the end; it's `~/.dcd/bin/dcd` by default, written out in full because config files don't expand `~`. + +Claude Desktop (`claude_desktop_config.json`) and Cursor (`~/.cursor/mcp.json`): ```jsonc { "mcpServers": { "devicecloud": { - "command": "npx", - "args": ["-y", "@devicecloud.dev/dcd", "mcp"] + "command": "/Users/you/.dcd/bin/dcd", + "args": ["mcp"] + } + } +} +``` + +VS Code (`.vscode/mcp.json`, or the user `mcp.json`) uses `servers` and a `type`: + +```jsonc +{ + "servers": { + "devicecloud": { + "type": "stdio", + "command": "/Users/you/.dcd/bin/dcd", + "args": ["mcp"] } } } ``` -If the CLI is installed globally, use `"command": "dcd", "args": ["mcp"]` instead. +Claude Code, run from a terminal, does see your PATH: + +```sh-session +$ claude mcp add --scope user devicecloud -- dcd mcp +``` + +If you use the npm package instead of the standalone binary, the command is `npx` with `"args": ["-y", "@devicecloud.dev/dcd", "mcp"]`. That needs Node 22 or newer on the app's PATH, and it checks npm each time it starts. It signs in the same way as the CLI: run `dcd login` once, or set `DEVICE_CLOUD_API_KEY` in `env`. Add `--read-only` to the args (or set `DCD_MCP_READONLY=1`) to serve only the tools that change nothing. If the hosted server can't be reached, it serves the `dcd_*` tools only and says why on stderr. diff --git a/install.ps1 b/install.ps1 index dab6b1f..c0c181e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -126,6 +126,9 @@ if (-not $alreadyOnPath) { Write-Host "Installed: $(& "$InstallDir\dcd.exe" --version)" Write-Host ' Try: dcd --help' } +Write-Host '' +Write-Host ' Connect an AI agent (Claude, Cursor, VS Code): dcd mcp install' +Write-Host " In an MCP config file, start dcd by its full path: $InstallDir\dcd.exe" # --- warn about a conflicting (shadowing) install --- # A leftover `npm install -g @devicecloud.dev/dcd` resolves earlier on PATH than diff --git a/install.sh b/install.sh index 3649cad..195663f 100755 --- a/install.sh +++ b/install.sh @@ -312,8 +312,13 @@ main() { ;; esac info " Get started:" - info " ${CYAN}dcd login${RESET} ${DIM}sign in to DeviceCloud${RESET}" - info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}" + info " ${CYAN}dcd login${RESET} ${DIM}sign in to DeviceCloud${RESET}" + info " ${CYAN}dcd mcp install${RESET} ${DIM}connect an AI agent (Claude, Cursor, VS Code)${RESET}" + info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}" + info "" + # Desktop apps don't read the shell rc that puts dcd on PATH, so an MCP + # config written by hand needs the full path (dcd mcp install uses it). + info " ${DIM}In an MCP config file, start dcd by its full path: $INSTALL_DIR/dcd${RESET}" info "" } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 3b9dd56..be7cb71 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,15 +1,200 @@ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; + +import * as p from '@clack/prompts'; import { defineCommand } from 'citty'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { createBridgeServer } from '../mcp/bridge.js'; import { isReadOnly, logStderr } from '../mcp/context.js'; import { telemetry } from '../services/telemetry.service.js'; +import { apiKeyOverride } from '../utils/auth.js'; +import { canPrompt } from '../utils/ci.js'; +import { CliError, getInstallMethod, logger } from '../utils/cli.js'; +import { readConfig } from '../utils/config-store.js'; +import { + CLIENT_LABELS, + claudeCodeAddArgs, + clientConfigPath, + MCP_CLIENTS, + type McpClient, + mcpServersSnippet, + SERVER_NAME, + serverLaunch, + type ServerLaunch, + shellLine, + stableNodePath, + vscodeAddArgs, + writeClientConfig, +} from '../utils/mcp-install.js'; +import { colors } from '../utils/styling.js'; +import { type Field, ui } from '../utils/ui.js'; + +const RELOAD_HINT: Record = { + 'claude-code': 'Start a new Claude Code session to load it.', + 'claude-desktop': 'Restart Claude Desktop to load it.', + cursor: 'Restart Cursor, or reload its MCP servers, to load it.', + vscode: 'VS Code asks you to trust the server the first time it starts.', +}; + +/** Run another tool's CLI without a shell. Undefined when it isn't installed. */ +function runTool(command: string, args: string[]): { ok: boolean; output: string } | undefined { + const result = spawnSync(command, args, { encoding: 'utf8' }); + if ((result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return undefined; + return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() }; +} + +async function pickClient(value: string | undefined): Promise { + if (value) { + if ((MCP_CLIENTS as readonly string[]).includes(value)) return value as McpClient; + throw new CliError(`Unknown client "${value}". Pick one of: ${MCP_CLIENTS.join(', ')}.`); + } + + if (!canPrompt()) { + throw new CliError(`Say which client to set up: dcd mcp install <${MCP_CLIENTS.join('|')}>.`); + } + + const picked = await p.select({ + message: 'Which AI tool should use DeviceCloud?', + options: MCP_CLIENTS.map((c) => ({ value: c, label: CLIENT_LABELS[c] })), + }); + if (p.isCancel(picked)) throw new CliError('Cancelled.'); + return picked; +} + +interface InstallResult { + /** The file written, or the command run, to add the server. */ + target: string; + replaced: boolean; +} + +/** Add the server to `client`, through its CLI or its JSON config file. */ +function install(client: McpClient, launch: ServerLaunch): InstallResult { + if (client === 'claude-code') { + const add = claudeCodeAddArgs(launch); + const removed = runTool('claude', ['mcp', 'remove', '--scope', 'user', SERVER_NAME]); + if (!removed) { + throw new CliError(`The claude command isn't on PATH. Run this where it is:\n ${shellLine('claude', add)}`); + } + + const added = runTool('claude', add)!; + if (!added.ok) throw new CliError(`claude mcp add failed: ${added.output}`); + return { target: shellLine('claude', add), replaced: removed.ok }; + } + + if (client === 'vscode') { + const add = vscodeAddArgs(launch); + const added = runTool('code', add); + if (!added) { + throw new CliError( + `The code command isn't on PATH (in VS Code: "Shell Command: Install 'code' command in PATH"). Or run:\n ${shellLine('code', add)}`, + ); + } + + if (!added.ok) throw new CliError(`code --add-mcp failed: ${added.output}`); + return { target: shellLine('code', add), replaced: false }; + } + + const file = clientConfigPath(client); + if (!file) { + throw new CliError( + `${CLIENT_LABELS[client]} has no known config file on ${process.platform}. Add this to it by hand:\n${JSON.stringify(mcpServersSnippet(launch), null, 2)}`, + ); + } + + return { target: file, ...writeClientConfig(file, launch) }; +} + +/** What `--print` shows: the command to run, or the file and the entry for it. */ +function preview(client: McpClient, launch: ServerLaunch): { target: string; text: string } { + if (client === 'claude-code') return { target: 'command', text: shellLine('claude', claudeCodeAddArgs(launch)) }; + if (client === 'vscode') return { target: 'command', text: shellLine('code', vscodeAddArgs(launch)) }; + return { + target: clientConfigPath(client) ?? 'your MCP config file', + text: JSON.stringify(mcpServersSnippet(launch), null, 2), + }; +} + +const installSub = defineCommand({ + meta: { + name: 'install', + description: 'Add the DeviceCloud MCP server to Claude Code, Claude Desktop, Cursor or VS Code', + }, + args: { + client: { + type: 'positional', + required: false, + description: `One of ${MCP_CLIENTS.join(', ')}. Asked when left out.`, + }, + 'read-only': { + type: 'boolean', + default: false, + description: 'Have the server offer only tools that change nothing', + }, + print: { + type: 'boolean', + default: false, + description: 'Show what would be added, and change nothing', + }, + json: jsonFlag, + }, + async run({ args }) { + telemetry.setCommand('mcp install'); + const client = await pickClient(args.client as string | undefined); + const installMethod = getInstallMethod(); + const launch = serverLaunch({ + installMethod, + execPath: installMethod === 'binary' ? process.execPath : stableNodePath(process.execPath), + // As started, not resolved: a global npm install's bin link keeps + // working across `npm install -g` upgrades. + scriptPath: process.argv[1] ? path.resolve(process.argv[1]) : undefined, + readOnly: Boolean(args['read-only']), + }); + + if (args.print) { + const shown = preview(client, launch); + if (args.json) { + logger.json({ status: 'SUCCESS', client, launch, target: shown.target, text: shown.text, written: false }); + return; + } + + logger.log(ui.section(`${CLIENT_LABELS[client]} config`)); + logger.log(ui.branch(ui.fields([['add to', shown.target]]))); + logger.log(shown.text); + return; + } + + const result = install(client, launch); + const signedIn = Boolean(readConfig()?.session) || Boolean(apiKeyOverride()); + if (args.json) { + logger.json({ status: 'SUCCESS', client, launch, target: result.target, replaced: result.replaced, written: true, signedIn }); + return; + } + + logger.log( + ui.success(`${result.replaced ? 'Updated' : 'Added'} DeviceCloud in ${CLIENT_LABELS[client]}`), + ); + const rows: Field[] = [ + [client === 'claude-code' || client === 'vscode' ? 'ran' : 'wrote', result.target], + ['starts', colors.highlight(shellLine(launch.command, launch.args))], + ]; + logger.log(ui.branch(ui.fields(rows))); + logger.log(ui.info(RELOAD_HINT[client])); + if (!signedIn) { + logger.log( + ui.warn(`Not signed in. Run ${colors.highlight('dcd login')} so the server can use your account.`), + ); + } + }, +}); /** * `dcd mcp`: the MCP server over stdio, for MCP clients to launch. It serves * the dcd_* cloud-test tools and the device tools (see src/mcp/bridge.ts). - * Nothing but JSON-RPC may reach stdout. + * Nothing but JSON-RPC may reach stdout. `dcd mcp install` adds it to a + * client's config. */ export const mcpCommand = defineCommand({ meta: { @@ -23,7 +208,11 @@ export const mcpCommand = defineCommand({ description: 'Serve only tools that change nothing (also DCD_MCP_READONLY=1)', }, }, - async run() { + subCommands: { install: installSub }, + async run({ rawArgs }) { + // citty runs this after a subcommand too; `dcd mcp install` must not + // then start a server. + if (rawArgs.some((arg) => !arg.startsWith('-'))) return; telemetry.setCommand('mcp'); const server = await createBridgeServer(); await server.connect(new StdioServerTransport()); diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts new file mode 100644 index 0000000..5d5faf0 --- /dev/null +++ b/src/utils/mcp-install.ts @@ -0,0 +1,171 @@ +/** + * What `dcd mcp install` writes: how an MCP client starts this dcd, and the + * config entry each client wants. + * + * Desktop apps (Claude Desktop, Cursor) are usually started from the Dock or + * Start menu, so they don't read the shell rc that put dcd on PATH. Every + * entry therefore names this dcd by its full path, never a bare `dcd`. + */ +import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { CliError, type InstallMethod } from './cli.js'; + +/** The server's name in every client's config. */ +export const SERVER_NAME = 'devicecloud'; + +export const MCP_CLIENTS = ['claude-code', 'claude-desktop', 'cursor', 'vscode'] as const; +export type McpClient = (typeof MCP_CLIENTS)[number]; + +export const CLIENT_LABELS: Record = { + 'claude-code': 'Claude Code', + 'claude-desktop': 'Claude Desktop', + cursor: 'Cursor', + vscode: 'VS Code', +}; + +export interface ServerLaunch { + command: string; + args: string[]; +} + +/** + * The command that starts `dcd mcp` from this install. The standalone binary + * is one file. An npm install is node plus the CLI's script; both are named + * by full path, so a different node on PATH, or none, doesn't matter. + */ +export function serverLaunch(opts: { + installMethod: InstallMethod; + execPath: string; + scriptPath: string | undefined; + readOnly: boolean; +}): ServerLaunch { + const tail = ['mcp', ...(opts.readOnly ? ['--read-only'] : [])]; + if (opts.installMethod === 'binary') return { command: opts.execPath, args: tail }; + + if (!opts.scriptPath || /\.[cm]?ts$/.test(opts.scriptPath)) { + throw new CliError( + 'Run `dcd mcp install` from an installed dcd, not from source: the config it writes must start a built dcd.', + ); + } + + if (opts.scriptPath.split(/[/\\]/).includes('_npx')) { + throw new CliError( + 'This dcd runs from the npx cache, which gets cleared, so a config pointing at it would stop working. Install dcd (curl -fsSL https://get.devicecloud.dev/install.sh | sh, or npm install -g @devicecloud.dev/dcd), then run `dcd mcp install`.', + ); + } + + return { command: opts.execPath, args: [opts.scriptPath, ...tail] }; +} + +/** + * The `node` on PATH that is this same node, else `execPath`. execPath is + * the resolved file, which can be a versioned folder (Homebrew's Cellar) that + * an upgrade removes; the PATH entry (/opt/homebrew/bin/node) survives it. + */ +export function stableNodePath( + execPath: string, + pathEnv: string | undefined = process.env.PATH, + platform: NodeJS.Platform = process.platform, +): string { + const exe = platform === 'win32' ? 'node.exe' : 'node'; + let target: string; + try { + target = realpathSync(execPath); + } catch { + return execPath; + } + + for (const dir of (pathEnv ?? '').split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, exe); + try { + if (realpathSync(candidate) === target) return candidate; + } catch { + // not there + } + } + + return execPath; +} + +/** + * The JSON config file a client reads, or undefined when it is configured + * through its own CLI (Claude Code, VS Code) or has no known location here. + */ +export function clientConfigPath( + client: McpClient, + platform: NodeJS.Platform = process.platform, + home: string = os.homedir(), + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (client === 'cursor') return path.join(home, '.cursor', 'mcp.json'); + if (client !== 'claude-desktop') return undefined; + if (platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + + if (platform === 'win32') { + const appData = env.APPDATA ?? path.join(home, 'AppData', 'Roaming'); + return path.join(appData, 'Claude', 'claude_desktop_config.json'); + } + + return undefined; +} + +/** The `mcpServers` block Claude Desktop and Cursor read, with ours in it. */ +export function mcpServersSnippet(launch: ServerLaunch): { mcpServers: Record } { + return { mcpServers: { [SERVER_NAME]: launch } }; +} + +/** + * Add our entry to a client's JSON config and write it back, keeping every + * other key. Returns whether an entry was already there (and is replaced). + * A file that isn't plain JSON (comments, say) is left alone. + */ +export function writeClientConfig(file: string, launch: ServerLaunch): { replaced: boolean } { + let config: Record = {}; + if (existsSync(file)) { + const text = readFileSync(file, 'utf8'); + if (text.trim()) { + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object'); + config = parsed as Record; + } catch { + throw new CliError( + `${file} isn't plain JSON, so it was left as it is. Add this to it by hand:\n${JSON.stringify(mcpServersSnippet(launch), null, 2)}`, + ); + } + } + } + + const servers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const replaced = SERVER_NAME in servers; + config.mcpServers = { ...servers, [SERVER_NAME]: launch }; + + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`); + renameSync(tmp, file); + return { replaced }; +} + +/** `claude mcp add` arguments, at user scope so it works in every project. */ +export function claudeCodeAddArgs(launch: ServerLaunch): string[] { + return ['mcp', 'add', '--scope', 'user', SERVER_NAME, '--', launch.command, ...launch.args]; +} + +/** `code --add-mcp` arguments, which add the server to the VS Code user profile. */ +export function vscodeAddArgs(launch: ServerLaunch): string[] { + return ['--add-mcp', JSON.stringify({ name: SERVER_NAME, ...launch })]; +} + +/** A command line to show a person, quoting the parts that need it. */ +export function shellLine(command: string, args: string[]): string { + return [command, ...args].map((part) => (/^[\w@%+=:,./-]+$/.test(part) ? part : `'${part.replaceAll("'", String.raw`'\''`)}'`)).join(' '); +} diff --git a/test/unit/mcp-install.test.ts b/test/unit/mcp-install.test.ts new file mode 100644 index 0000000..05c133b --- /dev/null +++ b/test/unit/mcp-install.test.ts @@ -0,0 +1,109 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { CliError } from '../../src/utils/cli.js'; +import { + claudeCodeAddArgs, + clientConfigPath, + serverLaunch, + shellLine, + stableNodePath, + vscodeAddArgs, + writeClientConfig, +} from '../../src/utils/mcp-install.js'; + +describe('dcd mcp install', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-mcp-install-')); + }); + afterEach(() => { + fs.rmSync(dir, { force: true, recursive: true }); + }); + + describe('serverLaunch', () => { + it('starts the standalone binary by its own path', () => { + expect( + serverLaunch({ installMethod: 'binary', execPath: '/home/a/.dcd/bin/dcd', scriptPath: undefined, readOnly: true }), + ).to.deep.equal({ args: ['mcp', '--read-only'], command: '/home/a/.dcd/bin/dcd' }); + }); + + it('starts an npm install as node plus the script', () => { + expect( + serverLaunch({ installMethod: 'npm', execPath: '/usr/bin/node', scriptPath: '/usr/lib/dcd/dist/index.js', readOnly: false }), + ).to.deep.equal({ args: ['/usr/lib/dcd/dist/index.js', 'mcp'], command: '/usr/bin/node' }); + }); + + it('refuses a run from source or from the npx cache', () => { + const from = (scriptPath: string) => () => + serverLaunch({ installMethod: 'npm', execPath: '/usr/bin/node', scriptPath, readOnly: false }); + expect(from('/repo/src/index.ts')).to.throw(CliError, /not from source/); + expect(from('/home/a/.npm/_npx/1a2b/node_modules/@devicecloud.dev/dcd/dist/index.js')).to.throw(CliError, /npx cache/); + }); + }); + + it('stableNodePath prefers the PATH entry that is the same node', () => { + const real = path.join(dir, 'Cellar', 'node', '26.1.0', 'bin'); + const linkDir = path.join(dir, 'bin'); + fs.mkdirSync(real, { recursive: true }); + fs.mkdirSync(linkDir); + fs.writeFileSync(path.join(real, 'node'), ''); + fs.symlinkSync(path.join(real, 'node'), path.join(linkDir, 'node')); + + const execPath = path.join(real, 'node'); + expect(stableNodePath(execPath, ['/nowhere', linkDir].join(path.delimiter), 'darwin')).to.equal(path.join(linkDir, 'node')); + expect(stableNodePath(execPath, '/nowhere', 'darwin')).to.equal(execPath); + }); + + it('clientConfigPath knows the desktop clients', () => { + expect(clientConfigPath('cursor', 'linux', '/h')).to.equal(path.join('/h', '.cursor', 'mcp.json')); + expect(clientConfigPath('claude-desktop', 'darwin', '/h')).to.equal( + path.join('/h', 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), + ); + expect(clientConfigPath('claude-desktop', 'win32', '/h', { APPDATA: '/ad' })).to.equal( + path.join('/ad', 'Claude', 'claude_desktop_config.json'), + ); + expect(clientConfigPath('claude-desktop', 'linux', '/h')).to.equal(undefined); + expect(clientConfigPath('claude-code', 'darwin', '/h')).to.equal(undefined); + }); + + describe('writeClientConfig', () => { + const launch = { args: ['mcp'], command: '/bin/dcd' }; + + it('creates the file and its folder', () => { + const file = path.join(dir, 'a', 'mcp.json'); + expect(writeClientConfig(file, launch)).to.deep.equal({ replaced: false }); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).to.deep.equal({ mcpServers: { devicecloud: launch } }); + }); + + it('keeps other servers and keys, and replaces its own entry', () => { + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { devicecloud: { command: 'old' }, other: { command: 'x' } }, theme: 'dark' })); + expect(writeClientConfig(file, launch)).to.deep.equal({ replaced: true }); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).to.deep.equal({ + mcpServers: { devicecloud: launch, other: { command: 'x' } }, + theme: 'dark', + }); + }); + + it('leaves a file that is not plain JSON untouched', () => { + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, '{ // a comment\n}'); + expect(() => writeClientConfig(file, launch)).to.throw(CliError, /isn't plain JSON/); + expect(fs.readFileSync(file, 'utf8')).to.equal('{ // a comment\n}'); + }); + }); + + it('builds the claude and code commands', () => { + const launch = { args: ['mcp', '--read-only'], command: '/Users/a b/.dcd/bin/dcd' }; + expect(claudeCodeAddArgs(launch)).to.deep.equal([ + 'mcp', 'add', '--scope', 'user', 'devicecloud', '--', '/Users/a b/.dcd/bin/dcd', 'mcp', '--read-only', + ]); + expect(JSON.parse(vscodeAddArgs(launch)[1])).to.deep.equal({ name: 'devicecloud', ...launch }); + expect(shellLine('claude', claudeCodeAddArgs(launch))).to.equal( + "claude mcp add --scope user devicecloud -- '/Users/a b/.dcd/bin/dcd' mcp --read-only", + ); + }); +});