// 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. Interactive CLIs // should be configured with their non-interactive/headless flags for use in the // request/response node pipeline. package cli import ( "context" "fmt" "io" "os/exec" "sort" "sync" "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 cliOutput struct { line string } type profileSession struct { name string profile config.CLIProfileConf cmd *exec.Cmd input io.Writer output <-chan cliOutput done <-chan error closeFn func() error mu sync.Mutex } type CLI struct { mu sync.Mutex profiles map[string]config.CLIProfileConf sessions map[string]*profileSession logger *zap.Logger } func New(cfg config.CLIConf, logger *zap.Logger) *CLI { return &CLI{ profiles: cfg.Profiles, sessions: make(map[string]*profileSession), 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 } // Start starts persistent profile sessions in deterministic (sorted) order. // On failure, already-started sessions are rolled back under c.mu. func (c *CLI) Start(ctx context.Context) error { // Sort profile names for deterministic start order. names := make([]string, 0, len(c.profiles)) for name := range c.profiles { if c.profiles[name].Persistent { names = append(names, name) } } sort.Strings(names) for _, name := range names { profile := c.profiles[name] sess, err := startProfileSession(ctx, name, profile, c.logger) if err != nil { // Roll back already-started sessions under the lock. c.mu.Lock() _ = c.stopAllSessions(context.Background()) c.sessions = make(map[string]*profileSession) c.mu.Unlock() return fmt.Errorf("cli adapter: start profile %q: %w", name, err) } c.mu.Lock() c.sessions[name] = sess c.mu.Unlock() c.logger.Info("cli adapter: persistent session started", zap.String("profile", name)) } return nil } // stopAllSessions closes all sessions and clears the map. // Must be called while c.mu is held. func (c *CLI) stopAllSessions(_ context.Context) error { var firstErr error for name, sess := range c.sessions { if err := sess.closeFn(); err != nil && firstErr == nil { firstErr = fmt.Errorf("cli adapter: close session %q: %w", name, err) } if sess.cmd != nil && sess.cmd.Process != nil { _ = sess.cmd.Process.Kill() } } c.sessions = make(map[string]*profileSession) return firstErr } // Stop stops all persistent sessions. Sessions are iterated in map order (non-deterministic), // but since Stop is idempotent and all sessions are stopped regardless of individual failures, // the order does not affect correctness. Errors are combined by reporting only the first error. func (c *CLI) Stop(_ context.Context) error { c.mu.Lock() // Snapshot sessions under the lock, then release it before doing // blocking close/kill so we don't hold c.mu while processes terminate. sessionsCopy := make(map[string]*profileSession, len(c.sessions)) for name, sess := range c.sessions { sessionsCopy[name] = sess } c.sessions = make(map[string]*profileSession) c.mu.Unlock() var firstErr error for name, sess := range sessionsCopy { if err := sess.closeFn(); err != nil && firstErr == nil { firstErr = fmt.Errorf("cli adapter: close session %q: %w", name, err) } if sess.cmd != nil && sess.cmd.Process != nil { _ = sess.cmd.Process.Kill() } } return firstErr } 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) } if profile.Persistent { return c.executePersistent(ctx, spec, profile, sink) } return c.executeOneShot(ctx, spec, profile, sink) }