203 lines
5.5 KiB
Go
203 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(newReviewCommand(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()
|
|
},
|
|
}
|
|
}
|