@devicecloud.dev/dcd 5.11.0-beta.3 → 5.11.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,26 +50,51 @@ There are three ways to connect:
50
50
 
51
51
  `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.
52
52
 
53
- Claude Code:
53
+ **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:
54
54
 
55
55
  ```sh-session
56
- $ claude mcp add devicecloud -- npx -y @devicecloud.dev/dcd mcp
56
+ $ dcd login
57
+ $ dcd mcp install cursor # or claude-code, claude-desktop, vscode
57
58
  ```
58
59
 
59
- Other clients:
60
+ 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.
61
+
62
+ **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 `~`.
63
+
64
+ Claude Desktop (`claude_desktop_config.json`) and Cursor (`~/.cursor/mcp.json`):
60
65
 
61
66
  ```jsonc
62
67
  {
63
68
  "mcpServers": {
64
69
  "devicecloud": {
65
- "command": "npx",
66
- "args": ["-y", "@devicecloud.dev/dcd", "mcp"]
70
+ "command": "/Users/you/.dcd/bin/dcd",
71
+ "args": ["mcp"]
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ VS Code (`.vscode/mcp.json`, or the user `mcp.json`) uses `servers` and a `type`:
78
+
79
+ ```jsonc
80
+ {
81
+ "servers": {
82
+ "devicecloud": {
83
+ "type": "stdio",
84
+ "command": "/Users/you/.dcd/bin/dcd",
85
+ "args": ["mcp"]
67
86
  }
68
87
  }
69
88
  }
70
89
  ```
71
90
 
72
- If the CLI is installed globally, use `"command": "dcd", "args": ["mcp"]` instead.
91
+ Claude Code, run from a terminal, does see your PATH:
92
+
93
+ ```sh-session
94
+ $ claude mcp add --scope user devicecloud -- dcd mcp
95
+ ```
96
+
97
+ 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.
73
98
 
74
99
  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.
75
100
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `dcd mcp`: the MCP server over stdio, for MCP clients to launch. It serves
3
3
  * the dcd_* cloud-test tools and the device tools (see src/mcp/bridge.ts).
4
- * Nothing but JSON-RPC may reach stdout.
4
+ * Nothing but JSON-RPC may reach stdout. `dcd mcp install` adds it to a
5
+ * client's config.
5
6
  */
6
7
  export declare const mcpCommand: import("citty").CommandDef<{
7
8
  readonly 'read-only': {
@@ -1,12 +1,158 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import * as path from 'node:path';
3
+ import * as p from '@clack/prompts';
1
4
  import { defineCommand } from 'citty';
2
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { jsonFlag } from '../config/flags/output.flags.js';
3
7
  import { createBridgeServer } from '../mcp/bridge.js';
4
8
  import { isReadOnly, logStderr } from '../mcp/context.js';
5
9
  import { telemetry } from '../services/telemetry.service.js';
10
+ import { apiKeyOverride } from '../utils/auth.js';
11
+ import { canPrompt } from '../utils/ci.js';
12
+ import { CliError, getInstallMethod, logger } from '../utils/cli.js';
13
+ import { readConfig } from '../utils/config-store.js';
14
+ import { CLIENT_LABELS, claudeCodeAddArgs, clientConfigPath, MCP_CLIENTS, mcpServersSnippet, SERVER_NAME, serverLaunch, shellLine, stableNodePath, vscodeAddArgs, writeClientConfig, } from '../utils/mcp-install.js';
15
+ import { colors } from '../utils/styling.js';
16
+ import { ui } from '../utils/ui.js';
17
+ const RELOAD_HINT = {
18
+ 'claude-code': 'Start a new Claude Code session to load it.',
19
+ 'claude-desktop': 'Restart Claude Desktop to load it.',
20
+ cursor: 'Restart Cursor, or reload its MCP servers, to load it.',
21
+ vscode: 'VS Code asks you to trust the server the first time it starts.',
22
+ };
23
+ /** Run another tool's CLI without a shell. Undefined when it isn't installed. */
24
+ function runTool(command, args) {
25
+ const result = spawnSync(command, args, { encoding: 'utf8' });
26
+ if (result.error?.code === 'ENOENT')
27
+ return undefined;
28
+ return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
29
+ }
30
+ async function pickClient(value) {
31
+ if (value) {
32
+ if (MCP_CLIENTS.includes(value))
33
+ return value;
34
+ throw new CliError(`Unknown client "${value}". Pick one of: ${MCP_CLIENTS.join(', ')}.`);
35
+ }
36
+ if (!canPrompt()) {
37
+ throw new CliError(`Say which client to set up: dcd mcp install <${MCP_CLIENTS.join('|')}>.`);
38
+ }
39
+ const picked = await p.select({
40
+ message: 'Which AI tool should use DeviceCloud?',
41
+ options: MCP_CLIENTS.map((c) => ({ value: c, label: CLIENT_LABELS[c] })),
42
+ });
43
+ if (p.isCancel(picked))
44
+ throw new CliError('Cancelled.');
45
+ return picked;
46
+ }
47
+ /** Add the server to `client`, through its CLI or its JSON config file. */
48
+ function install(client, launch) {
49
+ if (client === 'claude-code') {
50
+ const add = claudeCodeAddArgs(launch);
51
+ const removed = runTool('claude', ['mcp', 'remove', '--scope', 'user', SERVER_NAME]);
52
+ if (!removed) {
53
+ throw new CliError(`The claude command isn't on PATH. Run this where it is:\n ${shellLine('claude', add)}`);
54
+ }
55
+ const added = runTool('claude', add);
56
+ if (!added.ok)
57
+ throw new CliError(`claude mcp add failed: ${added.output}`);
58
+ return { target: shellLine('claude', add), replaced: removed.ok };
59
+ }
60
+ if (client === 'vscode') {
61
+ const add = vscodeAddArgs(launch);
62
+ const added = runTool('code', add);
63
+ if (!added) {
64
+ 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)}`);
65
+ }
66
+ if (!added.ok)
67
+ throw new CliError(`code --add-mcp failed: ${added.output}`);
68
+ return { target: shellLine('code', add), replaced: false };
69
+ }
70
+ const file = clientConfigPath(client);
71
+ if (!file) {
72
+ 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)}`);
73
+ }
74
+ return { target: file, ...writeClientConfig(file, launch) };
75
+ }
76
+ /** What `--print` shows: the command to run, or the file and the entry for it. */
77
+ function preview(client, launch) {
78
+ if (client === 'claude-code')
79
+ return { target: 'command', text: shellLine('claude', claudeCodeAddArgs(launch)) };
80
+ if (client === 'vscode')
81
+ return { target: 'command', text: shellLine('code', vscodeAddArgs(launch)) };
82
+ return {
83
+ target: clientConfigPath(client) ?? 'your MCP config file',
84
+ text: JSON.stringify(mcpServersSnippet(launch), null, 2),
85
+ };
86
+ }
87
+ const installSub = defineCommand({
88
+ meta: {
89
+ name: 'install',
90
+ description: 'Add the DeviceCloud MCP server to Claude Code, Claude Desktop, Cursor or VS Code',
91
+ },
92
+ args: {
93
+ client: {
94
+ type: 'positional',
95
+ required: false,
96
+ description: `One of ${MCP_CLIENTS.join(', ')}. Asked when left out.`,
97
+ },
98
+ 'read-only': {
99
+ type: 'boolean',
100
+ default: false,
101
+ description: 'Have the server offer only tools that change nothing',
102
+ },
103
+ print: {
104
+ type: 'boolean',
105
+ default: false,
106
+ description: 'Show what would be added, and change nothing',
107
+ },
108
+ json: jsonFlag,
109
+ },
110
+ async run({ args }) {
111
+ telemetry.setCommand('mcp install');
112
+ const client = await pickClient(args.client);
113
+ const installMethod = getInstallMethod();
114
+ const launch = serverLaunch({
115
+ installMethod,
116
+ execPath: installMethod === 'binary' ? process.execPath : stableNodePath(process.execPath),
117
+ // As started, not resolved: a global npm install's bin link keeps
118
+ // working across `npm install -g` upgrades.
119
+ scriptPath: process.argv[1] ? path.resolve(process.argv[1]) : undefined,
120
+ readOnly: Boolean(args['read-only']),
121
+ });
122
+ if (args.print) {
123
+ const shown = preview(client, launch);
124
+ if (args.json) {
125
+ logger.json({ status: 'SUCCESS', client, launch, target: shown.target, text: shown.text, written: false });
126
+ return;
127
+ }
128
+ logger.log(ui.section(`${CLIENT_LABELS[client]} config`));
129
+ logger.log(ui.branch(ui.fields([['add to', shown.target]])));
130
+ logger.log(shown.text);
131
+ return;
132
+ }
133
+ const result = install(client, launch);
134
+ const signedIn = Boolean(readConfig()?.session) || Boolean(apiKeyOverride());
135
+ if (args.json) {
136
+ logger.json({ status: 'SUCCESS', client, launch, target: result.target, replaced: result.replaced, written: true, signedIn });
137
+ return;
138
+ }
139
+ logger.log(ui.success(`${result.replaced ? 'Updated' : 'Added'} DeviceCloud in ${CLIENT_LABELS[client]}`));
140
+ const rows = [
141
+ [client === 'claude-code' || client === 'vscode' ? 'ran' : 'wrote', result.target],
142
+ ['starts', colors.highlight(shellLine(launch.command, launch.args))],
143
+ ];
144
+ logger.log(ui.branch(ui.fields(rows)));
145
+ logger.log(ui.info(RELOAD_HINT[client]));
146
+ if (!signedIn) {
147
+ logger.log(ui.warn(`Not signed in. Run ${colors.highlight('dcd login')} so the server can use your account.`));
148
+ }
149
+ },
150
+ });
6
151
  /**
7
152
  * `dcd mcp`: the MCP server over stdio, for MCP clients to launch. It serves
8
153
  * the dcd_* cloud-test tools and the device tools (see src/mcp/bridge.ts).
9
- * Nothing but JSON-RPC may reach stdout.
154
+ * Nothing but JSON-RPC may reach stdout. `dcd mcp install` adds it to a
155
+ * client's config.
10
156
  */
11
157
  export const mcpCommand = defineCommand({
12
158
  meta: {
@@ -20,7 +166,12 @@ export const mcpCommand = defineCommand({
20
166
  description: 'Serve only tools that change nothing (also DCD_MCP_READONLY=1)',
21
167
  },
22
168
  },
23
- async run() {
169
+ subCommands: { install: installSub },
170
+ async run({ rawArgs }) {
171
+ // citty runs this after a subcommand too; `dcd mcp install` must not
172
+ // then start a server.
173
+ if (rawArgs.some((arg) => !arg.startsWith('-')))
174
+ return;
24
175
  telemetry.setCommand('mcp');
25
176
  const server = await createBridgeServer();
26
177
  await server.connect(new StdioServerTransport());
@@ -0,0 +1,50 @@
1
+ import { type InstallMethod } from './cli.js';
2
+ /** The server's name in every client's config. */
3
+ export declare const SERVER_NAME = "devicecloud";
4
+ export declare const MCP_CLIENTS: readonly ["claude-code", "claude-desktop", "cursor", "vscode"];
5
+ export type McpClient = (typeof MCP_CLIENTS)[number];
6
+ export declare const CLIENT_LABELS: Record<McpClient, string>;
7
+ export interface ServerLaunch {
8
+ command: string;
9
+ args: string[];
10
+ }
11
+ /**
12
+ * The command that starts `dcd mcp` from this install. The standalone binary
13
+ * is one file. An npm install is node plus the CLI's script; both are named
14
+ * by full path, so a different node on PATH, or none, doesn't matter.
15
+ */
16
+ export declare function serverLaunch(opts: {
17
+ installMethod: InstallMethod;
18
+ execPath: string;
19
+ scriptPath: string | undefined;
20
+ readOnly: boolean;
21
+ }): ServerLaunch;
22
+ /**
23
+ * The `node` on PATH that is this same node, else `execPath`. execPath is
24
+ * the resolved file, which can be a versioned folder (Homebrew's Cellar) that
25
+ * an upgrade removes; the PATH entry (/opt/homebrew/bin/node) survives it.
26
+ */
27
+ export declare function stableNodePath(execPath: string, pathEnv?: string | undefined, platform?: NodeJS.Platform): string;
28
+ /**
29
+ * The JSON config file a client reads, or undefined when it is configured
30
+ * through its own CLI (Claude Code, VS Code) or has no known location here.
31
+ */
32
+ export declare function clientConfigPath(client: McpClient, platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string | undefined;
33
+ /** The `mcpServers` block Claude Desktop and Cursor read, with ours in it. */
34
+ export declare function mcpServersSnippet(launch: ServerLaunch): {
35
+ mcpServers: Record<string, ServerLaunch>;
36
+ };
37
+ /**
38
+ * Add our entry to a client's JSON config and write it back, keeping every
39
+ * other key. Returns whether an entry was already there (and is replaced).
40
+ * A file that isn't plain JSON (comments, say) is left alone.
41
+ */
42
+ export declare function writeClientConfig(file: string, launch: ServerLaunch): {
43
+ replaced: boolean;
44
+ };
45
+ /** `claude mcp add` arguments, at user scope so it works in every project. */
46
+ export declare function claudeCodeAddArgs(launch: ServerLaunch): string[];
47
+ /** `code --add-mcp` arguments, which add the server to the VS Code user profile. */
48
+ export declare function vscodeAddArgs(launch: ServerLaunch): string[];
49
+ /** A command line to show a person, quoting the parts that need it. */
50
+ export declare function shellLine(command: string, args: string[]): string;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * What `dcd mcp install` writes: how an MCP client starts this dcd, and the
3
+ * config entry each client wants.
4
+ *
5
+ * Desktop apps (Claude Desktop, Cursor) are usually started from the Dock or
6
+ * Start menu, so they don't read the shell rc that put dcd on PATH. Every
7
+ * entry therefore names this dcd by its full path, never a bare `dcd`.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
10
+ import * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ import { CliError } from './cli.js';
13
+ /** The server's name in every client's config. */
14
+ export const SERVER_NAME = 'devicecloud';
15
+ export const MCP_CLIENTS = ['claude-code', 'claude-desktop', 'cursor', 'vscode'];
16
+ export const CLIENT_LABELS = {
17
+ 'claude-code': 'Claude Code',
18
+ 'claude-desktop': 'Claude Desktop',
19
+ cursor: 'Cursor',
20
+ vscode: 'VS Code',
21
+ };
22
+ /**
23
+ * The command that starts `dcd mcp` from this install. The standalone binary
24
+ * is one file. An npm install is node plus the CLI's script; both are named
25
+ * by full path, so a different node on PATH, or none, doesn't matter.
26
+ */
27
+ export function serverLaunch(opts) {
28
+ const tail = ['mcp', ...(opts.readOnly ? ['--read-only'] : [])];
29
+ if (opts.installMethod === 'binary')
30
+ return { command: opts.execPath, args: tail };
31
+ if (!opts.scriptPath || /\.[cm]?ts$/.test(opts.scriptPath)) {
32
+ throw new CliError('Run `dcd mcp install` from an installed dcd, not from source: the config it writes must start a built dcd.');
33
+ }
34
+ if (opts.scriptPath.split(/[/\\]/).includes('_npx')) {
35
+ 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`.');
36
+ }
37
+ return { command: opts.execPath, args: [opts.scriptPath, ...tail] };
38
+ }
39
+ /**
40
+ * The `node` on PATH that is this same node, else `execPath`. execPath is
41
+ * the resolved file, which can be a versioned folder (Homebrew's Cellar) that
42
+ * an upgrade removes; the PATH entry (/opt/homebrew/bin/node) survives it.
43
+ */
44
+ export function stableNodePath(execPath, pathEnv = process.env.PATH, platform = process.platform) {
45
+ const exe = platform === 'win32' ? 'node.exe' : 'node';
46
+ let target;
47
+ try {
48
+ target = realpathSync(execPath);
49
+ }
50
+ catch {
51
+ return execPath;
52
+ }
53
+ for (const dir of (pathEnv ?? '').split(path.delimiter)) {
54
+ if (!dir)
55
+ continue;
56
+ const candidate = path.join(dir, exe);
57
+ try {
58
+ if (realpathSync(candidate) === target)
59
+ return candidate;
60
+ }
61
+ catch {
62
+ // not there
63
+ }
64
+ }
65
+ return execPath;
66
+ }
67
+ /**
68
+ * The JSON config file a client reads, or undefined when it is configured
69
+ * through its own CLI (Claude Code, VS Code) or has no known location here.
70
+ */
71
+ export function clientConfigPath(client, platform = process.platform, home = os.homedir(), env = process.env) {
72
+ if (client === 'cursor')
73
+ return path.join(home, '.cursor', 'mcp.json');
74
+ if (client !== 'claude-desktop')
75
+ return undefined;
76
+ if (platform === 'darwin') {
77
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
78
+ }
79
+ if (platform === 'win32') {
80
+ const appData = env.APPDATA ?? path.join(home, 'AppData', 'Roaming');
81
+ return path.join(appData, 'Claude', 'claude_desktop_config.json');
82
+ }
83
+ return undefined;
84
+ }
85
+ /** The `mcpServers` block Claude Desktop and Cursor read, with ours in it. */
86
+ export function mcpServersSnippet(launch) {
87
+ return { mcpServers: { [SERVER_NAME]: launch } };
88
+ }
89
+ /**
90
+ * Add our entry to a client's JSON config and write it back, keeping every
91
+ * other key. Returns whether an entry was already there (and is replaced).
92
+ * A file that isn't plain JSON (comments, say) is left alone.
93
+ */
94
+ export function writeClientConfig(file, launch) {
95
+ let config = {};
96
+ if (existsSync(file)) {
97
+ const text = readFileSync(file, 'utf8');
98
+ if (text.trim()) {
99
+ try {
100
+ const parsed = JSON.parse(text);
101
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
102
+ throw new Error('not an object');
103
+ config = parsed;
104
+ }
105
+ catch {
106
+ 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)}`);
107
+ }
108
+ }
109
+ }
110
+ const servers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
111
+ ? config.mcpServers
112
+ : {};
113
+ const replaced = SERVER_NAME in servers;
114
+ config.mcpServers = { ...servers, [SERVER_NAME]: launch };
115
+ mkdirSync(path.dirname(file), { recursive: true });
116
+ const tmp = `${file}.${process.pid}.tmp`;
117
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`);
118
+ renameSync(tmp, file);
119
+ return { replaced };
120
+ }
121
+ /** `claude mcp add` arguments, at user scope so it works in every project. */
122
+ export function claudeCodeAddArgs(launch) {
123
+ return ['mcp', 'add', '--scope', 'user', SERVER_NAME, '--', launch.command, ...launch.args];
124
+ }
125
+ /** `code --add-mcp` arguments, which add the server to the VS Code user profile. */
126
+ export function vscodeAddArgs(launch) {
127
+ return ['--add-mcp', JSON.stringify({ name: SERVER_NAME, ...launch })];
128
+ }
129
+ /** A command line to show a person, quoting the parts that need it. */
130
+ export function shellLine(command, args) {
131
+ return [command, ...args].map((part) => (/^[\w@%+=:,./-]+$/.test(part) ? part : `'${part.replaceAll("'", String.raw `'\''`)}'`)).join(' ');
132
+ }
package/package.json CHANGED
@@ -68,7 +68,7 @@
68
68
  "test:unit": "node scripts/test-runner.mjs --unit",
69
69
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
70
70
  },
71
- "version": "5.11.0-beta.3",
71
+ "version": "5.11.0-beta.4",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },