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", + ); + }); +});