記事をどれだけ読んだかを示します

Procler: AIエージェント向けCLIの設計

Procler: AIエージェント向けCLIの設計

ほとんどのCLIツールは人間向けに設計されている。綺麗なテーブル、色付き出力、対話プロンプト。AIエージェントが procler status を叩くと、正規表現でパースしなければならないフォーマット済みテーブルが返ってくる。これは逆だ。

Proclerは別の問いから始まった:主要ユーザーがLLMエージェントで、人間が副次的なユーザーだったら?

この記事では、その前提から導かれた設計判断を辿る。ベンチマークではない — まだ測るものがない。APIの形状の背後にある理由と、予想と違った結果になった部分だけ。


判断1: 全コマンドがJSONを返す

最も明白な判断だが、結果は json.dumps() より深い。

$ procler status api
{
  "success": true,
  "data": {
    "name": "api",
    "status": "running",
    "pid": 48291,
    "uptime_seconds": 3421,
    "linux_state": {
      "state_code": "S",
      "state_name": "sleeping",
      "is_killable": true
    }
  }
}

形状は常に {success, data?, error?, error_code?, suggestion?}。常に。エージェントは条件分岐のパースロジックが不要 — success を確認し、dataerror を読む。それだけ。

これが強制したこと: 全てのエラーパスが構造化出力を生成しなければならなくなった。print("something went wrong") の後に sys.exit(1) は許されない。全ての失敗に error_code(機械可読)と suggestion(エージェントが次のアクションを判断するため)を含む:

{
  "success": false,
  "error": "Process 'api' is already running",
  "error_code": "ALREADY_RUNNING",
  "suggestion": "Use 'procler restart api' to restart, or 'procler status api' to check current state"
}

suggestion フィールドが面白い。人間向けではない — 人間はエラーを読んで対処法を考えられる。プロセス管理の完全なメンタルモデルを理解せずに次のアクションを決める必要があるLLMエージェント向けだ。


判断2: 自己記述型コマンド

新しいシステムに接続するLLMエージェントは、何ができるか発見する必要がある。ほとんどのCLIツールはドキュメントや --help 出力の読解が必要。Proclerにはエントリーポイントが1つある:

$ procler capabilities
{
  "success": true,
  "data": {
    "commands": [
      {
        "name": "start",
        "args": [{"name": "name", "type": "string", "required": true}],
        "description": "Start a defined process",
        "idempotent": true
      },
      ...
    ]
  }
}

全コマンドが引数、型、冪等性、説明を宣言。エージェントは capabilities を一度呼ぶだけでシステムの完全なメンタルモデルを構築できる。

procler config explain はさらに踏み込む — 現在の設定を読み、平文で説明する:

$ procler config explain
{
  "success": true,
  "data": {
    "explanation": "You have 3 processes defined: 'api' runs uvicorn locally, 'worker' runs celery locally and depends on 'api' being healthy, 'redis' runs in Docker container 'my-redis'. The 'backend' group starts them in order: redis → api → worker.",
    "process_count": 3,
    "group_count": 1,
    "recipe_count": 1
  }
}

学んだこと: explain コマンドは人間にも便利だった。50行のYAMLを読んで依存関係を頭の中でパースするのは、段落を読むより難しい。LLMの便宜のつもりが、プロジェクトのプロセス構成を理解する最速の方法になった。


判断3: 全てを冪等に

AIエージェントはリトライする。ネットワーク呼び出しは失敗する。コンテキストウィンドウは操作の途中で切り詰められる。procler start api がapiが既に実行中の時にシステムをクラッシュさせるなら、エージェントは毎回の呼び出しに防御ロジックが必要になる。

代わりに:全操作を冪等にした。実行中のプロセスへの start は現在の状態を返す。停止済みプロセスへの stop は成功を返す。同名同コマンドの define はno-op。

$ procler start api    # プロセスを起動
$ procler start api    # 現在の状態を返す、エラーなし
{
  "success": true,
  "data": {
    "name": "api",
    "status": "running",
    "pid": 48291,
    "already_running": true
  }
}

already_running フラグでエージェントは起動を引き起こしていないことを知れるが、操作は成功。エラーハンドリング不要。

これが破綻した場所: レシピは冪等ではない。停止 → マイグレーション → 起動のデプロイレシピは、マイグレーション途中で安全に再実行できない。--dry-run をセーフティバルブとして追加したが、根本的な問題は残る:副作用のあるマルチステップ操作は冪等性に抵抗する。正直な答えは、レシピにはチェックポイント付きの適切なステートマシンが必要で、まだそれがないということ。


