Read OSS

Spec Kit のアーキテクチャ: CLI が AI 駆動開発をオーケストレートする仕組み

中級

前提知識

  • Python パッケージングの基本概念(pyproject.toml、wheel、エントリーポイント)
  • CLI フレームワーク(typer または click)の基礎知識
  • AI コーディングアシスタント(Claude、Copilot、Gemini)の概要理解

Spec Kit のアーキテクチャ: CLI が AI 駆動開発をオーケストレートする仕組み

AI コーディングアシスタントはそれぞれ独自の設定ディレクトリ、コマンド形式、指示の受け取り方を持っています。Claude Code、Copilot、Gemini CLI、Cursor など 20 以上のエージェントに対して構造化されたワークフローのガイダンスを提供しようとすると、フォーマット固有のファイルが爆発的に増えてしまいます。GitHub の Spec Kit はこの問題を単一の CLI ツール specify で解決します。共有のワークフローテンプレートセットを、25 以上のアシスタント向けのエージェント固有の指示へと変換するのです。この記事では、それを実現するアーキテクチャを解説します。

Spec Kit と Spec-Driven Development とは

Spec-Driven Development(SDD)は、仕様とコードの通常の関係を逆転させます。従来は仕様が実装のための一時的な足場として扱われてきましたが、SDD では仕様こそが主要な成果物です。コードは仕様の表現であり、その逆ではありません。

Spec Kit はこの哲学を 4 フェーズのワークフローとして具現化します。

flowchart LR
    A["/speckit.specify"] --> B["/speckit.plan"]
    B --> C["/speckit.tasks"]
    C --> D["/speckit.implement"]
    A -.->|"clarify"| E["/speckit.clarify"]
    E -.-> A

各フェーズは AI エージェントが実行するスラッシュコマンドです。specify コマンドは自然言語による機能説明を構造化された仕様に変換します。plan コマンドはその仕様を技術的な実装計画へと落とし込みます。tasks コマンドは計画を順序付きのタスクリストに分解し、implement がそれを実行します。AI エージェントはこれらのコマンドの読み手でもあり実行者でもあります。つまり、markdown ファイルそのものがプログラムであり、LLM がランタイムなのです。

specify CLI ツール自体はこれらのワークフローを実行しません。ツールが行うのは、開発者が使用する AI アシスタントに合った適切なファイルを、適切な形式でプロジェクトに用意する「ブートストラップ」です。25 以上の命令セットアーキテクチャを単一のソースからターゲットにするコンパイラのようなものと考えるとわかりやすいでしょう。

ディレクトリ構造とモジュールの役割

リポジトリは Python CLI パッケージと配布するコンテンツとを明確に分離した構成になっています。

