diff --git a/AGENTS.md b/AGENTS.md index d8351b9c..30815247 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=`), 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. diff --git a/README.md b/README.md index 6b18a16b..4e52933f 100644 --- a/README.md +++ b/README.md @@ -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 ] # print the current config as an Outfit outfit init-providers [path] # write the built-in catalogue out to edit -outfit harness [-H ] [args...] # launch the harness (--get shows it; --set stores the default) +outfit harness [-H ] [--outfit[=]] [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 @@ -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=`), 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. @@ -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 ``` diff --git a/cmd/outfit/harness_test.go b/cmd/outfit/harness_test.go index c899ddc1..fd1bc425 100644 --- a/cmd/outfit/harness_test.go +++ b/cmd/outfit/harness_test.go @@ -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=") { + 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") diff --git a/cmd/outfit/main.go b/cmd/outfit/main.go index e62ecac0..45d2a135 100644 --- a/cmd/outfit/main.go +++ b/cmd/outfit/main.go @@ -20,10 +20,11 @@ // outfit serve [path] # run llama-server for the Outfit (PRESET or MODEL) // outfit export [-p name] # print the current config as an Outfit // outfit init-providers [path] # write the embedded providers.yaml out -// outfit harness [-H name] # launch the harness (--get shows it; --set stores the default) +// outfit harness [-H name] [-O[=path]] # launch the harness, optionally applying an +// # Outfit 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). +// -c (context), -o (output), -u (base-url), -H (harness), -O (outfit). // // The API base URL can be overridden for any provider with --base-url/-u or the // OUTFIT_BASE_URL environment variable; the flag wins over the env var, and @@ -118,7 +119,8 @@ Usage: outfit serve [path] [--dry-run] (run llama-server from the PRESET) outfit export [--provider ] outfit init-providers [path] (defaults to ./providers.yaml) - outfit harness [-H ] [args...] (launch the harness; available: %s) + outfit harness [-H ] [--outfit[=]] [args...] + (launch the harness; available: %s) outfit version (or -v/--version) Flags: @@ -135,6 +137,9 @@ Flags: (or set OUTFIT_BASE_URL) -H, --harness which harness to configure (or set OUTFIT_HARNESS); overrides the stored default + -O, --outfit (harness only) apply this Outfit before launching; given + bare it applies ./`+outfit.DefaultFile+`, so attach the path + when naming one: --outfit= --providers path to a providers.yaml override (or set OUTFIT_PROVIDERS) @@ -157,9 +162,11 @@ init-providers: writes the binary's built-in providers.yaml to the working directory (or [path]) so you can customise the catalogue and point outfit at it with --providers/OUTFIT_PROVIDERS. Refuses to overwrite an existing file unless --force is given. -harness: launches the active harness, forwarding any trailing args to it. --get - prints the active harness instead of launching it; --set stores the - default harness and exits. Honours -H/--harness and OUTFIT_HARNESS. +harness: launches the active harness, forwarding any trailing args to it. + --outfit/-O applies an Outfit first, as if you had run apply before it. + --get prints the active harness instead of launching it; --set + stores the default harness and exits. Honours -H/--harness and + OUTFIT_HARNESS. show: lists the providers and models actually configured in the active harness's config (where list shows the catalogue of what you could configure). `, strings.Join(harness.Names(), ", ")) @@ -276,11 +283,12 @@ func applySelection(sel outfit.Selection, h harness.Harness) error { // readOutfit reads and parses the Outfit at path, defaulting to ./Outfit when // path is empty so a bare command works in a directory that holds one. When // path names a directory, the default Outfit file inside it is used, so a -// caller can pass either the file itself or the directory that holds it. cmd -// names the calling subcommand for the not-found hint. It returns the parsed +// caller can pass either the file itself or the directory that holds it. usage +// shows the caller's own way of naming a path (subcommands take it positionally, +// `harness` takes it as a flag) in the not-found hint. It returns the parsed // selection alongside the resolved path, which callers use to locate files // referenced relative to the Outfit. -func readOutfit(cmd, path string) (outfit.Selection, string, error) { +func readOutfit(usage, path string) (outfit.Selection, string, error) { if path == "" { path = outfit.DefaultFile } @@ -290,7 +298,7 @@ func readOutfit(cmd, path string) (outfit.Selection, string, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) && path == outfit.DefaultFile { - return outfit.Selection{}, path, fmt.Errorf("no %s found in the current directory (pass a path: outfit %s )", outfit.DefaultFile, cmd) + return outfit.Selection{}, path, fmt.Errorf("no %s found in the current directory (pass a path: %s)", outfit.DefaultFile, usage) } return outfit.Selection{}, path, fmt.Errorf("reading %s: %w", path, err) } @@ -325,7 +333,7 @@ func cmdApply(args []string) error { if rest := fs.Args(); len(rest) > 0 { path = rest[0] } - sel, _, err := readOutfit("apply", path) + sel, _, err := readOutfit("outfit apply ", path) if err != nil { return err } @@ -359,7 +367,7 @@ func cmdUnapply(args []string) error { if rest := fs.Args(); len(rest) > 0 { path = rest[0] } - sel, _, err := readOutfit("unapply", path) + sel, _, err := readOutfit("outfit unapply ", path) if err != nil { return err } @@ -388,7 +396,7 @@ func cmdServe(args []string) error { if rest := fs.Args(); len(rest) > 0 { path = rest[0] } - sel, outfitPath, err := readOutfit("serve", path) + sel, outfitPath, err := readOutfit("outfit serve ", path) if err != nil { return err } @@ -703,18 +711,51 @@ func removeSelection(sel outfit.Selection, h harness.Harness) error { return nil } +// outfitPathFlag is the harness command's --outfit/-O flag: the Outfit to apply +// before launching, whose value is optional. Given bare it means the default +// Outfit, exactly as a bare `outfit apply` does; --outfit= names one. +// The value has to be attached because everything positional after the flags +// belongs to the harness. +type outfitPathFlag struct { + set bool + path string +} + +func (f *outfitPathFlag) String() string { return f.path } + +// Set records the flag. The flag package passes "true" for the valueless form +// (see IsBoolFlag); that stands for the default Outfit, which is the empty path +// readOutfit resolves to ./Outfit. +func (f *outfitPathFlag) Set(v string) error { + f.set = true + if v == "true" { + v = "" + } + f.path = v + return nil +} + +// IsBoolFlag lets --outfit be given without a value. +func (f *outfitPathFlag) IsBoolFlag() bool { return true } + // cmdHarness launches the active harness, or with --set/--get manages and // reports the stored preference. The harness is resolved with the same // precedence as add/apply (--harness/-H > OUTFIT_HARNESS > preference > // default), and any trailing args after the flags are passed to the harness. +// With --outfit/-O it applies an Outfit first, so a single command dresses the +// harness and then runs it. func cmdHarness(args []string) error { fs := flag.NewFlagSet("harness", flag.ContinueOnError) - var set, harnessName string + var set, harnessName, providers string var get bool + var outfitPath outfitPathFlag fs.StringVar(&set, "set", "", "store this harness as the default and exit") fs.BoolVar(&get, "get", false, "print the active harness instead of launching it") fs.StringVar(&harnessName, "harness", "", "which harness to launch") fs.StringVar(&harnessName, "H", "", "which harness to launch (shorthand)") + fs.Var(&outfitPath, "outfit", "apply this Outfit before launching (bare: ./"+outfit.DefaultFile+")") + fs.Var(&outfitPath, "O", "apply this Outfit before launching (shorthand)") + fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") if err := fs.Parse(args); err != nil { return err } @@ -732,6 +773,8 @@ func cmdHarness(args []string) error { return err } + // --get reports the harness rather than running anything, so it applies + // nothing either. if get { pref, _ := harness.LoadPreference() fmt.Printf("Active harness: %s (from %s)\n", h.Name(), source) @@ -744,6 +787,12 @@ func cmdHarness(args []string) error { return nil } + if outfitPath.set { + if err := applyBeforeLaunch(outfitPath, providers, h, fs.Args()); err != nil { + return err + } + } + // Launch the harness, forwarding stdio and any trailing args. bin := h.Command() cmd := exec.Command(bin, fs.Args()...) @@ -764,6 +813,45 @@ func cmdHarness(args []string) error { return nil } +// applyBeforeLaunch applies the Outfit named by --outfit/-O to the harness that +// is about to be launched — exactly the work `outfit apply` does, so one command +// can dress the harness and then run it. rest is what will be forwarded to the +// harness, inspected only to catch a path that was meant for the flag. +func applyBeforeLaunch(f outfitPathFlag, providers string, h harness.Harness, rest []string) error { + // The flag's value has to be attached, so `--outfit ./dev/Outfit` would + // otherwise apply ./Outfit and quietly hand the path to the harness. + if f.path == "" && len(rest) > 0 && namesAnOutfit(rest[0]) { + return fmt.Errorf("--outfit takes its path attached: --outfit=%s (a bare --outfit applies ./%s)", rest[0], outfit.DefaultFile) + } + sel, path, err := readOutfit("outfit harness --outfit=", f.path) + if err != nil { + return err + } + // As for apply, --providers overrides the catalogue the selection resolves + // against (an Outfit never names one). + sel.Providers = providers + fmt.Printf("Applying %s\n\n", path) + if err := applySelection(sel, h); err != nil { + return err + } + fmt.Println() + return nil +} + +// namesAnOutfit reports whether arg points at an Outfit file, or at a directory +// holding one — the two shapes readOutfit accepts. +func namesAnOutfit(arg string) bool { + info, err := os.Stat(arg) + if err != nil { + return false + } + if info.IsDir() { + _, err := os.Stat(filepath.Join(arg, outfit.DefaultFile)) + return err == nil + } + return filepath.Base(arg) == outfit.DefaultFile +} + // cmdShow prints the providers and models currently configured in the active // harness's config. It takes the same --harness/-H override (and the same // flag > env > preference > default precedence) as every other command, so you diff --git a/docs/outfit-file.md b/docs/outfit-file.md index 1c1cf3d3..64a68e5a 100644 --- a/docs/outfit-file.md +++ b/docs/outfit-file.md @@ -40,6 +40,19 @@ a directory that holds an `Outfit` and it reads the file inside. Add After applying, just run your coding agent (`opencode`, or `pi`). +Or do both at once — `outfit harness` takes the same Outfit, as `--outfit`/`-O`, +and applies it before launching the agent: + +```sh +outfit harness -O # apply ./Outfit, then launch +outfit harness --outfit=path/to/Outfit # ...or a specific one +outfit harness --outfit=path/to/dir # ...or a directory holding an Outfit +``` + +Given bare, `--outfit` defaults to `./Outfit` like `apply` does; when you name a +path, attach it to the flag, because anything positional is forwarded to the +agent (`outfit harness -O run --model x` passes `run --model x` on). + To undo it again, `outfit unapply` (same default path and arguments) removes what the Outfit selects from the active harness's config — the inverse of `apply`, just as `remove` is to `add`. It honours `--harness`/`-H` and