判断4: コンテキスト抽象化

ローカルシェルだけを扱うプロセスマネージャーは subprocess のラッパーに過ぎない。Dockerだけを扱うプロセスマネージャーは docker-py のラッパーに過ぎない。面白い問題は、同じインターフェースで両方を管理すること。

processes:
  api:
    command: uvicorn main:app
    context: local          # サブプロセス

  postgres:
    command: postgres
    context: docker         # Docker SDK
    container: my-postgres

procler start apiprocler start postgres は異なる実行バックエンドを使うが、同一のJSON形状を返す。エージェントはどのコンテキストをプロセスが使っているか知る必要がない(し、気にしなくてよい)。

抽象化はPythonのABC — ExecutionContextstart()stop()status()logs()。2つの実装:LocalContext(asyncioサブプロセス)と DockerContext(docker-py SDK)。新しいコンテキスト(Podman、containerd、SSHリモート)の追加は4メソッドの実装で済む。

このコスト: Dockerコンテナ状態とサブプロセス状態はきれいに対応しない。コンテナは「作成済みだが未起動」になれる — サブプロセスに対応する状態がない。コンテキスト間で状態を正規化する必要があり、Docker固有の情報が平坦化される。linux_state フィールドはローカルプロセスにしか存在しない。トレードオフ承認 — 統一インターフェースは完全なDocker忠実度より価値がある。


判断5: 依存順序付きヘルスプローブ

ここからプロセス管理が開発環境で本当に便利になる。5つのサービスを正しい順序で起動し、それぞれの準備完了を待ってから次を起動する — 毎朝手動でやっていること。

processes:
  worker:
    command: celery worker
    depends_on:
      - name: api
        condition: healthy    # ただの "started" ではない

2つの依存条件:started(プロセスが実行中)と healthy(ヘルスチェック通過)。この違いは重要。データベースは「起動済み」(PIDが存在)でも「健康ではない」(まだWALをリプレイ中)ことがある。データベースが接続を受け付ける前にAPIを起動すると、10分のデバッグを浪費するエラーになる。

グループ起動は依存グラフを辿り、トポロジカル順にプロセスを起動し、各依存条件を待ってから進む。グループ停止は逆順。


テストスイート

320テスト、全パス。テスト構造はアーキテクチャを反映:

領域テスト数カバー内容
CLI45全コマンド、JSON出力形状、エラーコード
ProcessManager62起動/停止/再起動、冪等性、状態遷移
設定38YAMLロード、バリデーション、explain、エクスポート/インポート
グループ28順序付き起動/停止、依存解決
レシピ24ステップ実行、ドライラン、エラー処理
ヘルス18プローブ実行、依存条件
API52RESTエンドポイント、WebSocketサブスクリプション
Docker15コンテナ操作、コンテキスト切替
TUI13ウィジェット作成、ヘルパー、データロード
その他25スニペット、スケジューラー、レプリカ、ネームスペース

テスト哲学:全テストが存在ではなく正確な値をアサート。assert result["status"] == "running" であって assert result is not None ではない。コードが壊れていてもパスするテストは、テスト自体が壊れている。


うまくいかなかったこと

判断が思い通りにならなかった正直な報告:

  1. suggestion フィールドは十分に活用されていない。 実際にはほとんどのエージェントがこれを無視し、error_code に基づいて自分で判断する。フィールドは残っているが、期待したほどの効果はない。

  2. WebSocketサブスクリプションはエージェントには複雑。 ほとんどのLLMエージェントはリクエスト/レスポンスサイクルで動作する。リアルタイム更新のためのWebSocket接続維持には、ほとんどのエージェントフレームワークが十分にサポートしていない別のプログラミングモデルが必要。数秒ごとに procler status をポーリングする方がシンプルで十分に機能する。

  3. YAML設定は一長一短。 人間が読めてバージョン管理可能だが、YAMLの落とし穴(暗黙の型変換、インデント依存)がデバッグ困難な設定エラーを引き起こす。Pydanticバリデーションがほとんどを検出するが、エラーメッセージは問題のYAML行ではなくPydanticの内部を参照する。

  4. TUIはもっと作業が必要。 Textualベースのみを実行するTUIは監視には機能するが、起動/停止/再起動アクションはCLIほど実戦テストされていない。v1 — 機能するが洗練されていない。


はじめる

pip install procler               # または: uv add procler
procler config init               # .procler/config.yaml を作成
procler capabilities              # 利用可能な機能を確認

ソースは github.com/gabu-quest/procler。Python 3.12+、320テスト、MITライセンス。