ディレクトリ 役割
src/specify_cli/ Python パッケージ — CLI コマンド、integration システム、extension/preset マネージャー
templates/commands/ 9 つのスラッシュコマンドテンプレート(YAML フロントマター付き markdown)
templates/*.md ドキュメントテンプレート(spec、plan、tasks、constitution、checklist)
scripts/bash/ and scripts/powershell/ コマンドテンプレートから呼び出されるシェルスクリプト
extensions/git/ バンドル済み git extension — 5 つのコマンドと 18 のライフサイクルフック
presets/lean/ バンドル済み「lean」preset — 軽量なワークフローテンプレート
tests/ integration サブパッケージ構造を反映した pytest スイート
docs/ DocFX ドキュメントサイトのソース

src/specify_cli/ 以下の Python パッケージはコンパクトなモジュール構成です。

graph TD
    INIT["__init__.py<br/>(4,143 lines — CLI + TUI + orchestration)"]
    INT["integrations/<br/>__init__.py + base.py + 27 agent subpackages"]
    MAN["integrations/manifest.py<br/>(SHA-256 file tracking)"]
    EXT["extensions.py<br/>(ExtensionManifest, Registry, Manager)"]
    PRE["presets.py<br/>(PresetManifest, Registry, Manager)"]
    AGT["agents.py<br/>(CommandRegistrar — bridges extensions to agents)"]

    INIT --> INT
    INIT --> EXT
    INIT --> PRE
    EXT --> AGT
    PRE --> AGT
    INT --> MAN

すべては __init__.py を中心に展開します。integrations パッケージはエージェントの抽象化レイヤーを担当します。extensions と presets は並列のプラグインシステムで、どちらもエージェント固有の出力形式へのブリッジとして agents.py を使用します。

モノリシックな __init__.py — その意義

4,143 行に及ぶ src/specify_cli/__init__.py は、このプロジェクトの重力の中心です。ここには以下が含まれます。

  • main() エントリーポイントと Typer app の設定
  • すべての CLI コマンド: initcheckversionextensionpresetintegration
  • 進捗表示用 TUI コンポーネント StepTracker
  • インタラクティブな選択ウィジェット select_with_arrows()
  • 共有インフラのインストーラー(_install_shared_infra_locate_core_pack
  • wheel インストールとソースチェックアウトの両方に対応したアセット解決ロジック

この単一ファイル構成は意図的なトレードオフです。uv tool install でインストールして使う CLI ツールにとって、起動時間は重要な要素です。単一モジュールにすることで、import 時のファイルシステムのルックアップを減らせます。分解はこのファイルの周囲で行われます。integration のクラス階層は integrations/base.py に、manifest トラッキングは integrations/manifest.py に、extension/preset システムはそれぞれ独自のモジュールに切り出されています。

エントリーポイント自体は驚くほどシンプルです — __init__.py#L4139-L4143:

def main():
    app()

if __name__ == "__main__":
    main()

app オブジェクトは、ヘルプ出力の前に ASCII バナーを表示するカスタムの BannerGroup を持つ Typer インスタンスです — __init__.py#L301-L307:

app = typer.Typer(
    name="specify",
    help="Setup tool for Specify spec-driven development projects",
    add_completion=False,
    invoke_without_command=True,
    cls=BannerGroup,
)

ヒント: コードベースを初めて読む場合は、__init__.py 内の @app.command() デコレーターを検索してみましょう。すべての CLI コマンドがそこで定義されています。initextension addpreset list など、それぞれ独立したサブコマンドを担当する約 12 個が見つかるはずです。

モジュール依存グラフとブートストラップチェーン

ユーザーが specify init を実行すると、import チェーンが連鎖的に動き出し、コマンドロジックの最初の一行が実行される前に 25 以上の integration がすべて登録されます。その順序は次のとおりです。

flowchart TD
    A["specify (entry point)"] --> B["specify_cli:main()"]
    B --> C["__init__.py module loads"]
    C --> D["_build_agent_config()"]
    D --> E["from .integrations import INTEGRATION_REGISTRY"]
    E --> F["integrations/__init__.py loads"]
    F --> G["_register_builtins()"]
    G --> H["Imports 27 agent subpackages"]
    H --> I["_register() for each → populates INTEGRATION_REGISTRY"]
    I --> J["AGENT_CONFIG dict built from registry"]

核心となる関数は _build_agent_config() で、モジュールレベルで呼び出されます。

def _build_agent_config() -> dict[str, dict[str, Any]]:
    """Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
    from .integrations import INTEGRATION_REGISTRY
    config: dict[str, dict[str, Any]] = {}
    for key, integration in INTEGRATION_REGISTRY.items():
        if integration.config:
            config[key] = dict(integration.config)
    return config

AGENT_CONFIG = _build_agent_config()

つまり、__init__.py の読み込みが完了する前に INTEGRATION_REGISTRY が完全に構築されていなければなりません。これを実現するのが _register_builtins() です。この関数はすべての integration サブパッケージをアルファベット順にインポートし、各インスタンスを登録します。integrations/__init__.py の最終行で無条件に呼び出されます。

_register_builtins()

この「import 時に登録する」パターンにより、レジストリは常に完全かつ一貫した状態を保ちます。部分的に初期化された状態になるリスクがありません。ただし欠点もあります。構文エラーのある新しい integration を追加すると、CLI 全体が起動できなくなります。

agents.py モジュールは遅延初期化に近いパターンを採用しています。CommandRegistrar クラスは初回アクセス時にレジストリから AGENT_CONFIGS を構築し、モジュール読み込み中の循環インポートに対処するための try/except も備えています — agents.py#L30-L57

エアギャップ環境向けのバンドルと配布

エンタープライズ環境では、インストール中にインターネットへアクセスできないことがよくあります。Spec Kit はこの問題を Hatch の force-include 機能で解決します。すべてのランタイムアセットを Python wheel に直接バンドルするのです。

設定は pyproject.toml#L28-L45 にあります。

[tool.hatch.build.targets.wheel.force-include]
"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md"
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
"extensions/git" = "specify_cli/core_pack/extensions/git"
"presets/lean" = "specify_cli/core_pack/presets/lean"

ビルド時に Hatch はリポジトリの templates/scripts/extensions/presets/ ディレクトリを wheel 内の specify_cli/core_pack/ にコピーします。実行時には _locate_core_pack() がどちらのパスを使うかを解決します。

def _locate_core_pack() -> Path | None:
    candidate = Path(__file__).parent / "core_pack"
    if candidate.is_dir():
        return candidate
    return None
flowchart TD
    A["_locate_core_pack()"] --> B{"core_pack/ exists?"}
    B -->|"Yes (wheel install)"| C["Use specify_cli/core_pack/"]
    B -->|"No (source checkout)"| D["Fallback to repo root: templates/, scripts/"]

アセットを必要とするすべての関数 — _install_shared_infra()_locate_bundled_extension()_locate_bundled_preset() — はこのデュアルパスパターンに従います。リポジトリ相対パスへのフォールバックがあるおかげで、ソースチェックアウトから実行する開発者(uv run specify init)も、wheel をビルドせずに同じ動作を確認できます。

ヒント: アセット解決の問題をデバッグする際は、specify_cli のインストールディレクトリ配下に core_pack/ が存在するか確認しましょう。存在しない場合はソースから実行していることを意味します。開発目的には問題ありませんが、アセットパスの解決方法が異なる点に注意してください。

次のステップ

モノリシックな CLI コア、integration レジストリのブートストラップ、エアギャップ対応のバンドルといったアーキテクチャ全体を把握したところで、次はシステム全体の中で最も重要なコマンドを深掘りしていきましょう。Part 2 では specify init の完全な実行パスを追います。17 個の CLI パラメーターからインタラクティブな TUI を経て、空のディレクトリを完全にスキャフォールドされた SDD プロジェクトへと変換する 8 ステップのオーケストレーションパイプラインまでを詳しく見ていきます。