diff --git a/CLAUDE.md b/CLAUDE.md index 0e91224..411561b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,12 @@ The package ships a **second bin, `dcd-mcp`** (`bin.dcd-mcp` → `dist/mcp/index ## Architecture -Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, `upgrade`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`; `upgrade` self-updates the standalone bun binary (no-op for npm installs, which it redirects to `npm install -g`). Note `src/index.ts` deliberately **reimplements** citty's `runMain` rather than calling it — see the Telemetry section for why. +Top-level `defineCommand` in `src/index.ts` wires thirteen subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `device`, `mcp`, `live`, `upgrade`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `live` is hidden and deprecated: it still works for old scripts, and prints a deprecation line on stderr pointing at `device`. `cloud` is the primary command and replicates `maestro cloud`; `upgrade` self-updates the standalone bun binary (no-op for npm installs, which it redirects to `npm install -g`). Note `src/index.ts` deliberately **reimplements** citty's `runMain` rather than calling it — see the Telemetry section for why. **Flag composition.** Flag definitions are split by domain in `src/config/flags/*.flags.ts` (api, binary, device, environment, execution, github, output) and re-exported as a single `flags` object from `src/constants.ts`. Commands that need the full cloud surface spread `...flags` into their citty `args`; subset commands import individual flag groups. +**`dcd device` (Agent Devices).** Cloud device sessions that a person or an AI agent leases, drives and releases, over the API's `/device/sessions` routes (`src/gateways/device-gateway.ts`). Its rules differ from the other commands, and scripts and agents depend on them: `--json` prints one envelope, `{ ok, session, data, error }`, even on failure (`runDeviceCommand` in `src/utils/device-output.ts` prints it, so the top-level `{ status, error }` never appears); exit codes are 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found (`DEVICE_EXIT`); the session id may be left out when exactly one session is open (`resolveSessionId`); progress goes to stderr and there is no spinner unless stdin and stdout are TTYs. `DeviceApiError` keeps the API's machine code (`LIVE_NOT_FOUND`, …) and the `session` block an error carries, which `ApiError` drops. + **Output rendering.** All human-facing output goes through `src/utils/ui.ts` (the canonical layer: `section`/`branch`/`fields`/`status`/`success`/`info`/`warn`) on top of `src/utils/styling.ts` (`colors`, `symbols`, `statusPalette`). The visual language is a Claude Code-style tree (`⏺` section headings, `⎿` branch groups). Commands must not hand-roll layouts from `colors`/`symbols` or call `console.log`; the `--json` result goes through `logger.json`. Every command takes `--json` (`jsonFlag`), and `src/index.ts` turns thrown errors into `{ status: 'FAILED', error }` whenever `--json` is in argv. Full rules and a cookbook live in `STYLE_GUIDE.md`. **Layered call stack.** @@ -51,7 +53,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **MCP server.** `src/mcp/` is a third front-end onto the same service layer (a sibling to `src/commands/`), shipped as the `dcd-mcp` bin over stdio transport (`@modelcontextprotocol/sdk`, `zod` schemas). `index.ts` boots the server; `server.ts` registers tools; `context.ts` resolves auth + API URL **lazily**, and again when a `dcd login` session nears expiry (so `tools/list` works unauthenticated, auth errors surface as tool errors rather than a boot crash, and a long-lived server outlasts the one-hour access token) via the same `resolveAuth`/`resolveApiUrl` as the CLI — `DEVICE_CLOUD_API_KEY` env or stored `dcd login` session, with `DCD_API_URL` to override. **Critical invariant: stdout is the JSON-RPC channel** — tools must never call the `src/commands/*` layer or `utils/cli` `logger` (both write to stdout / can `process.exit`); they call services/gateways directly with `logStderr` and return data via `helpers.ts` `jsonResult`/`errorResult`. The `runTool` wrapper records `mcp tool …` telemetry and converts thrown errors to `isError` results. Tools: `dcd_list_devices`, `dcd_list_runs`, `dcd_get_status`, `dcd_download_artifacts` (read-only), and `dcd_run_cloud_test` (billable — gated out by `--read-only` / `DCD_MCP_READONLY=1`, annotated destructive, async-by-default). `dcd_run_cloud_test` reuses `computeCommonRoot`/`buildTestMetadataMap` from `src/services/flow-paths.ts` (extracted from `cloud.ts` so both build identical server-side paths). Registry manifest: `server.json` at repo root. -**`dcd mcp` (the bridge).** A second stdio entry point, `src/mcp/bridge.ts`, run by the hidden `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`, and the remote connection is remade when a session is renewed. 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` (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`, and the remote connection is remade when a session is renewed. 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. ## Contributing diff --git a/src/commands/device.ts b/src/commands/device.ts index 7a1adbb..718eff4 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -1013,9 +1013,6 @@ export const deviceCommand = defineCommand({ meta: { name: 'device', description: 'Cloud devices you (or an AI agent) can lease, drive and release [beta]', - // Left out of `dcd --help` until the /device/sessions API is in - // production (Agent Devices plan, Phase 8). It works when called by name. - hidden: true, }, subCommands: { start: startSub, diff --git a/src/commands/live.ts b/src/commands/live.ts index a5f82d0..bc79aac 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -16,6 +16,11 @@ import { type Field, ui } from '../utils/ui.js'; const PLATFORM_OPTIONS = ['android', 'ios'] as const; type Platform = (typeof PLATFORM_OPTIONS)[number]; +/** Printed to stderr by every `dcd live` command. */ +export const LIVE_DEPRECATION = ui.deprecation( + '`dcd live` is deprecated and will be removed. Use `dcd device` instead: see `dcd device --help`.', +); + const READY_TIMEOUT_MS = 180_000; const READY_POLL_MS = 2_000; @@ -709,8 +714,15 @@ export const liveCommand = defineCommand({ meta: { name: 'live', description: 'Start and interact with a live device session', + // Replaced by `dcd device` (Agent Devices plan 3.4). Kept working for + // existing scripts, but no longer listed in `dcd --help`. + hidden: true, }, subCommands, + // Runs before any subcommand. On stderr, so --json output is unchanged. + setup() { + process.stderr.write(`${LIVE_DEPRECATION}\n`); + }, // citty's runCommand does not early-return after dispatching to a // subcommand — it still invokes the parent `run` afterwards. So when a // subcommand was matched, bail out here; otherwise the menu would print diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 4e7975f..d6c797c 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -15,8 +15,6 @@ export const mcpCommand = defineCommand({ meta: { name: 'mcp', description: 'Run the DeviceCloud MCP server on stdio (cloud tests and cloud devices)', - // Listed in `dcd --help` with `dcd device`, once the device API is live. - hidden: true, }, args: { 'read-only': { diff --git a/test/unit/live-command.test.ts b/test/unit/live-command.test.ts index 5dd343c..74b0e55 100644 --- a/test/unit/live-command.test.ts +++ b/test/unit/live-command.test.ts @@ -42,3 +42,42 @@ describe('live command menu', () => { expect(captureMenu(['bogus'])).to.equal(true); }); }); + +describe('live command deprecation', () => { + it('is hidden from help', () => { + expect((liveCommand.meta as { hidden?: boolean }).hidden).to.equal(true); + }); + + it('prints the deprecation on stderr, never stdout', () => { + let err = ''; + let out = ''; + const writeErr = process.stderr.write.bind(process.stderr); + const writeOut = process.stdout.write.bind(process.stdout); + process.stderr.write = ((chunk: string) => { + err += chunk; + return true; + }) as typeof process.stderr.write; + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + (liveCommand.setup as () => void)(); + } finally { + process.stderr.write = writeErr; + process.stdout.write = writeOut; + } + + expect(err).to.include('`dcd live` is deprecated').and.include('dcd device'); + expect(out).to.equal(''); + }); +}); + +describe('launch: listed in help', () => { + it('lists dcd device and dcd mcp', async () => { + const { deviceCommand } = await import('../../src/commands/device.js'); + const { mcpCommand } = await import('../../src/commands/mcp.js'); + expect((deviceCommand.meta as { hidden?: boolean }).hidden).to.not.equal(true); + expect((mcpCommand.meta as { hidden?: boolean }).hidden).to.not.equal(true); + }); +});