Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
43cdf5f
deps: patch ip-address advisory (#256)
riglar Oct 23, 2026
93bef01
ci: authenticate the Claude action by workload identity federation (#…
riglar Oct 23, 2026
d6aea84
chore: merge production into dev after 5.10.0 (#254)
dcd-release-please[bot] Oct 23, 2026
69249e3
ci: authenticate the Claude action with the org CLAUDE_CODE_OAUTH_TOK…
riglar Oct 23, 2026
8fe73cd
deps: patch body-parser and fast-uri advisories (#262)
riglar Oct 26, 2026
eb7881f
feat: polish the install script and the progress spinner (#264)
riglar Oct 26, 2026
8d87e67
feat(device): add dcd device and dcd mcp for Agent Devices (#260)
riglar Oct 26, 2026
13a7158
fix(device): fit start's next steps to the session, and explain dcd m…
riglar Oct 26, 2026
2e566ef
fix(device): don't crash when no browser opener is installed (#265)
riglar Oct 26, 2026
8bff82e
feat(mcp): add dcd mcp install, and lead the docs with the standalone…
riglar Oct 26, 2026
b07af33
fix(device): drop Pixel 6 Pro and Pixel 7 Pro, and mirror the Pixel 1…
finalerock44 Oct 26, 2026
5f4c45e
feat(upload): record APK CPU architectures in binary metadata (#269)
riglar Oct 26, 2026
ca3653f
docs: keep private incident and customer detail out of this public re…
riglar Oct 26, 2026
d1cdbd4
fix(upload): retry Backblaze uploads on transient failures (#270)
riglar Oct 26, 2026
7be9899
feat(cloud): say which flows use extended runtime, and that they cost…
finalerock44 Oct 26, 2026
85cde1a
feat(device): inspect --all, element state, and screen text on a fail…
riglar Oct 26, 2026
ffdb4ed
ci: group Dependabot security-update PRs (#274)
riglar Oct 26, 2026
7b31b1e
feat(device): bundle only the files a directory's flows use (#275)
riglar Oct 26, 2026
91b8d7f
chore: bump typescript-eslint from 8.71.2 to 8.72.0 in the minor-and-…
dependabot[bot] Oct 26, 2026
7d751d2
feat(api): send the CLI version and CI identity on every API request …
finalerock44 Oct 26, 2026
498f300
fix(mcp): dry runs honour tags, single flow files bring their subflow…
riglar Oct 26, 2026
ec422fd
fix(device): say "since", not "asked", when a person took control una…
riglar Oct 26, 2026
f3a9da1
Merge remote-tracking branch 'origin/production' into promote/cli-5.11.0
finalerock44 Oct 27, 2026
e83ae64
chore: pin the 5.11.0 promotion
finalerock44 Oct 27, 2026
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
11 changes: 10 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
version: 2
# No `target-branch`: Dependabot targets the default branch (`dev`), which is
# what we want. Setting it explicitly also stops security-update PRs from
# using the groups and commit prefixes below.
# using the commit prefixes below.
#
# A group covers scheduled version updates only, unless it says
# `applies-to: security-updates`. Security PRs open the day an advisory
# lands, not on the schedule, so each npm entry carries a `security` group
# too: a burst of advisories arrives as one PR instead of one per package.
updates:
# npm / pnpm dependencies.
- package-ecosystem: npm
Expand All @@ -20,6 +25,10 @@ updates:
update-types:
- minor
- patch
security:
applies-to: security-updates
patterns:
- "*"

# GitHub Actions used by our workflows.
- package-ecosystem: github-actions
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,26 @@ jobs:
# The plugin also stops early on PRs it judges trivial. The prompt above
# tells Claude to post a one-line note in that case, so a deliberate skip
# leaves a comment and only a real failure trips this check.
#
# Nor is a PR that edits THIS file: the action refuses to run unless the
# workflow is identical to the default branch's ("Workflow validation
# failed"), a deliberate guard against a PR rewriting its own reviewer. It
# exits green having posted nothing, so pass with a notice instead.
- name: Check a review comment was posted
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
SELF: ${{ github.workflow_ref }}
run: |
# workflow_ref is "owner/repo/.github/workflows/<file>@<ref>".
self_path="${SELF#"$REPO"/}"
self_path="${self_path%@*}"
if gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename' | grep -qxF "$self_path"; then
echo "::notice::This PR edits $self_path, so the review action skipped it by design (it only runs the default branch's version of its own workflow). No review on this PR."
exit 0
fi

# Prints the time of every claude[bot] comment, inline comment and review.
claude_posts() {
gh api "repos/$REPO/issues/$PR/comments" --paginate \
Expand Down
12 changes: 10 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 @@ -41,7 +43,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, `

**Cloud command workflow** (the canonical shape other commands mirror): upload binary with SHA dedup → extract metadata / validate devices → build execution plan → submit tests → poll results every 10s → download artifacts (reports/videos/logs). `RunFailedError` from the polling service is the signal for a failed test run (distinct from infra errors).

**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched.
**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. The same headers carry the CLI's identity on every request (`x-dcd-cli-version`, plus `x-dcd-ci-provider` / `x-dcd-ci-wrapper-version` from `detectCiContext`), which the API uses for notice targeting and records on each upload and binary (dcd#1622). Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched.

**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `<frontend>/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and picks the org with `selectOrg` (`src/utils/orgs.ts`): `--org <name|id>` if given, else the only org, else the picker `dcd switch-org` uses, starting on the last login's org. That org only counts when the stored `api_url` matches, because org ids are per environment. Without a TTY (`canPrompt` in `src/utils/ci.ts`) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at `--org`. `dcd logout` deletes the whole config, so nothing is remembered after it. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`.

Expand All @@ -51,10 +53,16 @@ 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 `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`. 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):

- ⚠️ **This repo is public — write PR titles and bodies, commit messages, code comments, test names and issues for a stranger.** Work here is often prompted by a support ticket or incident in the private `dcd` repo; that context stays there. Keep out: customer or org names and ids, ticket refs (`SUP-…`), private-repo issue/PR numbers, customer usage numbers, internal hostnames, Supabase project refs and dashboard links, and anything about our own security posture — key rotations or revocations, when they happened, which CLI versions they broke. Describe the fault generically ("a dropped B2 request failed the upload") and link the private issue from the private side instead. Edits don't undo a leak: GitHub keeps every revision of a PR or issue body, and a pushed commit stays readable from the PR after a later commit fixes it, so get it right before the first push.

- **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly.
- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it.
- Type → bump: `feat` **minor**; `fix`/`perf`/`deps`/`revert`/`refactor` **patch**; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form.
Expand Down
78 changes: 75 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,81 @@ See full documentation: [Docs](https://docs.devicecloud.dev)

## MCP server

The same npm package ships an [MCP](https://modelcontextprotocol.io) server (`dcd-mcp` bin) so AI agents — Claude, Cursor, VS Code — can drive devicecloud.dev directly.
devicecloud.dev has an [MCP](https://modelcontextprotocol.io) server, so AI agents (Claude, Cursor, VS Code) can run cloud tests and drive cloud devices. You don't run it yourself. You add it to your MCP client's config, and the client starts it.

Add it to your MCP client config:
There are three ways to connect:

| | Install | Tools | Use it when |
| --- | --- | --- | --- |
| **`dcd mcp`** (recommended) | the CLI | device tools + `dcd_*` cloud-test tools | you want everything, including installing a local build or running local flow files |
| **Hosted** `https://api.devicecloud.dev/mcp` | nothing | device tools | the agent only drives devices, with apps and flows that are already uploaded or at a URL |
| **`dcd-mcp`** | the CLI | `dcd_*` cloud-test tools | you only run cloud tests (the older server; `dcd mcp` covers it) |

### `dcd mcp`

`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.

**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
$ dcd login
$ dcd mcp install cursor # or claude-code, claude-desktop, vscode
```

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": "/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"]
}
}
}
```

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.

**Device tools:** `list_devices`, `start_device`, `get_device_status`, `install_app`, `launch_app`, `inspect_screen`, `take_screenshot`, `run`, `get_command_status`, `get_logs`, `request_human`, `get_human_status`, `stop_device`, and `cheat_sheet` (Maestro syntax for the agent).

### Hosted

Point an HTTP-capable client at the URL. It signs you in through the browser (OAuth), and you can disconnect it later under Settings → Connected apps.

```sh-session
$ claude mcp add --transport http devicecloud https://api.devicecloud.dev/mcp
```

### `dcd-mcp`

The older server, with the `dcd_*` tools only. Add it to your MCP client config:

```jsonc
{
Expand All @@ -56,7 +128,7 @@ Add it to your MCP client config:

Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`.

**Tools**
**`dcd_*` tools** (served by both `dcd mcp` and `dcd-mcp`)

| Tool | What it does |
| --- | --- |
Expand Down
3 changes: 3 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading