Claude Code スキルで 3 媒体投稿を 3 リポ構成にした設計録
note・Qiita・Zenn の 3 つの記事プラットフォームに、テーマを 1 つ渡すだけで下書きまで自動で作って投稿してくれる。Claude Code の「スキル」(Claude に決まった手順を読み込ませて挙動を上書きする小さな手順書のこと)として組み上げた、個人用パイプラインの設計録です。
最初は note 専用のつもりで作っていました。媒体を増やすたびに「投稿の仕組みが媒体ごとに違う」「設定の置き場所」「他の人にも使ってもらおうと思ったときに何が起きるか」といった問題が次々に顔を出し、最終的には 3 つのリポジトリ(以下リポ)に役割を分けて配布する形で落ち着いています。
┌─────────────────────┐
│ Logic repository │
│ MY_NOTE_WRITER_HOME │
│ scripts/ │
│ skills/ │
│ docs/ │
└──────────┬──────────┘
│ reads
▼
┌────────────────────────────────┐
│ Workspace repository │
│ MY_NOTE_WRITER_WORKSPACE │
│ data/articles/<YYYY-MM>/*.md │
└────────────────────────────────┘
│ Zenn のみ:
│ 変換後 markdown を export
▼
┌─────────────────────┐
│ Zenn deploy repo │
│ ZENN_REPO_PATH │
│ articles/<slug>.md │
└─────────────────────┘
この記事は、3 リポ構成にたどり着くまでに何を捨てて何を選んだかを中心に、スキルを自分で書いてみたい人向けの記録としてまとめます。媒体ごとの実装の細部(note は Playwright というブラウザ自動操作ツール、Qiita は HTTP API、Zenn は GitHub 連携)はあくまで道具立てで、主題は 「スキルをどう設計するか」と「決定の根拠」 です。
メタな補足を 1 つ。この仕組み自体は、/grill-with-docs と /write-a-skill という 2 つのスキルを多用して組み上げました。前者は、書きかけの設計案に対して 1 問ずつ質問を投げて詰めてくれて、合意した用語をその場で CONTEXT.md(プロジェクトの用語集)と ADR(Architecture Decision Record、設計判断を 1 件 1 ファイルに残す形式)に書き戻してくれる対話スキルです。後者は、新しいスキルの SKILL.md(スキルの中身を書く文書)を所定の書式どおりに保ってくれるメタスキルです。両者の概念解説は別記事に詳しく書いたので、本記事では「この決定はそのセッションで詰めた」という事実だけを都度添えます。
一番最初に作ったスキル(note-write)
すべての始まりは note.com 専用のパイプラインでした。テーマを渡すと、リサーチ → 記事構造の設計 → 執筆 → 静的チェック → 下書き投稿 → 投稿後の検証、という 6 つの段階(以下「フェーズ」と呼びます)を順に回して 1 本の下書きを作ってくれる、/note-write という Claude Code スキルです。
スキルは 3 つの層に分けて書きます。
-
.claude/commands/note-write.md── 「/note-writeと打ったら何が起きるか」だけを書いた薄い入口 -
skills/note-write/SKILL.md── 各フェーズで Claude が何をすべきかを書いた手順書(現在 247 行) -
scripts/*.js── 手順書から呼ばれる Node.js のスクリプト群。publish.jsは投稿、lint-article.jsは静的チェック、など
/write-a-skill という別のスキルが「SKILL.md は description フィールド(他のスキルがこのスキルを呼ぶか判断するための 1 文)を 1024 字以内に収め、本体が 100 行を超えたら参考資料を別ファイルに分割」と決めているので、それに従って SKILL.md は手順だけにしぼり、コードの中身は scripts に押し出しました。
note.com には公式の API がないため、note への投稿だけは Playwright というブラウザを自動操作するツールでブラウザを起動し、note エディタに本文を貼り付けて下書き保存します。ここまでは普通のブラウザ自動化ですが、note 特有の事情で 「投稿後の検証」 という独自のフェーズが追加で必要になりました。
note エディタは ProseMirror という JavaScript 製のリッチテキストエディタで動いていて、Markdown を貼り付けたあと、カーソル位置やキーボード入力モード(IME)の状態によってブロックの構造が壊れることがあります。とくに箇条書きと見出しの境界、ブロックとブロックの間に何かを差し込もうとした時に再現性が出ました。投稿後の検証フェーズは、投稿が終わった note ページを開いてページ構造を取得し、それを期待していた Markdown と比較して、差分のレポートを出します。Qiita と Zenn は API のレスポンスや git の差分で投稿結果が分かるので、このフェーズは持ちません。
note-write が一番最初の実装だったぶん、フェーズの発想と「複雑な処理は scripts 側に押し出して、SKILL.md は『何を判断してどこに渡すか』だけに専念させる」という基本方針はここで確立しました。
媒体を増やして見えた共通フレーム(Qiita)
次に Qiita を足しました。Qiita には公式の HTTP API があり、POST /api/v2/items というリクエストを 1 回投げるだけで記事を作れます。Playwright を起動してブラウザを動かす必要はありません。
ところが、ここで「投稿の仕組みが媒体ごとに違うのに、スキルから見ると同じに見せたい」という設計の問題が出てきました。note は publish.js がブラウザを動かす、Qiita は publish-qiita.js が HTTP リクエストを投げる。中身はまったく別物ですが、SKILL.md からは「Phase P(Publish フェーズ)で publish スクリプトを呼ぶ」という同じ書き方ができるようにしたい。
そこで決めたのが、CLI の動詞を <動詞>-<媒体名>.js で揃えるという命名規約です。
| 動詞 | note | Qiita | Zenn(後述) |
|---|---|---|---|
| lint(静的チェック) | lint-article.js |
lint-qiita.js |
lint-zenn.js |
| publish(投稿) | publish.js |
publish-qiita.js |
publish-zenn.js |
中身が API リクエストか、ブラウザ操作か、git の push かは動詞で隠してしまい、運用者(と Claude)が「publish」という単語を共通の語として使えるようにする、という発想です。
このとき、各スキルのフェーズ構造も R / S / W / L / P で揃いました。Qiita は API のレスポンスで投稿結果が分かるので、note にあった「投稿後の検証フェーズ」は持ちません。フェーズの「ある / ない」で媒体ごとの違いを表現する設計が、ここで効き始めました。
Zenn を入れたら別種のリスクが出てきた
3 媒体目に Zenn を足したとき、それまでとは違う種類のリスクが顔を出しました。
note と Qiita のスクリプトは「自分のローカル PC から、外部のサービスに HTTP やブラウザ操作で送る」形でした。送り先は外部サービスの中で、こちらから直接触れない場所にあります。一方 Zenn の投稿は GitHub 連携の仕組みで、Zenn 側で連携先として登録された GitHub リポジトリの articles/*.md を Zenn が読み取り、自動で公開してくれます。つまり Zenn 用のスクリプトは、自分のローカルにある「もう 1 つの git リポジトリ」(以下、Zenn 連携リポ)に対して git add && git commit && git push を走らせる形になります。
外部サービスへの送信なら、間違っても多くの場合「下書きが 1 件増えるだけ」で済みます。ところが、Zenn 連携リポに意図しない commit を 1 度 push してしまうと、Zenn の公開履歴が汚れたうえに、Zenn 側の API から削除する手段は提供されていないので、復旧が手動になります。 うまく行ったときの利益と、壊れたときの復旧コスト が大きく食い違う、というのがこの非対称さの正体です。だから、Zenn まわりだけは防御を 1 段濃くしました。
/grill-with-docs を起動して「Zenn 連携リポを壊さないために何を確認すべきか」を 1 問ずつ詰めた結果、ADR-0001 として 5 つのガードに分解されました。
| # | ガード | 何を防ぐか |
|---|---|---|
| 1 |
.git の存在チェック |
ZENN_REPO_PATH が空、または別物のディレクトリだった事故 |
| 2 | 未 commit の変更がないか確認(--allow-dirty で明示許可) |
別作業中の変更を一緒に commit してしまう |
| 3 | 現在のブランチ名を metadata に記録 | 後で「どのブランチで commit したか」追えなくなる |
| 4 | push は git push origin HEAD で固定 |
git の設定差で、別ブランチに push される |
| 5 | commit は対象ファイルを明示する(git commit -- <file>) |
別の変更を一緒に commit してしまう |
5 つのガードがあれば「壊さない」のですが、Zenn の公開履歴を汚す本質的なリスク(よくあるのは、誤字 / published_at の消し忘れ / 連携対象でないブランチへの push)はまだ残ります。--publish --commit --push を 1 行で叩くと「Markdown 確定 → 公開」が一瞬で走ってしまうので、ここに対しては ADR-0002 で 「2 段階で進める」 を既定にしました。
## 1. 既定: ファイルの書き出しだけ(連携リポにも push しない)
publish-zenn.js --article <path>
## 2. 下書きを連携リポに push(Zenn 側で「下書き」として表示)
publish-zenn.js --article <path> --commit --push
## 3. 公開を 2 段階で(commit までで止める。後で手動で push)
publish-zenn.js --article <path> --publish --commit
## 後で連携リポに移って: git diff HEAD~1 && git push origin HEAD
## 4. 公開を 1 コマンドで通す(明示同意フラグが追加で必要)
publish-zenn.js --article <path> --publish --commit --push --yes-publish-and-push
--push 単体は下書きの push なのでガード対象外、--publish(本公開)が絡むときだけ追加のフラグ --yes-publish-and-push を要求する、という非対称な設計が肝です。「曖昧な依頼や初回テストは 2 段階で守り、明示同意のあるときだけ 1 コマンドで通す」という方針を、CLI のフラグの組み合わせとして書き下した形です。
3 リポジトリ配布アーキテクチャ
3 媒体が動く状態になり、次に頭を悩ませたのが「この仕組みを別の repo からも呼びたい」と「もし将来、人に渡すならどうなるか」という 2 つの問題でした。
実体験として強い動機の方は、自分の調べ物用 repo や記事素材用 repo を別に持ったときに発生します。/note-write を起動するときの実行ディレクトリが my-note-writer に固定されていると、自分の記事 markdown は my-note-writer 配下の data/articles/ に書かざるを得ません。すると、scripts(配布元の更新対象)と個人記事(自分が commit する成果物)が、同じ repo の中に同居することになります。これは、もし将来 my-note-writer を template として誰かに配布したとき、template 更新と個人 commit が衝突する形を意味します。
/grill-with-docs を起動して、この問題に対する代替案を全部書き出しました。ADR-0004 にまとめた候補(Considered Alternatives と呼びます)は 4 つあります。
| 案 | 発想 | 却下理由 |
|---|---|---|
| α: 実行ディレクトリを my-note-writer に強制 | scripts と data が常に同じ repo で完結する前提 | 他 repo から /note-write を呼べない |
| γ: スキルファイルの symlink から逆算 |
~/.claude/skills/<name> というシンボリックリンクの先のパスを取り出して、そこから repo の場所を割り出す |
配布された全員に symlink 運用を強制することになり、設定が暗黙的になりすぎる |
δ: bin/mnw という shell スクリプトを PATH に置く |
mnw <動詞> という形で CLI 体系を統一する |
この段階で CLI 体系を決めるとスコープが広がりすぎるので、将来の余地として残す |
| シングル repo 集約 | scripts と data を 1 つの repo にまとめて配布 | 配布された人が記事 commit するたびに template 更新と衝突する |
4 案を全部 No で否定すると残るのが、 役割で repo を分けて、それぞれの位置を環境変数で指定する 案です。これが採用案で、CONTEXT.md にも「3 リポジトリモデル」という用語として登録しました。
3 つの repo は完全に独立で、git submodule(他の repo を子として埋め込む仕組み)や symlink(別ファイルへのリンク)で物理的につなぐことはしません。my-note-writer の scripts が「同じ実行の中で、2 つ以上の repo を読み書きする」ことで、論理的にだけつながる構造です。
環境変数の解決順は、scripts 共通の _env.js というヘルパーで次のように決めています(優先度の高い順)。
| 変数 | 解決順 |
|---|---|
MY_NOTE_WRITER_HOME |
shell の環境変数 → script 自身の置き場から逆算(フォールバック) |
MY_NOTE_WRITER_WORKSPACE |
shell の環境変数 → $MY_NOTE_WRITER_HOME/.env → 実行時のディレクトリ(フォールバック) |
ZENN_REPO_PATH |
shell の環境変数 → $MY_NOTE_WRITER_HOME/.env
|
実行時のディレクトリにある .env は読みません。配布された人の workspace repo に紛れた .env を誤読するのを防ぐためです。この種の細かい安全策も、/grill-with-docs セッションで「もし、こうしたら何が壊れるか」と詰めた結果として残っています。
スキル設計の原則(zenn-write を例に)
ここでスキルの内部構造を、zenn-write/SKILL.md (156 行)を題材に見ていきます。3 媒体のうち最も新しく、フェーズ数も最少(R / W / L / P)で、3 層構造が一番見やすいスキルです。
frontmatter ── スキルが起動する条件
frontmatter とは、Markdown ファイルの先頭にある設定情報のことです。--- で囲まれたブロックに、スキル名と説明文を書きます。
---
name: zenn-write
description: Use when the user invokes /zenn-write, asks to generate a Zenn article, lint Zenn Markdown, publish to a Zenn GitHub-connected repository, or schedule a Zenn article.
---
description は、他のスキルや Claude が「このスキルが今の状況に合うか」を判定する唯一のフィールドです(/write-a-skill は 1024 字以内に収めるよう求めます)。スキルの起動条件はここで全部書き切る必要があります。
Phases リスト ── 全体の骨格
## Phases
### Phase R: Research(`/research-source` に委譲、ADR-0006)
### Phase W: Write
### Phase L: Lint
### Phase P: Publish
R(Research、リサーチ) / W(Write、執筆) / L(Lint、静的チェック) / P(Publish、投稿)の 4 つだけ。note の「投稿後の検証フェーズ」は持ちません(GitHub の差分で代用できるので)。各フェーズが「スキルから見える抽象的な単位」になっており、これが媒体間で共通の語として機能します。
Phase R ── 別スキルへの委譲
リサーチは /research-source skill に委譲する。直接 WebSearch を起動しない。
1. /zenn-write の引数から /research-source への引数を組み立てる
2. Skill ツール(別のスキルを起動するための仕組み)で /research-source を呼ぶ
3. 標準出力の最後にある BRIEF_PATH: <path> を捕まえる
4. 以降の Phase W では、そのパスにある brief.md を入力として使う
リサーチフェーズは「処理を書く」のではなく、別のスキルを呼ぶ薄いラッパー(包み)になっています。これが スキル同士の合成 で、ADR-0006 の決定事項です。
Phase L ── scripts への明示的な委譲
node "$MY_NOTE_WRITER_HOME/scripts/lint-zenn.js" data/articles/<YYYY-MM>/<slug>.md
SKILL.md には「lint-zenn.js を呼ぶ」と書かれているだけで、静的チェックの実装本体は scripts 側にあります。これが 複雑な動作は scripts に押し出す という基本方針です。
Phase P ── フラグの段階的な明示同意
## 既定: ファイルの書き出しだけ
publish-zenn.js --article <path>
## 下書きを Zenn 連携リポに同期(下書き UI に表示)
publish-zenn.js --article <path> --commit --push
## 公開を 2 段階で(commit までで止める)
publish-zenn.js --article <path> --publish --commit
## 後で手動で git push origin HEAD
## 公開を 1 コマンドで(明示同意フラグが追加で必要)
publish-zenn.js --article <path> --publish --commit --push --yes-publish-and-push
このフラグの段階構造が、前章で書いた「曖昧な依頼は 2 段階で守る、明示同意は 1 コマンドで通す」設計の本体です。
3 層が立ち上がる
5 つのブロックを縦に並べると、 slash command(薄い入口)→ SKILL.md(手順書)→ scripts(動詞) の 3 層が見えてきます。SKILL.md には「何を判断し、どこに渡すか」だけ書く。コードのロジックは scripts に集めて、scripts は 動詞 + プラットフォーム + フラグ で表現する。スキルが膨らまない理由はここにあります。
重複に気づいて切り出した
媒体が 3 つに揃った段階で、「同じものを 3 箇所に書いている」と気づく場面が 2 つ出ました。1 つはリサーチ処理、もう 1 つは tone-guide(文体・トーンの規範)です。
Phase R を /research-source に切り出す
note / Qiita / Zenn それぞれにリサーチフェーズ(テーマや URL からの構造化リサーチ)を持たせていたため、テーマ → Web 検索 → 構造化、というロジックが 3 箇所に書かれていました。長期的にはどこかで内容がズレていく形です。
ADR-0006 で、リサーチを独立スキル /research-source として切り出し、各執筆スキルのリサーチフェーズは「Skill ツールで /research-source を呼んで、その出力(BRIEF_PATH)を捕まえる」だけの薄いラッパーに置き換えました。新しいソースの種類(YouTube / web / theme / 複数ソースを混ぜたもの)を増やしたいときも、/research-source の中だけを直せばよくなります。
tone-guide を共通仕様にする
references/tone-guide.md は元々 note 専用に書かれていましたが、Qiita と Zenn が「参考にする」「base のみ参照」「上書きする」と異なる継承の形で流用しており、媒体に依存しない品質規範(誇張禁止 / 専門用語の補足 / 出典 URL)と、note 特化のルール(Bold は 1 段落 1 箇所まで / 表は禁止)が、1 ファイル内に混在していました。
ADR-0007 でこれを 2 層に分けました。
| ファイル | 内容 |
|---|---|
references/tone-guide.md |
媒体に依存しない品質規範(共通) |
references/note-compat.md |
note 特化(リズム技法、量的目安、表禁止) |
references/qiita-compat.md |
Qiita 特化 |
references/zenn-compat.md |
Zenn 特化(:::message の使い方、CJK 隣接 bold の注意点) |
3 つの SKILL.md は tone-guide.md を必ず参照し、加えて自分の媒体の *-compat.md を参照します。「note の tone-guide では X だが Zenn では Y」という上書き記述は SKILL.md から消えました。責務を 1 箇所に集めて、差分は別ファイルで分離する、という構造です。
ADR を test で守る
ADR(設計判断記録)を書いただけでは「願望」にしかなりません。半年後、ADR の存在を忘れた状態でコードを直したら、ガードがいつのまにか消えてしまう、ということが起こります。これを防ぐために、docs/adr/ に書いた決定は、可能な限り test/ 配下のユニットテストで強制しています。
| 決定 | 守るテスト |
|---|---|
| ADR-0001 dirty チェック必須 |
--allow-dirty なしで未 commit な repo に書こうとすると ERROR で停止する |
| ADR-0001 commit パス明示 |
git commit -- <file> の引数の並びを、テストの引数記録から検証する |
ADR-0002 --yes-publish-and-push 必須 |
--publish --push を --yes-publish-and-push なしで叩くと ERROR になる |
| ADR-0002 schedule + push の条件 |
--schedule と --push の組み合わせ条件をテスト |
execGit のような git コマンドの呼び出しを差し替える「fake」(ダミー実装)は、本物と同じ引数の形で書いています。引数 / 実行ディレクトリ / 順序まで全部記録できる形にしないと、実行ディレクトリの検証が落ちて、ADR の意味が消えてしまいます。
ADR を緩める / 廃止するときは、対応するテストを 先に 消します。順序を逆にすると、緩めた事実がテスト側に残らないからです。
これからスキルを作る人への 3 つの学び
ここまでの設計プロセスから、スキルを自分で書きたい人の頭の中の 3 つの問い「スキルとは何か / どう動かすか / どう育てるか」に対応する 3 つの学びを残しておきます。
学び 1. スキルは 3 層で書く
slash command は薄い入口、SKILL.md は手順書、scripts は動詞です。SKILL.md には「何を判断し、どこに渡すか」だけ書き、ロジックは scripts に押し出します。本リポジトリでも、note-write/SKILL.md が 247 行と一番大きく、zenn-write/SKILL.md は 156 行と最少です。後者のほうがフェーズ数が少なく、scripts 側に複雑さが押し出されている分、SKILL.md は薄く保てる、という関係性が数字に出ます。/write-a-skill の規則「100 行を超えたら REFERENCE.md / EXAMPLES.md(参考資料 / 例)に分割」もこの方向の指針です。
学び 2. フェーズで作業を分解し、明示同意フラグで停止点を作る
R / W / L / P のようなフェーズの境界で「Lint で必ず止める」「Publish はデフォルトで下書き」と決めるだけで、Claude も人間も検査ポイントを共有できます。フェーズの中でも、危険な操作には --yes-publish-and-push のような段階的なフラグを要求して、リスクの偏り(壊れた時に誰がコストを払うか)を CLI の階層として守ります。「曖昧な依頼や初回テストは 2 段階で守り、明示同意のあるときだけ 1 コマンドで通す」という方針を、フラグの組み合わせとして書き下した形です。
学び 3. スキルは合成し、共通仕様は外に出す
重複を見つけたら、サブスキルに切り出すか(/research-source)、共通仕様を別ファイルに分離する(tone-guide.md + *-compat.md)。SKILL.md には差分だけ残します。1 つのスキルが太り続けると Claude も人間も把握できなくなるので、「責務を 1 箇所に、差分を分離する」を明示的な意識として持っておくと、スキルが増えても秩序が保たれます。
おわりに
ここまでの 3 リポ構成は、「配布する側の更新と、使う側の記事 commit が衝突しないこと」と「任意の repo から /note-write 等を呼べること」を同時に満たすために、環境変数ベースで位置を解決する形に落ち着きました。
スキルを自分で書きたい人にとっての要点は、 3 層で書く / フェーズと明示同意フラグで停止点を作る / 合成と差分分離で育てる の 3 つです。1 つだけ覚えるなら、最初の「3 層で書く」だけで、SKILL.md の肥大化はかなり防げます。
この記事自体も /zenn-write で書かれて Zenn 連携リポに push されたものです。読んでいるもの自体が、ここまで書いた仕組みの出力 ── というのが、このパイプラインの一番気に入っているところです。
Discussion