author: GeneralD
Claude Code スキルを作って、5つつまずいた話

Astro + Cloudflare Workers でランディングページを自動デプロイする Claude Code スキル——launch-site——を作りました。作り終えてみると、5つの罠を踏んでいました。ドキュメントに書いていない罠、思い込みの罠、「それ仕様ですか」という罠。手元で確かめた順に書きます。
1つめ: Workers Builds にトリガー API はない
最初、こう考えていました——Workers Builds のトリガーを REST API で叩けば、push 後に自動デプロイが完了する、と。
Cloudflare の API リファレンスを探しました。見つかりませんでした。GitHub Actions経由ならあります。ダッシュボードには「Connect to Git」ボタンがあります。でも「ビルドをトリガーする」RESTエンドポイントは存在しません。
flowchart LR
A["期待\nPOST /workers/builds/trigger"] -->|そんな API ない| B["404"]
C["実際の仕組み"] --> D["Git push を検知"]
D --> E["Cloudflare が自動でビルド"]
C --> F["ダッシュボードで手動トリガー\n(UI のみ)"]
Workers Builds は「push で自動接続」という設計で作られていて、外部からトリガーを叩く口を意図的に作っていないようです。
回避策は **git push をデプロイトリガーとして割り切る**ことでした。スキルは Astroプロジェクトを生成してpushする。ビルドは Cloudflare が引き受ける。それ以上の制御は諦めました——というか、要りませんでした。API がないと分かった時点で設計を振り切れたので、むしろシンプルになりました。
2つめ: headless Chromium は Cloudflare ダッシュボードに弾かれる
Workers Builds を使うには、まず Gitリポジトリとの接続をダッシュボードで設定しなければなりません。スキルに「ダッシュボードを自動操作させよう」と思って playwright-cli を起動しました。
dash.cloudflare.com にナビしたら、こうなりました。
Just a moment...
Performing security verification...
Cloudflare自身が、headless Chromium のボットフィンガープリントを弾きました。自社ダッシュボードで自社のボット検知が火を噴く——なかなか面白い構図です。
headlessモードを諦めて --headed で起動しても、クッキーがなければ同じ画面で止まります。
突破口は browser session transplant——普段使いブラウザからクッキーを持ち込む方式です。セッションを移植してから --headed で起動したら、チャレンジを通過して認証済みのダッシュボードに到達しました。
移植の核心はこうです。既存ブラウザのクッキーを storageState として書き出し、次回起動時に読み込む。
# ブラウザの cookie を storageState として書き出す
~/.config/bin/pw-import-session --browser arc \
--domain dash.cloudflare.com --domain .cloudflare.com \
--out "${XDG_STATE_HOME:-$HOME/.local/state}/playwright-cli/state/cloudflare.json" \
--load-session cloudflare
// Playwright 起動時に storageState を読み込む
const context = await browser.newContext({
storageState: `${process.env.XDG_STATE_HOME ?? `${process.env.HOME}/.local/state`}/playwright-cli/state/cloudflare.json`,
});
一度仕組みを作れば、どのダッシュボードにも使い回せます。認証まわりは「手元で一度きちんと確かめる」と、あとが楽になります。
3つめ: bunx wrangler がファントムバージョンを引く
Wrangler は Cloudflare Workers のデプロイ CLI です。スキルは bunx wrangler deploy を叩いていました。これが予想外のバージョンを引きました。
$ bunx wrangler --version
⛅️ wrangler 4.14.0 ...
表示は 4.14.0。でも実際に動いていたのはプロジェクトの node_modules/.bin/wrangler ではなく、bunx が独自にキャッシュした別バージョンでした。package.json に書いた "wrangler": "^3.x" を無視して、bunx がネットから 4.x を引いてきます。
bunx は npx の Bun版で、package.json のバージョン制約に縛られません。ローカルにインストール済みのバイナリを優先するはずですが、キャッシュ状態によっては node_modules/.bin/wrangler すら無視します。
直し方は単純で、**node_modules/.bin/wrangler を直接呼ぶ**か、bun run で package.json の scripts経由にします。
# NG: ファントムバージョンを引く可能性がある
bunx wrangler deploy
# OK: package.json に固定したバージョンが確実に動く
./node_modules/.bin/wrangler deploy
あるいは wrangler を devDependencies に明示して bun install を必ず先に走らせる。bunx の「便利さ」に乗ると、バージョンが揺れます。手元で確かめた時にバージョンがずれていると、再現するまで時間がかかります。
4つめ: playwright-cli の三か所
Playwright でダッシュボードを叩き始めたら、三か所で転びました。独立した問題なので一つずつ書きます。
ひとつ: goto の戻りタイミング
page.goto(url) は loadイベント後に resolve します。SPA は load が来ても JS の初期化が終わっていません。ダッシュボードの「Git に接続」ボタンが DOM に現れる前に次のステップへ進んで、要素が見つからず止まる。
// NG: ボタンが DOM に現れる前に進んでしまう
await page.goto('https://dash.cloudflare.com/...');
await page.click('[data-testid="connect-git"]');
// OK: 目標要素の出現を待つ
await page.goto('https://dash.cloudflare.com/...');
await page.waitForSelector('[data-testid="connect-git"]', { state: 'visible' });
await page.click('[data-testid="connect-git"]');
waitForSelector で目標要素の出現を待つ。これだけです。goto の完了を「準備完了」と信じないことが起点です。
ふたつ: SPA のナビゲーションタイムアウト
接続フローの途中、Cloudflare が GitHub OAuth にリダイレクトします。OAuth認可後に Cloudflare側に戻るまで、waitForNavigation がデフォルト 30秒で止まりました。
timeout を 120秒まで伸ばして解決しました。OAuth のリダイレクトは人間の操作が必要な場合もあるので、スキルは「認可待ちの確認」を挟む設計にしました。待ち時間は余裕を持って設定したほうが安全です。
みっつ: window.confirm が自動キャンセルされる
GitHub連携フローの最後、「本当に接続しますか」確認ダイアログが window.confirm で出ます。Playwright はデフォルトでネイティブダイアログを自動 dismiss します——つまり「キャンセル」を押し続ける。
// ダイアログを自動承認する
page.on('dialog', async (dialog) => {
await dialog.accept();
});
これを仕込まないと、承認したつもりが永遠にキャンセルされます。dialog イベントのハンドラは、ネイティブダイアログが出そうなフローでは必ず仕込む——そう習慣づけました。
5つめ: Git アカウントセレクターのサイレント失敗
GitHub連携フローで「どのアカウントで接続するか」を選ぶドロップダウンがあります。スキルは自動化で組織アカウントを選択しようとしました。
問題は、選択しただけでは永続されないことです。

