Skip to content
Merged
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
10 changes: 8 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,14 @@ parts (`"panel": false` in `apps/claude/plugin-host.json`): no Baguette, serve-e
iOS mirror, with agent-device's commands as `device_*` tools, the skill in
`apps/claude/skills` and the shared agent-device skill. Packaging fails if a file exceeds the Claude plugin
directory's 5 MiB limit. The plugin folder uses `apps/claude/.claude-plugin/plugin.json`, which
starts `dist/server.mjs` with Node.js 22.19 or later from `PATH`, and leaves out the
Codex manifest, `.mcp.json`, and launcher. Check it with
starts `dist/server.mjs` through `scripts/launch-node.sh`. Claude opened from the Dock
starts plugins without the shell's `PATH`, so the launcher also looks for Node.js 22.19
or later where Homebrew, nvm, fnm, Volta, mise and asdf install it, and a
`SessionStart` hook (`hooks/hooks.json`) runs it with `--check` to tell the user and
Claude when none is found. The folder ships `apps/claude/README.md`, which the Claude
plugin directory lists, instead of the repository's README. It leaves out the Codex
manifest, `.mcp.json`, Codex's launcher, Codex skill metadata (`agents/openai.yaml`)
and npm's `.bin` symlinks, which plugin directories reject. Check it with
`claude plugin validate ./release/claude-plugin`, load it with
`claude --plugin-dir ./release/claude-plugin/mobile-dev`, or install it with
`claude plugin marketplace add ./release/claude-plugin` and
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ Android Emulator pane. Mobile Dev adds device control through the bundled
and Metro logs, app control and storage, interactive CPU, memory and FPS charts in
chat, and TraceSift findings.

On an Apple Silicon Mac with Node.js 22.19 or later on `PATH`, install the Claude
Code plugin from this repository's `release/claude` branch:
On an Apple Silicon Mac with Node.js 22.19 or later, install the Claude Code plugin
from this repository's `release/claude` branch. It finds Node on `PATH` or where
Homebrew, nvm, fnm, Volta, mise or asdf install it, and Claude tells you at the start
of a session when none is found:

