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
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,22 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag
- `cmd/outfit/main.go` — CLI: command dispatch (`add`/`remove`/`list`/`show`/`apply`/`unapply`/`alias`/`unalias`/`serve`/`export`/`harness`/`init-providers`/`remote`/`completion`, plus the hidden `__complete`), 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; the exception is a *leading* positional that names an Outfit, which is applied and not forwarded (see "Alias registry" below). `alias`/`unalias` manage the registry that lets a name stand in for a path, and `writeAliases` renders it for both `alias --list` and `show`. 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`, `alias_test.go`, `complete_test.go`.
- `cmd/outfit/complete.go` + `completion.bash` — tab completion, and the only reason the CLI is no longer a single source file: `//go:embed` needs the script beside it. `outfit completion bash` prints the script; the script calls the hidden `outfit __complete <words…>`, which prints one candidate per line and a final `:nofile`/`:file` directive. The `commands` table there mirrors `run`'s switch and each `cmdX`'s `FlagSet` (the `flag` package cannot enumerate a `FlagSet`); `TestCompletionCoversDispatch` scans `main.go` and fails when a new command is not completable.
- `internal/config` — outfit's own config file, `${XDG_CONFIG_HOME:-~/.config}/outfit/config.json`: the default-harness preference and the Outfit alias registry. A **leaf package** — stdlib imports only, and it must stay that way (in particular it must never import `internal/outfit` to parse an Outfit), because that is what lets `readOutfit` resolve a name without dragging in the harness abstraction. Every write goes through `Update`, a read-modify-write of the whole document; unknown top-level keys round-trip through `File.extra`.
- `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`/`REMOTE`). `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/outfit` — the `Outfit` file format and the shared `Selection` type: a flat, Dockerfile-style description of one provider selection (`PROVIDER`/`MODEL`/`ALIAS`/`CONTEXT`/`OUTPUT`/`BASEURL`/`PRESET`/`REMOTE`). `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`), which delegate to `internal/config` — that package, not this one, owns the config file. **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 (`apiKeyOptional` marks a provider that also works unauthenticated — `llamacpp` names both a keyless local server and the authenticated remote deployment — so the Pi builder writes its keyless placeholder instead of a `$VAR` that resolves to nothing; every other provider keeps the reference, which Pi resolves at run time): `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.
- `internal/catalog` — the embedded provider catalogue (`//go:embed providers.yaml`), its types, and the two block builders (`apiKeyOptional` marks a provider that also works unauthenticated — `llamacpp` names both a keyless local server and the authenticated remote deployment — so the Pi builder writes its keyless placeholder instead of a `$VAR` that resolves to nothing; every other provider keeps the reference, which Pi resolves at run time): `BuildProviderBlock` turns a provider+model selection into an opencode provider block; `BuildPiProvider` turns the same into a Pi provider entry. 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.
- `internal/opencode` — 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, context/output limits) for `export`.
- `internal/pi` — Pi config IO: resolves `~/.pi/agent/models.json` (note: `~/.pi/agent/`, **not** XDG), deep-merges one managed provider entry into it preserving everything else (other providers, unknown fields), `Remove`, and reads provider state back for `export`. Written `0600`.
- `cmd/outfit/remote.go` — the `remote` command group (`start`/`stop`/`status`/`deploy`), the CLI's only nested subcommand: each subcommand is a thin wrapper over `internal/remote`. `deploy` is the exception that carries logic: `deployConfigFor` turns an Outfit (plus its preset) into a `remote.DeployConfig` — `PROVIDER` picks the runner (`llamacpp`/`vllm`; other providers are not self-hosted engines, so there is nothing to deploy), `MODEL` or the preset's `hf` names the weights as `repo:QUANT`, `CONTEXT` or the preset's `ctx-size` the window, `ALIAS` the served name, and the preset's remaining flags become `serveArgs`. The `[*]` globals are layered under the section (so `ngl`/`jinja` written there survive), and `cloudOwnedFlags` drops what the cloud sets itself (host/port/model/hf/api-key/ctx-size/alias/metrics), matched on `preset.CanonicalKey` so short aliases count. Config discovery (`resolveRemoteConfig`): an explicit `[path]` argument must name an Outfit with a `REMOTE` instruction; with no argument, `./Outfit` is consulted when present (its `REMOTE` resolved relative to the Outfit, like `PRESET`), falling back to the per-user `remote.json`. Tests: `remote_test.go` (dispatch plus full flows against an `httptest.Server`, with `stubAWSEnv` pinning the AWS credential chain to static env credentials).
- `internal/remote` — controls the scale-to-zero GPU inference instance defined in [`remote/`](remote/) by calling its start/stop/deploy Lambda Function URLs (IAM auth, so requests are SigV4-signed via `aws-sdk-go-v2` — the repo's only AWS/network dependency). `Deploy` is the only call with a body, and SigV4 covers the payload hash, so `call`/`sign` take the bytes and hash exactly what is sent. `DeployConfig` deliberately carries no weights prefix: the Lambda derives the S3 layout and seeds the weights when they are absent, replying `{seeding, seedInstanceId}`. Config in `${XDG_CONFIG_HOME:-~/.config}/outfit/remote.json` (`start_url`/`stop_url`/`deploy_url`/`region`), overridable via `OUTFIT_REMOTE_*` env vars; `deploy_url` is optional so configs predating deploy still work for start/stop, and only `Deploy` complains when it is missing; the region falls back to `AWS_REGION`, then to the region embedded in the Function URL host. `Start` retries while the endpoint reports 503/starting, honouring `retry_after_seconds`.
- `internal/contextsize` — parses human-friendly sizes (`128k`, `1.5m`, `200000`); `Apply` sets `limit.context` and `limit.output` on opencode model blocks; `DefaultOutput` returns a quarter of the context (the fallback when `OUTPUT` is unset).
- `internal/preset` — parses llama.cpp [preset `.ini`](https://github.com/ggml-org/llama.cpp/blob/master/docs/preset.md) files into named sections of flags, and renders a `llama-server` command for `serve` (`Parse`/`Flags`/`FormatCommand`).
- `internal/catalog/providers.yaml` — externalised provider/model-family data (URLs, model ids, key env vars, and a `pi:` block per Pi-capable provider). **Add providers/models here, not in Go.** Embedded at build time but kept external for maintenance.
- `internal/catalog/providers.yaml` — externalised provider plumbing (URLs, key env vars, npm packages, and a `pi:` block per Pi-capable provider) — no model ids. **Add providers here, not in Go.** Embedded at build time but kept external for maintenance.
- `examples/` — runnable guides, each a directory with a README and an `Outfit`.
- `remote/` — the cloud GPU deployment `outfit remote` drives: a TypeScript CDK project (AWS Lambdas, EC2 Image Builder, S3 weights) with its own `package.json`, tests and docs, built and tested by pnpm rather than Go. It is the only non-Go part of the repository; CI runs its checks separately. **This repository is public, so nothing identifying a deployment — account ids, ARNs, Function URL hosts, allocated addresses, bucket names, resource ids — may be committed here or anywhere else in the repo.** `scripts/check-no-cloud-identifiers.sh` enforces that in CI; deployment state belongs in the gitignored files under `remote/` (`.env`, `Outfit`, `remote.json`, `cdk-outputs.json`).

## Architecture notes (the important part)

**Harness routing.** `cmd` does the harness-neutral work (load the catalogue, validate the family/model, parse sizes, pick the provider) and then hands a `Selection` to the harness resolved by `harness.Resolve`. Each adapter owns its config format end-to-end. The import graph is one-way: `harness` imports `catalog`/`opencode`/`pi`/`contextsize`/`outfit`/`config`; none of those import `harness`, and `config` sits at the bottom importing nothing, so there are no cycles. A model is keyed by its `ALIAS` when given, otherwise the provider-native `MODEL` (`modelKey` in `adapters.go`).
**Harness routing.** `cmd` does the harness-neutral work (load the catalogue, validate the model, parse sizes, pick the provider) and then hands a `Selection` to the harness resolved by `harness.Resolve`. Each adapter owns its config format end-to-end. The import graph is one-way: `harness` imports `catalog`/`opencode`/`pi`/`contextsize`/`outfit`/`config`; none of those import `harness`, and `config` sits at the bottom importing nothing, so there are no cycles. A model is keyed by its `ALIAS` when given, otherwise the provider-native `MODEL` (`modelKey` in `adapters.go`).

**Alias registry.** `outfit alias` registers an Outfit under a name, and `readOutfit` — the single choke point `apply`/`unapply`/`serve`/`harness --outfit` all share — resolves it, so one change gives every command the shorthand. Three rules hold it together. (1) The lookup is gated on the argument being *name-shaped* (`config.NameShaped`), so a path-shaped argument never causes a config read at all; that is what keeps `serve` working with no config of ours present, and what keeps the test suite off the developer's real config. (2) A path on disk beats a registered name (`namesAnOutfit` decides), so registering an alias can never change what an already-working command does; the shadowing is reported, not silent. (3) The registry stores the absolute path of the **file**, never the directory, so `serve`'s relative-`PRESET` resolution still lands next to the aliased Outfit. Under `harness`, the first positional is taken as an Outfit only when no `--outfit` was given, flag parsing was not terminated by an explicit `--` (`flagsTerminated`), and it names one; a `--` immediately after a *consumed* Outfit is dropped, so every other command line is forwarded byte-for-byte.

Expand All @@ -59,7 +59,7 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag

**`serve`.** `outfit serve` launches `llama-server` for an Outfit without touching any harness config. With a `PRESET` it flattens the matching `.ini` section (plus `[*]` defaults) into flags via `internal/preset`; otherwise it derives a command from `MODEL`/`ALIAS`/`CONTEXT`/`BASEURL`. Anything the Outfit states overrides the preset. `--dry-run`/`-n` prints the command without running it.

Invariants to keep: opencode merges stay idempotent and never drop unrelated config or comments; Pi merges preserve sibling providers and unknown fields; output stays `0600`; the harness never appears in an Outfit, and neither does an alias — both are machine-local, so an Outfit stays portable; outfit's own `config.json` is only ever written read-modify-write, so a new setting cannot clobber an existing one; `__complete` never returns an error and never writes to stderr, whatever the state of the config or catalogue; and every family's `defaultModel` must be one of its `models` (enforced by `TestCatalogIntegrity`).
Invariants to keep: opencode merges stay idempotent and never drop unrelated config or comments; Pi merges preserve sibling providers and unknown fields; output stays `0600`; the harness never appears in an Outfit, and neither does an alias — both are machine-local, so an Outfit stays portable; outfit's own `config.json` is only ever written read-modify-write, so a new setting cannot clobber an existing one; `__complete` never returns an error and never writes to stderr, whatever the state of the config or catalogue.

## Traps

Expand Down
27 changes: 13 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ See what's in the catalogue:
outfit list
```

Add a provider and a model family:
Add a provider and a model:

```sh
# OpenRouter needs a key — put it in .env first:
echo 'DEEPSEEK_API_KEY=sk-or-v1-...' > .env

outfit add --provider openrouter --model-family deepseek-v4
outfit add --provider openrouter --model deepseek/deepseek-v4-flash
```

Then just run `opencode`. That's it — your agent is pointed at the new model, and
Expand All @@ -102,17 +102,17 @@ the rest of your config is untouched.

```sh
# A local Ollama model (no key required)
outfit add -p ollama -f llama
outfit add -p ollama -m llama3.2

# Claude on AWS Bedrock (uses your AWS credentials)
outfit add -p amazon-bedrock -f claude
outfit add -p amazon-bedrock -m anthropic.claude-3-5-sonnet

# Any OpenAI-compatible endpoint, base URL via flag
OPENAI_API_KEY=sk-... \
outfit add -p openai-compatible -m my-model --base-url https://my-endpoint/v1

# Pin a specific default model
outfit add -p openrouter -f deepseek-v4 -m deepseek/deepseek-v4-pro
outfit add -p openrouter -m deepseek/deepseek-v4-pro

# Set the context window — human suffixes or an absolute count, both fine
outfit add -p llamacpp -m my-model -c 128k
Expand All @@ -124,8 +124,8 @@ outfit add -p llamacpp -m my-model -c 128k -o 32k
# Take a provider back out
outfit remove -p ollama

# Or just drop one family's models
outfit remove -p openrouter -f deepseek-v4
# Or just drop one model
outfit remove -p openrouter -m deepseek/deepseek-v4-flash
```

On opencode, `add` sets the chosen model as the default and `remove` clears it
Expand All @@ -145,8 +145,8 @@ quarter of the context for you. It can't exceed the context window.
```sh
outfit list
outfit show [--harness <name>] # show what the harness has configured
outfit add --provider <name> [--model-family <family>] [--model <id>] [--alias <name>] [--context <size>] [--output <size>] [--base-url <url>]
outfit remove --provider <name> [--model-family <family>] [--model <id>]
outfit add --provider <name> [--model <id>] [--alias <name>] [--context <size>] [--output <size>] [--base-url <url>]
outfit remove --provider <name> [--model <id>] [--alias <name>]
outfit apply [path] [--output <size>] # apply an Outfit file or directory (default ./Outfit)
outfit unapply [path] # remove what an Outfit file selects
outfit alias [path] [-n <name>] [-l] # name an Outfit; -l lists them
Expand All @@ -163,7 +163,7 @@ outfit remote <start|stop|status|deploy> [path]
# (deploy sets what it serves, from the Outfit)
```

Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (outfit), and under `alias`: `-n` (name), `-l` (list), `-F` (force).
Short flags: `-p` (provider), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (outfit), and under `alias`: `-n` (name), `-l` (list), `-F` (force).

Anywhere a `[path]` appears above you can put a name registered with
[`outfit alias`](#aliases) instead.
Expand All @@ -185,7 +185,7 @@ is also supported. The harness is chosen at runtime — never baked into an `Out
file — so the same selection works for either.

```sh
outfit add -p ollama -f llama --harness pi # this command only
outfit add -p ollama -m llama3.2 --harness pi # this command only
outfit harness --set pi # make Pi the default for future commands
outfit harness # launch the active harness (forwards trailing args)
outfit harness -O # apply ./Outfit, then launch the harness
Expand All @@ -207,8 +207,7 @@ your coding agent? Drop an `Outfit` in your project:
```dockerfile
# Outfit
PROVIDER openrouter
FAMILY deepseek-v4
MODEL deepseek/deepseek-v4-pro # optional; the provider-native model ref
MODEL deepseek/deepseek-v4-pro # the provider-native model ref
ALIAS deepseek # optional; friendly name for the model
CONTEXT 128k # optional; context window
OUTPUT 32k # optional; max output tokens
Expand Down Expand Up @@ -348,7 +347,7 @@ with a ready-to-apply `Outfit`:
## Adding providers and models

Everything `outfit` knows lives in `internal/catalog/providers.yaml`. Add a
provider, a model family, or a new model there and rebuild — no Go required. The
provider there and rebuild — no Go required. The
file is commented with the schema.

Don't want to rebuild? Write the catalogue out with
Expand Down
Loading