# satotek.dev > nosuke の個人ブログ・技術メモ。以下は全公開記事の本文をまとめたものです。 # Pixel 11 で 5G の通信状態を可視化するアプリを作った URL: https://satotek.dev/posts/cellscope-pixel-5g Date: 2026-10-24 Category: 技術 Tags: Android, Kotlin, Jetpack Compose, Material 3 Expressive, 5G, Pixel Pixel 11 を買ってから、5G の接続状態をもう少し詳しく見たくなりました。 Android には NetMonster のように電波情報を確認できるアプリがあります。ただ、実際にどの周波数帯を使っているのか、Carrier Aggregation(CA)がどう組まれているのかまで見ようとすると、少し物足りません。 こうした用途では Network Signal Guru(NSG)が有名ですが、残念ながら Pixel 11 では使えません。 そこで、Android から取れる情報をできる限り可視化するアプリ「CellScope」を作りました。 ソースコードは GitHub で公開しています: [satotek/cellscope](https://github.com/satotek/cellscope) ## Network Signal Guru が使えない Network Signal Guru は、スマートフォンのモデムから詳細な無線情報を取得できる Android アプリです。 一般的な電波情報アプリでも RSRP や SINR、接続バンドくらいは確認できます。NSG は対応端末であれば、RRC や NAS のシグナリングメッセージといった、さらに下のレイヤーの情報まで見られます。バンド固定のような操作もできます。 これは、Qualcomm の DIAG のようなモデムの診断インターフェースを使って、モデム内部の情報を直接読んでいるからです。NSG は Qualcomm のほか、MediaTek Dimensity などにも対応していますが、どれもチップや端末ごとの対応が前提です。 一方、Pixel 11 は Google Tensor G6 を搭載していて、モデムは MediaTek 製です(Pixel 10 までは Samsung の Exynos モデムでした)。執筆時点では、Pixel 11 は NSG の対応端末に入っていません。 モデムの診断インターフェースに届かない限り、root 化しても NSG と同じ情報は取れません。アプリ側の工夫ではどうにもならない部分です。 NSG と同等のものを一から作るのは現実的ではありません。ただ、Android の Telephony API と、root 権限で読めるシステム情報を組み合わせれば、日常的に電波を観察するのに必要な情報はそれなりに取れそうです。 それなら自分で作ってみよう、というのが今回のきっかけです。 ## CellScope でできること CellScope は、Android のセルラー通信の情報を表示・記録するアプリです。 Pixel 11 で使うことを前提にしていて、接続中のセルの情報だけでなく、CA 構成や電波品質の推移、通信速度までまとめて確認できます。 主な機能は次のとおりです。 - **セル情報**: LTE / NR の RSRP、RSRQ、SINR、PCI、バンド、周波数など - **CA 構成**: LTE と NR それぞれの Primary / Secondary Component Carrier と帯域幅 - **電波の履歴**: RSRP、RSRQ、SINR、近隣セルの時系列グラフと、セル変更イベント - **通信**: スループットと Ping の推移、NDT7 による速度測定 - **統計**: 在圏バンドの比率や、電波品質の分布 - **ログ**: CSV / JSONL への記録と、あとからの再生 - **スナップショット**: 地図と通信状態を 1 枚にまとめたカード画像の保存 - **訪問セル**: これまでに接続したセルを端末内に記録 - **PiP・オーバーレイ**: 他のアプリを使いながら通信状態を確認 - **無線制御**: 5G SA のみ、LTE のみといった通信方式の固定 デュアル SIM にも対応していて、ホーム画面で両方の SIM の状態を並べて見られます。 ただし、これらすべてが通常の Android アプリの権限で取れるわけではありません。後で書くように、使える権限によって表示できる項目が変わります。 {/* 画像2(任意): ホーム画面をライトテーマで 1 枚。あれば機能紹介のここに置く */} ## Kotlin + Jetpack Compose でネイティブアプリとして作る CellScope は Kotlin と Jetpack Compose で書いた、Android のネイティブアプリです。 普段は Web まわりの技術を触ることが多いので、React Native や Flutter のようなクロスプラットフォームのフレームワークを使う選択肢もありました。 ただ、今回は最初から Pixel 11 で使うことが目的で、iOS 対応は必要ありません。それに、CellScope は `TelephonyManager` や `TelephonyCallback` といった Android 固有の API を大量に使います。特権 API や root 権限を使う処理もあるので、クロスプラットフォームのフレームワークを選んでも、結局ネイティブのコードを書く部分が大半になりそうでした。 普段あまり触らない Kotlin でも踏み切れたのは、AI コーディングツールのおかげです。慣れている技術より、作りたいものに合った技術を選べるようになりました。ただ、非公開 API や端末依存の挙動は AI に聞いても分からないことが多く、結局は実機で確かめるしかありませんでした(このあたりは後述のハマりどころにも出てきます)。 ### UI は Jetpack Compose UI は Jetpack Compose で書いています。 以前の Android 開発では XML でレイアウトを定義するのが一般的でしたが、Compose では Kotlin のコードで UI を宣言的に書けます。状態に応じて UI が再構成されるという考え方は React と似ていて、Web 側から来た身としては馴染みやすいものでした。 CellScope は、接続方式や電波品質、接続セルなど、時間とともに変わる情報を大量に扱います。取得した通信情報を状態として持ち、それに合わせて UI が組み直される Compose とは相性が良いと感じました。 一方で、電波情報はほぼ毎秒更新されるので、何も考えないと不要な再コンポジションが走ります。状態をどこで分割するか、いつ更新するかには気を配る必要がありました。 ## Material 3 Expressive で Pixel らしい UI にする せっかく Pixel 向けに作るなら、見た目もできるだけ Pixel に馴染ませたいと考えました。 そこで採用したのが Material 3 Expressive(M3E)です。Material Design 3 を発展させたデザインシステムで、形状やタイポグラフィ、モーションがより表現豊かになっています。Pixel のシステム UI とも揃っているので、標準アプリに近い雰囲気にできます。 CellScope では、トーナルなカードや `ShortNavigationBar`、`ButtonGroup` を使ったトグルなどを Compose の Material 3 コンポーネントで組んでいます。M3E の部品は執筆時点ではまだ Material 3 のベータ版にしか入っていないので、Compose BOM もベータ版を使っています。 配色は Material You のダイナミックカラーに対応させていて、壁紙に合わせてアプリの色が変わります。ただ、RAT(通信方式)の色や、電波品質の良い / 普通 / 悪いを表す色は、意味を持つ色として固定しています。 ![壁紙を変えたときのホーム画面 2 枚。左は青系、右はピンク系の配色になっている。RAT や電波品質の色は変わらない](https://img.satotek.dev/cellscope-pixel-5g/dynamic-color.webp "壁紙に合わせて配色が変わる") 個人的には、ツール系のアプリでも見た目や操作感は大事だと思っています。 電波情報アプリはどうしても数値だらけになりがちですが、すべての値を同じ大きさで並べると、かえって必要な情報が見つけにくくなります。そこで、RSRP のように頻繁に見る値は大きく表示し、詳細なセル情報や統計は別の画面に分けました。数値も単に並べるだけでなく、色やグラフで状態がひと目で分かるようにしています。 M3E のコンポーネントを使うことで、独自のデザインシステムを一から作らずに、Pixel らしい UI に仕上げられました。 ## Android からどこまで通信情報を取得できるのか ### 公開 API で取れるもの Android には、モバイル通信の情報を取得するための `TelephonyManager` があります。 たとえば `CellInfo` を使うと、接続中のセルや周辺セルの情報が取れます。LTE なら RSRP、RSRQ、RSSNR、PCI、TAC、EARFCN などが代表的です。5G NR でも、SS-RSRP、SS-RSRQ、SS-SINR、NR-ARFCN などが取れる場合があります。 CellScope では、EARFCN や NR-ARFCN から 3GPP の規格表をもとにバンドと周波数を計算して表示しています。モデムがバンドを直接返してくれる場合はそちらを優先します。 更新は `TelephonyCallback` で受け取っています。セル情報、電波強度、`ServiceState`、表示用のネットワーク種別などのリスナーを登録して、変化があれば UI に反映します。 ちなみに、`CellInfo` は位置情報の権限がないと空のリストが返ってきます。電波情報アプリなのに位置情報の許可を求めるのはこのためです。 ### ステータスバーの「5G」と実際の接続は別物 5G には大きく分けて NSA(Non-Standalone)と SA(Standalone)があります。NSA は LTE をアンカーにして NR を組み合わせる方式、SA は LTE を使わずに NR と 5G Core だけで通信する方式です。 少しややこしいのが、ステータスバーの「5G」表示と、実際の NR の接続状態が必ずしも一致しないことです。 ステータスバーの表示に使われる `TelephonyDisplayInfo` は、あくまでユーザー向けの表示のための情報です。`NR_NSA` になっていても、それは「このエリアで 5G が使える」という意味でしかなく、いま NR で通信していることの根拠にはなりません。 そこで CellScope では、データ通信の RAT が NR なら SA、鮮度のある NR のセル情報や物理チャネルの情報があれば NSA で接続中、と判定しています。どちらの根拠もない場合は、「5G 利用可」「制限あり」「不明」のどれかとして表示します。 下の 2 枚は、どちらも povo の SIM を表示しているホーム画面です。左は LTE の B3 をアンカーに n77 を束ねた NSA、右は n77 だけで繋がっている SA です。CellScope ではこのように、NSA なのか SA なのか、どのバンドを束ねているのかまで区別して表示しています。 ![CellScope のホーム画面 2 枚。左は LTE B3 + NR n77 の 5G NSA、右は n77 の 5G SA で接続している](https://img.satotek.dev/cellscope-pixel-5g/home-nsa-sa.webp "左: NSA(B3 + n77)、右: SA(n77)") また、`ServiceState` の `toString()` には、`nrState` や `isEnDcAvailable` といった非公開フィールドが含まれています。root なしでこれらを読む方法は、この文字列をパースするくらいしかありません。しかも Android 17 では `nrState` がマスクされるようになったので、他の値から推定しています。OS のアップデートで壊れやすい部分です。 ### Carrier Aggregation の構成を取る 今回、特に見たかったのが CA の構成です。 たとえば LTE の B3 に接続していると分かっても、それだけでは他にどの Component Carrier を束ねているのかは分かりません。 Android には `PhysicalChannelConfig` という、物理チャネルの構成を表すクラスがあります。チャネル番号や帯域幅、Primary / Secondary の区別など、CA 構成を知るのに必要な情報がそろっています。 ただし、これを取得する API には権限の制限があり、普通のサードパーティアプリからは使えません。 そこで root 権限を使うことにしました。 ## root と priv-app で取れる範囲を広げる CellScope は、使える権限に応じて 3 段階で動作します。 | 段階 | 権限 | 主に取れる情報 | | -------- | --------------------- | ------------------------------------------------------------------------------ | | 通常 | 電話の状態 + 位置情報 | セル情報、電波品質、接続バンド、5G の状態など | | root | 上記 + `su` | `dumpsys` 経由の物理チャネル構成(CA)、IMS の登録状態、PDN ごとの接続情報など | | priv-app | 上記 + 特権権限 | 物理チャネル構成の更新通知、VoNR の状態、通信方式の固定 | 通常モードは Android の公開 API だけを使います。 root モードでは `su` 経由で `dumpsys telephony.registry` などを実行し、その出力をパースして、通常のアプリでは取れない情報を読みます。CA の構成もここから取っています。 さらに、KernelSU Next のモジュールとしてアプリを priv-app 化する仕組みも作りました。アプリ内の設定から有効化して再起動すると、CellScope が `/system/priv-app` に入った状態で起動します。 モジュールはアプリ自身が書き出すので、KernelSU Next のモジュール一覧にはこのように並びます。 ![KernelSU Next のモジュール一覧に表示された CellScope priv-app モジュール](https://img.satotek.dev/cellscope-pixel-5g/ksu-module.webp "アプリが生成した priv-app モジュール") priv-app になると `READ_PRIVILEGED_PHONE_STATE` などの特権権限が使えるので、`PhysicalChannelConfigListener` で CA 構成の変化をイベントとして受け取れます。`dumpsys` を定期的に叩いてポーリングする必要がなくなるのが利点です。 また、`setAllowedNetworkTypesForReason` を使った通信方式の固定もできるようになります。5G SA だけを使いたいとき(NSA は LTE のアンカーが必要なので、このモードでは使われなくなります)や、LTE に固定して比べたいときに便利です。ただし、実際にどの方式で繋がるかは、モデムやキャリア側の設定にも左右されます。 ### priv-app 化でハマったところ priv-app 化まわりは、思ったよりハマりました。 1 つ目は KernelSU Next のモジュールの仕組みです。KernelSU Next 3.x では `system/` のマウントが「メタモジュール」に任されていて、メタモジュールを入れていないとモジュールの中身がマウントされません。そこで、モジュール側で自前のマウント処理を持たせ、tmpfs に置いてから overlayfs で `/system/priv-app` に重ねるようにしました。`/data` が casefold の f2fs なので、overlayfs の下層に直接使えなかったのが tmpfs を挟んでいる理由です。 2 つ目は `PhysicalChannelConfigListener` です。必要な権限が足りないと、登録が黙って失敗し、最初の 1 回分の値だけが届いて以降は更新されない、という分かりにくい挙動になります。CA の表示が変わらないので、しばらく原因が分かりませんでした。 さらに、このリスナーを単独で登録すると、PCI が「不明」に伏せられた状態で届きます。位置情報の権限チェックが、位置に関係するイベントを含む登録のときにしか行われないためで、`CellInfoListener` と同じコールバックに同居させることで解決しました。 どちらもドキュメントには書かれておらず、実機で動かしてログを見ながら探るしかない部分でした。 ## root 化しても取れない情報はある ここまで作ってみて分かったのは、root 権限があっても取れない情報があることです。 たとえば、NSG のように RRC や NAS の L3 メッセージを見ることはできません。バンドやセルの固定も、Android にそのための API がなく、Pixel 11 では実現できません。 Android Framework から見える情報と、モデムの中だけで管理されている情報の間には、はっきりした境界があります。root 化で Android 側の権限制限は回避できても、モデムが外に出していない情報が見えるようになるわけではありません。 ここが CellScope の限界です。 ## 実際に Pixel 11 で使ってみる CellScope では、いまの通信状態だけでなく、その変化を時系列で追えるようにしています。 同じ場所にいても、接続セルの切り替えや CA 構成の変化で通信品質は変わります。RSRP や SINR をグラフにし、セル変更のタイミングを縦線で重ねることで、どこで状態が変わったのかを追いやすくしました。グラフをタップすると、その時点の接続セル、NR の Secondary Cell、スループット、近隣セルなどを詳しく見られます。 下は povo で記録した 2 分間です。15:10 台の後半、B21 から n77 の SA に移ったあと、数秒おきに n77 と LTE の B1 を行ったり来たりしているのが黄色の破線で分かります。右はグラフをタップして開いた詳細で、前後 30 秒のセル切り替えが一覧で見られます。 ![電波タブの RSRP / RSRQ / SINR のグラフと、グラフをタップして開いた詳細シート。n77 と B1 の切り替えが短時間に繰り返されている](https://img.satotek.dev/cellscope-pixel-5g/signal-history.webp "左: 電波タブ、右: タップした時点の詳細") 通信速度や Ping も一緒に記録しているので、電波の品質と実際の通信性能を見比べることもできます。 速度測定には M-Lab の NDT7 を使っています。下は IIJ の SIM で測ったときの画面で、下りはおよそ 110 Mbps でした。測定中のスループットはグラフにもそのまま残ります。 ![通信タブで NDT7 の速度測定をしている画面。下り約 110 Mbps と、スループットの時系列グラフ](https://img.satotek.dev/cellscope-pixel-5g/speed-test.webp "通信タブでの速度測定") ログは CSV と JSONL で書き出していて、あとから読み込んで再生できます。再生画面ではピンチで時間軸を拡大できるので、移動中に記録したログを見返すときに便利です。 気になる場所では、スナップショットで地図と通信状態を 1 枚の画像に残せます。これは IIJ(ドコモ回線)の SIM で撮ったもので、LTE の B1・B21・B42・B42 の 4 波、合計 70 MHz の CA が組まれていました。 ![地図付きのスナップショット。IIJ の SIM で LTE B1 に接続し、B1 + B21 + B42 + B42 の 4CC で CA を組んでいる](https://img.satotek.dev/cellscope-pixel-5g/snapshot.webp "スナップショット") ただし、Android が返すセル情報には更新周期や端末側の実装による制限があるので、常にモデムの最新の状態が見えているとは限りません。特に NR の Secondary Cell は、情報が取れなかったり、実際の接続状態と表示がずれたりすることもあります。 {/* TODO: povo やスリランカでの測定結果を別記事にする場合は、ここからリンクする */} ## NSG の代わりになるのか 結論として、CellScope は NSG の完全な代わりにはなりません。 NetMonster と NSG に並べると、だいたい次のような位置づけです(CellScope は Pixel 11 で使った場合、ほかの 2 つは対応端末で使った場合の、執筆時点で私が把握している範囲です)。 | 機能 | NetMonster | CellScope | NSG | | ------------------------------ | ------------------ | -------------------------------------------- | ------------ | | root | 不要 | 不要(root / priv-app で取れる範囲が広がる) | 基本的に必要 | | Pixel 11 で使えるか | 使える | 使える | 非対応 | | 接続セル・近隣セル・電波品質 | ○ | ○ | ○ | | CA 構成 | 端末が公開する範囲 | root 以上で PCC / SCC と帯域幅まで | ○ | | 時系列グラフ | ○ | ○ | ○ | | ログの記録 | 接続したセルの記録 | CSV / JSONL に記録して再生 | ○ | | 基地局の地図 | ○ | ×(現在地のスナップショットのみ) | — | | 速度測定 | × | ○(NDT7) | ○ | | 通信方式(RAT)の固定 | × | ○(priv-app) | ○ | | バンド・セルの固定 | × | × | ○ | | RRC / NAS などの L3 メッセージ | × | × | ○ | NSG が対応モデムの診断インターフェースから取る情報と、CellScope が Android Framework を通して取る情報には大きな差があります。RRC / NAS のシグナリングメッセージのように、CellScope では取れない情報もあります。 一方で、普段使っている Pixel 11 で接続バンドや CA 構成、電波品質の推移を確認する、という用途であれば、必要な情報はかなり集められるようになりました。 電波状態の表示から履歴の記録、速度測定までを一つのアプリで完結できるのは、自作したからこそのメリットだと感じています。 ## Play ストアには出していない 本当は Play ストアで公開したいのですが、今のところは GitHub でソースを公開するだけにしています。個人開発者にとって、公開までのハードルが上がっているためです。 2023 年 11 月以降に作った個人の開発者アカウントでは、本番公開の前に、12 人以上のテスターに 14 日間続けてクローズドテストに参加してもらう必要があります。自分用に作ったツールで、テスターを 12 人集めて 2 週間付き合ってもらうのはなかなか大変です。 アプリの性質の問題もあります。CellScope は電話の状態や位置情報の権限を使うので、審査ではそれぞれの用途の説明が求められます。root や priv-app を前提にした機能も、ストアで配るアプリとしてはかなり扱いにくい部類です。 さらに Android では、Play ストア以外から入れるアプリにも開発者の本人確認を求める仕組みが始まっていて、2027 年以降は世界的に広がる予定です。サイドロードで配る場合でも、いずれ同じような手続きは避けられなさそうです。 {/* TODO: Play ストア公開について自分の言葉で一言(今後の予定など)あれば足す */} ## おわりに Pixel 11 で Network Signal Guru が使えないことをきっかけに作り始めた CellScope ですが、Android の Telephony API や root 権限、モデムと Android Framework の関係など、想像していた以上にいろいろなことを調べる機会になりました。 普段使っている Pixel に馴染む見た目で、自分が欲しかった機能を形にできるのは、個人開発ならではの面白さだと思います。 まだ取れない情報や端末依存の挙動もありますが、引き続き改善していくつもりです。 ソースコードはこちらです: [satotek/cellscope](https://github.com/satotek/cellscope) ## 参考 - [TelephonyManager | Android Developers](https://developer.android.com/reference/android/telephony/TelephonyManager) - [PhysicalChannelConfig | Android Developers](https://developer.android.com/reference/android/telephony/PhysicalChannelConfig) - [Material 3 Expressive | Material Design](https://m3.material.io/blog/building-with-m3-expressive) - [Jetpack Compose の Material Design 3 | Android Developers](https://developer.android.com/develop/ui/compose/designsystems/material3) - [Network Signal Guru](https://play.google.com/store/apps/details?id=com.qtrun.QuickTest) --- # WebMCP というものを試してみた URL: https://satotek.dev/posts/webmcp-blog-tools Date: 2026-08-30 Category: 技術 Tags: WebMCP, AI, Chrome, TypeScript, MCP 最近、WebMCP というものを知りました。Web サイトが AI エージェント向けにツールを公開できる仕組みだ、というところまでは分かったのですが、実際に組み込んだときの使い心地や、どのくらいの実装で動くのかは、触ってみないと分かりません。 そこで、このブログにツールを登録して、Chrome と Codex から呼び出してみました。目指したのは、エージェントにこう頼めるようにすることです。 > このブログで WebMCP について書いた記事を探して、最初の記事を開いて 画面を見て検索フォームを探し、文字を入力し、リンクをクリックする代わりに、ページが公開したツールを直接呼び出す。これが本当に動くのかを確かめます。 ブログを AI 対応サービスに仕立てるのが目的ではありません。どのくらいの実装で何ができるのか、DOM を直接操作する場合と何が変わるのかを知ることが目的です。 ## WebMCP とは [WebMCP](https://developer.chrome.com/docs/ai/webmcp?hl=ja) は、Web サイトが AI エージェント向けの構造化されたツールを公開するための、提案段階の Web 標準です。 普通のブラウザ操作では、エージェントはまず DOM を読みます。ボタンのラベルや入力欄の位置から「これは検索ボタンだろう」と推測して操作するので、画面が少し変わっただけで失敗することがあります。 WebMCP では、ページ側が「このページには `search_posts` というツールがあり、`query` という文字列を受け取る」と宣言できます。エージェントは見た目ではなく、ツール名・説明・JSON Schema を見て機能を呼び出します。 ### 通常の MCP との違い 一番の違いは、ツールがどこにあるかです。 ```text 通常の MCP AI エージェント ── MCP ── 外部サーバー / API WebMCP AI エージェント ── ブラウザ ── Web ページが公開したツール ``` Chrome の [MCP と WebMCP の比較](https://developer.chrome.com/docs/ai/webmcp/compare-mcp?hl=ja)にまとまっていますが、そこから派生する違いがいくつかあります。 | | 通常の MCP | WebMCP | | ------------ | --------------------------- | ----------------------------------- | | 実行場所 | 任意のプラットフォーム | ブラウザのみ | | 定義方法 | JSON-RPC と各言語の SDK | JavaScript または HTML 属性 | | 寿命 | 常駐(サーバー / デーモン) | タブに紐づき一時的 | | コンテキスト | ヘッドレス・外部から | DOM、Cookie、ログイン中のセッション | | ツールの発見 | エージェント側の登録フロー | ユーザーがページを開いた時点 | このうち、実装していて意識させられたのは下の 2 つです。 **ツールはタブと一緒に消えます。** サーバーとして常駐する MCP と違い、WebMCP のツールはページが開いている間しか存在しません。ユーザーがタブを閉じたり別のサイトへ移動したりすれば、エージェントはもう何も呼び出せません。 これは今回の `open_post` で実際に起きます。実装は `window.location.assign()` によるフル遷移なので、記事を開いた瞬間に元のページはアンロードされ、ツールは一度破棄されます。遷移先で登録モジュールが再実行され、あらためて 3 つのツールが登録される。エージェントから見れば同じツールが並んでいるだけですが、実体は別のページのものです。 **認証を別に用意する必要がありません。** ツールはユーザーが開いているそのページで動くので、ログイン済みのセッションや Cookie をそのまま使えます。MCP サーバーのように API キーを発行して権限を設計する、という手順が要りません。 便利な反面、これは「エージェントがユーザーの権限で操作できる」ということでもあります。だからこそ、入力の検証をページ側できちんと行う必要があります(後述)。 そして、この 2 つは対立するものではありません。Chrome のドキュメントも両者を組み合わせる形を勧めています。バックグラウンドで完結する処理や、ブラウザを開いていなくても動いてほしい処理は MCP サーバーへ。画面を開いている文脈でこそ意味がある操作は WebMCP へ、という住み分けになります。 Web サイトをバックエンドの MCP サーバーに置き換えるものではなく、ページ自身が、そのページで今できることをブラウザへ公開する仕組みです。 ### RSS や llms.txt との違い WebMCP を入れたからといって、RSS や Markdown の配信が不要になるわけではありません。役割が違います。 | 仕組み | 役割 | | ---------------------------------- | --------------------------- | | HTML / RSS / Markdown / `llms.txt` | AI がサイトの**情報を読む** | | WebMCP | AI がサイトの**機能を使う** | 記事本文を要約したいだけなら、RSS や Markdown、`llms.txt` のほうが扱いやすい場面もあります。反対に「このサイトの中から条件に合う記事を探して開く」という操作は、ツールとして表現したほうが意図を伝えやすい。このサイトでは、コンテンツの配信とエージェント向けの操作を別の層として持たせるのがよさそうです。 なお、まだ実験的な API なので、通常のサイト機能を置き換えるのではなく、対応ブラウザやエージェントがあるときだけ機能が増える progressive enhancement として扱うのが現実的です。 ## ブログで試す題材を決める WebMCP の例としては、予約フォームやショッピングカートのような「人間が操作すると複数の手順が必要な機能」が分かりやすいと思います。個人ブログに同じ必然性はありません。記事を読むだけなら HTML でも RSS でも足ります。 それでも題材として選んだのは、ブログには「記事を探す」「条件で絞り込む」「見つけた記事を開く」という、ツールにしやすい操作が揃っているからです。検索結果から記事を開くところまで一つの会話でつながれば、感触をつかむには十分です。WebMCP の記事を WebMCP で試せるのも面白そうでした。 試す機能は、読み取り系を中心に 3 つへ絞りました。最初から本文の要約や推薦まで入れると、仕組みを試しているのかツールを作り込んでいるのか分からなくなるからです。 | ツール | 引数 | 何をするか | | -------------- | ----------------------------- | ---------------------------------------------- | | `search_posts` | `query`, `limit?` | 記事のタイトル・説明・カテゴリ・タグを検索する | | `list_posts` | `category?`, `tag?`, `limit?` | カテゴリやタグで絞り込んだ記事一覧を返す | | `open_post` | `slug` | 現在のタブで記事を開く | `search_posts` と `list_posts` は、記事本文全体ではなく、エージェントが次の判断に使いやすい短いメタデータだけを返します。 ```json { "slug": "nix-flakes-dotfiles", "title": "dotfiles を Nix Flakes で管理している話", "url": "http://localhost:3000/posts/nix-flakes-dotfiles", "date": "2026-07-04", "category": "技術", "tags": ["Nix", "Home Manager", "nix-darwin"] } ``` `open_post` は、検索や一覧で返された slug を受け取って現在のタブで記事を開きます。任意の URL を受け取って遷移するのではなく、公開済み記事の一覧に slug が存在するか確認してから遷移します。 記事検索は、記事本文の Markdown を毎回解析しているわけではありません。このサイトではビルド時に Markdown から記事のサマリを生成していて、ツールはそのサマリをブラウザ上で検索します。 ```text Markdown ↓ ビルド時 記事サマリ(summaries.json) ↓ postRepository ↓ WebMCP tools ├─ search_posts ├─ list_posts └─ open_post ``` ## 宣言的 API と命令的 API WebMCP がツールを公開する方法は 2 つあります。 **宣言的 API(Declarative API)** は、既存の HTML フォームに属性を足すだけでツールにします。JavaScript は書きません。 ```html
``` `
` に `toolname` と `tooldescription`、各入力欄に `toolparamdescription` を付けると、フォームの構造から JSON Schema が組み立てられます。既定ではエージェントが値を埋めるところまでで、送信はユーザーが Submit を押します。自動で送信させたい場合は `toolautosubmit` を足します。 **命令的 API(Imperative API)** は、JavaScript から `document.modelContext.registerTool()` を呼んでツールを登録します。入力スキーマも実行内容も自分で書きます。 違いは「すでに画面にある操作を、そのままエージェントへ開放するか」「画面には無い操作を、エージェント向けに定義するか」です。 | | 宣言的 API | 命令的 API | | -------------- | ------------------------------------ | ------------------------- | | 書く場所 | HTML の属性 | JavaScript | | スキーマ | フォーム構造から自動生成 | 自分で JSON Schema を書く | | 実行内容 | フォーム送信 | 任意の処理 | | 向いている対象 | 予約・問い合わせなど、既存のフォーム | フォームに紐づかない操作 | 宣言的 API の良いところは、人間が使うフォームとエージェントが使うツールが同じ実装になる点です。片方だけ直して食い違う、ということが起きません。 今回は命令的 API を使いました。このブログには検索フォームが無く、`search_posts` も `list_posts` も画面上の UI に対応していないためです。開放したい操作がフォームとして存在しない以上、宣言的 API では表現できません。 ## 命令的 API でツールを登録する 最小構成はこういう形です。 ```ts await document.modelContext.registerTool({ name: "search_posts", description: "Search published articles by keyword.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Keyword or phrase to search for.", }, }, required: ["query"], }, execute: async ({ query }) => { return searchPosts(query); }, }); ``` このサイトでは、ルートシェルから一度だけ登録モジュールを import しています。 ```tsx title="src/routes/__root.tsx" import "#/webmcp/register"; ``` 登録処理では、WebMCP に対応していないブラウザでも通常どおり表示できるように、`document.modelContext` の存在を確認します。 ```ts title="src/webmcp/register.ts" function currentModelContext() { if (typeof document === "undefined") return undefined; try { return document.modelContext; } catch { return undefined; } } export function registerBlogWebMcpTools() { const modelContext = currentModelContext(); if (!modelContext) return undefined; // search_posts / list_posts / open_post を登録する // ... } ``` ### 型は webmcp-types から借りる WebMCP は TypeScript の標準 DOM 型にまだ含まれていません。手元の TypeScript 7.0.2 の `lib` を検索しても `modelContext` は 1 件も出てきませんでした。 型は、仕様を出している W3C Web Machine Learning CG が公開している [`webmcp-types`](https://www.npmjs.com/package/webmcp-types) を使っています。 ```sh bun add -d webmcp-types ``` このパッケージは `WebMCP` 名前空間と `Document.modelContext` をグローバルに宣言します。import するものが無いので、`tsconfig.json` の `types` に足して読み込ませます。 ```json "types": ["vite-plus/client", "node", "webmcp-types"] ``` これだけで `document.modelContext` に型が付き、ツールの型もグローバルの名前空間からそのまま書けます。 ```ts export function createBlogWebMcpTools(): readonly WebMCP.ModelContextTool[] { ``` 型を読んでいて気づいたのは、`execute` の第 2 引数が必須だということです。 ```ts type ToolExecuteCallback = Record> = ( inputObject: T, options: ToolExecuteCallbackOptions, ) => MaybePromise; ``` `ToolExecuteCallbackOptions` が持つのは `signal: AbortSignal` です。エージェントが実行を打ち切ったことをツールへ伝えるためのもので、時間のかかる処理を書くなら見る必要があります。今回の 3 つはどれも即座に返るので使っていませんが、テストでは実際のブラウザと同じ形で呼ぶようにしました。 ```ts const executeOptions = { signal: new AbortController().signal }; const searchResult = await search.execute({ query: "Nix" }, executeOptions); ``` ## 読み取り専用とページ遷移を分ける 3 つのツールを同じものとして扱わないことも意識しました。 ```ts annotations: { readOnlyHint: true, untrustedContentHint: true, } ``` `search_posts` と `list_posts` は記事データを読むだけなので `readOnlyHint: true`。`open_post` は現在のタブを変更するため、読み取り専用ではありません。 この区別は飾りではありません。エージェントやクライアントが、確認なしで実行してよい処理か、ユーザーに確認すべき処理かを判断する材料になります。今回は記事を開くだけなので影響は小さいですが、「記事を削除する」「下書きを公開する」といったツールを足すなら、確認を挟む設計が必要になります。 ## Chrome で試す ローカル開発では、Chrome のフラグを有効にします。 1. `chrome://flags/#enable-webmcp-testing` を開く 2. WebMCP testing を Enabled にする ![Chrome の chrome://flags で WebMCP for testing を Enabled にした状態](https://img.satotek.dev/webmcp-blog-tools/chrome-webmcp-testing-flag.webp "WebMCP for testing の設定") 3. Chrome を再起動する 4. `http://localhost:3000` を開く Chrome DevTools の Application パネルには WebMCP ペインがあり、ページが公開しているツール名、説明、入力スキーマ、呼び出し履歴を確認できます。 ![Chrome DevTools の Application > WebMCP パネルに表示されたブログのツール一覧](https://img.satotek.dev/webmcp-blog-tools/devtools-webmcp.webp "Chrome DevTools の WebMCP パネル") 今回のローカル環境では、次の 3 つが表示されました。 - `list_posts` - `open_post` - `search_posts` ただし DevTools のパネルは「ページがどんなツールを公開しているか」を確認する場所です。エージェントから使うには、WebMCP に対応したクライアントをブラウザへ接続します。 ## MCP クライアントから呼び出す Chrome DevTools MCP を使って、起動中の Chrome に接続しました。これは、ページの WebMCP ツールを MCP クライアントへ橋渡しする MCP サーバーです。 `--category-experimental-webmcp` が WebMCP 用ツールを有効にするオプションです。`--auto-connect` を使う場合は、Chrome の `chrome://inspect/#remote-debugging` からリモートデバッグを許可しておきます。 ![Chrome DevTools の Remote debugging 設定画面。リモートデバッグを許可し、127.0.0.1:9222 でサーバーが起動している状態](https://img.satotek.dev/webmcp-blog-tools/remote-debugging.webp "Chrome DevTools の Remote debugging 設定") Codex から使えるようにするには、次のコマンドを実行します。 ```sh codex mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest --auto-connect --category-experimental-webmcp ``` そのうえで Chrome を WebMCP のフラグを有効にした状態で起動し、MCP クライアントを再起動します。あとは Codex に頼むだけです。 ```text 今開いている Chrome の localhost:3000 で、 Nix の記事を検索して、検索結果の最初の記事を開いて chrome-devtoolsを使って ``` 実際に試すと、まず次の呼び出しが行われました。 ```json { "toolName": "search_posts", "input": { "query": "Nix", "limit": 3 } } ``` 検索結果から `nix-flakes-dotfiles` が返り、その slug を使って次の呼び出しが行われます。 ```json { "toolName": "open_post", "input": { "slug": "nix-flakes-dotfiles" } } ``` 最後に、Chrome の現在のタブが `http://localhost:3000/posts/nix-flakes-dotfiles` に遷移しました。 ![Codex から WebMCP ツールを呼び出し、検索結果の記事を Chrome で開いた実行例](https://img.satotek.dev/webmcp-blog-tools/codex-webmcp-execution.webp "Codex から WebMCP ツールを呼び出した実行例") `Application > WebMCP` パネルでは呼び出し履歴も確認できます。実行結果に加えて、エージェントが渡した入力値とツールが返した出力が一覧で見られます。 ![Chrome DevTools の WebMCP パネルに表示された search_posts と open_post の実行履歴](https://img.satotek.dev/webmcp-blog-tools/devtools-webmcp-invocation-history.webp "WebMCP ツールの実行履歴") ここまで動くと、WebMCP が「AI に読ませるためのページ情報」ではなく「ページ自身が提供する操作のインターフェース」だということが実感できます。 ## 試して分かったこと 想像していたことと、動かして分かったことには少し差がありました。 ### ツールの数は少ないほうがよい 本文取得、関連記事推薦、タグ追加、アクセス解析と、いくらでもツールにできます。しかし増えるほど、エージェントはどれを使うべきか迷います。 今回は検索・一覧・遷移の 3 つだけにしました。記事を探して開くという流れが成立するので、呼び出しを試すにはこれで十分です。機能を作り込むより、登録・発見・実行・遷移を一通り通すことを優先しました。 ### 戻り値は短く、次の判断に必要な形にする `search_posts` が記事本文まで返すと、レスポンスが大きくなります。検索結果に必要なのは、タイトル、URL、説明、タグ、slug くらいです。 「何を返せばエージェントが次の一手を選べるか」を考えると、ツールの設計は自然に小さくなります。 ### ページ側の検証が重要 WebMCP のツールを実行するのは、ブラウザ上のページです。入力値をエージェントが生成する以上、ツール側で検証しなければなりません。 今回も、空の検索語を拒否し、`limit` を 1〜10 に制限し、`open_post` では公開済み記事に存在する slug だけを受け付けています。エージェントの指示を信頼するのではなく、通常の公開 API と同じように扱う必要があります。 ## 次に試すなら 候補は記事本文を返す `get_post` です。ただし本文をそのまま返すのではなく、Markdown、見出し一覧、指定した見出しの内容、と分けたほうが扱いやすそうです。ほかにも次のようなツールは相性がよさそうでした。 - タグやカテゴリの一覧を取得する - 現在の記事に関連する記事を探す - 記事の特定の見出しだけを返す - 読んだ記事をもとに次に読む記事を提案する 一方、コメント投稿や記事の編集のように外部状態を変更する機能は、認証と確認フローを先に考えるべきでしょう。便利そうだからといって何でも AI から操作できるようにするのは危険です。 触ってみた率直な感想としては、ブログのように読むことが中心のサイトで WebMCP がなければ困る場面は、まだ多くありません。それでも、ページ側がツール名と入力スキーマを明示できることで、DOM を読んで推測させるのとは違うインターフェースが作れるのは確かでした。`search_posts` の結果から `open_post` へ、一つの指示でつながったときが一番おもしろかったところです。 現時点ではまだ実験的で、ブラウザやクライアントによって使える機能も違います。本番機能の前提にするには早いものの、実装の規模感を知る題材としてはかなり良かったと思います。気が向いたら記事本文の一部取得や、ページ内の状態を扱うツールを試してみます。 ## 付録: MCP API から呼び出す ここまでの例では Codex に自然言語で指示していましたが、実際には Chrome DevTools MCP が提供する MCP ツールを経由して、ブラウザ上の WebMCP ツールが呼ばれています。 MCP クライアントから見ると、まず `list_webmcp_tools` で対象ページが公開しているツールを取得します。 ```json { "name": "list_webmcp_tools", "arguments": { "pageId": 13 } } ``` すると、ページ上の `search_posts`、`list_posts`、`open_post` と、それぞれの説明・入力スキーマが返ります。 次に `execute_webmcp_tool` へ、WebMCP ツール名と入力を渡します。`input` は JSON オブジェクトそのものではなく、JSON 文字列として渡す点に注意が必要です。 ```json { "name": "execute_webmcp_tool", "arguments": { "pageId": 13, "toolName": "search_posts", "input": "{\"query\":\"Nix\",\"limit\":3}" } } ``` 戻り値は、WebMCP 側の `execute` が返した構造化データです。 ```json { "status": "Completed", "output": { "query": "Nix", "total": 1, "returned": 1, "hasMore": false, "posts": [ { "slug": "nix-flakes-dotfiles", "title": "dotfiles を Nix Flakes で管理している話" } ] } } ``` 検索結果の slug を使えば、同じ仕組みでページ遷移も呼び出せます。 ```json { "name": "execute_webmcp_tool", "arguments": { "pageId": 13, "toolName": "open_post", "input": "{\"slug\":\"nix-flakes-dotfiles\"}" } } ``` ややこしいのは、`search_posts` や `open_post` が MCP サーバーのツールとして存在しているわけではないことです。MCP サーバーである Chrome DevTools MCP が、ページの WebMCP ツールを発見・実行するための窓口を提供しています。 ```text MCP クライアント ↓ list_webmcp_tools / execute_webmcp_tool Chrome DevTools MCP ↓ ブラウザ経由 satotek.dev の WebMCP ├─ search_posts ├─ list_posts └─ open_post ``` 実際のクライアントでは、この JSON を手書きする代わりに Codex や Claude Code がツールスキーマを見て呼び出します。API の形を直接確認したいときは、[Chrome DevTools MCP のツールリファレンス](https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/docs/tool-reference.md)が参考になります。 参考: - [WebMCP | Chrome for Developers](https://developer.chrome.com/docs/ai/webmcp?hl=ja) - [MCP と WebMCP の比較 | Chrome for Developers](https://developer.chrome.com/docs/ai/webmcp/compare-mcp?hl=ja) - [Imperative API | Chrome for Developers](https://developer.chrome.com/docs/ai/webmcp/imperative-api) - [Declarative API | Chrome for Developers](https://developer.chrome.com/docs/ai/webmcp/declarative-api) - [Debug WebMCP tools | Chrome DevTools](https://developer.chrome.com/docs/devtools/application/webmcp) - [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) --- # dotfiles を Nix Flakes で管理している話 URL: https://satotek.dev/posts/nix-flakes-dotfiles Date: 2026-07-04 Category: 技術 Tags: Nix, Home Manager, nix-darwin, dotfiles, zsh, Claude Code 私の dotfiles は Nix Flakes で管理しています。 リポジトリはこちらです: [satotek/dotfiles](https://github.com/satotek/dotfiles) 2025年の終わり頃に作り始めて、半年ほど運用してきました。最初はよくある「シェルスクリプトで symlink を張るインストーラ」方式だったのですが、途中で丸ごと Nix に寄せて、今はインストーラスクリプトは残っていません。 この記事では、なぜ Nix にしたのか、どういう構成にしているのか、運用してみてどうだったかを書いていきます。 ## なぜ Nix にしたのか 一番の理由は、環境が増えたことです。 普段使いの macOS に加えて、WSL、それから作業用の Azure VM(Ubuntu)と、同じ設定を使いたいマシンが気づけば増えていました。シェルスクリプトのインストーラでもある程度は対応できるのですが、 - OS ごとの分岐がスクリプト内にどんどん増えていく - 「このツール入ってなかった」を実行時に踏んでから気づく - 何度実行しても同じ状態になる(冪等性)の保証を自前で頑張る必要がある あたりがつらくなってきます。特に CLI ツールのインストールは、Homebrew・apt・cargo・npm と入れ方がバラバラで、マシンごとに微妙に違う環境ができあがりがちでした。 Nix はこのあたりを仕組みごと解決してくれます。パッケージも設定ファイルも全部宣言的に書いて、`switch` 一発で「宣言どおりの状態」に収束させる。分岐は Nix の式として書けるので、OS ごとの差分も構造として整理できます。 ### mise じゃだめだったのか 選定の段階では [mise](https://mise.jdx.dev/) も有力候補でした。`mise.toml` にツール名とバージョンを書くだけで揃う手軽さは魅力ですし、学習コストは Nix とは比べものにならないくらい低い。「CLI ツールをマシン間で揃える」ことだけが目的なら、mise のほうが早く幸せになれると思います。 それでも Nix を選んだのは、半分は合理で、半分はノリです。 合理の部分は守備範囲です。mise が担当するのはツールのバージョン管理までですが、Nix + Home Manager はそれに加えて、設定ファイルの配置、シェルの中身、macOS のシステム設定(nix-darwin)までを同じ仕組みで宣言できます。dotfiles 全体を1つの流儀に寄せたかったので、ここは大きな差でした。 ノリの部分は、「パッケージのビルドを純粋関数として扱う」という Nix の考え方が単純に面白そうだったことです。同じ入力(宣言と `flake.lock`)からは必ず同じ環境が出てくる。関数型プログラミングの発想がそのまま環境構築になっているのは、触っていて普通に楽しいです。あとは正直に言うと、当時 X のタイムラインで Nix がなにかと流行っていて、乗ってみたくなったというのもあります。動機なんてそんなものです。 ## 全体構成 macOS では `nix-darwin + Home Manager`、Linux では standalone の `Home Manager` を使っています。 ```text dotfiles/ ├── flake.nix # 全マシンの構成のエントリポイント ├── nix/ │ ├── home-manager/ │ │ ├── default.nix # 共有 Home Manager 設定の入口 │ │ ├── data/ # ツール横断で共有する純データ (mcp-servers.nix など) │ │ ├── home/ # シェル・プロファイル・migration などのコア設定 │ │ ├── platforms/ # darwin.nix / linux.nix │ │ ├── presets/ # パッケージのまとまり: base / devtools / webdevtools │ │ └── programs/ # ツールごとに1ファイル (git, zsh, nvim, claude-code, ...) │ ├── hosts/ # マシン(ユーザー)ごとの定義 │ └── nix-darwin/ # macOS システム層 (Homebrew casks, macOS 設定) ├── .config/ # リポジトリ直置きの設定ファイル (nvim, ghostty, starship, ...) └── .github/workflows/ # flake.lock の自動更新 CI ``` macOS で使うときのポイントは、システム層とホーム層の2層に分かれていることです。 - **システム層(nix-darwin)**: Homebrew の cask、フォント、macOS のシステム設定。`sudo` が要る層 - **ホーム層(Home Manager)**: それ以外ぜんぶ。CLI ツール、シェル、各ツールの設定。`sudo` 不要 日々いじるのはほぼホーム層だけで、こちらは `nix-switch` というエイリアス一発で反映できます。cask や macOS 設定を変えたときだけ `darwin-switch`(sudo あり)を叩く運用です。 マシンの定義は `flake.nix` に集約していて、macOS・Linux (x86_64 / aarch64)・WSL・Azure VM 用に 8 つの homeConfiguration を出力しています。共通部分は `mkHomeConfiguration` という関数にまとめてあるので、マシンが増えても数行足すだけです。 ```nix title="flake.nix" homeConfigurations."${darwinUsername}@${darwinHostname}" = mkHomeConfiguration { system = darwinSystem; userName = darwinUsername; homeDirectory = "/Users/${darwinUsername}"; extraModules = [ ./nix/home-manager/platforms/darwin.nix ./nix/home-manager/presets/base.nix ./nix/home-manager/presets/devtools.nix ./nix/home-manager/presets/webdevtools.nix ]; }; ``` パッケージは `presets/` で「どのマシンにも入れる base」「開発マシン用の devtools」「Web 開発用の webdevtools」に分けています。Azure VM のような用途が限られたマシンには base だけ、みたいな組み合わせができるのが地味に便利です。 ## 設定ファイルの持ち方は2種類に使い分け Home Manager で設定を管理する方法は大きく2つあって、これの使い分けが運用上けっこう重要でした。 **1. Home Manager のネイティブオプションで書く** `git`、`tmux`、`zsh` あたりはこちらです。設定が Nix の式になるので、変数や条件分岐が使えて、他の設定と値を共有できます。 **2. リポジトリ内のファイルを `mkOutOfStoreSymlink` で直接リンクする** ```nix title="nix/home-manager/programs/starship.nix" xdg.configFile."starship.toml".source = config.lib.file.mkOutOfStoreSymlink "${dotfilesDir}/.config/starship.toml"; ``` Neovim、Ghostty、Starship、WezTerm など、試行錯誤しながら頻繁にいじる設定はこちらです。 普通に Home Manager でファイルを管理すると、設定は Nix ストアにコピーされるので、1行変えるたびに `switch`(再ビルド)が必要になります。`mkOutOfStoreSymlink` を使うとリポジトリのファイルへの symlink になるので、**エディタで保存した瞬間に反映**されます。Neovim の設定をいじるたびにビルドを待つのは現実的ではないので、この使い分けに落ち着きました。 「変更頻度が低くて構造化の恩恵が大きいものはネイティブオプション、変更頻度が高いものは直リンク」という基準です。 ## プロジェクトごとの環境は flake + direnv ここまではマシン全体の環境の話ですが、プロジェクトごとに必要なツールチェーンは、dotfiles ではなく各リポジトリの `flake.nix` + [direnv](https://direnv.net/) で管理しています。mise でいうプロジェクトごとのツール切り替えに相当する部分です。 リポジトリに devShell を定義した `flake.nix` と、`use flake` と書いた `.envrc` を置いておくと、そのディレクトリに `cd` した瞬間に必要なツールが PATH に入り、離れると消えます。 ```nix title="flake.nix(プロジェクト側・抜粋)" devShells.default = pkgs.mkShell { packages = with pkgs; [ nodejs_22 pnpm terraform ]; }; ``` ```sh title=".envrc" use flake ``` 素の direnv + Nix の組み合わせは `cd` のたびに flake の評価が走って遅いのですが、[nix-direnv](https://github.com/nix-community/nix-direnv) を挟むと評価結果がキャッシュされて2回目以降は一瞬になります。この direnv 自体の設定も Home Manager で宣言しています。 ```nix title="nix/home-manager/programs/direnv.nix" programs.direnv = { enable = true; enableZshIntegration = true; nix-direnv.enable = true; config.global = { # flake の初回評価は数秒かかるので「読み込みが遅い」警告を出さない warn_timeout = "0s"; # cd 毎の `export +FOO +BAR ...` 差分表示を隠す hide_env_diff = true; }; }; ``` 「グローバルに入れるのは dotfiles で宣言したツールだけ、プロジェクト固有のものはリポジトリの flake に書く」と分けておくと、マシン全体は汚れませんし、プロジェクトを行き来しても `cd` するだけで正しいツールチェーンに切り替わります。 ## シェルの起動を速くする工夫 Zsh のプラグインマネージャは [sheldon](https://sheldon.cli.rs/) を使っています。以前は zinit を使っていたのですが、本家リポジトリが突然消えて community fork(zdharma-continuum)に引き継がれた騒動があったこと、TOML に書くだけという設定の楽さ、そして最近なにかと流行っていたこともあって、sheldon に乗り換えました。 sheldon はプラグインを `plugins.toml` に書くだけです。zinit の turbo mode に相当する遅延読み込みも、zsh-defer を通す template を1つ定義すれば再現できます。 ```toml title=".config/sheldon/plugins.toml(抜粋)" [templates] defer = "{{ hooks?.pre | nl }}{% for file in files %}zsh-defer source \"{{ file }}\"\n{% endfor %}{{ hooks?.post | nl }}" [plugins.fast-syntax-highlighting] github = "zdharma-continuum/fast-syntax-highlighting" use = ["fast-syntax-highlighting.plugin.zsh"] apply = ["defer"] ``` 「設定は TOML に宣言、読み込みは遅延」という形に整理できて、dotfiles 全体を宣言的に寄せている方針とも噛み合っています。 プロンプトは starship、ディレクトリ移動は zoxide です。sheldon も含めて、この手のツールは起動時に `eval "$(starship init zsh)"` のように init スクリプトを毎回生成させるのが定番です。ただ、これが積み重なるとシェルの起動が目に見えて遅くなります。 そこで、生成結果をファイルにキャッシュして、設定ファイルより古くなったときだけ再生成するようにしています。この仕組みは「[究極のzshプラグイン読み込み高速化: プラグインマネージャーの限界を越えろ](https://zenn.dev/fuzmare/articles/zsh-plugin-manager-cache)」を参考にしたものです。 ```zsh title=".zshrc(zsh.nix が生成・抜粋)" if [[ -n "$_zoxide_bin" && ( ! -r "$_zoxide_cache" || "$_zoxide_bin" -nt "$_zoxide_cache" ) ]]; then "$_zoxide_bin" init zsh >| "$_zoxide_cache" fi [[ -r "$_zoxide_cache" ]] && source "$_zoxide_cache" ``` ほかにも、 - `source` をフックして読み込むスクリプトを自動で `zcompile`(Zsh のバイトコンパイル) - `compinit`(補完の初期化)は macOS では `zsh-defer` でプロンプト表示後に遅延実行 といった細かい積み重ねで、プラグインを盛っているわりに起動は軽く保てています。 実際どれくらいなのか、[hyperfine](https://github.com/sharkdp/hyperfine) で測ってみました。hyperfine 自体も `nix run` で一時的に呼び出せるので、インストール不要です。 ```sh nix run nixpkgs#hyperfine -- --warmup 3 'zsh -i -c exit' ``` 手元の M5 MacBook Pro での結果は **46 ms ± 8 ms** でした。比較のため、実行のたびにキャッシュを削除する(= 毎回 init スクリプトを生成する素の状態に相当)条件でも測ると 74 ms だったので、30 ms 弱をキャッシュで稼いでいる計算です。さらに `compinit` や syntax highlighting などの重い処理は `zsh-defer` でプロンプト表示後に回しているので、体感の「入力できるようになるまで」はこの数字よりさらに短くなっています。 ### 落とし穴: nix-darwin のグローバル compinit 実はこの記事を書きながら測ったとき、最初は 105 ms でした。zprof でプロファイルしても自分の `.zshrc` は 40 ms 分ほどしか使っていない。残りはどこだと犯人を探したら、nix-darwin が生成する `/etc/zshrc` でした。 ```zsh title="/etc/zshrc(nix-darwin が生成・抜粋)" autoload -U promptinit && promptinit && prompt suse && setopt prompt_sp autoload -U compinit && compinit autoload -U bashcompinit && bashcompinit ``` nix-darwin で `programs.zsh.enable = true` にすると、デフォルトでは **キャッシュを使わないフルの `compinit`** と、starship にどうせ上書きされるプロンプト初期化が、自分の `.zshrc` が始まる前に毎回走ります。ユーザー側でどれだけ compinit を遅延・キャッシュしても、その手前で毎回フル初期化されていたわけです。 nix-darwin 側でこう無効化しました。補完はユーザー側の遅延 compinit が担うので機能は失われません。 ```nix title="nix/nix-darwin/system.nix" programs.zsh.enable = true; programs.zsh.enableGlobalCompInit = false; programs.zsh.enableBashCompletion = false; programs.zsh.promptInit = ""; ``` これだけで **105 ms → 46 ms** になりました。`.zshrc` をどれだけ磨いてもグローバル側の `/etc/zshrc` は見落としがちです。nix-darwin に限らず、`NOSYSZSHRC=1 zsh -i -c exit` と比べて差が大きいときはグローバル設定を疑ってみてください。 ## AI エージェントの設定も Nix で管理する 最近の dotfiles らしいところだと思うのですが、Claude Code や Codex といったコーディングエージェントの設定もすべて Nix で宣言管理しています。 ```nix title="nix/home-manager/programs/claude-code.nix" programs.claude-code = { enable = true; package = pkgs.llm-agents.claude-code; settings = { outputStyle = "Explanatory"; model = "claude-opus-4-8"; permissions.allow = [ "Bash(pnpm typecheck)" "Bash(pnpm test)" # ... ]; }; }; ``` エージェント本体は [llm-agents.nix](https://github.com/numtide/llm-agents.nix) 経由でパッケージとして入れて、settings.json も permissions も statusline スクリプトも全部コードとして管理します。新しいマシンでも `switch` した時点でいつものエージェント環境が揃うのはかなり快適です。 工夫した点として、MCP サーバーの定義を「純データ」のファイルに切り出して、Claude Code と Codex の両方から共有しています。 ```nix title="nix/home-manager/data/mcp-servers.nix" # 起動方法だけを書いた純データ { context7 = { command = "npx"; args = [ "-y" "@upstash/context7-mcp" ]; }; # ... } ``` Claude Code は settings の `mcpServers`、Codex は config.toml の `[mcp_servers]` と設定の置き場所も形式も違うのですが、大元のデータを1ファイルにしておいて、エージェント固有の項目(Claude の `type = "stdio"` など)は各 import 側で付与する方式です。MCP サーバーを1つ足すと両方のエージェントに反映されます。 Agent Skills も flake の input としてバージョン固定で取り込んでいて、Anthropic 公式や Vercel のスキル集がこれも `switch` 一発で入ります。 ## flake.lock は CI が自動更新する Nix Flakes は `flake.lock` で依存をバージョン固定するので再現性は完璧なのですが、放っておくと全部が古くなっていきます。かといって手で `nix flake update` し続けるのは面倒です。 そこで GitHub Actions で自動更新しています。ワークフローは2本です。 - **AI 系 input(毎日)**: Claude Code などのエージェント類は動きが速いので毎日更新 - **基盤系 input(3日おき)**: nixpkgs / nix-darwin / home-manager。こちらは **リリースから3日以上経った版だけを取り込む「age gate」付き** で、当日リリースの事故を踏まないようにしています どちらのワークフローも、更新後に Linux の Home Manager 構成を実際にビルドして検証してから PR を作り、通れば自動マージします。壊れた更新が main に入らないので、各マシンでは `git pull` して `nix-switch` するだけで安心して追従できます。 リポジトリは public なので GitHub ホストランナーは無料です。dotfiles の依存更新が Dependabot 的に回り続けるのは、一度組んでしまうとかなり楽です。 ## 新しいマシンのセットアップ ここまでやった結果、新しいマシンの環境構築はこうなりました。 ```bash # Nix をインストール (Determinate Nix) curl -fsSL https://install.determinate.systems/nix | sh -s -- install # clone して switch git clone https://github.com/satotek/dotfiles.git ~/dotfiles cd ~/dotfiles nix run home-manager/master -- switch --flake "path:$PWD#nosuke@linux-x86_64" ``` これで CLI ツールもシェルもエディタもエージェントも、いつもの環境が一式立ち上がります(macOS の場合は、これに加えてシステム層の `darwin-rebuild switch` が1コマンド増えます)。Git のユーザー名やシークレットのようなマシン固有・秘匿情報だけはリポジトリの外(`~/.config/git.local` など)に置く運用です。 実際、Azure VM を作り直すことが何度かあったのですが、環境の復元で悩むことはほぼなくなりました。 ## さいごに: Nix は難しい。でも今は LLM がいる 正直に言うと、Nix の学習コストは高いです。Nix 言語は独特ですし、エラーメッセージも親切とは言えません。「dotfiles を Nix にしたい」と思って調べ始めても、flake・Home Manager・nix-darwin と概念が多くて、以前なら挫折する人が多かったジャンルだと思います。 ただ、これは LLM が出てくる前の話です。 実はこのリポジトリの設定も、かなりの部分を Claude Code に手伝ってもらいながら書いています。「starship の設定は再ビルドなしで反映されるようにしたい」「MCP サーバーの定義を Claude Code と Codex で共有したい」と日本語でやりたいことを伝えれば、Nix の式に落とすところはエージェントがやってくれます。エラーが出たらそのまま貼れば、あの不親切なエラーメッセージも解読してくれます。 Nix は宣言的なので、この進め方と相性が抜群です。生成された設定が正しいかは `switch` が通るかで機械的に検証できますし、変になってもロールバックできます。「LLM に書かせて、ビルドで検証する」というループが安全に回せるんですね。 この記事で紹介した構成も、AI エージェントの設定を Nix で管理して、その Nix を AI エージェントに書いてもらう、という循環でできています。 学習コストを理由に Nix を避けていた人は、いま一番始めやすいタイミングだと思います。手元のエージェントに「dotfiles を Nix Flakes で管理したい」と伝えるところから始めてみてください。 リポジトリはこちらです: [satotek/dotfiles](https://github.com/satotek/dotfiles) --- # Cloudflare Workers でサイトを作ってみた URL: https://satotek.dev/posts/cloudflare-workers-site Date: 2026-06-07 Category: 技術 Tags: Cloudflare, Workers, Hono, Azure このサイトは Cloudflare Workers でホストしています。 普段は仕事で Azure を触ることが多いのですが、個人サイトまで同じノリで作るのも少し面白くないなと思い、今回は Cloudflare Workers を使ってみました。 結論から言うと、思っていたよりかなり手軽でした。個人サイトや小さなツールを置く場所としては、かなり相性が良さそうです。 全体像はこんな感じです。ビルド時に Markdown を静的 HTML 化しておき、配信時は Cloudflare のエッジから静的アセットを返す。動的な処理だけ Worker(Hono)がフォールバックで受け持つ、という構成です。 ![システム構成図:ビルド時に Markdown を静的HTML化し、配信時は Cloudflare エッジから静的アセットを返す](https://img.satotek.dev/cloudflare-workers-site/architecture.svg) ## なぜ Azure ではなく Cloudflare にしたのか Azure で Webサイトをホスティングするなら、App Service、Azure Static Web Apps、Azure Container Apps あたりが候補になると思います。 ただ、今回はあえて Cloudflare Workers にしました。 理由はだいたいこのあたりです。 - 業務で使っている環境をそのまま使っても面白くないし嫌 - 最近 Cloudflare Workers が何かと話題 - そもそも Azure とか AWS、GCP は個人の趣味程度では冗長すぎるし費用も高い - ドメイン管理や自宅鯖公開用途ですでにCloudflare を使っていた 特に最後は大きくて、すでにドメインを Cloudflare に寄せていたので、「せっかくならホスティングも寄せてみるか」という感じでした。 ## デプロイがとにかく速い 一番驚いたのはここでした。 ```sh wrangler deploy ``` これだけでデプロイできます。 もちろん実際には設定ファイルやビルドの準備はありますが、一度形にしてしまえば、あとはコマンド一発で反映できます。数秒で反映されるので、個人サイトを触るにはかなり気軽です。 個人サイトで「ちょっと記事を出したい」という用途には、wrangler deploy の軽さはかなりありがたいです。 ## 実行モデルの違いが面白い Cloudflare Workers は V8 isolate という仕組みで動いています。 雑に言うと、リクエストごとにコンテナやプロセスを起動するというより、軽量な実行環境でコードを動かす仕組みです。そのおかげで、よくあるサーバーレスの「久しぶりにアクセスしたときだけ遅い」感じがかなり薄いです。 このあたりは、Azure Functions や Lambda みたいなサーバーレスを触ったことがあると違いが分かりやすい気がします。 個人サイトだとアクセス数は多くないので、コールドスタートっぽい遅さは地味に気になります。その点で、Workers の体感の軽さはかなり好印象でした。 ## 設定が見通しやすい このサイトの構成は、かなりの部分が `wrangler.jsonc` 一枚に集約されています。 ルーティング、静的アセット、デプロイ先など、見るべき場所が比較的少ないです。 クラウドの管理画面をいろいろ開いて、「結局どこで何を設定していたっけ」となることはよくありますが、個人サイトではそこまでしたくありません。 その点、Workers は設定の見通しが良くて、個人開発にはちょうどいいと感じました。 ## 無料枠は噂どおり手厚い Workers の無料枠は 1 日あたり 10 万リクエストまで、と太っ腹なのですが、それ以前に **静的アセットへのリクエストはこのカウント対象外(無料・無制限)** だったりします。 このサイトはページも CSS も画像もほとんどが静的アセットなので、普通に読まれている分にはリクエスト枠がほとんど減りません。枠を消費するのは、sitemap やリダイレクト、エラーページのように Worker が実際に動くときだけです。個人ブログの規模なら、まず使い切れないと思います。 アクセス解析も Cloudflare Web Analytics を使えます。 Google Analytics ほど細かく見るというより、ページビューや流入元、表示速度まわりをざっくり見る用途ならこれで良さそうです。個人サイトでそこまで細かい分析をするつもりもないので、今のところは Cloudflare Web Analytics で十分かなと思っています。Google Analytics と違って外部スクリプトを読み込まないのもいいですね。 ## フレームワークは Hono にした このサイトのアプリ部分は [Hono](https://hono.dev/) という Web フレームワークで書いています。 最初は Cloudflare Workers と相性が良さそう、くらいの理由で選びました。実際に使ってみると、かなり薄く書けて気持ちいいです。 このサイトでは、記事を Markdown で書いて、それを読み込んで HTML にして返す、という構成にしています。React や Next.js を使うほどではないけれど、素の文字列連結で HTML を書くのもつらい。そういう温度感に Hono の JSX がちょうどよかったです。 Hono は作者が日本の方らしいです。 Hono のそもそもの始まりが「Cloudflare Workers でアプリを書きたいけれど、しっくりくるフレームワークが無かったから自分で作った」という経緯らしく、Workers との相性が良いのも納得です。名前の Hono(炎)も日本語の「ほのお」から来ていて、ロゴはそのまま🔥。 こういう軽量なフレームワークが世界中で使われていて、それを自分のサイトでも使っているというのは、少し嬉しいところですね。 技術的にも、Hono は Web標準の API をベースにしているので、Cloudflare Workers だけでなく、Deno、Bun、Node.js などでも動かせるようです。今後どこかで動かす場所を変えたくなっても、ある程度逃げ道がありそうなのも良いです。 ## さいごに Cloudflare Workers の身軽さは個人開発レベルのちょっとしたブログやツールを置く場所としてぴったりで、当面このサイトはここで育てていくつもりです。 Cloudflare Workers は無料で使えるのでぜひ使ってみてください。 --- # はじめまして URL: https://satotek.dev/posts/first-post Date: 2026-06-07 Category: 日常 Tags: 雑記 はじめまして。nosuke と申します。 最近 Web まわりの技術に触れることが多くなってきたので、せっかくなら自分のサイトを立ててみようと思い、作りました。学生時代の知人の「一人一つはドメインを持つべき」という過激思想に洗脳され、すでにドメインを持っていた、というのも理由のひとつです。 ## このサイトについて このサイトは Cloudflare Workers でホストし、Hono(JSX)で構築しています。Cloudflare の無料枠の手厚さはありがたいですね。どうやって成り立っているのか、永遠の謎です。 記事はすべて Markdown で書いていて、ビルド時に静的な HTML にしてから配信しています。表示はできるだけ速く、書くのは気楽に。そんな方針で、少しずつ手を入れていこうと思っています。Astro などを使うのが一般的だと思いますが、そのような本格的な Web フレームワークは使いたくないな、という無駄なこだわりです。 ちなみに、ヘッダーのアニメーションは Anthropic のホームページのオマージュです。また、このサイトにはちょっとした隠し要素も2つ仕込んでいます。よかったら探してみてください。(ヒント:ブラウジングしてるとたまに遭遇するアレ) ## 趣味とこれから書くこと 趣味はいろいろあって、たとえば次のようなものです。 - 旅行 - 写真 - 読書 - ガジェットいじり - 技術系の話 こうした内容を中心に、気が向いたときに記事を書いていければと思っています。 ## 写真のこと 特に写真は、せっかく撮っても HDD の肥やしになるばかりで、ほとんど活用できていません。その供養もできたら、というのもこのサイトの目的のひとつです。 実は、写真を久々に Instagram に上げようかと考えていたところ、気に入っている写真が横写真ばかりで、少し相性が悪いなと感じていました。その点、Web サイトなら横写真も自然に見せられそうだなと思った次第です。それもこのサイト制作に至った動機の一つです。 とりあえず、お気に入りの一枚を貼っておきます。 ![虚像(富士山)](https://img.satotek.dev/first-post/DSC_7242.webp "虚像") ## さいごに そんなゆるい感じのサイトですが、今後ともよろしくお願いいたします。