agent-orchestrator/backend/internal/cli/root.go

202 lines
5.5 KiB
Go

// Package cli implements the user-facing ao command. It stays thin: commands
// discover the local daemon, call its loopback HTTP API, and format output.
package cli
import (
"context"
"errors"
"io"
"net/http"
"os"
"os/exec"
"time"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/daemon"
"github.com/aoagents/agent-orchestrator/backend/internal/processalive"
)
// Execute runs the ao CLI with process stdio.
func Execute() error {
return NewRootCommand(DefaultDeps()).Execute()
}
// usageError marks a command-line misuse (bad flag, wrong arg count). It lets
// the process entrypoint return exit code 2 for usage errors versus 1 for
// runtime failures, matching the convention CLIs are scripted against.
type usageError struct{ err error }
func (e usageError) Error() string { return e.err.Error() }
func (e usageError) Unwrap() error { return e.err }
// ExitCode maps a CLI error to a process exit code: 2 for usage errors, 1 for
// any other failure, 0 for success.
func ExitCode(err error) int {
if err == nil {
return 0
}
var ue usageError
if errors.As(err, &ue) {
return 2
}
return 1
}
// Deps holds the small set of side effects the CLI needs. Tests replace these
// functions without reaching into process-global state.
type Deps struct {
In io.Reader
Out io.Writer
Err io.Writer
HTTPClient *http.Client
Executable func() (string, error)
StartProcess func(processStartConfig) error
ProcessAlive func(pid int) bool
LookPath func(file string) (string, error)
CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
CommandOutputInDir func(ctx context.Context, dir, name string, args ...string) ([]byte, error)
// DoctorGitHubRESTBase lets tests point the doctor GitHub token probe at
// httptest without mutating package-global state.
DoctorGitHubRESTBase string
Now func() time.Time
Sleep func(time.Duration)
}
// DefaultDeps returns production dependencies.
func DefaultDeps() Deps {
return Deps{
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
HTTPClient: &http.Client{Timeout: 2 * time.Second},
Executable: os.Executable,
StartProcess: startProcess,
ProcessAlive: processalive.Alive,
LookPath: exec.LookPath,
CommandOutput: commandOutput,
CommandOutputInDir: commandOutputInDir,
DoctorGitHubRESTBase: defaultDoctorGitHubRESTBase,
Now: time.Now,
Sleep: time.Sleep,
}
}
func commandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
func commandOutputInDir(ctx context.Context, dir, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
return cmd.CombinedOutput()
}
func (d Deps) withDefaults() Deps {
def := DefaultDeps()
if d.In == nil {
d.In = def.In
}
if d.Out == nil {
d.Out = def.Out
}
if d.Err == nil {
d.Err = def.Err
}
if d.HTTPClient == nil {
d.HTTPClient = def.HTTPClient
}
if d.Executable == nil {
d.Executable = def.Executable
}
if d.StartProcess == nil {
d.StartProcess = def.StartProcess
}
if d.ProcessAlive == nil {
d.ProcessAlive = def.ProcessAlive
}
if d.LookPath == nil {
d.LookPath = def.LookPath
}
if d.CommandOutput == nil {
d.CommandOutput = def.CommandOutput
}
if d.CommandOutputInDir == nil {
d.CommandOutputInDir = def.CommandOutputInDir
}
if d.DoctorGitHubRESTBase == "" {
d.DoctorGitHubRESTBase = def.DoctorGitHubRESTBase
}
if d.Now == nil {
d.Now = def.Now
}
if d.Sleep == nil {
d.Sleep = def.Sleep
}
return d
}
// NewRootCommand builds a testable root command.
func NewRootCommand(deps Deps) *cobra.Command {
deps = deps.withDefaults()
ctx := &commandContext{deps: deps}
root := &cobra.Command{
Use: "ao",
Short: "Agent Orchestrator",
Long: "Agent Orchestrator manages the local daemon that supervises parallel coding-agent sessions.",
Version: VersionString(),
SilenceUsage: true,
SilenceErrors: true,
}
root.SetIn(deps.In)
root.SetOut(deps.Out)
root.SetErr(deps.Err)
root.CompletionOptions.DisableDefaultCmd = true
// Tag flag-parse failures as usage errors so the entrypoint can exit 2 for
// misuse versus 1 for runtime failures. Subcommands inherit this func.
root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return usageError{err}
})
root.AddCommand(newDaemonCommand())
root.AddCommand(newStartCommand(ctx))
root.AddCommand(newStopCommand(ctx))
root.AddCommand(newStatusCommand(ctx))
root.AddCommand(newDoctorCommand(ctx))
root.AddCommand(newSpawnCommand(ctx))
root.AddCommand(newSendCommand(ctx))
root.AddCommand(newHooksCommand(ctx))
root.AddCommand(newProjectCommand(ctx))
root.AddCommand(newSessionCommand(ctx))
root.AddCommand(newOrchestratorCommand(ctx))
root.AddCommand(newCompletionCommand())
root.AddCommand(newVersionCommand())
return root
}
type commandContext struct {
deps Deps
}
func noArgs(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(0)(cmd, args); err != nil {
return usageError{err}
}
return nil
}
func newDaemonCommand() *cobra.Command {
return &cobra.Command{
Use: "daemon",
Short: "Run the AO backend daemon",
Hidden: true,
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return daemon.Run()
},
}
}