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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Run a single test: `go test -run TestWriteConfig_Idempotent ./...`

The binary lives under `cmd/`; the domain logic is split into `internal/` packages so each concern is isolated and independently testable.

- `cmd/outfit/main.go` — CLI: command dispatch (`add`/`remove`/`list`/`show`/`apply`/`unapply`/`serve`/`export`/`harness`/`init-providers`), flag parsing (`parseSelection` registers both long and short flags against the same vars, and returns the chosen harness name separately so it never leaks into a `Selection`), and user-facing output. `add` and `apply` share `applySelection`, the core that writes one provider selection; `remove` and `unapply` share `removeSelection`, its inverse. Both resolve the active harness (`harness.Resolve`) and route through it. `show` reports the active harness's current config via `harness.State` (the same read `export` reconstructs an Outfit from). `serve` is harness-agnostic (it launches `llama-server`, not a config write). `harness` launches the active harness (forwarding trailing args), or manages the stored default with `--get`/`--set`. Tests: `main_test.go` (dispatch/add/remove/list), `apply_test.go` (`apply`/`unapply`/`export`), `harness_test.go` (the `harness` command, `show`, and Pi-routed add/export/remove), `serve_test.go`.
- `cmd/outfit/main.go` — CLI: command dispatch (`add`/`remove`/`list`/`show`/`apply`/`unapply`/`serve`/`export`/`harness`/`init-providers`), flag parsing (`parseSelection` registers both long and short flags against the same vars, and returns the chosen harness name separately so it never leaks into a `Selection`), and user-facing output. `add` and `apply` share `applySelection`, the core that writes one provider selection; `remove` and `unapply` share `removeSelection`, its inverse. Both resolve the active harness (`harness.Resolve`) and route through it. `show` reports the active harness's current config via `harness.State` (the same read `export` reconstructs an Outfit from). `serve` is harness-agnostic (it launches `llama-server`, not a config write). `harness` launches the active harness (forwarding trailing args), or manages the stored default with `--get`/`--set`; its `--outfit`/`-O` routes through the same `applySelection` before launching, so a launch can dress the harness first. That flag's value is optional (`outfitPathFlag` implements `IsBoolFlag`) — bare it means the default `./Outfit`, so a path has to be attached (`--outfit=<path>`), since positional args belong to the harness. Tests: `main_test.go` (dispatch/add/remove/list), `apply_test.go` (`apply`/`unapply`/`export`), `harness_test.go` (the `harness` command, `show`, and Pi-routed add/export/remove), `serve_test.go`.
- `internal/outfit` — the `Outfit` file format and the shared `Selection` type: a flat, Dockerfile-style description of one provider selection (`PROVIDER`/`FAMILY`/`MODEL`/`ALIAS`/`CONTEXT`/`OUTPUT`/`BASEURL`/`PRESET`). `Parse` reads it into a `Selection` (keywords case-insensitive via `canonicalKeyword`, UPPERCASE canonical, `#` comments); `Format` renders one back out for `export`. `apply` defaults to `./Outfit` (`outfit.DefaultFile`). The harness is deliberately **not** a keyword — it is a runtime choice, so an Outfit stays portable.
- `internal/harness` — the harness abstraction. The `Harness` interface (`Name`/`Command`/`ConfigPath`/`Apply`/`Remove`/`State`), a `registry` of the opencode and pi adapters (`adapters.go`, which wrap `catalog` + `opencode`/`pi` + `contextsize`), runtime selection via `Resolve` with precedence **`--harness`/`-H` flag > `OUTFIT_HARNESS` env > stored preference > opencode**, and preference load/save (`LoadPreference`/`SavePreference`) in `${XDG_CONFIG_HOME:-~/.config}/outfit/config.json`. **Start here when adding a third harness:** implement the interface and register it.
- `internal/catalog` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and the two block builders: `BuildProviderBlock` turns a provider+family+model selection into an opencode provider block; `BuildPiProvider` turns the same into a Pi provider entry. `MatchFamily` does the reverse for `export` (configured models -> family name). The catalogue can be overridden at runtime via the `--providers` flag or `OUTFIT_PROVIDERS` env var (flag > env > embedded; see `ResolveCatalogPath`/`LoadFrom`). `providers.yaml` lives here.
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,12 @@ outfit unapply [path] # remove what an Outfit file selects
outfit serve [path] [--dry-run] # run llama-server from the Outfit's PRESET
outfit export [--provider <name>] # print the current config as an Outfit
outfit init-providers [path] # write the built-in catalogue out to edit
outfit harness [-H <name>] [args...] # launch the harness (--get shows it; --set stores the default)
outfit harness [-H <name>] [--outfit[=<path>]] [args...]
# launch the harness (--outfit applies one first;
# --get shows it; --set stores the default)
```

Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness).
Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (outfit).

## Harnesses

Expand All @@ -172,10 +174,18 @@ outfit harness --get # show the current default
outfit harness # launch the active harness (forwards trailing args)
outfit harness -H pi # launch a specific harness, ignoring the default

outfit harness -O # apply ./Outfit, then launch the harness
outfit harness --outfit=path/to/Outfit # ...applying a specific one first

outfit show # what the active harness has configured
outfit show --harness pi # ...for a specific harness, without changing the default
```

`outfit harness --outfit`/`-O` applies an `Outfit` on the way in — the same work
`outfit apply` does — so one command dresses the agent and launches it. Given
bare it takes `./Outfit` like every other command; to name one, attach the path
(`--outfit=<path>`), since everything positional is forwarded to the harness.

