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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Then just run `opencode`.
oc-config list
oc-config add --provider <name> [--model-family <family>] [--model <id>] [--context <size>] [--base-url <url>]
oc-config remove --provider <name> [--model-family <family>] [--model <id>]
oc-config apply [path] # apply an Outfit file (default ./Outfit)
oc-config export [--provider <name>] # print the current config as an Outfit
```

Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-c` (context), `-b` (base-url).
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
27 changes: 27 additions & 0 deletions catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
56 changes: 56 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strings"

"github.com/tailscale/hujson"
Expand Down Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions docs/outfit-file.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions examples/llamacpp/gemma4/Outfit
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading