// 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) }