Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand All @@ -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 `<api>/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 `<api>/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

Expand Down
3 changes: 0 additions & 3 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/commands/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
39 changes: 39 additions & 0 deletions test/unit/live-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading