カレンダーは Calendar.app 経由で書いて、API で読む。日程候補を avail 60 の 1 コマンドで出すまで
外部の方から「打ち合わせの候補日をいくつか出してください」と言われたとき、いまはこれを打って、出てきた文面をそのまま貼って返しています(下の出力は README に載せている例です。既定では明日から 7 日間を探します)。
$ avail 60
以下の時間帯の中で、60分ほど調整可能です。
・9月8日(火)10:00〜12:00
・9月9日(水)11:30〜15:00
・9月10日(木)16:30〜18:00
これを支えているのは自作の CLI 2 本です。sync-calendar がクライアント先のカレンダーの予定を自社のカレンダー 1 つへ書き写し、avail がその 1 つを読んで候補を出します。
同じ Google カレンダーを相手にしているのに、2 本は Google への入り方が違います。書く側は Mac の Calendar.app を経由し、読む側は Google Calendar API を calendar.freebusy スコープだけで叩いています。分けたのは認証の事情からで、1 つのカレンダーへ寄せておいたおかげで、読む側は最小の権限のまま 1 コマンドにできました。
この記事では、そう分けた理由と、読む側に書き込み機能を足さなかった判断、2 本の継ぎ目で起きている弱点を書きます(2026 年 10 月時点。sync-calendar v0.2.0 / avail 0.1.0、macOS 15.6.1、icalBuddy 1.10.1)。
書く側と読む側で、Google への入り方を分けた
| sync-calendar | avail | |
|---|---|---|
| やること | 予定を集約先へ書き写す | 集約先から空き時間を読み出す |
| 経路 | icalBuddy で読み、osascript で Calendar.app に書く | Google Calendar API(FreeBusy) |
| 認証 | Calendar.app に登録済みのアカウントを使う | 自社 Workspace の OAuth アプリ |
| 言語 | Python | Rust |
| 公開 | public | private(この記事でもコードは出しません) |
分けた理由は、それぞれの側でどこに OAuth アプリを作れるかでした。書き写す元はクライアント先のカレンダーで、読む先は自社のカレンダーです。
2 本は同じ集約カレンダーでつながっています。sync-calendar には Calendar.app 上のカレンダー名を、avail には Google のカレンダー ID を指定していて、手元ではこの 2 つが同じ文字列です。真似るなら、Calendar.app 上の名前と Google 側の ID が同じカレンダーを指すように合わせます。avail が参照するカレンダーを 1 つに絞れているのは、sync-calendar が先に寄せているからです。
書く:クライアント先を読むための OAuth アプリを作れないので、Calendar.app に乗る
集約先は自社のカレンダーなので、書き込むだけなら自社の OAuth アプリでもできます。引っかかるのは読む元のほうで、クライアント先の予定は先方の Google Workspace にあります。私の立場では、先方の Workspace に自作ツール用の OAuth アプリを作れません。 自社で作った OAuth アプリも Internal にしてあり、自社の組織のアカウントしか認可できないので、クライアント先のアカウントでは使えません。
一方で、Mac の Calendar.app にはクライアント先のアカウントも自社のアカウントもすでに登録してありました。そこで sync-calendar は、予定の読み取りを icalBuddy に、書き込みを osascript 経由の Calendar.app に任せています。認証は Calendar.app が済ませているので、ツールの側で OAuth を扱わずに済みます。
設定は TOML です。クライアントごとにプレフィックスを決めておくと、集約先には [acme] 定例 のような形で入ります。README では、集約先を「個人」、クライアント先を「会社」と呼んでいます。
[general]
days_ahead = 14
[personal]
calendar = "aggregate@example.com"
[[work]]
calendar = "acme@example.com"
prefix = "acme"
reverse_sync = true
[reverse]
title = "予定あり"
--reverse を付けて実行すると、順方向の代わりに逆方向の同期をします。対象は reverse_sync = true のクライアントです。集約先の予定を、ほかのクライアントの予定も含めて、クライアント先のカレンダーへ「予定あり」というタイトルで入れます。先方から見ると、私が埋まっている時間だけが分かります。ただし、そのクライアントから写した予定([acme] 付き)まで送り返すと循環するので、それだけは除外しています。なお README には [reverse] prefixes という設定が載っていますが、いまの実装では読み込むだけで使っていません。
定期実行は launchd で、1 日 4 回です。順方向が成功したら、続けて --reverse で逆方向を回しています。Slack には、コピーが 1 件以上あったときか、書き込みの失敗を検出したときだけ通知します。読み取り(icalBuddy)が失敗したときは予定 0 件として扱われるので、いまの通知では拾えません。
もとは 2025 年 10 月に dotfiles へ置いた shell スクリプトで、2026 年 6 月に Python のパッケージへ書き直しました。書き直すときも、Calendar.app 経由という経路は変えていません。先方の Workspace に OAuth アプリを作れない事情が、そのまま残っているためです。README の「将来の拡張」には Google Calendar API 対応を挙げていますが、クライアント先のカレンダーをどう読むかの答えはまだありません。
読む:自社の Workspace なら OAuth アプリを作れる。スコープは calendar.freebusy だけ
読む先の集約カレンダーは自社の Workspace にあります。ここなら自分で OAuth アプリを作れるので、avail は Google Calendar API を直接叩いています。
OAuth アプリの User Type は Internal にしました。組織内でしか使わないので、外部公開の審査が要りません。
参考: Manage App Audience - Google Cloud Platform Console Help
Google のドキュメントでは、calendar.freebusy の説明は「View your availability in your calendars」です。予定のタイトルも参加者も読めず、分かるのは埋まっている時間帯だけです。
参考: Choose Google Calendar API scopes
候補を出すだけなら、それで足ります。API の FreeBusy は、カレンダーごとに「busy として扱う時間帯の一覧」を返します。
参考: Freebusy: query
avail は、空白からバッファ・勤務時間外・土日祝を削って候補にする
FreeBusy の空白をそのまま並べても、候補としては使えません。10:00〜11:00 の予定のすぐあとの 11:00 から打ち合わせを入れると、移動も準備もできません。
avail は、空白から次のものを削ってから候補を出します。
- 既存の予定の前後 30 分(バッファ。設定で変えられる)
- 勤務時間の外(既定は 10:00〜18:00)
- 土日と祝日(祝日は自作の 祝日 API に問い合わせる)
- 所要時間に満たない隙間
予定に挟まれた区間は、こうなります。
既存予定1: 10:00〜11:00 → 09:30〜11:30 が使えない
既存予定2: 13:00〜14:00 → 12:30〜14:30 が使えない
挟まれた空き: 11:30〜12:30
avail 60 ならこの 11:30〜12:30 も候補に入り、avail 90 では外れます(この日は 14:30〜18:00 も空いているので、そちらはどちらでも出ます)。
空いている時間帯は、所要時間ごとのコマには割りません。10:00〜12:00 が空いていて所要時間が 60 分なら、10:00〜11:00 と 11:00〜12:00 の 2 行ではなく、10:00〜12:00 の 1 行で返します。冒頭の出力が「以下の時間帯の中で、60分ほど調整可能です」で始まるのは、この範囲のどこかで 60 分、と読んでもらうためです。
祝日 API に届かなかったときは、候補を出さずに止まります。祝日を判定できないまま候補を出すと、祝日を候補に入れて送ってしまうかもしれないからです。祝日 API の作りは 消費者が LLM な API を設計する に書きました。
--copy を付ければクリップボードにも入るので、メールやチャットにそのまま貼れます。出力に入るのは日時だけで、予定の中身は入りません。そもそも読めないスコープで取っているからです。
予定を書き込む機能は、いまは足していない
候補を出して送ったあとは、提示した枠をカレンダーに仮押さえしておきたくなります。avail から直接押さえられれば 1 往復減ります。
それでも、いまは足していません。予定を書き込めるスコープを Google のドキュメントで見ると、既存の集約カレンダーに書けるものはどれも予定を見る権限込みです。
| スコープ | ドキュメントの説明 |
|---|---|
calendar.events | View and edit events on all your calendars. |
calendar.events.owned | See, create, change, and delete events on Google calendars you own. |
calendar.app.created | Make secondary Google calendars, and see, create, change, and delete events on them. |
calendar.app.created はアプリが作ったカレンダーにしか書けないので、既存の集約カレンダーには使えません。仮押さえ専用のカレンダーを avail に作らせればこれで足りそうですが、9 月に検討したときはこの手に気づいておらず、まだ検討していません。残りの 2 つは、書けるようになると同時に予定の中身も見えるようになります。実装で中身を取りにいかなければ読まずには済みますが、「予定の中身を読む権限を持たない」という avail の前提は崩れます。
それに、9 月の時点で仮押さえは Claude Code と Google カレンダーの MCP に頼めていました。avail の出力を渡して「この 3 枠を仮押さえして」と言えば済みます。広い権限は MCP の側にあるので、権限が手元から消えるわけではありません。avail には読むための権限だけを持たせておく、という線引きです。
もし作るなら、仮押さえだけを別のサブコマンドにして、そのときだけ追加で認可する形にするつもりです。仮押さえは作るより消すほうが面倒です。先方が 1 枠を選んだら残りを消す必要がありますし、押さえた枠は次の avail 60 で busy になるので、先方に同じ候補を出し直したいときに見えなくなります。作るなら、押さえた枠の一覧と一括削除までをセットにします。
集約カレンダーと実際の予定がずれると、候補もずれる
avail が見ているのは集約カレンダーだけです。集約先がクライアント先の実際の予定とずれていると、候補もそのままずれます。実際に踏んだものが 2 つ、コードを読んで気づいたものが 2 つあります。
sync-calendar は追加しかしないので、古いコピーが残る
クライアント先で予定が動いたり消えたりしても、sync-calendar は集約先のコピーを消しません。
重複の判定は「タイトルと開始時刻の完全一致」です。Python が組み立てた AppleScript の中で判定しています。
set existingEvents to (every event of targetCal whose summary is "..." and start date is startDateTime)
そのため、開始時刻が 11:00 から 12:00 に動いた予定は別物として追加されます。9 月には、同じタイトルの予定が 11:00 と 12:00 に 2 件並び、カレンダーを見てもどちらが生きているのか分かりませんでした。
古いコピーも集約先では予定なので、avail から見れば busy です。その時間帯は、実際には空いていても候補に出ません。
反対に、埋まっているのに候補に出てしまうずれも、コードを読む限りありそうです。順方向の判定は終了時刻を見ていないので、10:00〜11:00 の予定が 10:00〜12:00 に延びても、集約先は 10:00〜11:00 のままのはずです。そうなると avail は 11:30 から候補に入れますが、実際には 12:00 まで(バッファ込みなら 12:30 まで)埋まっています。試してはいません。
直すなら、写すときに元の予定の識別子を説明欄へ埋めて、それで突き合わせる形を考えています。写した予定には説明欄に [カレンダー同期] と入れてあるので、ツールが作った予定だけを区別することはいまでもできます。
同期の範囲と間隔の外は、集約先に入っていない
これもコードと設定から分かることで、踏んではいません。手元の sync-calendar は、当日から 14 日先までを 1 日 4 回写しています。avail の既定の検索範囲は明日から 7 日間なので普段は収まります。ただ avail 60 --days 30 のように 14 日より先を探すと、クライアント先の予定がまだ集約先に無い日も候補に出るはずです。同期と同期の間に入った予定も、次の同期までは avail から見えません。
クライアント先から写した終日予定で、その日が丸ごと消える
avail は FreeBusy の判定にそのまま従っていて、終日の予定を特別扱いしていません。FreeBusy は、予定ごとの「予定あり / 空き」の設定を見て busy を決めます。API では transparency の opaque / transparent がこれに当たります。API で予定を作るときの既定値は opaque、つまり予定ありです。
“opaque” - Default value. The event does block time on the calendar. This is equivalent to setting Show me as to Busy in the Calendar UI.
参考: Events - Google Calendar API
集約先の終日の予定が予定ありになっていると、その日は丸ごと busy として返ってきます。その日に実際は空きがあっても、候補には 1 枠も出ません。私が踏んだのは、sync-calendar がクライアント先から写した終日の予定でした。写したのは Calendar.app 経由なので API の既定値がそのまま効いたわけではありませんが、結果として予定ありで入っていました。終日の予定を写すようになったのは v0.2.0 からです(コミットは 2026 年 6 月)。
FreeBusy が返す busy の区間には、それが終日の予定によるものかどうかの情報がありません。なので avail の側で終日の予定だけを無視するには、FreeBusy とは別の取り方が要りそうです。カレンダー側でできそうなのは、集約先にあるその予定を「空き」にしておくことです。sync-calendar は追加しかしないので、クライアント先で変えても集約先には届きません。この回避策はまだ試していません。
まとめ
- 日程候補は
avail 60で出し、出力をそのまま先方へ貼っている。支えているのは書く側の sync-calendar と読む側の avail で、2 本は同じ集約カレンダーでつながっている - 書く側(sync-calendar)は、私の立場ではクライアント先を読むための OAuth アプリを作れないので、読み書きとも Calendar.app に登録済みのアカウントへ乗った(
icalBuddy+osascript) - 読む側は自社の Workspace なので OAuth アプリを作れる。スコープは
calendar.freebusyだけにして、予定の中身は読まない - 私の運用では、空白から予定の前後 30 分・勤務時間外・土日祝を除き、所要時間以上の区間だけを候補にしている
- 仮押さえの機能は足していない。既存の集約カレンダーに書けるスコープ(
calendar.events/calendar.events.owned)は予定の中身も見えるようになり、仮押さえは MCP に頼めていた。専用カレンダーを作るcalendar.app.createdの案は未検討 - 集約カレンダーと実際の予定がずれると、候補もずれる。踏んだのは古いコピーの残りと、写された終日予定でその日が消えたこと。コードからは、埋まっているのに候補に出るずれもありそう