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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ 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`/`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.
- `cmd/outfit/serve.go` — the `serve` command: `engineFor` and the `serveEngine` table, the binary lookups (`llamaServerBinary`, `resolveOMLXBinary` — PATH then the `/Applications/oMLX.app` bundle), the Outfit→flags mappings (`llamacppServeParams`/`omlxServeParams`/`bindAddressParams`), and the helpers `remote.go` shares (`resolvePresetPath`, `isModelPath`, `hostPortFromURL`). It is a separate file for a second reason beyond cohesion: `TestCompletionCoversDispatch` scans `main.go` for `case "…":` at one tab, so any other switch on string literals living there is mistaken for a new command.
- `cmd/outfit/complete.go` + `completion.bash` — tab completion, and one of the reasons 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`/`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.
Expand All @@ -33,7 +34,7 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag
- `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/preset` — parses [preset `.ini`](https://github.com/ggml-org/llama.cpp/blob/master/docs/preset.md) files into named sections of flags, and renders a server command for `serve` (`Parse`/`Flags`/`FormatCommand`). Parsing is engine-neutral; only the *spelling* of flags differs, which a `Dialect` carries (`LlamaCpp`, `OMLX`). The package-level `Flags`/`CanonicalKey`/`Preset.Args`/`Preset.Command` render LlamaCpp, so pre-dialect callers — notably `remote.go`'s `isCloudOwned` — keep their behaviour; `Preset.CommandIn` takes a dialect plus the engine's subcommand.
- `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`).
Expand All @@ -57,7 +58,7 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag

**Key/option resolution.** `catalog.BuildProviderBlock` sets `options.apiKey` (opencode) to the provider's `apiKeyEnv` as an `{env:VAR}` reference — never the secret — validating `apiKeyPrefix` and erroring when `apiKeyRequired` against the value it resolves. Resolution is `opencode.EnvResolver(dir)`: a `.env` **beside the Outfit being applied**, then the environment — `dir` is empty for a flags-only `add`, which falls back to the working directory. It deliberately is not relative to the binary or the source file (the old `runtime.Caller` form resolved to the build machine's path, so an installed binary never found one). The option is omitted only for a provider with no key variable, or an `apiKeyOptional` one at a local endpoint. Both harnesses therefore rely on the variable being set when the agent runs, which `harnessEnv` in the CLI arranges at launch. `optionsFromEnv` injects other options (e.g. `baseURL`, `region`) when their env vars are set. The opencode top-level `model` is `<providerId>/<modelKey>`. Base-URL precedence for both builders: `--base-url`/`-u` flag > `OUTFIT_BASE_URL` > the catalogue's `pi.baseUrl`/`options.baseURL`.

