// Package aichannel provides an interface for communicating with AI coding agents via CLI.
package aichannel
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
// Common errors
var (
ErrNotConfigured = errors.New("channel not configured")
ErrNotAvailable = errors.New("AI agent not available")
ErrTimeout = errors.New("request timed out")
)
// AgentType represents a known AI coding agent.
type AgentType string
const (
AgentClaude AgentType = "claude"
AgentCopilot AgentType = "copilot"
AgentGemini AgentType = "gemini"
AgentOpenCode AgentType = "opencode"
AgentKimi AgentType = "kimi-cli"
AgentAuggie AgentType = "auggie"
AgentAider AgentType = "aider"
AgentCursor AgentType = "cursor-agent"
AgentCustom AgentType = "custom"
)
// Config holds the configuration for an AI channel.
type Config struct {
// Agent is the type of AI agent to use
Agent AgentType `json:"agent"`
// Command is the executable command (defaults based on agent type)
Command string `json:"command,omitempty"`
// Args are additional arguments to pass to the command
Args []string `json:"args,omitempty"`
// NonInteractiveFlag is the flag to use for non-interactive mode (e.g., "-p")
NonInteractiveFlag string `json:"non_interactive_flag,omitempty"`
// QuietFlag suppresses progress output (e.g., "-s" for copilot, "-q" for gemini)
QuietFlag string `json:"quiet_flag,omitempty"`
// OutputFormat specifies desired output format ("text", "json", "stream-json")
// Note: Not all agents support all formats. Use SupportsJSONOutput() to check.
OutputFormat string `json:"output_format,omitempty"`
// OutputFormatFlag is the CLI flag for output format (e.g., "--output-format")
// Set automatically based on agent type.
OutputFormatFlag string `json:"output_format_flag,omitempty"`
// SupportsJSON indicates if this agent supports structured JSON output
SupportsJSON bool `json:"supports_json,omitempty"`
// UseStdin determines if context should be piped via stdin
UseStdin bool `json:"use_stdin"`
// UsePTY runs the command in a pseudo-terminal (required for some CLI tools)
UsePTY bool `json:"use_pty"`
// Timeout for the request (default 2 minutes)
Timeout time.Duration `json:"timeout,omitempty"`
// Environment variables to set
Env map[string]string `json:"env,omitempty"`
// --- API Mode Configuration ---
// UseAPI enables API mode instead of CLI mode.
// When true, uses the Provider interface instead of executing CLI commands.
UseAPI bool `json:"use_api,omitempty"`
// LLMProvider specifies which LLM provider to use in API mode.
// If empty, auto-detects based on available API keys.
LLMProvider LLMProvider `json:"llm_provider,omitempty"`
// APIKey is the authentication key for API-based providers.
// If empty, uses environment variables based on provider.
APIKey string `json:"api_key,omitempty"`
// Model specifies the model to use.
// For CLI mode (e.g., Claude Code): passed via --model flag. Defaults to "haiku".
// For API mode: uses the provider's default model if empty.
Model string `json:"model,omitempty"`
// MaxTokens limits API response length (default 1024).
MaxTokens int `json:"max_tokens,omitempty"`
// SystemPrompt provides context/instructions for API-based completions.
SystemPrompt string `json:"system_prompt,omitempty"`
}
// Channel represents a communication channel to an AI coding agent.
type Channel struct {
config Config
configured bool
provider Provider // For API mode
}
// New creates a new AI channel.
func New() *Channel {
return &Channel{}
}
// NewWithConfig creates a new AI channel with the given configuration.
func NewWithConfig(config Config) *Channel {
ch := &Channel{}
ch.Configure(config)
return ch
}
// Configure sets up the channel with the given configuration.
func (c *Channel) Configure(config Config) {
// Apply defaults based on agent type
config = applyDefaults(config)
c.config = config
c.configured = true
// Set up provider for API mode
if config.UseAPI {
c.provider = c.createProvider()
}
}
// createProvider creates the appropriate Provider based on configuration.
func (c *Channel) createProvider() Provider {
// Determine which provider to use
provider := c.config.LLMProvider
if provider == "" {
// Auto-detect based on available API keys
provider = GetDefaultProvider()
}
if provider == "" {
// No provider available
return nil
}
langchainProvider, err := NewLangChainProvider(LangChainConfig{
Provider: provider,
APIKey: c.config.APIKey,
Model: c.config.Model,
MaxTokens: c.config.MaxTokens,
})
if err != nil {
// Log error but return nil - IsAvailable will handle this
return nil
}
return langchainProvider
}
// applyDefaults fills in default values based on agent type.
func applyDefaults(config Config) Config {
if config.Timeout == 0 {
config.Timeout = 2 * time.Minute
}
// Set command and flags based on agent type if not specified
switch config.Agent {
case AgentClaude:
if config.Command == "" {
config.Command = "claude"
}
if config.NonInteractiveFlag == "" {
config.NonInteractiveFlag = "-p"
}
if config.OutputFormat == "" {
config.OutputFormat = "text"
}
// Default to haiku for Claude Code CLI (fast and cost-effective for summaries)
if config.Model == "" {
config.Model = "haiku"
}
config.OutputFormatFlag = "--output-format"
config.SupportsJSON = true // Claude supports json and stream-json
config.UseStdin = true
config.UsePTY = true // Claude Code requires a PTY for -p mode
case AgentCopilot:
if config.Command == "" {
config.Command = "copilot"
}
if config.NonInteractiveFlag == "" {
config.NonInteractiveFlag = "-p"
}
if config.QuietFlag == "" {
config.QuietFlag = "-s"
}
config.SupportsJSON = false // Copilot uses -s for clean output, no structured JSON
config.UseStdin = true
case AgentGemini:
if config.Command == "" {
config.Command = "gemini"
}
if config.NonInteractiveFlag == "" {
config.NonInteractiveFlag = "-e"
}
if config.QuietFlag == "" {
config.QuietFlag = "-q"
}
config.OutputFormatFlag = "--output-format"
config.SupportsJSON = true // Gemini CLI supports --output-format json
config.UseStdin = true
case AgentOpenCode:
if config.Command == "" {
config.Command = "opencode"
}
config.SupportsJSON = false // TBD - uses TUI
case AgentKimi:
if config.Command == "" {
config.Command = "kimi-cli"
}
config.SupportsJSON = false // No documented JSON output
config.UseStdin = true
case AgentAuggie:
if config.Command == "" {
config.Command = "auggie"
}
config.SupportsJSON = false // TBD
case AgentAider:
if config.Command == "" {
config.Command = "aider"
}
// Aider uses --message for non-interactive
if config.NonInteractiveFlag == "" {
config.NonInteractiveFlag = "--message"
}
config.SupportsJSON = false // No documented JSON output
case AgentCursor:
if config.Command == "" {
config.Command = "cursor-agent"
}
if config.NonInteractiveFlag == "" {
config.NonInteractiveFlag = "-p"
}
config.SupportsJSON = false // Documented but format unclear
case AgentCustom:
// For custom agents, allow user to specify SupportsJSON and OutputFormatFlag
// If OutputFormat is json/stream-json and OutputFormatFlag is set, enable JSON support
if config.OutputFormatFlag != "" &&
(config.OutputFormat == "json" || config.OutputFormat == "stream-json") {
config.SupportsJSON = true
}
}
return config
}
// SupportsJSONOutput returns true if the configured agent supports JSON output format.
func (c *Channel) SupportsJSONOutput() bool {
return c.config.SupportsJSON
}
// IsAvailable checks if the configured AI agent is available.
func (c *Channel) IsAvailable() bool {
if !c.configured {
return false
}
// For API mode, check if provider is configured
if c.config.UseAPI {
return c.provider != nil && c.provider.IsConfigured()
}
// For CLI mode, check if command exists
_, err := exec.LookPath(c.config.Command)
return err == nil
}
// IsAPIMode returns true if the channel is configured to use API mode.
func (c *Channel) IsAPIMode() bool {
return c.config.UseAPI
}
// Send sends a prompt to the AI agent and returns the response.
// If context is non-empty, it's passed appropriately based on the agent type.
func (c *Channel) Send(ctx context.Context, prompt string, inputContext string) (string, error) {
if !c.configured {
return "", ErrNotConfigured
}
// Use API mode if configured
if c.config.UseAPI {
return c.sendWithAPI(ctx, prompt, inputContext)
}
if !c.IsAvailable() {
return "", fmt.Errorf("%w: %s not found in PATH", ErrNotAvailable, c.config.Command)
}
// Use adapter-based execution for CLI mode
return RunWithAdapter(ctx, c.config, prompt, inputContext, c.config.SystemPrompt)
}
// sendWithAPI sends a prompt using the API provider.
func (c *Channel) sendWithAPI(ctx context.Context, prompt string, inputContext string) (string, error) {
if c.provider == nil {
return "", fmt.Errorf("%w: provider not configured", ErrNotAvailable)
}
if !c.provider.IsConfigured() {
return "", ErrNoAPIKey
}
resp, err := c.provider.CompleteWithContext(ctx, c.config.SystemPrompt, prompt, inputContext)
if err != nil {
return "", err
}
return resp.Result, nil
}
// ErrAgentError is returned when the AI agent reports an error in its response.
var ErrAgentError = errors.New("agent error")
// SendAndParse sends a prompt and parses the response based on the configured OutputFormat.
// This provides a structured response with metadata for JSON/stream-json formats.
// For agents that don't support JSON output, it falls back to text parsing.
// For API mode, always returns a structured Response directly from the provider.
//
// If the agent returns an error (is_error: true), this method returns ErrAgentError
// with the error message. This ensures errors are reported to users rather than
// being passed to downstream LLM calls.
func (c *Channel) SendAndParse(ctx context.Context, prompt string, inputContext string) (*Response, error) {
// For API mode, get response directly from provider
if c.config.UseAPI {
if c.provider == nil {
return nil, fmt.Errorf("%w: provider not configured", ErrNotAvailable)
}
if !c.provider.IsConfigured() {
return nil, ErrNoAPIKey
}
return c.provider.CompleteWithContext(ctx, c.config.SystemPrompt, prompt, inputContext)
}
// For CLI mode, send and parse the output
rawOutput, err := c.Send(ctx, prompt, inputContext)
if err != nil {
return nil, err
}
// Determine actual output format based on agent support
format := OutputFormat(c.config.OutputFormat)
if format == "" {
format = OutputFormatText
}
// If JSON was requested but agent doesn't support it, fall back to text
if (format == OutputFormatJSON || format == OutputFormatStreamJSON) && !c.config.SupportsJSON {
format = OutputFormatText
}
resp, err := ParseResponse(rawOutput, format)
if err != nil {
return nil, err
}
// Check for agent-reported errors (is_error: true in JSON response)
// Report these to the user instead of passing to downstream LLM calls
if resp.IsError {
errMsg := resp.Result
if errMsg == "" {
errMsg = "unknown error"
}
if resp.Subtype != "" {
return nil, fmt.Errorf("%w (%s): %s", ErrAgentError, resp.Subtype, errMsg)
}
return nil, fmt.Errorf("%w: %s", ErrAgentError, errMsg)
}
return resp, nil
}
// stripANSI removes ANSI escape sequences from a string.
func stripANSI(s string) string {
var result strings.Builder
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' {
inEscape = true
continue
}
if inEscape {
// Check for CSI sequences (ESC [)
if s[i] == '[' {
// Skip until we hit a letter (the command byte)
for i++; i < len(s); i++ {
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
break
}
}
inEscape = false
continue
}
// Check for OSC sequences (ESC ])
if s[i] == ']' {
// Skip until BEL or ST
for i++; i < len(s); i++ {
if s[i] == '\x07' { // BEL
break
}
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { // ST
i++
break
}
}
inEscape = false
continue
}
// Single character escape
inEscape = false
continue
}
// Skip carriage returns (terminal line endings)
if s[i] == '\r' {
continue
}
result.WriteByte(s[i])
}
return result.String()
}
// buildArgs constructs the command arguments based on configuration.
func (c *Channel) buildArgs(prompt string) []string {
var args []string
// Add configured args first
args = append(args, c.config.Args...)
// Add model flag for Claude Code CLI
if c.config.Agent == AgentClaude && c.config.Model != "" {
args = append(args, "--model", c.config.Model)
}
// Add non-interactive flag with prompt (only if prompt is non-empty)
if prompt != "" {
if c.config.NonInteractiveFlag != "" {
args = append(args, c.config.NonInteractiveFlag, prompt)
} else {
args = append(args, prompt)
}
}
// Add quiet flag
if c.config.QuietFlag != "" {
args = append(args, c.config.QuietFlag)
}
// Add output format if agent supports it and format is not "text"
if c.config.SupportsJSON && c.config.OutputFormatFlag != "" &&
c.config.OutputFormat != "" && c.config.OutputFormat != "text" {
args = append(args, c.config.OutputFormatFlag, c.config.OutputFormat)
}
return args
}
// Config returns a copy of the current configuration.
func (c *Channel) Config() Config {
return c.config
}
// GetKnownAgents returns a list of known AI agent types.
func GetKnownAgents() []AgentType {
return []AgentType{
AgentClaude,
AgentCopilot,
AgentGemini,
AgentOpenCode,
AgentKimi,
AgentAuggie,
AgentAider,
AgentCursor,
}
}
// DetectAvailableAgents returns a list of AI agents that are available in PATH.
func DetectAvailableAgents() []AgentType {
var available []AgentType
for _, agent := range GetKnownAgents() {
ch := NewWithConfig(Config{Agent: agent})
if ch.IsAvailable() {
available = append(available, agent)
}
}
return available
}