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
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ brews:
commit_author:
name: goreleaser-bot
email: bot@goreleaser.com
# Install the completions the binary prints, so `brew install` wires TAB up
# with no extra step. (PowerShell is supported too, but not by Homebrew.)
extra_install: |
generate_completions_from_executable(bin/"outfit", "completion", shells: [:bash, :zsh])
test: |
system "#{bin}/outfit", "--help"

Expand Down
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ Run a single test: `go test -run TestWriteConfig_Idempotent ./...`

The binary lives under `cmd/`; the domain logic is split into `internal/` packages so each concern is isolated and independently testable.

- `cmd/outfit/main.go` — CLI: command dispatch (`add`/`remove`/`list`/`show`/`apply`/`unapply`/`serve`/`export`/`harness`/`init-providers`), flag parsing (`parseSelection` registers both long and short flags against the same vars, and returns the chosen harness name separately so it never leaks into a `Selection`), and user-facing output. `add` and `apply` share `applySelection`, the core that writes one provider selection; `remove` and `unapply` share `removeSelection`, its inverse. Both resolve the active harness (`harness.Resolve`) and route through it. `show` reports the active harness's current config via `harness.State` (the same read `export` reconstructs an Outfit from). `serve` is harness-agnostic (it launches `llama-server`, not a config write). `harness` launches the active harness (forwarding trailing args), or manages the stored default with `--get`/`--set`; 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. Tests: `main_test.go` (dispatch/add/remove/list), `apply_test.go` (`apply`/`unapply`/`export`), `harness_test.go` (the `harness` command, `show`, and Pi-routed add/export/remove), `serve_test.go`.
- `cmd/outfit/main.go` — CLI: command dispatch (`add`/`remove`/`list`/`show`/`apply`/`unapply`/`alias`/`unalias`/`serve`/`export`/`harness`/`init-providers`/`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`). `Parse` reads it into a `Selection` (keywords case-insensitive via `canonicalKeyword`, UPPERCASE canonical, `#` comments); `Format` renders one back out for `export`. `apply` defaults to `./Outfit` (`outfit.DefaultFile`). The harness is deliberately **not** a keyword — it is a runtime choice, so an Outfit stays portable.
- `internal/harness` — the harness abstraction. The `Harness` interface (`Name`/`Command`/`ConfigPath`/`Apply`/`Remove`/`State`), a `registry` of the opencode and pi adapters (`adapters.go`, which wrap `catalog` + `opencode`/`pi` + `contextsize`), runtime selection via `Resolve` with precedence **`--harness`/`-H` flag > `OUTFIT_HARNESS` env > stored preference > opencode**, and preference load/save (`LoadPreference`/`SavePreference`) in `${XDG_CONFIG_HOME:-~/.config}/outfit/config.json`. **Start here when adding a third harness:** implement the interface and register it.
- `internal/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: `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/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`.
Expand All @@ -35,7 +37,9 @@ The binary lives under `cmd/`; the domain logic is split into `internal/` packag

## 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`; none of those import `harness`, 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 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`).

**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.

**In-place JSONC merge, never overwrite (opencode).** This is the core invariant of the opencode adapter. Understand it before touching `internal/opencode`:

Expand All @@ -52,7 +56,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 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; and every family's `defaultModel` must be one of its `models` (enforced by `TestCatalogIntegrity`).

## Reference

Expand Down
69 changes: 65 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,21 @@ outfit add --provider <name> [--model-family <family>] [--model <id>] [--alia
outfit remove --provider <name> [--model-family <family>] [--model <id>]
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
outfit unalias <name> # drop a registered name
outfit serve [path] [--dry-run] # run llama-server from the Outfit's PRESET
outfit export [--provider <name>] # print the current config as an Outfit
outfit init-providers [path] # write the built-in catalogue out to edit
outfit harness [-H <name>] [--outfit[=<path>]] [args...]
# launch the harness (--outfit applies one first;
# --get shows it; --set stores the default)
outfit harness [<outfit>] [-H <name>] [--outfit[=<path>]] [args...]
# launch the harness (a leading Outfit or alias is
# applied first; --get shows it; --set stores it)
outfit completion <shell> # tab completion (bash, zsh, powershell)
```

Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (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).

Anywhere a `[path]` appears above you can put a name registered with
[`outfit alias`](#aliases) instead.

## Harnesses

Expand All @@ -176,6 +182,7 @@ outfit harness -H pi # launch a specific harness, ignoring the default

outfit harness -O # apply ./Outfit, then launch the harness
outfit harness --outfit=path/to/Outfit # ...applying a specific one first
outfit harness qwen3.6-27b # ...or name one positionally (see Aliases)

outfit show # what the active harness has configured
outfit show --harness pi # ...for a specific harness, without changing the default
Expand Down Expand Up @@ -226,6 +233,60 @@ 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/).

## Aliases

Keeping a directory per model soon means typing a path per command. Name one
once with `outfit alias` and the name works wherever a path does:

```sh
$ ls
Outfit preset.ini

$ head -2 Outfit
ALIAS qwen3.6-27b
CONTEXT 128k

$ outfit alias
Added alias "qwen3.6-27b" for /home/me/models/qwen3.6/Outfit …

$ outfit apply qwen3.6-27b # from anywhere, no path needed
$ outfit serve qwen3.6-27b
$ outfit harness qwen3.6-27b -- --some-agent-arg
```

The name defaults to the `Outfit`'s own `ALIAS`; `--name`/`-n` picks a different
one, and is required when the file has no `ALIAS` to borrow — `outfit` never
invents a name you didn't write. `--force`/`-F` re-points a name you have
already used.

```sh
outfit alias --list # every name, its Outfit, and whether it still exists
outfit show # ...alongside what the harness has configured
outfit unalias qwen3.6-27b # drop the name (the Outfit file is left alone)
```

A path on disk always wins over a registered name, so adding an alias can never
change what an already-working command does. Under `outfit harness`, only a
*leading* argument is read as an Outfit and everything after it goes to the
agent — put `--` between the two, and start with `--` to opt out entirely if a
name of yours collides with one of the agent's own subcommands.

The registry lives in `outfit`'s own config
(`${XDG_CONFIG_HOME:-~/.config}/outfit/config.json`), not in any `Outfit`, so
your `Outfit` files stay as portable and committable as they were.

### Tab completion

```sh
source <(outfit completion bash) # add to ~/.bashrc
source <(outfit completion zsh) # or ~/.zshrc (needs compinit)
outfit completion powershell | Out-String | Invoke-Expression # or $PROFILE
```

TAB then completes commands, flags, providers, harnesses, and your registered
aliases — `outfit unalias <TAB>` offers exactly the names you have. Homebrew
installs the bash and zsh completions for you.

## Serving a local model

Running a model with llama.cpp? `outfit serve` reads an `Outfit` and launches
Expand Down
Loading