author: GeneralD

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


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 Chromium が Cloudflare のセキュリティ検証で弾かれるシーン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 を引いてきます。

bunxnpx の Bun版で、package.json のバージョン制約に縛られません。ローカルにインストール済みのバイナリを優先するはずですが、キャッシュ状態によっては node_modules/.bin/wrangler すら無視します。

直し方は単純で、**node_modules/.bin/wrangler を直接呼ぶ**か、bun runpackage.json の scripts経由にします。

# NG: ファントムバージョンを引く可能性がある
bunx wrangler deploy

# OK: package.json に固定したバージョンが確実に動く
./node_modules/.bin/wrangler deploy

あるいは wranglerdevDependencies に明示して 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 アカウントが選ばれたドロップダウン — 困惑するエンジニアのイラスト

ドロップダウンでアカウントを選ぶ → 「次へ」 → 次のフォームへ進む。ここまでは動きます。しかし「次へ」の内部で非同期の保存処理が走っており、その完了前に進むとアカウントが 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 の完了は要素の出現ではなく、ダイアログはハンドラがなければ黙って消えて、保存は非同期で走る。仕込み忘れはサイレントに通過してサイレントに壊れます。

動かしてみて分かったのは、小さく転がすと、どこが効いているのかが手触りで分かる——という一点でした。