はじめに
Claude Code に iOS アプリの実装を任せていると、
「ビルドは通りました。手元で動作を確認してください」で終わる報告によく出会います。
コードとしては正しくても、実際に画面を触ると
タップが効かない、レイアウトがずれる、といったことは珍しくありません。
結局、確認のたびに人がシミュレータを触ることになり、AI に任せた意味が半分になります。
そこで、ビルド → 起動 → タップ・スワイプ → 画面の読み取り → 判定までを
Claude Code 自身に回させる仕組みを作りました。
部品は次の 2 つの CLI です。
| No. | ツール | 役割 |
|---|---|---|
| 1 | FlowDeck | ビルド・テスト・シミュレータへのインストールと起動・ログ取得 |
| 2 | AXe | シミュレータの UI 操作(タップ・スワイプ・文字入力)とアクセシビリティツリーの取得・スクリーンショット |
この記事では、ツールを入れただけでは足りなかった部分
(ラッパー・hook・CLAUDE.md の指示・並行作業での分離)と、
実際にハマったところをまとめます。
動作環境: macOS 26 / Xcode 27.2 Beta / FlowDeck 1.25.1 / AXe 1.8.0 / Claude Code(VS Code 拡張とターミナル)
FlowDeck とは
FlowDeck は、
AI のコーディングエージェントが iOS / macOS アプリをビルド・実行・テストできるようにするための CLI です。
公式サイトでも「エージェントが自分の作業を実行し、テストし、直せるようにする」ことをうたっていて、
Claude Code・Codex・Cursor などからの利用を想定しています。
Xcode の開発では、普段は次のような Apple 純正のコマンドを使い分けます。
| No. | 純正コマンド | 用途 |
|---|---|---|
| 1 | xcodebuild |
ビルド・テスト |
| 2 | xcrun simctl |
シミュレータの作成・起動・アプリのインストールと起動 |
| 3 | xcrun devicectl |
実機へのインストールと起動 |
| 4 |
log stream など |
ログの取得 |
FlowDeck は、これらを 1 つのコマンド体系にまとめたものです。
flowdeck build -w App.xcworkspace -s App -S "iPhone 17" # ビルド
flowdeck run -w App.xcworkspace -s App -S "iPhone 17" # ビルド → インストール → 起動
flowdeck test -w App.xcworkspace -s App -S "iPhone 17" # テスト
flowdeck logs # 起動中アプリのログ
flowdeck simulator list # シミュレータ一覧
エージェントにとって何がうれしいか
xcodebuild を Claude Code に直接叩かせることもできます。
ただ、実際にやってみると次の問題が出ます。
-
出力が大きすぎる: ビルドログは数千行あり、成功しても失敗しても丸ごとコンテキストに流れ込みます。
エラーの行を探すだけでトークンを大量に使います -
コマンドの組み立てを間違える:
-destination 'platform=iOS Simulator,name=...'のような長い指定や、
simctl のサブコマンドの使い分けを、エージェントがよく間違えます - 手順が多い: 「ビルド → .app の場所を探す → simctl install → simctl launch」を毎回組み立てる必要があります
FlowDeck は、結果を要点だけの読みやすい形(--json を付ければ構造化された JSON)で返し、
run 1 つで「ビルド → インストール → 起動」まで済ませます。
エージェントが迷う余地が減るので、試行錯誤の往復が目に見えて減りました。
ほかにも、エージェント向けに次のような機能があります。
-
--headless: Simulator.app を開かずにシミュレータで実行する -
flowdeck ui simulator ...: ラベル指定のタップや deeplink を開くなど、簡単な UI 操作 - ビルド結果の DerivedData をプロジェクトのディレクトリごとに分ける
FlowDeck は有料のツールです(2026 年 10 月時点で CLI は年額 59 ドル、14 日間の試用あり)。
最新の料金は公式サイトで確認してください。
AXe とは
AXe は、
iOS シミュレータの画面を CLI から読み取り、操作するためのオープンソースのツールです(MIT ライセンス)。
Meta の idb を土台にしていて、
Apple のアクセシビリティ API と HID(タッチやキー入力などの入力装置)の機能を使います。
アクセシビリティ検査ツールの「axe(Deque Systems)」とは別物です。
できることは大きく 2 つです。
| No. | 種類 | コマンドの例 | 何に使うか |
|---|---|---|---|
| 1 | 画面を読む |
describe-ui、screenshot、record-video
|
画面にどの要素があるか(ラベル・値・座標)を知る。期待どおりの状態になったか判定する |
| 2 | 画面を操作する |
tap、swipe、touch、type、button、key、batch
|
人の代わりにタップ・スワイプ・文字入力・ホームボタンを押す |
describe-ui は、画面上の要素をアクセシビリティツリーとして JSON で返します。
VoiceOver が読み上げに使う情報と同じものなので、
画像認識をしなくても「どこに何があるか」をテキストで分かるのがポイントです。
{
"type" : "Button",
"AXLabel" : "設定",
"AXValue" : null,
"AXUniqueId" : "SettingsButton",
"frame" : { "x" : 340, "y" : 60, "width" : 44, "height" : 44 }
}
Claude Code はこれを読んで「設定ボタンは (362, 82) にある」と分かり、
axe tap --id SettingsButton や axe tap -x 362 -y 82 で押せます。
押したあとにもう一度 describe-ui を取れば、画面が変わったかを確かめられます。
インストール
brew install cameroncooke/axe/axe
axe list-simulators # 動作確認
なぜ FlowDeck と AXe を併用するのか
実は FlowDeck にも flowdeck ui simulator tap / swipe / batch / assert など、UI 操作のコマンドがひととおりあります。
逆に AXe は、ビルドやインストールはできません。
それでも両方を使っているのは、次の 3 つの理由からです。
1. 役割で分けると、どちらも得意なところだけを使える
| No. | 担当 | 使うツール | 主なコマンド |
|---|---|---|---|
| 1 | アプリを動く状態にする | FlowDeck |
build / test / run / logs、シミュレータの作成・起動 |
| 2 | 動いているアプリを触って確かめる | AXe |
describe-ui / tap / swipe / type / batch / screenshot
|
| 3 | AXe で通らないときの代わり | FlowDeck |
ui simulator tap "<ラベル>"、ui simulator open-url
|
2. AXe のほうが 1 手が軽く、読むタイミングを自分で選べる
FlowDeck の UI コマンドは、既定で 操作後に画面が落ち着くのを待ち、画面のツリー全体を返します。
1 手ごとに結果を確かめたいときは便利ですが、
スクロールを何回か繰り返すだけでも、そのたびに大きな出力がコンテキストに入ります
(--no-screen で省略もできます)。
AXe は操作を送ったらすぐ戻り、出力もほとんどありません。
「数手まとめて操作 → 最後に 1 回だけ describe-ui を縮めて読む」という流れを組みやすく、
エージェントに何度も試行させる用途では、こちらのほうが軽快でした。
3. AXe にしかない操作があり、互いの弱点を補える
| No. | やりたいこと | AXe | FlowDeck |
|---|---|---|---|
| 1 | 修飾キー付きの入力(Cmd+V で貼り付け、Cmd+A で全選択) |
key-combo でできる |
キーコードの入力だけ |
| 2 | 指を動かしながらのドラッグ(並べ替えなど) | drag |
— |
| 3 | スライダーを決まった値に合わせる | slider |
— |
| 4 | iOS 26 のスイッチ(Toggle)の切り替え |
tap では値が変わらないことがある |
ラベル指定の tap で通る |
| 5 | 横向きでのタップ | 座標がずれる | ラベル指定の tap で通る |
| 6 | deeplink を開く | — | open-url |
FlowDeck だけだと 1〜3 で、AXe だけだと 4〜6 で手詰まりになります。
両方を使えるようにしておくと、Claude Code は片方で通らなければもう片方で試せます。
実際、CLAUDE.md には「一発で拾えなくても粘る」と書いていて、
ツールを切り替えての再挑戦もその手段の 1 つになっています。
XCUITest ではだめなのか
UI の自動テストといえば XCUITest があります。
ただ、XCUITest は「あらかじめテストコードを書いてビルドし、決まった手順を流す」ものです。
今回やりたいのは、実装の途中で、エージェントがその場で画面を見ながら手順を考えて触ることです。
| No. | 観点 | XCUITest | AXe |
|---|---|---|---|
| 1 | 手順の決め方 | テストコードに書いておく | その場で 1 コマンドずつ決められる |
| 2 | 準備 | UI テストのターゲットとテストコードが要る | 起動中のアプリにそのまま使える |
| 3 | 1 手の重さ | 手順を変えるたびにテストのビルドと起動がかかる | コマンド 1 回で済み、ビルドは要らない |
| 4 | 向いている用途 | 回帰テストとして毎回同じ手順を流す | 実装中の動作確認・不具合の再現 |
決まった手順を毎回流すなら XCUITest、
エージェントに探りながら確かめさせるなら AXe、という使い分けです。
全体像
Claude Code が回すループは次のとおりです。
-
fd runでビルドしてシミュレータに入れ、起動する -
axe describe-uiで画面の要素(ラベル・座標・値)を読む -
axe tap/axe swipeで操作する - もう一度
describe-uiやスクリーンショットで、期待どおりの状態になったかを判定する - 違っていればコードを直して 1 に戻る
FlowDeck でビルド・起動する
実際に Claude Code に使わせているコマンドは次のとおりです。
シミュレータ名にチェックアウト名(app1)が入っている理由は後述します。
# ビルドしてシミュレータで起動(Simulator.app を開かない)
flowdeck run -w App.xcworkspace -s App -S "iPhone17_26.3_app1" --headless
# ユニットテスト
flowdeck test -w App.xcworkspace -s App -S "iPhone17_26.3_app1" --progress
# アプリのログ
flowdeck logs
そのまま使わず、薄いラッパー fd を挟む
実際には flowdeck を直接呼ばせず、fd という薄いシェルスクリプトを経由させています。
やっていることは「毎回付けるべきオプションの自動付与」です。
#!/bin/bash
set -euo pipefail
repo=$(git rev-parse --show-toplevel)
sub=${1:-}
args=("$@")
case "$sub" in
build|test|run)
# SPM のキャッシュをチェックアウトごとに分ける(後述)
args+=("--xcodebuild-options=-packageCachePath $repo/.spm/cache")
;;
esac
# シミュレータで run するときは Simulator.app を前面に出さない
if [[ "$sub" == run && " $* " != *" -D "* ]]; then
args+=("--headless")
fi
# 失敗したら音で知らせる(バックグラウンド実行でも気付けるように)
if flowdeck "${args[@]}"; then
afplay /System/Library/Sounds/Glass.aiff &
else
status=$?
afplay /System/Library/Sounds/Basso.aiff &
exit $status
fi
ラッパーにしておくと、Claude Code が引数を付け忘れても結果が変わりません。
「CLAUDE.md に書いておく」よりも確実です。
長いビルドは進捗をファイルに書き出す
フルビルドは数分かかります。
フォアグラウンドで実行すると、Claude Code の画面にはスピナーしか出ず、
人からは止まっているのか進んでいるのか分かりません。
そこで、ビルドはバックグラウンドで起動させ、
ラッパーが FlowDeck のログから進捗を 1 行のステータスファイルに書き出すようにしました。
app1 | 0:57 | 99% | 残り 約 0:00 | 12 タスク | AppTests
Claude Code には「30 秒おきにこのファイルを読んでチャットに書く」よう指示しています。
AXe でシミュレータを操作する
Claude Code がよく使うコマンドはこのあたりです。
UDID=$(axe list-simulators | grep app1 | cut -d' ' -f1)
# 画面の要素を JSON で取得
axe describe-ui --udid "$UDID"
# アクセシビリティ ID やラベルでタップ(座標より壊れにくい)
axe tap --id SettingsButton --udid "$UDID"
axe tap --label "設定" --udid "$UDID"
# スワイプ(ポイント単位)
axe swipe --start-x 200 --start-y 700 --end-x 200 --end-y 200 --duration 0.3 --udid "$UDID"
# スクリーンショット
axe screenshot --output /tmp/shot.png --udid "$UDID"
# 複数の操作を 1 回の呼び出しで流す
axe batch --udid "$UDID" \
--step "tap --label 設定" \
--step "tap --label 通知" \
--wait-timeout 5
batch は、Claude Code のツール呼び出し 1 回で複数の手順を流せるので、
往復の回数が減って速くなります。
describe-ui は縮めてから渡す
describe-ui の JSON は、要素ごとにフレーム・ロール・トレイトなどが入っていてとても大きいです。
iOS のホーム画面を取っただけで 402,601 バイトありました。
これを毎回 Claude Code に読ませると、すぐにコンテキストを使い切ります。
判定に要るのは、ほとんどの場合「種類・ラベル・値・ID・中心座標」だけです。
そこで、1 要素 1 行に縮めるスクリプトを挟みます。
#!/usr/bin/env python3
"""axe describe-ui の JSON を「1 要素 1 行」に縮める"""
import json
import sys
def walk(node, depth=0):
label = node.get("AXLabel") or ""
value = node.get("AXValue") or ""
uid = node.get("AXUniqueId") or ""
if label.strip() or value or uid:
f = node.get("frame", {})
cx = round(f.get("x", 0) + f.get("width", 0) / 2)
cy = round(f.get("y", 0) + f.get("height", 0) / 2)
parts = [node.get("type", "?"), repr(label)]
if value:
parts.append(f"value={value!r}")
if uid:
parts.append(f"id={uid}")
parts.append(f"@({cx},{cy})")
print(" " * depth + " ".join(parts))
for child in node.get("children") or []:
walk(child, depth + 1)
for root in json.load(sys.stdin):
walk(root)
axe describe-ui --udid "$UDID" | python3 ax_flat.py
Button 'マップ' value='ウィジェット' id=マップ @(108,184)
Button 'カレンダー' value='ウィジェット, スタック' id=カレンダー @(294,184)
同じホーム画面で 3,185 バイト、約 1/120 になりました。
座標は中心点なので、そのまま axe tap -x -y に渡せます。
hook で「使ってほしくないコマンド」を止める
CLAUDE.md に「xcodebuild や simctl を直接使わないで」と書いても、
Claude Code はときどき慣れた xcrun simctl に手を伸ばします。
指示ではなく仕組みで止めるために、Claude Code の PreToolUse hook を使います。
hook のスクリプトが終了コード 2 で終わると、そのツール呼び出しは実行されず、
標準エラーの内容が Claude Code に返ります。
「代わりに何を使えばよいか」を書いておけば、Claude Code はその場で切り替えます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/sim-guard.sh" }
]
}
]
}
}
#!/bin/bash
cmd=$(jq -r '.tool_input.command // ""')
block() {
echo "$1" >&2
exit 2
}
case "$cmd" in
*"xcrun simctl boot"*|*"xcrun simctl install"*|*"xcrun simctl launch"*)
block "simctl は使わず fd run(FlowDeck)を使ってください" ;;
*"xcrun simctl openurl"*)
block "deeplink は fd ui simulator open-url を使ってください" ;;
*"xcodebuild build"*|*"xcodebuild test"*)
block "xcodebuild は使わず fd build / fd test を使ってください" ;;
esac
exit 0
実際のスクリプトでは、; や && で区切られた各コマンドの先頭だけを見るようにしています。
単純な部分一致だと、echo "xcodebuild test" のような無害なコマンドまで止めてしまうためです。
あわせて PostToolUse hook で、FlowDeck のビルド結果(成功・失敗とエラーの末尾)を JSONL に記録しています。
失敗したときは、Claude Code にまずこのログと「既知の失敗パターン」のメモを見させます。
同じ原因のビルド失敗を毎回ゼロから調べ直さずに済みます。
CLAUDE.md で「確認するまでが仕事」と決める
ツールと hook がそろっても、指示が無ければ Claude Code は
「ビルドが通ったので完了です」で止まりがちです。
CLAUDE.md には次のような指示を入れています(抜粋・要約)。
### 動作確認はユーザーに投げずに自分が必ず行う
- 修正・実装後、ビルド成功だけで満足しない。関連シナリオをシミュレータで実行する
- 一発で拾えなくても粘る。describe-ui で ID を探す・touch の低レベル操作・
swipe の --duration 調整・batch での連続実行を試す
- 再現できないシナリオに限り、理由を明示してから手動確認を依頼する
- 「手元で試してみてください」だけの報告は禁止
### シミュレータ確認は常に音声なしで行う
### 検証の録画が 1 分を超えそうなら止めて相談する
### UT・反映の前に単独ビルドを走らせない
- fd test / fd run はビルドを内包するので、事前の fd build は時間の二重消費になる
ポイントは、「やってはいけない報告」を具体的に書くことです。
「動作確認をしてください」だけでは、ビルドの成功を動作確認と見なして報告してくることがありました。
「手元で試してみてください、だけの報告は禁止」と書いてからは、
自分で画面を触ってから報告するようになりました。
複数セッションを並行で走らせても壊さない
筆者は、同じリポジトリのチェックアウトを 5 つ並べ、
それぞれで Claude Code のセッションを同時に走らせています。
ここで何も考えずにいると、セッション同士が資源を取り合って壊れます。
| No. | 取り合うもの | 起きたこと | 対策 |
|---|---|---|---|
| 1 | シミュレータ | 別セッションが起動したシミュレータに乗っかり、テストやアプリを上書きした | チェックアウトごとに専用シミュレータを作る(iPhone17_26.3_app1 のように名前にチェックアウト名を入れる)。他の番号の端末が起動していても使わない、と CLAUDE.md に書く |
| 2 | SPM のキャッシュ | 複数のビルドが共有キャッシュを同時に取得し、パッケージ解決が壊れた |
-packageCachePath <repo>/.spm/cache でチェックアウトごとに分ける(fd が自動で付与) |
| 3 | DerivedData | 片方のセッションがクリーンしたら、もう片方のビルドが謎のエラーで落ち続けた | FlowDeck がチェックアウトごとに分けてくれる。消すときは自分の分だけ。flowdeck clean --all は permissions の deny に入れる |
| 4 | 画面のフォーカス | ビルドのたびに Simulator.app が前面に出て、作業中のウィンドウから引き剥がされた |
fd run で --headless を自動付与する |
シミュレータ名の照合は末尾一致にしています。
部分一致だと app1 が app12 にも当たってしまうためです。
ハマりどころ
実際に詰まったものから、AI に任せるときに特に効いたものを選びました。
| No. | 症状 | 原因 | 対策 |
|---|---|---|---|
| 1 | Claude Code の中からだけ SPM の解決が失敗する(Couldn't get the list of tags) |
Claude Code の Bash サンドボックスが git に safe.bareRepository=explicit を注入し、SPM の bare clone が拒否される |
ラッパーで GIT_CONFIG_VALUE_0=all に戻してから FlowDeck を呼ぶ |
| 2 | スクショから拾った座標でタップしても反応しない | スクリーンショットはピクセル(3 倍)、AXe の座標はポイント | 座標は describe-ui の frame から取る。スクショから拾うなら 3 で割る |
| 3 | iOS 26 のスイッチ(Toggle)を axe tap しても値が変わらない。しかも AXe は「成功」と返す |
タップが短すぎてスイッチが反応しない |
axe touch --down --up --delay 0.3 を使う。それでもだめならラベル指定の fd ui simulator tap。操作後は describe-ui の値で必ず確かめる |
| 4 |
axe type で URL を入れると : や / が壊れる |
日本語入力が有効なまま入力され、記号が全角などに変換される | ペーストボードに入れて Cmd+V(axe key-combo --modifiers 227 --key 25)で貼る |
| 5 |
fd run が成功で終わったのに、画面が古いまま |
内部のビルドが失敗していても 0 で返ることがあった | ビルドログの BUILD SUCCEEDED を確かめてから操作に進ませる |
| 6 | シミュレータから動画の音が鳴り続ける | 音量は端末ごとの設定ファイル(sim_volume)にあり、起動のたびに 60 に戻る。defaults write ... AudioMuted は効かない |
fd run の後に、音量が 0 でない端末だけ Simulator.app のメニュー(I/O → Decrease Volume)で下げるスクリプトを呼ぶ |
| 7 | 端末を回転させたい | AXe には回転のコマンドが無い。flowdeck ui simulator rotate は二本指の回転ジェスチャで、端末の向きとは別物 |
AppleScript で Simulator.app に Cmd+← / Cmd+→ を送る |
| 8 | 横向きでタップ位置がずれる | 横向きでも座標系とスクショが縦のまま扱われる | 横向きではラベル指定のタップを使う。スクショは 90 度回して見る |
| 9 |
xcrun simctl openurl で deeplink を開かせたい |
hook で止めているため |
fd ui simulator open-url --udid <UDID> "myapp://..." を使う |
| 10 | Xcode 27 の Beta に上げたら AXe の全コマンドが落ちた(Failed to load essential private frameworks) |
Xcode 27 で SimulatorKit.framework の置き場所が変わった |
当時は旧 Xcode を DEVELOPER_DIR に指定するラッパーで回避した。AXe 1.8.0 では Xcode 27.2 Beta でもそのまま動くことを確認済み(2026-10-21) |
まとめ
Claude Code にシミュレータの動作確認まで任せるには、ツールを入れるだけでは足りませんでした。
効いたのは次の 4 つです。
- ラッパーで、付け忘れると壊れるオプションを自動で付ける
- hook で、使ってほしくないコマンドを止め、代わりのコマンドを伝える
- CLAUDE.md で、「ビルドが通った」は完了ではないと決め、やってはいけない報告を具体的に書く
-
出力を縮める(
describe-uiを 1 行ずつにする)ことで、何度も画面を読んでもコンテキストが持つようにする
今では、UI の修正を頼むと「ここをタップして、値がこう変わるのを確認しました」という報告が返ってきます。
人がシミュレータを触るのは、最後に目で見て判断したいときだけになりました。