diff --git a/.gitignore b/.gitignore index 56345a5e..62e99853 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ # Secrets — contains the OpenRouter API key .env -# Compiled binary (when built with `go build`) +# Compiled binary (`go build -o oc-config .`, or the default `go build .` name) /oc-config +/configure-opencode # GoReleaser output /dist/ diff --git a/AGENTS.md b/AGENTS.md index 31790e46..28acc249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,11 +20,13 @@ Run a single test: `go test -run TestWriteConfig_Idempotent ./...` ## Layout -- `main.go` — CLI: command dispatch (`add`/`remove`/`list`), flag parsing (`parseSelection` registers both long and short flags against the same vars), and user-facing output. -- `catalog.go` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and `buildProviderBlock`, which turns a provider+family+model selection into an opencode provider block. The catalogue can be overridden at runtime via the `--providers` flag or `OC_CONFIG_PROVIDERS` env var (flag > env > embedded; see `resolveCatalogPath`/`loadCatalogFrom`). -- `config.go` — opencode config IO: JSONC read/merge/write, env/key resolution, JSON-Pointer helpers. +- `main.go` — CLI: command dispatch (`add`/`remove`/`list`/`apply`/`export`), flag parsing (`parseSelection` registers both long and short flags against the same vars), and user-facing output. `add` and `apply` share `applySelection`, the core that writes one provider selection. +- `outfit.go` — the `Outfit` file format: a flat, Dockerfile-style description of one provider selection (`PROVIDER`/`FAMILY`/`MODEL`/`CONTEXT`/`BASEURL`, the last two mapping to `--context`/`--base-url`). `parseOutfit` reads it into a `selection` (keywords case-insensitive via `canonicalKeyword`, UPPERCASE canonical, `#` comments); `formatOutfit` renders one back out for `export`. `apply` defaults to `./Outfit` (`DefaultOutfitFile`). +- `catalog.go` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and `buildProviderBlock`, which turns a provider+family+model selection into an opencode provider block. `matchFamily` does the reverse for `export` (configured models -> family name). The catalogue can be overridden at runtime via the `--providers` flag or `OC_CONFIG_PROVIDERS` env var (flag > env > embedded; see `resolveCatalogPath`/`loadCatalogFrom`). +- `config.go` — opencode config IO: JSONC read/merge/write, env/key resolution, JSON-Pointer helpers. `loadConfigState` reads the config back (configured providers, their model keys, the default model) for `export`. - `providers.yaml` — externalised provider/model-family data (URLs, model ids, key env vars). **Add providers/models here, not in Go.** Embedded at build time but kept external for maintenance. -- `*_test.go` — `catalog_test.go` (catalogue integrity + `buildProviderBlock`), `config_test.go` (merge/remove/IO), `main_test.go` (CLI layer). +- `examples/` — runnable guides, each a directory with a README and an `Outfit`. +- `*_test.go` — `catalog_test.go` (catalogue integrity + `buildProviderBlock`), `config_test.go` (merge/remove/IO), `main_test.go` (CLI layer), `outfit_test.go` (Outfit parse/format + `apply`/`export`). ## Architecture notes (the important part) diff --git a/CHANGELOG.md b/CHANGELOG.md index 081ec676..194d74d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- feat: add `Outfit` files — declarative, Dockerfile-style provider selections + applied with `oc-config apply` (defaults to `./Outfit`). Supports `PROVIDER`, + `FAMILY`, `MODEL`, `CONTEXT`, and `BASEURL` instructions +- feat: add `oc-config export` to capture the current config as an `Outfit` + +### Changed +- docs: document the `Outfit` file format in `docs/outfit-file.md` +- docs: move the llama.cpp guides under `examples/`, each with an `Outfit` + ## [0.2.0] - 2026-06-22 ### Added - feat: allow the provider catalogue to be overridden at runtime diff --git a/README.md b/README.md index 3be208be..3baf3af2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Then just run `opencode`. oc-config list oc-config add --provider [--model-family ] [--model ] [--context ] [--base-url ] oc-config remove --provider [--model-family ] [--model ] +oc-config apply [path] # apply an Outfit file (default ./Outfit) +oc-config export [--provider ] # print the current config as an Outfit ``` Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-c` (context), `-b` (base-url). @@ -96,6 +98,30 @@ if it pointed at something you removed. forgiving: `128k`, `1m`, `1.5m`, `200000`, `128,000`, even `128 K tokens` all land where you'd expect (`k`/`m`/`g` are decimal — `128k` is 128,000 tokens). +## Outfit files + +Prefer to keep a provider selection in a file — like a `Dockerfile`, but for +opencode? Drop an **Outfit** in your project: + +```dockerfile +# Outfit +PROVIDER openrouter +FAMILY deepseek-v4 +MODEL deepseek/deepseek-v4-pro # optional; becomes the default +CONTEXT 128k # optional; context window +BASEURL https://gateway/v1 # optional; API base URL override +``` + +```sh +oc-config apply # reads ./Outfit and applies it +oc-config apply path/to/Outfit +oc-config export > Outfit # capture your current setup as an Outfit +``` + +An Outfit describes one provider selection and applies exactly like the +equivalent `add`. Full syntax is in [`docs/outfit-file.md`](docs/outfit-file.md), +and ready-to-use examples live under [`examples/`](examples/). + ## Keys and endpoints Each provider declares which environment variable holds its key (`oc-config @@ -118,10 +144,11 @@ and the per-provider variables (`OLLAMA_BASE_URL`, `LLAMACPP_BASE_URL`, ## Guides -Provider- and model-specific walkthroughs live in [`docs/`](docs/): +Provider- and model-specific walkthroughs live in [`examples/`](examples/), each +with a ready-to-apply `Outfit`: -- [Qwen3.6-35B-A3B on llama.cpp](docs/llamacpp/qwen3.6.md) -- [Gemma-4-12B-IT on llama.cpp](docs/llamacpp/gemma4.md) +- [Qwen3.6-35B-A3B on llama.cpp](examples/llamacpp/qwen3.6/README.md) +- [Gemma-4-12B-IT on llama.cpp](examples/llamacpp/gemma4/README.md) ## Adding providers and models diff --git a/catalog.go b/catalog.go index 8b2ba2a7..524f5940 100644 --- a/catalog.go +++ b/catalog.go @@ -113,6 +113,33 @@ func (f *Family) modelKeys() []string { return keys } +// matchFamily returns the name of the provider family whose model set exactly +// matches keys, or "" if none does. It lets `oc-config export` name a family +// instead of listing the individual models it expands to. +func matchFamily(p *Provider, keys []string) string { + want := make(map[string]bool, len(keys)) + for _, k := range keys { + want[k] = true + } + for _, name := range p.sortedFamilyNames() { + fk := p.Families[name].modelKeys() + if len(fk) != len(want) { + continue + } + matched := true + for _, k := range fk { + if !want[k] { + matched = false + break + } + } + if matched { + return name + } + } + return "" +} + // buildProviderBlock turns a provider plus an optional family and/or explicit // model into an opencode provider block, returning the block and the // fully-qualified default model (provider/model), or "" if none was selected. diff --git a/catalog_test.go b/catalog_test.go index 7739047c..ae895189 100644 --- a/catalog_test.go +++ b/catalog_test.go @@ -62,6 +62,35 @@ func TestCatalogIntegrity(t *testing.T) { } } +// TestMatchFamily checks the reverse lookup used by `oc-config export`: a set of +// configured model keys maps back to a family only when it matches exactly. +func TestMatchFamily(t *testing.T) { + cat, _ := loadCatalog() + p := cat.Providers["openrouter"] + famKeys := p.Families["deepseek-v4"].modelKeys() + + if got := matchFamily(p, famKeys); got != "deepseek-v4" { + t.Errorf("exact match = %q, want deepseek-v4", got) + } + + // A superset (all the family's models plus a stray) is not a match. + if got := matchFamily(p, append(append([]string{}, famKeys...), "stray-model")); got != "" { + t.Errorf("superset matched %q, want no match", got) + } + + // A subset (one model short) is not a match either. + if len(famKeys) > 1 { + if got := matchFamily(p, famKeys[:len(famKeys)-1]); got != "" { + t.Errorf("subset matched %q, want no match", got) + } + } + + // Unrelated keys match nothing. + if got := matchFamily(p, []string{"something-else"}); got != "" { + t.Errorf("unrelated keys matched %q, want no match", got) + } +} + func TestResolveCatalogPath(t *testing.T) { t.Setenv(providersEnv, "/from/env.yaml") if got := resolveCatalogPath("/from/flag.yaml"); got != "/from/flag.yaml" { diff --git a/config.go b/config.go index bc035ba2..5525b185 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "github.com/tailscale/hujson" @@ -197,6 +198,61 @@ func removeConfig(path, providerID string, modelKeys []string) (int, error) { return removed, nil } +// providerState is one configured provider, read back from the opencode config: +// its model keys (sorted), any options.baseURL, and the per-model limit.context +// for those models that set one. It is what `oc-config export` reconstructs an +// Outfit from. +type providerState struct { + modelKeys []string + baseURL string + contexts map[string]int +} + +// loadConfigState reads the opencode config and reports each configured +// provider's state plus the top-level default model. It is the inverse of +// writeConfig, used to reconstruct an Outfit on export. +func loadConfigState(path string) (providers map[string]providerState, defaultModel string, err error) { + root, err := loadRoot(path) + if err != nil { + return nil, "", err + } + if m := root.Find("/model"); m != nil { + _ = json.Unmarshal(m.Pack(), &defaultModel) + } + + providers = map[string]providerState{} + pv := root.Find("/provider") + if pv == nil { + return providers, defaultModel, nil + } + var raw map[string]struct { + Options struct { + BaseURL string `json:"baseURL"` + } `json:"options"` + Models map[string]struct { + Limit struct { + Context int `json:"context"` + } `json:"limit"` + } `json:"models"` + } + if err := json.Unmarshal(pv.Pack(), &raw); err != nil { + return nil, "", fmt.Errorf("reading providers from %s: %w", path, err) + } + for name, p := range raw { + keys := make([]string, 0, len(p.Models)) + contexts := map[string]int{} + for k, m := range p.Models { + keys = append(keys, k) + if m.Limit.Context > 0 { + contexts[k] = m.Limit.Context + } + } + sort.Strings(keys) + providers[name] = providerState{modelKeys: keys, baseURL: p.Options.BaseURL, contexts: contexts} + } + return providers, defaultModel, nil +} + // applyPatch marshals and applies an RFC 6902 patch to the config AST. func applyPatch(root *hujson.Value, ops []map[string]any) error { patch, err := json.Marshal(ops) diff --git a/docs/outfit-file.md b/docs/outfit-file.md new file mode 100644 index 00000000..6e260e98 --- /dev/null +++ b/docs/outfit-file.md @@ -0,0 +1,107 @@ +# The `Outfit` file + +An **Outfit** is a small, declarative file that captures one opencode provider +selection — which provider, and which model family and/or model — so you can +apply it with a single command instead of remembering flags. Think of it like a +`Dockerfile`, but for pointing opencode at a model. + +```dockerfile +# Outfit — point opencode at one provider +PROVIDER openrouter +FAMILY deepseek-v4 +MODEL deepseek/deepseek-v4-pro # optional; becomes the default model +CONTEXT 128k # optional; context window +BASEURL https://gateway/v1 # optional; API base URL override +``` + +Applying it is the same as running the equivalent `oc-config add`, so everything +you already have in your opencode config is preserved. + +## Applying an Outfit + +```sh +oc-config apply # reads ./Outfit in the current directory +oc-config apply path/to/Outfit +``` + +Run `oc-config apply` with no arguments and it looks for a file named `Outfit` +in the current directory. Point it at any path to apply a different file. + +After applying, just run `opencode`. + +## Syntax + +One instruction per line: a keyword followed by a single value. + +| Keyword | Required? | Maps to | Example | +| ---------- | -------------------------- | -------------- | ------------------------------ | +| `PROVIDER` | yes | `--provider` | `PROVIDER openrouter` | +| `FAMILY` | one of `FAMILY` / `MODEL` | `--model-family` | `FAMILY deepseek-v4` | +| `MODEL` | one of `FAMILY` / `MODEL` | `--model` | `MODEL deepseek/deepseek-v4-pro` | +| `CONTEXT` | no | `--context` | `CONTEXT 128k` | +| `BASEURL` | no | `--base-url` | `BASEURL https://gateway/v1` | + +Rules: + +- An Outfit describes **exactly one provider**. `PROVIDER` is required and may + appear only once; so may every other keyword. +- You need **at least one** of `FAMILY` or `MODEL`. Give a `FAMILY` to add all + of that family's models; give a `MODEL` to add or pin a specific one; give + both to add the family but make `MODEL` the default. +- `CONTEXT` sets the context window for the model(s). It accepts human suffixes + (`128k`, `1m`) or an absolute count (`200000`). +- `BASEURL` overrides the provider's API base URL — handy for a gateway or a + llama.cpp server on a non-default port. `URL`, `BASE-URL`, and `BASE_URL` are + accepted as aliases. +- Keywords are **case-insensitive** — `provider`, `Provider`, and `PROVIDER` are + all accepted — but **UPPERCASE is canonical** and is what `oc-config export` + writes. +- **Comments** start with `#`, either on their own line or at the end of a line. + Blank lines are ignored. + +To see the available providers, families, and models, run `oc-config list`. + +## Examples + +A local model served by llama.cpp (no API key needed): + +```dockerfile +PROVIDER llamacpp +MODEL qwen3.6-35b-a3b +``` + +A whole model family from OpenRouter (its key comes from your `.env` or +environment, exactly as with `oc-config add`): + +```dockerfile +PROVIDER openrouter +FAMILY deepseek-v4 +``` + +Any OpenAI-compatible endpoint, with a single pinned model: + +```dockerfile +PROVIDER openai-compatible +MODEL my-model +``` + +Ready-to-use Outfits live under [`examples/`](../examples/). + +## Capturing your current setup + +`oc-config export` prints your current opencode configuration as an Outfit, so +you can save a setup you built by hand: + +```sh +oc-config export > Outfit +``` + +By default it exports the provider behind your default model (or the only +configured provider). If you have several, choose one with `-p`: + +```sh +oc-config export -p openrouter > Outfit +``` + +Where the configured models match a known family, export names the `FAMILY`; +otherwise it writes the specific `MODEL`. diff --git a/examples/llamacpp/gemma4/Outfit b/examples/llamacpp/gemma4/Outfit new file mode 100644 index 00000000..163f693b --- /dev/null +++ b/examples/llamacpp/gemma4/Outfit @@ -0,0 +1,6 @@ +# Gemma-4-12B-IT served locally by llama.cpp. +# The model name is just a label llama-server reports; see the README. +PROVIDER llamacpp +MODEL gemma-4-12b-it +CONTEXT 32768 # match the server's --ctx-size +# BASEURL http://127.0.0.1:9090/v1 # uncomment for a non-default host/port diff --git a/docs/llamacpp/gemma4.md b/examples/llamacpp/gemma4/README.md similarity index 86% rename from docs/llamacpp/gemma4.md rename to examples/llamacpp/gemma4/README.md index beb104ba..9c4e9f3c 100644 --- a/docs/llamacpp/gemma4.md +++ b/examples/llamacpp/gemma4/README.md @@ -2,7 +2,7 @@ Run Unsloth's GGUF build of Gemma-4-12B-IT locally with `llama-server`, using Multi-Token Prediction (MTP) for faster inference, then point opencode at it -with `oc-config`. +with the [`Outfit`](Outfit) in this directory. This model uses MTP: a small draft model (`mtp-gemma-4-12b-it.gguf`) runs ahead of the main model to propose candidate tokens, which the main model @@ -107,29 +107,33 @@ curl http://127.0.0.1:8080/v1/models ## 3. Point opencode at it `llama-server` speaks the OpenAI-compatible API, which is exactly what the -`llamacpp` provider targets (default base URL `http://localhost:8080/v1`): +`llamacpp` provider targets (default base URL `http://localhost:8080/v1`). Apply +the [`Outfit`](Outfit) in this directory: ```sh -oc-config add --provider llamacpp --model gemma-4-12b-it -# short form: -oc-config add -p llamacpp -m gemma-4-12b-it +oc-config apply examples/llamacpp/gemma4/Outfit +# or, from this directory: +oc-config apply ``` -The model name is just a label — `llama-server` serves whichever model it has -loaded regardless of what's requested — so call it whatever you find readable. +The Outfit is: -Match opencode's context window to the `--ctx-size` you launched the server -with, so it doesn't overshoot what `llama-server` will accept: - -```sh -oc-config add -p llamacpp -m gemma-4-12b-it --context 32k +```dockerfile +PROVIDER llamacpp +MODEL gemma-4-12b-it +CONTEXT 32768 # match the server's --ctx-size ``` -Running on a non-default host or port? Point the provider at it with the -`--base-url`/`-b` flag (or `LLAMACPP_BASE_URL`): +The model name is just a label — `llama-server` serves whichever model it has +loaded regardless of what's requested — so call it whatever you find readable. +`CONTEXT` matches opencode's context window to the `--ctx-size` you launched the +server with, so it doesn't overshoot what `llama-server` will accept. -```sh -oc-config add -p llamacpp -m gemma-4-12b-it --base-url http://127.0.0.1:9090/v1 +Running on a non-default host or port? Add a `BASEURL` line to the Outfit (the +file ships one commented out): + +```dockerfile +BASEURL http://127.0.0.1:9090/v1 ``` Now start `opencode` and select `llamacpp/gemma-4-12b-it`. diff --git a/examples/llamacpp/qwen3.6/Outfit b/examples/llamacpp/qwen3.6/Outfit new file mode 100644 index 00000000..8e9b4142 --- /dev/null +++ b/examples/llamacpp/qwen3.6/Outfit @@ -0,0 +1,6 @@ +# Qwen3.6-35B-A3B served locally by llama.cpp. +# The model name is just a label llama-server reports; see the README. +PROVIDER llamacpp +MODEL qwen3.6-35b-a3b +CONTEXT 32768 # match the server's --ctx-size +# BASEURL http://127.0.0.1:9090/v1 # uncomment for a non-default host/port diff --git a/docs/llamacpp/qwen3.6.md b/examples/llamacpp/qwen3.6/README.md similarity index 81% rename from docs/llamacpp/qwen3.6.md rename to examples/llamacpp/qwen3.6/README.md index 842ac377..2a9561ab 100644 --- a/docs/llamacpp/qwen3.6.md +++ b/examples/llamacpp/qwen3.6/README.md @@ -1,7 +1,7 @@ # Qwen3.6-35B-A3B on llama.cpp Run Unsloth's GGUF build of Qwen3.6-35B-A3B locally with `llama-server`, then -point opencode at it with `oc-config`. +point opencode at it with the [`Outfit`](Outfit) in this directory. `A3B` means it's a mixture-of-experts model: ~35B total parameters but only ~3B active per token, so it's far lighter to run than its size suggests. @@ -82,29 +82,33 @@ curl http://127.0.0.1:8080/v1/models ## 3. Point opencode at it `llama-server` speaks the OpenAI-compatible API, which is exactly what the -`llamacpp` provider targets (default base URL `http://localhost:8080/v1`): +`llamacpp` provider targets (default base URL `http://localhost:8080/v1`). Apply +the [`Outfit`](Outfit) in this directory: ```sh -oc-config add --provider llamacpp --model qwen3.6-35b-a3b -# short form: -oc-config add -p llamacpp -m qwen3.6-35b-a3b +oc-config apply examples/llamacpp/qwen3.6/Outfit +# or, from this directory: +oc-config apply ``` -The model name is just a label — `llama-server` serves whichever model it has -loaded regardless of what's requested — so call it whatever you find readable. +The Outfit is: -Match opencode's context window to the `--ctx-size` you launched the server -with, so it doesn't overshoot what `llama-server` will accept: - -```sh -oc-config add -p llamacpp -m qwen3.6-35b-a3b --context 32k +```dockerfile +PROVIDER llamacpp +MODEL qwen3.6-35b-a3b +CONTEXT 32768 # match the server's --ctx-size ``` -Running on a non-default host or port? Point the provider at it with the -`--base-url`/`-b` flag (or `LLAMACPP_BASE_URL`): +The model name is just a label — `llama-server` serves whichever model it has +loaded regardless of what's requested — so call it whatever you find readable. +`CONTEXT` matches opencode's context window to the `--ctx-size` you launched the +server with, so it doesn't overshoot what `llama-server` will accept. -```sh -oc-config add -p llamacpp -m qwen3.6-35b-a3b --base-url http://127.0.0.1:9090/v1 +Running on a non-default host or port? Add a `BASEURL` line to the Outfit (the +file ships one commented out): + +```dockerfile +BASEURL http://127.0.0.1:9090/v1 ``` Now start `opencode` and select `llamacpp/qwen3.6-35b-a3b`. diff --git a/main.go b/main.go index 3da11509..7ef9a748 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,8 @@ // oc-config list // oc-config add --provider [--model-family ] [--model ] // oc-config remove --provider [--model-family ] [--model ] +// oc-config apply [path] # apply an Outfit file (defaults to ./Outfit) +// oc-config export [-p name] # print the current config as an Outfit // // Short flags: -p (provider), -f (model-family), -m (model), -c (context), // -b (base-url). @@ -18,12 +20,17 @@ // The API base URL can be overridden for any provider with --base-url/-b or the // OC_CONFIG_BASE_URL environment variable; the flag wins over the env var, and // either wins over the catalogue's defaults. +// +// An Outfit is a declarative, Dockerfile-style file describing one provider +// selection, applied with `oc-config apply`; see outfit.go. package main import ( "flag" "fmt" "os" + "sort" + "strconv" "strings" ) @@ -47,6 +54,10 @@ func run(args []string) error { return cmdRemove(rest) case "list": return cmdList(rest) + case "apply": + return cmdApply(rest) + case "export": + return cmdExport(rest) case "help", "-h", "--help": usage() return nil @@ -63,6 +74,8 @@ Usage: oc-config list oc-config add --provider [--model-family ] [--model ] [--context ] oc-config remove --provider [--model-family ] [--model ] + oc-config apply [path] (defaults to ./Outfit) + oc-config export [--provider ] Flags: -p, --provider provider name (see `+"`oc-config list`"+`) @@ -80,6 +93,9 @@ add: deep-merges the provider into the opencode config, preserving everything model's limit.context window. remove: removes the provider, or just the named models when a family/model is given. Clears the default model if it pointed at something removed. +apply: applies an Outfit file — a declarative, Dockerfile-style description of + one provider selection — as if you had run the equivalent add. +export: prints the current config as an Outfit (oc-config export > Outfit). `) } @@ -121,8 +137,15 @@ func cmdAdd(args []string) error { if err != nil { return err } + return applySelection(sel) +} + +// applySelection writes a single provider selection into the opencode config. +// It is the shared core of `add` and `apply`: both resolve a selection (from +// flags or an Outfit file) and hand it here. +func applySelection(sel selection) error { if sel.family == "" && sel.model == "" { - return fmt.Errorf("specify --model-family/-f and/or --model/-m") + return fmt.Errorf("a provider selection needs a model family and/or a model") } cat, err := loadCatalogFrom(resolveCatalogPath(sel.providers)) @@ -184,6 +207,148 @@ func cmdAdd(args []string) error { return nil } +// cmdApply reads an Outfit file and applies it. The path defaults to ./Outfit +// when none is given, so a bare `oc-config apply` works in a directory that +// holds one. +func cmdApply(args []string) error { + fs := flag.NewFlagSet("apply", flag.ContinueOnError) + var providers string + fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") + if err := fs.Parse(args); err != nil { + return err + } + + path := DefaultOutfitFile + if rest := fs.Args(); len(rest) > 0 { + path = rest[0] + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) && path == DefaultOutfitFile { + return fmt.Errorf("no %s found in the current directory (pass a path: oc-config apply )", DefaultOutfitFile) + } + return fmt.Errorf("reading %s: %w", path, err) + } + sel, err := parseOutfit(data) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + sel.providers = providers + return applySelection(sel) +} + +// cmdExport reconstructs an Outfit from the current opencode config and prints +// it to stdout, so an existing setup can be captured (oc-config export > Outfit). +func cmdExport(args []string) error { + fs := flag.NewFlagSet("export", flag.ContinueOnError) + var provider, providers string + fs.StringVar(&provider, "provider", "", "provider to export") + fs.StringVar(&provider, "p", "", "provider to export (shorthand)") + fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") + if err := fs.Parse(args); err != nil { + return err + } + + configFile, err := resolveConfigFile() + if err != nil { + return err + } + states, defaultModel, err := loadConfigState(configFile) + if err != nil { + return err + } + if len(states) == 0 { + return fmt.Errorf("no providers configured in %s", configFile) + } + + names := make([]string, 0, len(states)) + for n := range states { + names = append(names, n) + } + sort.Strings(names) + + // Pick which provider to export: the flag, else the default model's + // provider, else the sole configured provider. + if provider == "" && len(names) == 1 { + provider = names[0] + } + if provider == "" && defaultModel != "" { + provider = strings.SplitN(defaultModel, "/", 2)[0] + } + if provider == "" { + return fmt.Errorf("multiple providers configured; choose one with -p (have: %s)", strings.Join(names, ", ")) + } + st, ok := states[provider] + if !ok { + return fmt.Errorf("provider %q is not configured in %s (have: %s)", provider, configFile, strings.Join(names, ", ")) + } + + sel := selection{provider: provider, baseURL: st.baseURL} + if prefix := provider + "/"; strings.HasPrefix(defaultModel, prefix) { + sel.model = strings.TrimPrefix(defaultModel, prefix) + } + + cat, catErr := loadCatalogFrom(resolveCatalogPath(providers)) + + // Prefer naming a family when the configured models match one, and drop a + // MODEL line that would only restate that family's default. + if catErr == nil { + if p, ok := cat.Providers[provider]; ok { + if fam := matchFamily(p, st.modelKeys); fam != "" { + sel.family = fam + if p.Families[fam].DefaultModel == sel.model { + sel.model = "" + } + } + // Drop a baseURL that only restates the catalogue's default — keep it + // only when it is a genuine override worth recording. + if def, _ := p.Options["baseURL"].(string); sel.baseURL == def { + sel.baseURL = "" + } + } + } + + // Ensure the Outfit still selects something if we recognised neither a + // family nor a default model. + if sel.family == "" && sel.model == "" && len(st.modelKeys) > 0 { + sel.model = st.modelKeys[0] + } + + // Reconstruct the context window when the exported models agree on one. + sel.context = exportContext(sel, st) + + fmt.Print(formatOutfit(sel)) + return nil +} + +// exportContext returns the context window to record for an export, as a token +// count string, when the models the Outfit selects all share a single value. +// It returns "" when no context was set or the models disagree (e.g. a config +// hand-edited to differ), so export never invents or guesses a value. +func exportContext(sel selection, st providerState) string { + var keys []string + switch { + case sel.family != "": + keys = st.modelKeys // a matched family covers exactly these models + case sel.model != "": + keys = []string{sel.model} + } + distinct := map[int]bool{} + for _, k := range keys { + if c, ok := st.contexts[k]; ok { + distinct[c] = true + } + } + if len(distinct) != 1 { + return "" + } + for c := range distinct { + return strconv.Itoa(c) + } + return "" +} + func cmdRemove(args []string) error { sel, err := parseSelection("remove", args) if err != nil { diff --git a/outfit.go b/outfit.go new file mode 100644 index 00000000..44357e2f --- /dev/null +++ b/outfit.go @@ -0,0 +1,137 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "strings" +) + +// An Outfit is a declarative description of a single opencode provider plus an +// optional model family and/or model — the file equivalent of one `oc-config +// add` invocation. It uses a flat, Dockerfile-style syntax: +// +// # point opencode at one provider +// PROVIDER openrouter +// FAMILY deepseek-v4 +// MODEL deepseek/deepseek-v4-pro # optional; sets the default +// CONTEXT 128k # optional; context window +// BASEURL https://gateway/v1 # optional; API base URL override +// +// Keywords are matched case-insensitively, but UPPERCASE is canonical (it is +// what `oc-config export` emits). Blank lines, full-line `#` comments, and +// trailing ` #` comments are ignored. + +// Outfit keywords, in their canonical (lower-cased) form for matching. +const ( + kwProvider = "provider" + kwFamily = "family" + kwModel = "model" + kwContext = "context" + kwBaseURL = "baseurl" +) + +// canonicalKeyword resolves an Outfit keyword (already lower-cased) to its +// canonical form, accepting a few friendly aliases for the base URL. It returns +// "" for an unrecognised keyword. +func canonicalKeyword(kw string) string { + switch kw { + case kwProvider, kwFamily, kwModel, kwContext: + return kw + case kwBaseURL, "base-url", "base_url", "url": + return kwBaseURL + default: + return "" + } +} + +// DefaultOutfitFile is the filename `oc-config apply` looks for when no path is +// given. +const DefaultOutfitFile = "Outfit" + +// parseOutfit parses an Outfit file into a selection. It enforces that the file +// names exactly one provider and sets each instruction at most once. +func parseOutfit(data []byte) (selection, error) { + var sel selection + seen := map[string]int{} // keyword -> line it first appeared on + + scanner := bufio.NewScanner(bytes.NewReader(data)) + for line := 1; scanner.Scan(); line++ { + text := strings.TrimSpace(stripComment(scanner.Text())) + if text == "" { + continue + } + + fields := strings.Fields(text) + canon := canonicalKeyword(strings.ToLower(fields[0])) + if canon == "" { + return selection{}, fmt.Errorf("line %d: unknown keyword %q (expected PROVIDER, FAMILY, MODEL, CONTEXT, or BASEURL)", line, fields[0]) + } + switch { + case len(fields) < 2: + return selection{}, fmt.Errorf("line %d: %s needs a value", line, strings.ToUpper(canon)) + case len(fields) > 2: + return selection{}, fmt.Errorf("line %d: %s takes a single value, got %d", line, strings.ToUpper(canon), len(fields)-1) + } + value := fields[1] + + if prev, ok := seen[canon]; ok { + return selection{}, fmt.Errorf("line %d: duplicate %s (already set on line %d)", line, strings.ToUpper(canon), prev) + } + seen[canon] = line + + switch canon { + case kwProvider: + sel.provider = value + case kwFamily: + sel.family = value + case kwModel: + sel.model = value + case kwContext: + sel.context = value + case kwBaseURL: + sel.baseURL = value + } + } + if err := scanner.Err(); err != nil { + return selection{}, err + } + + if sel.provider == "" { + return selection{}, fmt.Errorf("Outfit is missing a PROVIDER instruction") + } + return sel, nil +} + +// stripComment removes a comment from an Outfit line. A line whose first +// non-blank character is `#` is dropped entirely; otherwise a trailing ` #` +// (or tab-`#`) comment is removed. Provider, family, and model identifiers +// never contain spaces, so this cannot truncate a real value. +func stripComment(s string) string { + if t := strings.TrimLeft(s, " \t"); strings.HasPrefix(t, "#") { + return "" + } + if i := strings.IndexAny(s, " \t"); i >= 0 { + if j := strings.Index(s[i:], "#"); j >= 0 { + return s[:i+j] + } + } + return s +} + +// formatOutfit renders a selection as a canonical, UPPERCASE Outfit file. The +// "%-8s" padding aligns every value at the same column. +func formatOutfit(sel selection) string { + var b strings.Builder + line := func(keyword, value string) { + if value != "" { + fmt.Fprintf(&b, "%-8s %s\n", keyword, value) + } + } + line("PROVIDER", sel.provider) + line("FAMILY", sel.family) + line("MODEL", sel.model) + line("CONTEXT", sel.context) + line("BASEURL", sel.baseURL) + return b.String() +} diff --git a/outfit_test.go b/outfit_test.go new file mode 100644 index 00000000..dea8f6fe --- /dev/null +++ b/outfit_test.go @@ -0,0 +1,361 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// mustWrite writes content to path or fails the test. +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestParseOutfit(t *testing.T) { + cases := []struct { + name string + in string + want selection + }{ + { + name: "provider and family", + in: "PROVIDER openrouter\nFAMILY deepseek-v4\n", + want: selection{provider: "openrouter", family: "deepseek-v4"}, + }, + { + name: "case-insensitive keywords", + in: "provider ollama\nFamily llama\n", + want: selection{provider: "ollama", family: "llama"}, + }, + { + name: "model only", + in: "PROVIDER llamacpp\nMODEL gemma-4-12b-it\n", + want: selection{provider: "llamacpp", model: "gemma-4-12b-it"}, + }, + { + name: "comments, blanks, and inline comments", + in: "# my Outfit\n\nPROVIDER openrouter # the provider\nMODEL m1\t# inline tab comment\n", + want: selection{provider: "openrouter", model: "m1"}, + }, + { + name: "extra whitespace and tabs as separator", + in: "PROVIDER\tollama\nFAMILY llama\n", + want: selection{provider: "ollama", family: "llama"}, + }, + { + name: "context and base url", + in: "PROVIDER llamacpp\nMODEL gemma\nCONTEXT 128k\nBASEURL http://localhost:9090/v1\n", + want: selection{provider: "llamacpp", model: "gemma", context: "128k", baseURL: "http://localhost:9090/v1"}, + }, + { + name: "base url aliases", + in: "PROVIDER openai-compatible\nMODEL m\nURL https://gw/v1\n", + want: selection{provider: "openai-compatible", model: "m", baseURL: "https://gw/v1"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseOutfit([]byte(tc.in)) + if err != nil { + t.Fatalf("parseOutfit: %v", err) + } + if got.provider != tc.want.provider || got.family != tc.want.family || got.model != tc.want.model { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestParseOutfit_Errors(t *testing.T) { + cases := map[string]string{ + "missing provider": "FAMILY llama\n", + "unknown keyword": "PROVIDER ollama\nFLAVOUR vanilla\n", + "keyword no value": "PROVIDER\n", + "too many values": "PROVIDER a b\n", + "duplicate keyword": "PROVIDER a\nPROVIDER b\n", + "duplicate alias": "PROVIDER a\nMODEL m\nBASEURL u1\nURL u2\n", + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + if _, err := parseOutfit([]byte(in)); err == nil { + t.Errorf("expected error for %q", in) + } + }) + } +} + +func TestFormatOutfitRoundTrip(t *testing.T) { + sel := selection{ + provider: "openrouter", + family: "deepseek-v4", + model: "deepseek/deepseek-v4-pro", + context: "128000", + baseURL: "https://gateway.example/v1", + } + out := formatOutfit(sel) + if !strings.HasPrefix(out, "PROVIDER openrouter\n") { + t.Errorf("export not canonical:\n%s", out) + } + got, err := parseOutfit([]byte(out)) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + if got != sel { + t.Errorf("round-trip changed selection: %+v -> %+v", sel, got) + } +} + +// TestCmdApply_ContextAndBaseURL checks that CONTEXT and BASEURL in an Outfit +// land as limit.context on the model and options.baseURL on the provider. +func TestCmdApply_ContextAndBaseURL(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + outfit := filepath.Join(dir, "Outfit") + mustWrite(t, outfit, "PROVIDER llamacpp\nMODEL gemma\nCONTEXT 128k\nBASEURL http://127.0.0.1:9090/v1\n") + captureStdout(t, func() { + if err := cmdApply([]string{outfit}); err != nil { + t.Fatalf("cmdApply: %v", err) + } + }) + + m := readConfigMap(t, filepath.Join(dir, "opencode", "opencode.json")) + llamacpp := m["provider"].(map[string]any)["llamacpp"].(map[string]any) + if got := llamacpp["options"].(map[string]any)["baseURL"]; got != "http://127.0.0.1:9090/v1" { + t.Errorf("baseURL = %v", got) + } + model := llamacpp["models"].(map[string]any)["gemma"].(map[string]any) + if got := model["limit"].(map[string]any)["context"]; got != float64(128000) { + t.Errorf("limit.context = %v, want 128000", got) + } +} + +// TestCmdExport_ContextAndBaseURL checks the export side of the round-trip: a +// non-default base URL and a context window are both recovered. +func TestCmdExport_ContextAndBaseURL(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + outfit := filepath.Join(dir, "Outfit") + mustWrite(t, outfit, "PROVIDER llamacpp\nMODEL gemma\nCONTEXT 200000\nBASEURL http://127.0.0.1:9090/v1\n") + captureStdout(t, func() { + if err := cmdApply([]string{outfit}); err != nil { + t.Fatalf("cmdApply: %v", err) + } + }) + + out := captureStdout(t, func() { + if err := cmdExport(nil); err != nil { + t.Fatalf("cmdExport: %v", err) + } + }) + for _, want := range []string{"PROVIDER llamacpp", "MODEL gemma", "CONTEXT 200000", "BASEURL http://127.0.0.1:9090/v1"} { + if !strings.Contains(out, want) { + t.Errorf("export missing %q:\n%s", want, out) + } + } +} + +func TestCmdApply_EndToEnd(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") + + outfit := filepath.Join(dir, "Outfit") + mustWrite(t, outfit, "PROVIDER openrouter\nFAMILY deepseek-v4\n") + + out := captureStdout(t, func() { + if err := cmdApply([]string{outfit}); err != nil { + t.Fatalf("cmdApply: %v", err) + } + }) + if !strings.Contains(out, "Default model:") { + t.Errorf("missing summary in output:\n%s", out) + } + + m := readConfigMap(t, filepath.Join(dir, "opencode", "opencode.json")) + if _, ok := m["provider"].(map[string]any)["openrouter"]; !ok { + t.Error("openrouter provider not written") + } + if m["model"] != "openrouter/deepseek/deepseek-v4-flash" { + t.Errorf("model = %v", m["model"]) + } +} + +func TestCmdApply_DefaultFileMissing(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Chdir(t.TempDir()) // a directory with no Outfit + + if err := cmdApply(nil); err == nil { + t.Error("expected error when ./Outfit is missing") + } +} + +func TestCmdExport_RoundTripsWithApply(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") + + // Seed config via apply, then export it back out. + outfit := filepath.Join(dir, "Outfit") + mustWrite(t, outfit, "PROVIDER openrouter\nFAMILY deepseek-v4\n") + captureStdout(t, func() { + if err := cmdApply([]string{outfit}); err != nil { + t.Fatalf("cmdApply: %v", err) + } + }) + + out := captureStdout(t, func() { + if err := cmdExport(nil); err != nil { + t.Fatalf("cmdExport: %v", err) + } + }) + // The family's models match deepseek-v4, so export should name the family. + if !strings.Contains(out, "PROVIDER openrouter") || !strings.Contains(out, "FAMILY deepseek-v4") { + t.Errorf("unexpected export:\n%s", out) + } + + // And the exported Outfit must parse cleanly. + if _, err := parseOutfit([]byte(out)); err != nil { + t.Errorf("exported Outfit does not parse: %v", err) + } +} + +func TestCmdExport_NoProviders(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := cmdExport(nil); err == nil { + t.Error("expected error when nothing is configured") + } +} + +// TestCmdExport_ModelOnlyFallsBackToModel covers a provider whose configured +// models match no known family (a bare llama.cpp label): export should still +// produce a valid Outfit naming the MODEL. +func TestCmdExport_ModelOnlyFallsBackToModel(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + outfit := filepath.Join(dir, "Outfit") + mustWrite(t, outfit, "PROVIDER llamacpp\nMODEL my-local-model\n") + captureStdout(t, func() { + if err := cmdApply([]string{outfit}); err != nil { + t.Fatalf("cmdApply: %v", err) + } + }) + + out := captureStdout(t, func() { + if err := cmdExport(nil); err != nil { + t.Fatalf("cmdExport: %v", err) + } + }) + if !strings.Contains(out, "PROVIDER llamacpp") || !strings.Contains(out, "MODEL my-local-model") { + t.Errorf("unexpected export:\n%s", out) + } + if strings.Contains(out, "FAMILY") { + t.Errorf("did not expect a FAMILY line for an unrecognised model:\n%s", out) + } + // The provider sits on its catalogue-default base URL, so export should not + // record a redundant BASEURL line. + if strings.Contains(out, "BASEURL") { + t.Errorf("did not expect a BASEURL line for the default base URL:\n%s", out) + } +} + +// TestCmdExport_FamilyPlusNonDefaultModel checks that when the default model is +// not the family's own default, export keeps both the FAMILY and the MODEL. +func TestCmdExport_FamilyPlusNonDefaultModel(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("DEEPSEEK_API_KEY", "sk-or-v1-test") + + cat, _ := loadCatalog() + fam := cat.Providers["openrouter"].Families["deepseek-v4"] + // Find a model in the family that is not its default. + var nonDefault string + for _, k := range fam.modelKeys() { + if k != fam.DefaultModel { + nonDefault = k + break + } + } + if nonDefault == "" { + t.Skip("family has no non-default model to exercise this path") + } + + outfit := filepath.Join(dir, "Outfit") + mustWrite(t, outfit, "PROVIDER openrouter\nFAMILY deepseek-v4\nMODEL "+nonDefault+"\n") + captureStdout(t, func() { + if err := cmdApply([]string{outfit}); err != nil { + t.Fatalf("cmdApply: %v", err) + } + }) + + out := captureStdout(t, func() { + if err := cmdExport(nil); err != nil { + t.Fatalf("cmdExport: %v", err) + } + }) + if !strings.Contains(out, "FAMILY deepseek-v4") || !strings.Contains(out, "MODEL "+nonDefault) { + t.Errorf("expected both FAMILY and the non-default MODEL:\n%s", out) + } +} + +// TestCmdExport_MultipleProviders covers provider selection when several are +// configured: without a hint it errors, with -p it exports the chosen one, and +// an unknown -p errors. +func TestCmdExport_MultipleProviders(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + // Seed two providers with no default model, so neither is implied. + path, _ := resolveConfigFile() + cat, _ := loadCatalog() + for _, id := range []string{"ollama", "llamacpp"} { + block, _, err := buildProviderBlock(id, cat.Providers[id], "", "label-"+id, "", noEnv) + if err != nil { + t.Fatal(err) + } + if err := writeConfig(path, id, block, ""); err != nil { + t.Fatal(err) + } + } + + if err := cmdExport(nil); err == nil { + t.Error("expected error when several providers are configured and none is implied") + } + + out := captureStdout(t, func() { + if err := cmdExport([]string{"-p", "ollama"}); err != nil { + t.Fatalf("cmdExport -p ollama: %v", err) + } + }) + if !strings.Contains(out, "PROVIDER ollama") { + t.Errorf("export -p ollama gave:\n%s", out) + } + + if err := cmdExport([]string{"-p", "nonesuch"}); err == nil { + t.Error("expected error for a provider that is not configured") + } +} + +func TestCmdApply_BadOutfitContent(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + bad := filepath.Join(dir, "Outfit") + mustWrite(t, bad, "FAMILY llama\n") // no PROVIDER + if err := cmdApply([]string{bad}); err == nil { + t.Error("expected error for an Outfit without a PROVIDER") + } +} + +func TestCmdApply_MissingExplicitPath(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := cmdApply([]string{filepath.Join(t.TempDir(), "nope.outfit")}); err == nil { + t.Error("expected error for a missing explicit path") + } +}