Where `outfit list` shows the catalogue of providers you *could* configure,
`outfit show` reports what a harness *currently has* configured — its providers,
each provider's models with their context/output limits, and the default model.
Expand Down Expand Up @@ -208,6 +218,7 @@ BASEURL https://gateway/v1 # optional; API base URL override
outfit apply # reads ./Outfit and applies it
outfit apply path/to/Outfit
outfit apply path/to/dir # or a directory that holds an Outfit
outfit harness -O # apply ./Outfit, then launch the agent wearing it
outfit export > Outfit # capture your current setup as an Outfit
```

Expand Down
122 changes: 122 additions & 0 deletions cmd/outfit/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,128 @@ func TestHarness_GetAndSet(t *testing.T) {
}
}

// stubHarnessBinary puts a script named after the harness binary first on PATH,
// recording its argv to argsFile, so a launch can be tested without a real agent
// installed. It returns nothing; the caller reads argsFile.
func stubHarnessBinary(t *testing.T, name, argsFile string) {
t.Helper()
dir := t.TempDir()
body := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + argsFile + "\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}

// TestHarness_AppliesOutfitBeforeLaunch checks that --outfit applies the named
// Outfit — the same work `outfit apply` does — and then launches the harness
// with the trailing args.
func TestHarness_AppliesOutfitBeforeLaunch(t *testing.T) {
home := isolateConfig(t)
argsFile := filepath.Join(t.TempDir(), "args")
stubHarnessBinary(t, "opencode", argsFile)

outfitPath := filepath.Join(t.TempDir(), "Outfit")
mustWrite(t, outfitPath, "PROVIDER llamacpp\nMODEL gemma\nCONTEXT 128k\n")

out := captureStdout(t, func() {
if err := cmdHarness([]string{"--outfit=" + outfitPath, "run", "hello"}); err != nil {
t.Fatalf("cmdHarness --outfit: %v", err)
}
})
if !strings.Contains(out, "Applying "+outfitPath) {
t.Errorf("apply not reported:\n%s", out)
}

m := readConfigMap(t, filepath.Join(home, ".config", "opencode", "opencode.json"))
if m["model"] != "llamacpp/gemma" {
t.Errorf("Outfit was not applied before launch: default model = %v", m["model"])
}

// The harness still ran, with the trailing args forwarded to it.
got, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("harness was not launched: %v", err)
}
if strings.TrimSpace(string(got)) != "run\nhello" {
t.Errorf("forwarded args = %q, want \"run\\nhello\"", got)
}
}

// TestHarness_OutfitDefaultsToCurrentDirectory checks that a bare --outfit takes
// the default Outfit in the working directory, as a bare `outfit apply` does.
func TestHarness_OutfitDefaultsToCurrentDirectory(t *testing.T) {
home := isolateConfig(t)
stubHarnessBinary(t, "opencode", filepath.Join(t.TempDir(), "args"))

dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "Outfit"), "PROVIDER llamacpp\nMODEL gemma\n")
t.Chdir(dir)

captureStdout(t, func() {
if err := cmdHarness([]string{"-O"}); err != nil {
t.Fatalf("cmdHarness -O: %v", err)
}
})

m := readConfigMap(t, filepath.Join(home, ".config", "opencode", "opencode.json"))
if m["model"] != "llamacpp/gemma" {
t.Errorf("./Outfit was not applied: default model = %v", m["model"])
}
}

// TestHarness_OutfitErrors covers the ways --outfit can be given wrongly: no
// Outfit to default to, an unreadable path, and a path left detached from the
// flag (which would otherwise be forwarded to the harness).
func TestHarness_OutfitErrors(t *testing.T) {
isolateConfig(t)
stubHarnessBinary(t, "opencode", filepath.Join(t.TempDir(), "args"))

dir := t.TempDir()
t.Chdir(dir)

err := cmdHarness([]string{"--outfit"})
if err == nil || !strings.Contains(err.Error(), "outfit harness --outfit=<file>") {
t.Errorf("expected a hint naming the flag, got %v", err)
}

if err := cmdHarness([]string{"--outfit=" + filepath.Join(dir, "nope")}); err == nil {
t.Error("expected an error for a missing Outfit")
}

// A detached path is a typo, not a harness arg: the flag takes no value, so
// the Outfit would silently be the default one.
mustWrite(t, filepath.Join(dir, "Outfit"), "PROVIDER llamacpp\nMODEL gemma\n")
other := filepath.Join(t.TempDir(), "Outfit")
mustWrite(t, other, "PROVIDER llamacpp\nMODEL other\n")
err = cmdHarness([]string{"--outfit", other})
if err == nil || !strings.Contains(err.Error(), "--outfit="+other) {
t.Errorf("expected the detached-path hint, got %v", err)
}
}

// TestHarness_GetDoesNotApply checks that --get stays an inspection command:
// it reports the harness and applies nothing, even alongside --outfit.
func TestHarness_GetDoesNotApply(t *testing.T) {
home := isolateConfig(t)

dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "Outfit"), "PROVIDER llamacpp\nMODEL gemma\n")
t.Chdir(dir)

out := captureStdout(t, func() {
if err := cmdHarness([]string{"--get", "-O"}); err != nil {
t.Fatalf("cmdHarness --get -O: %v", err)
}
})
if !strings.Contains(out, "Active harness: opencode") {
t.Errorf("unexpected --get output:\n%s", out)
}
if _, err := os.Stat(filepath.Join(home, ".config", "opencode", "opencode.json")); !os.IsNotExist(err) {
t.Error("--get should not have applied the Outfit")
}
}

func TestCmdShow(t *testing.T) {
isolateConfig(t)
t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test")
Expand Down
Loading