Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,42 +108,46 @@ go build -o sao ./cmd/sao
sao init-machine
```

2. In a repo you want `sao` to watch, create the repo config:
2. In a repo you want `sao` to watch, create the repo config and register the repo in the machine config:

```bash
sao init-repo
sao init-project
```

3. Register that repo in the machine config:

```bash
sao add-repo /path/to/repo
```

4. Validate the setup:
3. Validate the setup:

```bash
sao validate
```

5. Preview what would run:
4. Preview what would run:

```bash
sao plan
```

6. Run one cycle:
5. Run one cycle:

```bash
sao once
```

7. Or run the foreground loop:
6. Or run the foreground loop:

```bash
sao
```

You can still run the setup steps separately with `sao init-repo` and `sao add-repo /path/to/repo` when needed.

## Update

Update an installed binary to the latest GitHub release:

```bash
sao update
```

## Default Task Selection

By default, a repo is configured to look for:
Expand Down Expand Up @@ -175,8 +179,10 @@ State:
## Supported Commands

- `sao init-machine`
- `sao init-project`
- `sao init-repo`
- `sao add-repo /path/to/repo`
- `sao update`
- `sao validate`
- `sao agents`
- `sao plan`
Expand Down
127 changes: 121 additions & 6 deletions internal/sao/sao.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runOnce(ctx, stdout, stderr)
case "init-machine":
return initMachine(stdout)
case "init-project":
return initProject(stdout)
case "init-repo":
return initRepo(stdout)
case "add-repo":
return addRepo(args, stdout)
case "update":
return update(ctx, stdout, stderr)
case "validate":
return validate(stdout, stderr)
case "agents":
Expand All @@ -59,8 +63,10 @@ func printHelp(w io.Writer) {
fmt.Fprintln(w, " sao Run the foreground orchestration loop")
fmt.Fprintln(w, " sao once Run a single orchestration cycle")
fmt.Fprintln(w, " sao init-machine Create ~/.config/sao/config.yaml")
fmt.Fprintln(w, " sao init-project Create repo config and register current repo")
fmt.Fprintln(w, " sao init-repo Create .simple-agent-orchestration.yaml in the current repo")
fmt.Fprintln(w, " sao add-repo PATH Register a repo in machine config")
fmt.Fprintln(w, " sao update Install the latest released sao binary")
fmt.Fprintln(w, " sao validate Validate configs, git remotes, gh auth, and agent prerequisites")
fmt.Fprintln(w, " sao agents Show configured agents")
fmt.Fprintln(w, " sao plan Show ranked candidate tasks without dispatching")
Expand Down Expand Up @@ -142,19 +148,62 @@ func initRepo(stdout io.Writer) error {
if err != nil {
return err
}
created, err := ensureRepoConfig(repoRoot)
if err != nil {
return err
}
if created {
fmt.Fprintf(stdout, "created repo config: %s\n", config.RepoConfigPath(repoRoot))
} else {
fmt.Fprintf(stdout, "repo config already exists: %s\n", config.RepoConfigPath(repoRoot))
}
return nil
}

func initProject(stdout io.Writer) error {
repoRoot, err := resolveRepoRoot(".")
if err != nil {
return err
}

createdRepoConfig, err := ensureRepoConfig(repoRoot)
if err != nil {
return err
}
if createdRepoConfig {
fmt.Fprintf(stdout, "created repo config: %s\n", config.RepoConfigPath(repoRoot))
} else {
fmt.Fprintf(stdout, "repo config already exists: %s\n", config.RepoConfigPath(repoRoot))
}

machinePath, err := config.MachineConfigPath()
if err != nil {
return err
}
cfg, err := ensureMachineConfig(machinePath)
if err != nil {
return err
}
cfg = config.AddProject(cfg, repoRoot)
if err := config.SaveMachineConfig(machinePath, cfg); err != nil {
return err
}
fmt.Fprintf(stdout, "registered repo: %s\n", repoRoot)
return nil
}

func ensureRepoConfig(repoRoot string) (bool, error) {
path := config.RepoConfigPath(repoRoot)
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(stdout, "repo config already exists: %s\n", path)
return nil
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
return false, err
}
cfg := config.DefaultRepoConfig()
if err := config.SaveRepoConfig(path, cfg); err != nil {
return err
return false, err
}
fmt.Fprintf(stdout, "created repo config: %s\n", path)
return nil
return true, nil
}

func addRepo(args []string, stdout io.Writer) error {
Expand All @@ -181,6 +230,37 @@ func addRepo(args []string, stdout io.Writer) error {
return nil
}

func update(ctx context.Context, stdout, stderr io.Writer) error {
if _, err := exec.LookPath("bash"); err != nil {
return errors.New("required CLI missing: bash")
}
if _, err := exec.LookPath("curl"); err != nil {
return errors.New("required CLI missing: curl")
}

repo := os.Getenv("SAO_REPO")
if repo == "" {
repo = "code-rabi/simple-agent-orchastration"
}
installerURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/install.sh", repo)

cmd := exec.CommandContext(ctx, "bash", "-c", `set -euo pipefail; curl -fsSL "$SAO_INSTALL_URL" | bash`)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Env = append(os.Environ(), "SAO_INSTALL_URL="+installerURL)
if os.Getenv("SAO_INSTALL_DIR") == "" {
if installDir, ok := currentInstallDir(); ok {
cmd.Env = append(cmd.Env, "SAO_INSTALL_DIR="+installDir)
}
}

fmt.Fprintf(stdout, "updating sao from %s\n", installerURL)
if err := cmd.Run(); err != nil {
return fmt.Errorf("update failed: %w", err)
}
return nil
}

func validate(stdout, stderr io.Writer) error {
var problems []string

Expand Down Expand Up @@ -336,6 +416,41 @@ func loadMachineConfig() (config.MachineConfig, error) {
return config.LoadMachineConfig(machinePath)
}

func currentInstallDir() (string, bool) {
executable, err := os.Executable()
if err != nil {
return "", false
}
executable, err = filepath.EvalSymlinks(executable)
if err != nil {
return "", false
}
switch filepath.Base(executable) {
case "sao", "sao.exe":
default:
return "", false
}

dir := filepath.Dir(executable)
if !dirIsWritable(dir) {
return "", false
}
return dir, true
}

func dirIsWritable(dir string) bool {
file, err := os.CreateTemp(dir, ".sao-write-test-*")
if err != nil {
return false
}
name := file.Name()
if closeErr := file.Close(); closeErr != nil {
_ = os.Remove(name)
return false
}
return os.Remove(name) == nil
}

func effectivePollInterval(cfg config.MachineConfig) time.Duration {
seconds := cfg.Runtime.PollIntervalSeconds
if seconds <= 0 {
Expand Down
62 changes: 62 additions & 0 deletions internal/sao/sao_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package sao

import (
"bytes"
"context"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

Expand All @@ -10,6 +16,62 @@ import (
"github.com/nitayr/simple-agent-orchastration/internal/state"
)

func TestInitProjectCreatesRepoConfigAndRegistersProject(t *testing.T) {
tmpDir := t.TempDir()
repoDir := filepath.Join(tmpDir, "repo")
if err := os.Mkdir(repoDir, 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
cmd := exec.Command("git", "init")
cmd.Dir = repoDir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init error = %v\n%s", err, output)
}

t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "config"))
t.Setenv("HOME", tmpDir)

originalWD, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
if err := os.Chdir(repoDir); err != nil {
t.Fatalf("Chdir() error = %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(originalWD); err != nil {
t.Errorf("restore working directory: %v", err)
}
})

var stdout bytes.Buffer
if err := Run(context.Background(), []string{"init-project"}, &stdout, io.Discard); err != nil {
t.Fatalf("Run(init-project) error = %v", err)
}

if _, err := os.Stat(config.RepoConfigPath(repoDir)); err != nil {
t.Fatalf("repo config was not created: %v", err)
}

machinePath, err := config.MachineConfigPath()
if err != nil {
t.Fatalf("MachineConfigPath() error = %v", err)
}
cfg, err := config.LoadMachineConfig(machinePath)
if err != nil {
t.Fatalf("LoadMachineConfig() error = %v", err)
}
if len(cfg.Projects) != 1 {
t.Fatalf("len(cfg.Projects) = %d, want 1", len(cfg.Projects))
}
if cfg.Projects[0].Path != repoDir {
t.Fatalf("cfg.Projects[0].Path = %q, want %q", cfg.Projects[0].Path, repoDir)
}
if !cfg.Projects[0].Enabled {
t.Fatal("cfg.Projects[0].Enabled = false, want true")
}
}

func TestSelectDispatchPlansHonorsLimits(t *testing.T) {
t.Parallel()

Expand Down