**`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.
**`serve`.** `outfit serve` launches a local inference server for an Outfit without touching any harness config. The engine comes from `PROVIDER` via `engineFor` (`cmd/outfit/serve.go`), the local twin of `runnerFor`: `llamacpp` runs `llama-server`, `omlx` runs the Apple-Silicon oMLX CLI, and anything else is an error. A `serveEngine` carries the binary, any subcommand, the preset dialect, the Outfit→flags mapping, and the install hint. With a `PRESET` it flattens the matching `.ini` section (plus `[*]` defaults) into flags in that engine's dialect; otherwise it derives a command from the Outfit alone. Anything the Outfit states overrides the preset. `--dry-run`/`-n` prints the command without running it. oMLX serves a whole `--model-dir` and picks per request, so only `BASEURL` becomes a launch flag for it, and it starts happily with neither `PRESET` nor `MODEL`. `serve` never resolves or passes an API key — it prints the command it runs.

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.

Expand All @@ -71,6 +72,8 @@ These are mistakes already made here; each was silent rather than loud, which is

**Match preset flags by canonical name.** Preset keys have short aliases (`ngl` is `n-gpu-layers`, `c` is `ctx-size`, `hf` is `hf-repo`, `a` is `alias`). Comparing raw keys against a list of flag names therefore matches only the spelling you happened to think of. Use `preset.CanonicalKey`, which is exported for exactly this; `Flags` already dedupes layers by canonical name, last layer winning.

**A preset dialect is not interchangeable.** `internal/preset` parses any INI the same way, so an oMLX preset fed through llama.cpp's dialect parses cleanly and produces a *wrong* command: the alias table rewrites `m` to `--model` and `c` to `--ctx-size`, and the boolean table drops a `key = 0` entirely. Nothing errors — the server just receives flags it does not accept, or silently loses a setting. The dialect always comes from the engine `PROVIDER` names, never from the file.

**`contextsize.Parse` is decimal.** `128k` is 128000, not 131072 — a `CONTEXT` written that way is not the power-of-two window it looks like. It also *overrides* a preset's `ctx-size` (both in `serve` and in `remote deploy`), so the Outfit, not the preset, decides the window whenever it states one.

## Reference
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ 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 an omlx provider to the catalogue
- feat: teach outfit serve to launch oMLX on Apple Silicon

### Changed
- feat!: outfit serve picks its engine from PROVIDER, and rejects providers that
are not self-hosted engines (it previously ran llama-server whatever the
Outfit said)

## [1.9.0] - 2026-07-29
### Added
- feat: add a vllm provider to the catalogue
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ model refs and quirks, and none of it is written down where you need it.
it dresses the agent for you:

- **One command, any model.** Pick from a built-in catalogue — OpenRouter,
Bedrock, Ollama, llama.cpp, or any OpenAI-compatible endpoint. No URLs or model
ids to look up.
Bedrock, Ollama, llama.cpp, vLLM, oMLX, or any OpenAI-compatible endpoint. No
URLs or model ids to look up.
- **Your config survives.** Settings are merged *into* what you already have.
Other providers, your theme, even your comments stay exactly where you left them.
- **Keys stay where they belong.** Secrets are read from a local `.env` and never
Expand Down Expand Up @@ -262,9 +262,10 @@ the bash and zsh completions for you.

## Serving a local model

Running a model with llama.cpp? `outfit serve` reads an `Outfit` and launches
`llama-server` for it — so the same file that points opencode at a model can
start it too. The simple case needs no preset:
Running a model locally? `outfit serve` reads an `Outfit` and launches the
inference server its `PROVIDER` names — `llamacpp` runs `llama-server`, `omlx`
runs [oMLX](https://omlx.ai) on Apple Silicon — so the same file that points
opencode at a model can start it too. The simple case needs no preset:

```dockerfile
# Outfit
Expand Down Expand Up @@ -320,7 +321,8 @@ minutes while the instance boots and loads the model; `--timeout` (default

Each provider declares which environment variable holds its key (`outfit
list` shows them). Values are looked up in a `.env` beside the `Outfit` first, then
your shell environment. Local providers like Ollama and llama.cpp need no key;
your shell environment. Local providers like Ollama, llama.cpp and oMLX need no
key;
Bedrock authenticates through your AWS credentials.

Base URLs default to the usual local ports. Override the endpoint for **any**
Expand All @@ -334,7 +336,7 @@ OUTFIT_BASE_URL=https://gateway/v1 outfit add -p openai-compatible -m my-model

The flag wins over the env var, and either wins over the catalogue's defaults
and the per-provider variables (`OLLAMA_BASE_URL`, `LLAMACPP_BASE_URL`,
`OPENAI_BASE_URL`).
`OMLX_BASE_URL`, `VLLM_BASE_URL`, `OPENAI_BASE_URL`).

## Guides

Expand All @@ -343,6 +345,8 @@ with a ready-to-apply `Outfit`:

- [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)
- [Qwen3.6-35B-A3B on oMLX (Apple Silicon)](examples/omlx/qwen3.6/README.md)
- [Gemma-4-E2B on oMLX (Apple Silicon)](examples/omlx/gemma-4-e2b/README.md)

## Adding providers and models

Expand Down
Loading