iop/apps/node/internal/adapters/cli/cli.go
toki 24789c398b feat: CLI adapter with onshot/persistent mode support
- Add onshot.go and persistent.go for distinct CLI modes
- Update cli.go, factory_internal_test.go, cli_test.go
- Update configs/edge.yaml and packages/config
- Update READMEs for edge and node apps
2026-05-03 18:09:56 +09:00

159 lines
4.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. 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)
}