ドロップダウンでアカウントを選ぶ → 「次へ」 → 次のフォームへ進む。ここまでは動きます。しかし「次へ」の内部で非同期の保存処理が走っており、その完了前に進むとアカウントが null のまま後続の API が呼ばれます。ログには何も出ません。エラーもありません。ただ null で進みます。
page.waitForResponse でネットワークリクエストの完了を待つことで解決しました。
// アカウント選択後、保存 API のレスポンスを待つ
const [response] = await Promise.all([
page.waitForResponse((resp) =>
resp.url().includes('/api/accounts') && resp.status() === 200
),
page.selectOption('#account-selector', orgAccountId),
]);
ログに何も出ないまま処理が進む状態は、手元で確かめるのに一番時間がかかりました。「動いてるのに動いてない」を見たら、まずネットワークを見る——これが手元で分かったことです。
やってみて分かったこと
5つ踏んで、5つ這い出しました。
自動化を組むとき、「API がある前提」で設計しないほうが安全です。Workers Builds のように、意図的に API がない仕様もあります。push = デプロイと割り切れると、設計はむしろシンプルになりました。
ブラウザ自動化は、「見えた」を信じない。goto の完了は要素の出現ではなく、ダイアログはハンドラがなければ黙って消えて、保存は非同期で走る。仕込み忘れはサイレントに通過してサイレントに壊れます。
動かしてみて分かったのは、小さく転がすと、どこが効いているのかが手触りで分かる——という一点でした。