バックエンドシステム¶
このドキュメントでは、clinvoker のバックエンド抽象化レイヤを深掘りします。複数の AI CLI ツールを共通インターフェースで統一する方法、レジストリパターンの実装、スレッドセーフな設計、そして新しいバックエンドを拡張する方法を説明します。
バックエンドインターフェース設計¶
Backend インターフェース(internal/backend/backend.go:16-46)は、clinvoker が複数の AI CLI ツールをシームレスに扱うための中核となる抽象です。
type Backend interface {
Name() string
IsAvailable() bool
BuildCommand(prompt string, opts *Options) *exec.Cmd
ResumeCommand(sessionID, prompt string, opts *Options) *exec.Cmd
BuildCommandUnified(prompt string, opts *UnifiedOptions) *exec.Cmd
ResumeCommandUnified(sessionID, prompt string, opts *UnifiedOptions) *exec.Cmd
ParseOutput(rawOutput string) string
ParseJSONResponse(rawOutput string) (*UnifiedResponse, error)
SeparateStderr() bool
}
インターフェース設計の意図¶
このインターフェースは、AI とのやり取りのライフサイクルに沿って設計されています。
- 発見(Discovery):
Name()とIsAvailable()による識別と検出 - コマンド組み立て:
BuildCommand*が実行コマンドを生成 - セッション再開:
ResumeCommand*が既存会話を継続 - 出力処理:
ParseOutput()とParseJSONResponse()が応答を正規化 - stderr 取り扱い:
SeparateStderr()が stderr の分離戦略を決定
レジストリパターン¶
バックエンドレジストリ(internal/backend/registry.go)は、スレッドセーフなレジストリパターンでバックエンドの登録とルックアップを管理します。
レジストリの構造¶
flowchart TB
subgraph Registry["レジストリ(internal/backend/registry.go:11-16)"]
RWMU[sync.RWMutex]
BACKENDS[map[string]Backend]
CACHE[availabilityCache]
TTL[30 秒 TTL]
end
subgraph Operations["レジストリ操作"]
REGISTER[Register]
UNREGISTER[Unregister]
GET[Get]
LIST[List]
AVAILABLE[Available]
end
RWMU --> BACKENDS
BACKENDS --> CACHE
CACHE --> TTL
REGISTER --> RWMU
UNREGISTER --> RWMU
GET --> RWMU
LIST --> RWMU
AVAILABLE --> CACHE
スレッドセーフな設計¶
レジストリは sync.RWMutex を使って並行アクセスを制御します。
// Read operations use RLock for concurrent reads
func (r *Registry) Get(name string) (Backend, error) {
r.mu.RLock()
defer r.mu.RUnlock()
// ... lookup logic
}
// Write operations use Lock for exclusive access
func (r *Registry) Register(b Backend) {
r.mu.Lock()
defer r.mu.Unlock()
// ... registration logic
delete(r.availabilityCache, b.Name()) // Invalidate cache
}
この設計により次が可能になります。
- 複数の同時読み取り(例: ヘルスチェック、一覧表示)
- 排他書き込み(例: 登録、登録解除)
- 複数 goroutine からの安全な同時アクセス
可用性キャッシュ¶
レジストリは可用性チェックのために 30 秒 TTL のキャッシュを実装しています。
type cachedAvailability struct {
available bool
checkedAt time.Time
}
func (r *Registry) isAvailableCachedLocked(b Backend) bool {
name := b.Name()
if cached, ok := r.availabilityCache[name]; ok &&
time.Since(cached.checkedAt) < r.availabilityCacheTTL {
return cached.available
}
available := b.IsAvailable()
r.availabilityCache[name] = &cachedAvailability{
available: available,
checkedAt: time.Now(),
}
return available
}
30 秒 TTL の理由:
- 性能:
exec.LookPath()の頻繁な呼び出しを回避 - 鮮度: インストール状況の変化を十分早く検出
- バランス: 正確性と性能のトレードオフ
バックエンド実装¶
Claude バックエンド¶
type Claude struct{}
func (c *Claude) Name() string { return "claude" }
func (c *Claude) IsAvailable() bool {
_, err := exec.LookPath("claude")
return err == nil
}
func (c *Claude) BuildCommand(prompt string, opts *Options) *exec.Cmd {
args := []string{"--print"}
if opts != nil {
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
// ... additional options
}
args = append(args, prompt)
cmd := exec.Command("claude", args...)
if opts != nil && opts.WorkDir != "" {
cmd.Dir = opts.WorkDir
}
return cmd
}
Codex バックエンド¶
type Codex struct{}
func (c *Codex) Name() string { return "codex" }
func (c *Codex) IsAvailable() bool {
_, err := exec.LookPath("codex")
return err == nil
}
func (c *Codex) BuildCommand(prompt string, opts *Options) *exec.Cmd {
args := []string{"--json"}
if opts != nil && opts.Model != "" {
args = append(args, "--model", opts.Model)
}
args = append(args, prompt)
return exec.Command("codex", args...)
}
Gemini バックエンド¶
type Gemini struct{}
func (g *Gemini) Name() string { return "gemini" }
func (g *Gemini) IsAvailable() bool {
_, err := exec.LookPath("gemini")
return err == nil
}
func (g *Gemini) BuildCommand(prompt string, opts *Options) *exec.Cmd {
args := []string{"--output-format", "json"}
if opts != nil && opts.Model != "" {
args = append(args, "--model", opts.Model)
}
args = append(args, prompt)
return exec.Command("gemini", args...)
}
統一オプション(UnifiedOptions)の扱い¶
UnifiedOptions 構造体(internal/backend/unified.go:174-219)は、バックエンドに依存しない形で AI CLI コマンドを設定するための手段です。
type UnifiedOptions struct {
WorkDir string
Model string
ApprovalMode ApprovalMode
SandboxMode SandboxMode
OutputFormat OutputFormat
AllowedTools string
AllowedDirs []string
Interactive bool
Verbose bool
DryRun bool
MaxTokens int
MaxTurns int
SystemPrompt string
ExtraFlags []string
Ephemeral bool
}
フラグマッピングのアーキテクチャ¶
flowchart TB
subgraph Unified["UnifiedOptions"]
MODEL[Model]
APPROVAL[ApprovalMode]
SANDBOX[SandboxMode]
OUTPUT[OutputFormat]
end
subgraph Mapper["フラグマッパ(internal/backend/unified.go:273-568)"]
MAP_MODEL[mapModel()]
MAP_APPROVAL[mapApprovalMode()]
MAP_SANDBOX[mapSandboxMode()]
MAP_OUTPUT[mapOutputFormat()]
end
subgraph Backends["バックエンド固有フラグ"]
CLAUDE[Claude フラグ]
CODEX[Codex フラグ]
GEMINI[Gemini フラグ]
end
MODEL --> MAP_MODEL
APPROVAL --> MAP_APPROVAL
SANDBOX --> MAP_SANDBOX
OUTPUT --> MAP_OUTPUT
MAP_MODEL --> CLAUDE
MAP_MODEL --> CODEX
MAP_MODEL --> GEMINI
MAP_APPROVAL --> CLAUDE
MAP_APPROVAL --> CODEX
MAP_APPROVAL --> GEMINI
MAP_SANDBOX --> CLAUDE
MAP_SANDBOX --> CODEX
MAP_SANDBOX --> GEMINI
MAP_OUTPUT --> CLAUDE
MAP_OUTPUT --> CODEX
MAP_OUTPUT --> GEMINI
モデル名のパススルー¶
モデル名は変換せず、そのままバックエンド CLI に渡されます。モデルの解決(別名/エイリアス)は各 CLI が担当します。
# モデル名はバックエンド CLI にそのまま渡されます
clinvk -b claude -m sonnet "task" # Claude CLI が "sonnet" を解決
clinvk -b gemini -m gemini-2.5-flash "task" # Gemini CLI はモデル名をそのまま利用
clinvk -b codex -m o3 "task" # Codex CLI はモデル名をそのまま利用
承認モードのマッピング¶
承認モードは、バックエンドがユーザー確認をどう扱うかを制御します。
func (m *flagMapper) mapApprovalMode(mode ApprovalMode) []string {
switch m.backend {
case "claude":
switch mode {
case ApprovalAuto:
return []string{"--permission-mode", "acceptEdits"}
case ApprovalNone:
return []string{"--permission-mode", "dontAsk"}
case ApprovalAlways:
return []string{"--permission-mode", "default"}
}
case "codex":
switch mode {
case ApprovalAuto:
return []string{"--ask-for-approval", "on-request"}
case ApprovalNone:
return []string{"--ask-for-approval", "never"}
case ApprovalAlways:
return []string{"--ask-for-approval", "untrusted"}
}
// ...
}
return nil
}
出力の解析と正規化¶
各バックエンドは、ネイティブ出力を統一形式へ解析します。
JSON レスポンスの解析¶
func (c *Claude) ParseJSONResponse(rawOutput string) (*UnifiedResponse, error) {
// First try to parse as error response
var errResp claudeErrorResponse
if err := json.Unmarshal([]byte(rawOutput), &errResp); err == nil {
if errResp.Error != "" {
return &UnifiedResponse{
SessionID: errResp.SessionID,
Error: errResp.Error,
}, nil
}
}
var resp claudeJSONResponse
if err := json.Unmarshal([]byte(rawOutput), &resp); err != nil {
return nil, err
}
return &UnifiedResponse{
Content: resp.Result,
SessionID: resp.SessionID,
DurationMs: resp.DurationMs,
Usage: &TokenUsage{
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
},
}, nil
}
統一レスポンス構造¶
type UnifiedResponse struct {
Content string
SessionID string
Model string
DurationMs int64
Usage *TokenUsage
Error string
Raw map[string]any
}
新しいバックエンドを追加する¶
clinvoker に新しい AI CLI バックエンドを追加する手順:
ステップ 1: 実装ファイルを作成¶
internal/backend/newbackend.go を作成します。
package backend
import "os/exec"
type NewBackend struct{}
func (n *NewBackend) Name() string {
return "newbackend"
}
func (n *NewBackend) IsAvailable() bool {
_, err := exec.LookPath("newbackend-cli")
return err == nil
}
func (n *NewBackend) BuildCommand(prompt string, opts *Options) *exec.Cmd {
args := []string{"--output", "json"}
if opts != nil && opts.Model != "" {
args = append(args, "--model", opts.Model)
}
args = append(args, prompt)
cmd := exec.Command("newbackend-cli", args...)
if opts != nil && opts.WorkDir != "" {
cmd.Dir = opts.WorkDir
}
return cmd
}
func (n *NewBackend) ResumeCommand(sessionID, prompt string, opts *Options) *exec.Cmd {
args := []string{"--resume", sessionID, "--output", "json"}
if prompt != "" {
args = append(args, prompt)
}
return exec.Command("newbackend-cli", args...)
}
func (n *NewBackend) BuildCommandUnified(prompt string, opts *UnifiedOptions) *exec.Cmd {
return n.BuildCommand(prompt, MapFromUnified(n.Name(), opts))
}
func (n *NewBackend) ResumeCommandUnified(sessionID, prompt string, opts *UnifiedOptions) *exec.Cmd {
return n.ResumeCommand(sessionID, prompt, MapFromUnified(n.Name(), opts))
}
func (n *NewBackend) ParseOutput(rawOutput string) string {
return rawOutput
}
func (n *NewBackend) ParseJSONResponse(rawOutput string) (*UnifiedResponse, error) {
var resp struct {
Content string `json:"content"`
SessionID string `json:"session_id"`
Usage struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(rawOutput), &resp); err != nil {
return nil, err
}
return &UnifiedResponse{
Content: resp.Content,
SessionID: resp.SessionID,
Usage: &TokenUsage{
InputTokens: resp.Usage.Input,
OutputTokens: resp.Usage.Output,
},
}, nil
}
func (n *NewBackend) SeparateStderr() bool {
return false
}
ステップ 2: レジストリへ登録¶
internal/backend/registry.go に追加します。
func init() {
globalRegistry.Register(&Claude{})
globalRegistry.Register(&Codex{})
globalRegistry.Register(&Gemini{})
globalRegistry.Register(&NewBackend{}) // Add this line
}
ステップ 3: 許可フラグを追加¶
internal/backend/unified.go:10-27 の allowlist を更新します。
var allowedFlagPatterns = map[string][]string{
"newbackend": {
"--model", "--output-format", "--json",
"--resume", "--sandbox",
},
// ...
}
ベストプラクティス¶
コマンド組み立て¶
- 実行前にパスを必ず検証する
- 引数のエスケープを適切に行う(Go の
exec.Commandが多くを担う) - 対話/バッチの両方をサポートする
- 非対話出力に
--print相当があれば利用する
出力解析¶
- 部分的/不正な JSON を適切に扱う
- 制御文字や ANSI コードを除去する
- デバッグ用にエラーメッセージを保持する
- 可能なら構造化エラーを返す
エラーハンドリング¶
- バックエンドエラーとシステムエラーを区別する
- 明確で実行可能なエラーメッセージにする
- エラー出力にトラブルシューティングのヒントを含める