```sh
claude plugin marketplace add https://github.com/callstackincubator/mobile-dev.git#release/claude
Expand Down Expand Up @@ -71,6 +73,8 @@ What the Claude version runs on your Mac, besides its Node.js server:
there.
- **Developer tools** already on your Mac: `xcrun simctl`, `devicectl`, `adb`, and
the Android emulator, and TraceSift when you analyze a profile.
- **A Node.js launcher and a session check** in the Claude Code plugin, which find
Node.js and start the server.

It sends plugin errors and aggregate measurements to Sentry, as described under
[Privacy and support](#privacy-and-support), and nothing else to Callstack;
Expand Down
3 changes: 2 additions & 1 deletion apps/claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
"termsOfServiceUrl": "https://github.com/callstackincubator/mobile-dev/blob/main/TERMS.md",
"mcpServers": {
"mobile-dev": {
"command": "node",
"command": "/bin/sh",
"args": [
"${CLAUDE_PLUGIN_ROOT}/scripts/launch-node.sh",
"${CLAUDE_PLUGIN_ROOT}/dist/server.mjs"
]
}
Expand Down
80 changes: 80 additions & 0 deletions apps/claude/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Mobile Dev for Claude

Build, run, and debug iOS and Android apps without leaving Claude. Claude Code shows
simulators and emulators in its own pane; Mobile Dev gives Claude the tools to control
them, read their logs, inspect your app, and measure its performance. It works with
Expo, React Native, SwiftUI, and other native mobile projects.

- **Device control and inspection:** Claude taps, types, scrolls, reads the
accessibility tree, and takes screenshots through the bundled
[agent-device](https://agent-device.dev/) runtime.
- **App control:** install, launch, and stop apps, open deep links, send test pushes,
and set permissions and languages.
- **Logs:** iOS unified logs, Android logcat, physical iPhone logs, and Metro console
messages.
- **Performance:** CPU, memory, and FPS recordings shown as interactive charts in
chat, which Claude can analyze and compare.
- **App storage:** read what your app saved, such as UserDefaults, SharedPreferences,
AsyncStorage, MMKV, and SQLite, without adding code to it.
- **Profile analysis:** TraceSift findings for local CPU and React profiles.

## Install

In Claude Code:

```sh
claude plugin marketplace add https://github.com/callstackincubator/mobile-dev.git#release/claude
claude plugin install mobile-dev@mobile-dev
```

Start a new session and ask Claude to open your app on a simulator. Approve the tool
calls when prompted. Run `claude plugin marketplace update mobile-dev` to update.

## Requirements

- An Apple Silicon Mac.
- **Node.js 22.19 or later.** Mobile Dev looks for it on `PATH` and in the usual
Homebrew, nvm, fnm, Volta, mise, and asdf locations. If none is found, Claude tells
you at the start of a session; install it with `brew install node` or from
[nodejs.org](https://nodejs.org), then restart Claude.
- **iOS:** Xcode 26 or later with an installed simulator runtime. Physical iPhone
logs need Xcode 27 or later.
- **Android:** Android SDK platform-tools and emulator, and an AVD or a device with
USB or wireless debugging authorized.

## What it runs on your Mac

The plugin bundles its runtimes and native helpers, so no `npm install`, account, or
hosted service is needed. Your project's own tools build the app. Besides its Node.js
server, it runs:

- **A Node.js launcher and a session check:** `scripts/launch-node.sh` finds Node.js
in the locations above and starts the server; at the start of each session it
checks that one is available.
- **agent-device**, its bundled Node.js runtime, for device control. On first iOS use
it builds its XCTest runner from the included source with your Xcode and caches it
in `~/.agent-device/apple-runner`; it keeps session state and logs in a temporary
directory.
- **Native helpers** for logs and measurements: a physical iPhone log reader built on
libimobiledevice, an iOS FPS and foreground-app reader, and Android CPU and FPS
collectors that it copies to a connected Android device with `adb` and runs there.
- **Developer tools** already on your Mac: `xcrun simctl`, `devicectl`, `adb`, and the
Android emulator, and TraceSift when you analyze a profile.

## Privacy and support

Mobile Dev reports plugin errors, crashes, usage counters, sampled traces, and
aggregate plugin performance measurements to Sentry by default. App logs,
screenshots, input content, tool payloads, app storage contents, profiles, and device
performance recordings are excluded from that telemetry. Errors and crashes use a
randomly generated persistent installation ID. Nothing else goes to Callstack, and
profiles go only to the TraceSift service on your Mac. Set `MOBILE_DEV_TELEMETRY=off`
in the environment Claude starts from, then restart Claude, to turn reporting off.
Read the [Privacy Policy](PRIVACY.md) and [Terms of Service](TERMS.md).

For help, email [hello@callstack.com](mailto:hello@callstack.com) or see
[Support](SUPPORT.md).

## License

MIT. See [LICENSE](LICENSE) and [third-party notices](THIRD_PARTY_NOTICES.md).
17 changes: 17 additions & 0 deletions apps/claude/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"description": "Tells the user and Claude when Node.js 22.19 or later, which the Mobile Dev server needs, is missing.",
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "/bin/sh \"${CLAUDE_PLUGIN_ROOT}/scripts/launch-node.sh\" --check",
"timeout": 10
}
]
}
]
}
}
2 changes: 2 additions & 0 deletions apps/claude/plugin-host.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@
"panel": false,
"build": "tooling/build.mjs",
"skills": ["skills/mobile-dev", "../../packages/skills/agent-device"],
"folders": ["scripts", "hooks"],
"readme": "README.md",
"maxFileBytes": 5242880
}
43 changes: 43 additions & 0 deletions apps/claude/scripts/launch-node.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/bin/sh
# Runs Mobile Dev with Node.js 22.19 or later.
# launch-node.sh <script> [args] runs the script with the first usable Node.
# launch-node.sh --check prints a SessionStart hook result when none is found.
# Claude starts plugin servers and hooks with the PATH it was launched with. Opened
# from the Dock, that PATH usually lacks version managers, so also try their usual
# install locations.
set -u

message="Mobile Dev needs Node.js 22.19 or later, and none was found on PATH or in Homebrew, nvm, fnm, Volta, mise or asdf locations. Install it, for example with 'brew install node' or from https://nodejs.org, then restart Claude."

usable() {
[ -x "$1" ] && "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' >/dev/null 2>&1
}

candidates() {
command -v node
for directory in /opt/homebrew/bin /opt/homebrew/opt/node/bin /opt/homebrew/opt/node@24/bin /opt/homebrew/opt/node@22/bin /usr/local/bin \
"$HOME/.volta/bin" "$HOME/.local/share/mise/shims" "$HOME/.asdf/shims" \
"$HOME/.local/share/fnm/aliases/default/bin" "$HOME/Library/Application Support/fnm/aliases/default/bin"; do
echo "$directory/node"
done
for path in "$HOME"/.nvm/versions/node/*/bin/node "$HOME"/.local/share/mise/installs/node/*/bin/node \
"$HOME"/.local/share/fnm/node-versions/*/installation/bin/node "$HOME"/Library/Application\ Support/fnm/node-versions/*/installation/bin/node; do
echo "$path"
done
}

node=$(candidates 2>/dev/null | while IFS= read -r candidate; do
if [ -n "$candidate" ] && usable "$candidate"; then echo "$candidate"; break; fi
done)

if [ "${1:-}" = "--check" ]; then
[ -n "$node" ] && exit 0
printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Mobile Dev tools are unavailable: Node.js 22.19 or later was not found. When the user asks for iOS or Android device work, explain this and offer to install Node.js (with Homebrew when brew is available, otherwise from https://nodejs.org), then ask them to fully restart Claude."}}\n' "$message"
exit 0
fi

if [ -z "$node" ]; then
echo "$message" >&2
exit 1
fi
exec "$node" "$@"
6 changes: 4 additions & 2 deletions apps/claude/skills/mobile-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ panel in Claude: Claude Code shows simulators itself, and Mobile Dev adds device
control, logs, app inspection, and performance recordings with their charts.

If the Mobile Dev tools are missing, tell the user "Mobile Dev could not start." and
check that Node.js 22.19 or later is on the PATH Claude starts plugins with, and that
the plugin is enabled. Source edits or compilation can continue without device tools.
follow [missing-tools recovery](references/missing-tools.md): check which Node.js the
plugin's launcher finds and that the plugin is enabled, and offer to install Node.js
22.19 or later when none is found. Source edits or compilation can continue without
device tools.

## Showing a simulator or emulator

Expand Down
27 changes: 27 additions & 0 deletions apps/claude/skills/mobile-dev/references/missing-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Recover missing Mobile Dev tools

Use this when the installed plugin's tools are unavailable. Diagnose and explain the
setup problem in chat from the actual startup failure.

Find the installed plugin root from the absolute path of the loaded `SKILL.md`: the
skill is under `<plugin-root>/skills/mobile-dev/SKILL.md`. Inspect its
`.claude-plugin/plugin.json` and whether the plugin is enabled. Use that installed
copy rather than a source checkout or a guessed cache version. In Claude Code,
`claude mcp list` and `/mcp` show the `mobile-dev` server's status.

The server starts through `scripts/launch-node.sh`, which needs Node.js 22.19 or
later and looks for it on `PATH` and in the usual Homebrew, nvm, fnm, Volta, mise and
asdf locations. Run `/bin/sh "<plugin-root>/scripts/launch-node.sh" -p process.version`
to see which Node it finds. If it reports that none was found, explain that Mobile
Dev needs Node.js 22.19 or later and offer to install it: with `brew install node`
when `brew` is available, otherwise from https://nodejs.org. Do not install it
without the user's agreement. After the install, check the launcher again.

Report the actual failure, or say what remains unknown. A generic timeout does not
justify increasing the timeout. Keep diagnostics local; startup failures occur before
the server's Sentry SDK starts.

After resolving the startup failure, ask the user to fully quit and restart Claude
and start a new session so it can rediscover the server, then confirm the tools are
available before resuming device work. Keep the original app task, and explain what
remains blocked.
4 changes: 2 additions & 2 deletions packages/skills/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Mobile Dev skills

Agent skills for the tools every host's server exposes. Each skill is a standard
`SKILL.md` with its references; `agents/openai.yaml` adds Codex's display metadata and
other hosts ignore it. A host's package step includes the skills its host supports:
`SKILL.md` with its references; `agents/openai.yaml` adds Codex's display metadata,
which other hosts' packages leave out. A host's package step includes the skills its host supports:
the Codex and Cursor plugins ship `mobile-dev`, `mobile-dev-setup` and `agent-device`.
The Claude Code plugin ships its own `mobile-dev` skill from `apps/claude/skills`, for
its server without the panel, and the shared `agent-device` skill; Claude Desktop's
Expand Down
17 changes: 13 additions & 4 deletions tooling/package-plugin.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import { cp, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { basename, dirname, join, relative, resolve } from "node:path";
import { telemetryBuildEnvironment } from "./telemetry-build.mjs";
import { assertRuntime, oversizedFiles, RELEASE_DOCUMENTS as documents, releaseVersion, stageRuntime } from "./host-runtime.mjs";
import { pluginHost } from "./plugin-hosts.mjs";
Expand All @@ -21,11 +21,17 @@ const version = await releaseVersion();
const runtime = resolve(`release/${name}-runtime`);
await rm(runtime, { recursive: true, force: true });
await stageRuntime(runtime, { panel: host.panel });
// The server runs agent-device's CLI by its path; npm's bin links are symlinks, which
// plugin directories reject.
await rm(`${runtime}/agent-device/node_modules/.bin`, { recursive: true, force: true });
if (host.build) {
const { buildHost } = await import(resolve(host.build));
await buildHost(runtime);
}

// A host with its own README ships it instead of the repository's.
const documentSource = path => path === "README.md" && host.readme ? host.readme : path;

// A host whose store limits file sizes fails here rather than in review.
async function assertFileSizes(directory) {
if (host.maxFileBytes === undefined) return;
Expand All @@ -42,7 +48,7 @@ if (host.desktopBundle) {
await rm(directory, { recursive: true, force: true });
await mkdir(directory, { recursive: true });
await cp(runtime, `${directory}/dist`, { recursive: true, verbatimSymlinks: true });
for (const path of documents) await copyFile(path, `${directory}/${path}`);
for (const path of documents) await copyFile(documentSource(path), `${directory}/${path}`);
await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n");
await assertFileSizes(directory);
const archive = resolve(`release/mobile-dev-${version}-${name}-desktop-darwin-arm64.mcpb`);
Expand All @@ -58,8 +64,11 @@ await rm(directory, { recursive: true, force: true });
await mkdir(`${directory}/${host.manifest}`, { recursive: true });
await cp(runtime, `${directory}/dist`, { recursive: true, verbatimSymlinks: true });
await cp(`${host.app}/assets`, `${directory}/assets`, { recursive: true });
for (const skill of host.skills) await cp(skill, `${directory}/skills/${basename(skill)}`, { recursive: true, verbatimSymlinks: true });
for (const path of documents) await copyFile(path, `${directory}/${path}`);
// A skill's agents/openai.yaml is Codex's display metadata.
const skillFiles = skill => source => relative(skill, source) !== "agents";
for (const skill of host.skills) await cp(skill, `${directory}/skills/${basename(skill)}`, { recursive: true, verbatimSymlinks: true, filter: skillFiles(skill) });
for (const folder of host.folders) await cp(folder, `${directory}/${basename(folder)}`, { recursive: true });
for (const path of documents) await copyFile(documentSource(path), `${directory}/${path}`);
await writeFile(`${directory}/${host.manifest}/plugin.json`, JSON.stringify({ name: pluginName, version, ...fields }, null, 2) + "\n");
await assertFileSizes(directory);
await rm(runtime, { recursive: true, force: true });
Expand Down
6 changes: 6 additions & 0 deletions tooling/plugin-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { join } from "node:path";
* - `build`: an optional module in the app exporting `buildHost(outdir)`, which
* builds the host's own server into its staged runtime.
* - `skills`: the skill folders it ships, relative to the app, instead of the shared ones.
* - `folders`: app folders its plugin ships as they are, such as a launcher's
* `scripts` and the `hooks` that check it.
* - `readme`: the app's README, which its packages ship instead of the repository's,
* for a host whose directory lists the plugin with it.
* - `maxFileBytes`: the host's file size limit; packaging and the host regression
* tests fail past it.
* Adding such a host is adding that app; these scripts find it.
Expand All @@ -37,6 +41,8 @@ export function pluginHost(name) {
...host, app, panel: host.panel !== false,
desktopBundle: host.desktopBundle && `${app}/${host.desktopBundle}`,
build: host.build && `${app}/${host.build}`,
folders: (host.folders ?? []).map(path => join(app, path)),
readme: host.readme && join(app, host.readme),
skills: host.skills?.map(path => join(app, path)) ?? sharedSkills,
};
}
Loading
Loading