iop/apps/node/internal/adapters/cli/cli.go
toki 2d6fde0876 기능: IOP 모노레포 스캐폴드 초기 구현
apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture,
mock/cli adapter, fx DI, SQLite 실행 이력 저장.
edge/control-plane/worker는 cobra placeholder.
유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
2026-05-02 13:20:35 +09:00

144 lines
3.4 KiB
Go

// Package cli provides an Adapter that runs external CLI tools as inference
// backends. Profiles (claude, gemini, codex, opencode) are configured in
// configs/node.yaml and select the command + args to execute.
package cli
import (
"bufio"
"context"
"fmt"
"os/exec"
"strings"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/runtime"
"iop/packages/config"
)
const Name = "cli"
// known profiles — must match CLI tool names
var knownProfiles = []string{"claude", "gemini", "codex", "opencode"}
type CLI struct {
profiles map[string]config.CLIProfileConf
logger *zap.Logger
}
func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
return &CLI{
profiles: cfg.Profiles,
logger: logger,
}
}
func (c *CLI) Name() string { return Name }
func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
models := make([]string, 0, len(c.profiles))
for name := range c.profiles {
models = append(models, name)
}
// Always advertise known profiles even if not in config yet.
for _, p := range knownProfiles {
if _, ok := c.profiles[p]; !ok {
models = append(models, p)
}
}
return runtime.Capabilities{
AdapterName: Name,
Models: models,
MaxConcurrency: 4,
}, nil
}
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
profile, ok := c.profiles[spec.Model]
if !ok {
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
}
prompt := extractPrompt(spec.Input)
args := append(append([]string{}, profile.Args...), prompt)
cmd := exec.CommandContext(ctx, profile.Command, args...)
// Pass profile env vars
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("cli adapter: stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("cli adapter: stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("cli adapter: start %q: %w", profile.Command, err)
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
scanner := bufio.NewScanner(stdout)
outputTokens := 0
for scanner.Scan() {
line := scanner.Text()
outputTokens += len(strings.Fields(line))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: line + "\n",
Timestamp: time.Now(),
})
}
// Collect stderr for error reporting
var errBuf strings.Builder
errScanner := bufio.NewScanner(stderr)
for errScanner.Scan() {
errBuf.WriteString(errScanner.Text())
errBuf.WriteByte('\n')
}
if err := cmd.Wait(); err != nil {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: fmt.Sprintf("command failed: %v — %s", err, errBuf.String()),
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: command exited with error: %w", err)
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: "cli execution complete",
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(prompt)),
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
}
func extractPrompt(input map[string]any) string {
if input == nil {
return ""
}
if v, ok := input["prompt"]; ok {
if s, ok := v.(string); ok {
return s
}
}
return fmt.Sprintf("%v", input)
}