From a26dce43861f18e352f0b70fd1a4c670f4787624 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 10 Aug 2026 20:14:50 +0100 Subject: [PATCH] docs(openspec): propose creating an Outfit from a Hugging Face ref Add the add-hf-outfit-command change: an `outfit hf ` command that reads a Hugging Face model repo and writes the Outfit for it, inferring the provider from the repo's files, the quantisation from its GGUF names, and the context window from its published config. The local Hugging Face and llama.cpp caches are read so an already-downloaded model is named by its path rather than downloaded a second time; outfit itself never transfers weights. An optional token is resolved from the usual places for gated and private repos. Covers two new capabilities, huggingface-hub and huggingface-outfits, with the design and task breakdown behind them. --- .../add-hf-outfit-command/.openspec.yaml | 2 + .../changes/add-hf-outfit-command/design.md | 225 ++++++++++++++++++ .../changes/add-hf-outfit-command/proposal.md | 92 +++++++ .../specs/huggingface-hub/spec.md | 156 ++++++++++++ .../specs/huggingface-outfits/spec.md | 221 +++++++++++++++++ .../changes/add-hf-outfit-command/tasks.md | 116 +++++++++ 6 files changed, 812 insertions(+) create mode 100644 openspec/changes/add-hf-outfit-command/.openspec.yaml create mode 100644 openspec/changes/add-hf-outfit-command/design.md create mode 100644 openspec/changes/add-hf-outfit-command/proposal.md create mode 100644 openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md create mode 100644 openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md create mode 100644 openspec/changes/add-hf-outfit-command/tasks.md diff --git a/openspec/changes/add-hf-outfit-command/.openspec.yaml b/openspec/changes/add-hf-outfit-command/.openspec.yaml new file mode 100644 index 00000000..d7bc0110 --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/add-hf-outfit-command/design.md b/openspec/changes/add-hf-outfit-command/design.md new file mode 100644 index 00000000..71c08a1b --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/design.md @@ -0,0 +1,225 @@ +## Context + +See proposal.md — Why. The constraints that shape the approach: + +- **outfit has almost nothing to install.** Three direct module dependencies + today (`hujson`, `yaml.v3`, `aws-sdk-go-v2`), and the AWS SDK is called out in + AGENTS.md as "the repo's only AWS/network dependency". Anything added here is + measured against that. +- **`internal/discovery` already sets the pattern for outbound HTTP**: a small + client, a bounded timeout, an in-process cache, and failures that never spew. + Hub access should look like its sibling, not like a new subsystem. +- **The pieces the command produces already exist.** `outfit.Selection` and + `outfit.Format` render an Outfit; `applySelection` writes one to a harness; + `contextsize.Parse` reads `128k`. This change is a resolver feeding parts that + are already built. +- **The engines already download.** `llama-server -hf`, vLLM and mlx all fetch + their own weights on first use. There is no gap to fill there, only a second + copy to avoid. + +## Goals / Non-Goals + +**Goals:** + +- One command from a pasted model reference to a working Outfit, with every + inference visible and individually overridable. +- Use the Hugging Face cache that is already on the machine — for the weights + and, when possible, for the metadata, so a downloaded model resolves offline. +- Keep the dependency footprint where it is. + +**Non-Goals:** + +- Downloading weights (see the spec — `outfit hf` never transfers a weights + file). No `--pull`, no progress bars, no resumable transfers. +- Writing the Hugging Face cache in any way. Reading it is a stable, documented + layout; writing it correctly means blob dedup, symlinks, locking and etag + bookkeeping, and there is nothing to gain from owning that. +- Searching or browsing the Hub (`outfit hf --search qwen`). A reference is + something you paste, and the model page is a better browser than a terminal. +- Datasets, spaces, adapters, or non-model repo types. +- Teaching the catalogue about Hugging Face. `providers.yaml` stays plumbing + only; nothing here adds a provider. + +## Decisions + +### A new `outfit hf` command rather than `--hf` on `add`/`apply` + +The output of this work is a *file* — a reproducible description of a model +choice — and the existing commands take a selection rather than produce one. +Making it a separate command also keeps its flag set honest: `-q`, `--no-cache` +and `--output-file` are meaningless on `add`. + +`--apply` closes the one-liner gap by routing the resolved `Selection` through +the same `applySelection` that `add` and `apply` use, so there is one code path +that dresses a harness, not two. + +*Alternative considered:* `outfit add --hf `. Rejected because it produces +no artefact — the user ends up running `outfit export` to get back the file the +resolver already had in hand. + +### `-o` means `--output-file` here, and there is no output-tokens flag + +Every other command spells `-o` as `--output` (max output tokens). On `hf` it +names the file to write, as requested. To make that unambiguous rather than +merely inconsistent, `outfit hf` **omits an output-tokens flag entirely**: the +Outfit it writes carries no `OUTPUT` line, and applying one defaults output to a +quarter of the context (`contextsize.DefaultOutput`), which is what an +unspecified `OUTPUT` already means. There is therefore no reading of `-o` on +this command that silently does the other thing. The docs page states the +difference explicitly. + +### A small in-repo Hub client, not a Go SDK + +The closest thing to a Go SDK is `github.com/gomlx/go-huggingface` +(Apache-2.0), which does share the Python cache layout. Its module requires +`gomlx/gomlx`, `gomlx/compute`, `parquet-go`, `protobuf`, `go-sentencepiece`, +`lipgloss` and `klog` — a machine-learning framework's dependency graph, taken +on for a CLI that reads two JSON endpoints and lists a directory. The other +candidates (`bodaay/HuggingFaceModelDownloader`, `cozy-creator/hf-hub`, +`sgl-project/ome`) are downloader-shaped: their value is the write path this +change explicitly does not want. + +`internal/hf` is therefore stdlib-only, importing nothing of ours except +`internal/contextsize`. What it needs: + +- `GET {endpoint}/api/models/{repo}/revision/{rev}` — `siblings[].rfilename` + for the file list, plus `tags`, `library_name` and `sha`. +- `GET {endpoint}/{repo}/resolve/{rev}/config.json` — the model's declared + window. +- The cache layout: `models--{org}--{name}/refs/{rev}` holds the commit sha; + `snapshots/{sha}/{path}` is a symlink into `blobs/`. + +If the write path is ever wanted, the package boundary is where a library would +slot in without the command changing. + +### The cache is read through injected roots, never a package-level default + +`internal/hf` resolves cache roots in one place (`HF_HUB_CACHE`, else +`$HF_HOME/hub`, else `~/.cache/huggingface/hub`; `LLAMA_CACHE`, else the +platform cache dir) and every lookup takes them as arguments. That is not +tidiness: `internal/config`'s alias registry has the same rule, for the same +reason — a test that silently reads the developer's real cache passes on their +machine and fails in CI, or worse, the reverse. Tests point the roots at temp +dirs; nothing reaches `$HOME` unless the caller asked for it. + +A snapshot entry counts as cached only when the symlink resolves to a readable +regular file. An interrupted download leaves either a `.incomplete` blob or a +dangling snapshot link, and both must read as "not cached" — the same rule the +`model-weights` capability already applies to the cloud's fetch marker. + +### llama.cpp's cache is matched loosely, and a miss is free + +`llama-server -hf` writes into `LLAMA_CACHE` (else its platform cache dir), not +the Hugging Face cache, so a model downloaded by `serve` is invisible to the HF +layout and vice versa. Its filename convention is not a documented contract, so +the lookup is a case-insensitive scan of that directory for a `.gguf` whose name +carries the repo's owner, name and the chosen quant. A false negative costs +nothing — the Outfit falls back to the repo reference and `llama-server` finds +its own cached copy anyway. A false positive is what must not happen, hence +requiring all three parts to match. + +### Quantisation: parse the filenames, prefer a mid-sized K-quant + +Quant names live in GGUF filenames (`…-Q4_K_M.gguf`, +`…-UD-Q4_K_XL.gguf`, `…-Q4_K_M-00001-of-00003.gguf`) and sometimes in a +directory level (`Q4_K_M/…gguf`). The parser strips the extension and any +`-000NN-of-000NN` shard suffix, then matches a quant token +(`IQ*`/`Q*_*`/`UD-*`/`F16`/`BF16`/`F32`/`MXFP4`), preferring a directory name +when the repo uses one. Files under one quant name group into a single choice. + +The default preference order is an explicit, documented list — `Q4_K_M`, +`UD-Q4_K_XL`, `Q4_K_S`, `Q5_K_M`, `Q6_K`, `Q8_0` — falling back to the smallest +non-full-precision group, with the name as tie-break so the choice is stable. +Roughly 4-bit is the size that fits the machines people run local models on; +the narration lists what else was there, so the default never has to be right, +only defensible. + +### Provider inference from files and library, in that order + +- any `.gguf` → `llamacpp` +- else `library_name` is `mlx`, or the repo carries an `mlx` tag → `omlx` +- else any `.safetensors` → `vllm` +- else fail, naming what the repo appears to hold + +Files come before tags because a repo's tags are freely edited and its files are +not. `-p` overrides the whole chain and is not validated against the repo's +contents beyond existing in the catalogue. + +### `MODEL` is written in each engine's own vocabulary + +| Engine | Cached | Not cached | +| --- | --- | --- | +| `llamacpp` | path to the (first) `.gguf` on disk | `org/model:QUANT` | +| `omlx` | `org/model` | `org/model` | +| `vllm` | `org/model` | `org/model` | + +`llama-server` given the first shard of a split GGUF loads the rest itself, so a +path is safe for sharded quants. oMLX serves a whole model directory and picks +per request, and vLLM resolves repo ids through the HF cache on its own, so +neither gains from a path — and `local-serving` already says `MODEL` keeps its +harness-facing meaning for oMLX. + +`--no-cache` forces the right-hand column. A path makes an Outfit +machine-specific, and Outfits get committed (`remote/Outfit` is, deliberately), +so the escape hatch is a flag rather than a comment in the docs. + +### Context comes from the config, or not at all + +`max_position_embeddings` (falling back to `text_config.max_position_embeddings` +for multimodal configs) is read from the repo's `config.json`, which GGUF and +MLX repos generally republish. Nothing is derived from the GGUF header — that +would mean a ranged read of a weights file, which the spec forbids for good +reason. No window declared means no `CONTEXT` line, matching `export`'s existing +refusal to invent one. + +The declared maximum is written as-is even when it is large. It is a published +fact rather than a guess, the narration shows it, and `-c` overrides it; picking +a "sensible" smaller number would be outfit inventing a policy it cannot justify +per machine. + +### stdout is the artefact, stderr is the reasoning + +`outfit hf > Outfit` has to produce a clean file, so every explanation — +provider and why, quant chosen and alternatives, context and its source, cache +hit and which cache — goes to stderr. This matches `export` (pure stdout) and +`serve` (which narrates before it runs). + +## Risks / Trade-offs + +**A cached-path `MODEL` is not portable** → the narration says so at the moment +it happens, and `--no-cache` produces the shareable form. The default favours +the common case: an Outfit in a working directory, on the machine that has the +model. + +**Hub API responses could change shape** → only three fields are relied on +(`siblings[].rfilename`, `library_name`/`tags`, `sha`), all long-standing. +Unparseable responses fail with a message naming the repo and the endpoint, and +the cache path keeps working regardless. + +**llama.cpp's cache filenames are a convention, not a contract** → matching is +deliberately conservative and a miss degrades to the repo reference, which is +what the command would have written anyway. + +**The default quant may not be what the user wanted** → it is named on stderr +with the alternatives beside it, and both `:QUANT` and `-q` override it. A wrong +default costs one re-run, not a download. + +**A second outbound host** → `huggingface.co` joins the provider endpoints +`discovery` already contacts. Same discipline: bounded timeout, no retries, no +credentials sent unless a token was explicitly configured, nothing logged. + +**Test suites that read a real cache** → all cache roots are injected, and the +test helpers set `HF_ENDPOINT`, `HF_HUB_CACHE` and `LLAMA_CACHE` to temp +locations. No test may depend on what the developer has downloaded. + +## Migration Plan + +Purely additive: a new command, a new leaf package, no change to the Outfit +format, the catalogue, or any harness adapter. Nothing to migrate and nothing to +roll back beyond reverting the commit. + +## Open Questions + +- Whether a later change should add `--pull` (fetch into the HF cache through + outfit) or `outfit hf list` (what is already cached). Both sit on top of this + package without altering it, and neither is needed to make the command useful. diff --git a/openspec/changes/add-hf-outfit-command/proposal.md b/openspec/changes/add-hf-outfit-command/proposal.md new file mode 100644 index 00000000..a411c490 --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/proposal.md @@ -0,0 +1,92 @@ +## Why + +Hugging Face is where local models actually come from, but `outfit` knows +nothing about it. Writing an Outfit for one is a manual research job: open the +model page, work out whether it is GGUF, MLX or safetensors (which decides the +`PROVIDER`), scroll the file list for the quantisation you want, find +`max_position_embeddings` in `config.json` for the `CONTEXT`, and invent an +`ALIAS`. Get any of it wrong and the first thing you learn is that `serve` +fails, or that a 40 GB download has started for a quant you did not want. + +Every fact needed to write that file is already published by the Hub — and for +a model you have downloaded before, it is already on your disk in the Hugging +Face cache. This change reads both and writes the Outfit for you. + +## What Changes + +- **`outfit hf `**: a new command that turns a Hugging Face model + reference into an Outfit. It inspects the repo, infers the `PROVIDER`, + `MODEL`, `ALIAS` and `CONTEXT`, prints the file to stdout, and says what it + inferred and why on stderr. +- **References are written the way people paste them**: `org/model`, + `org/model:QUANT`, `hf.co/org/model`, a full `https://huggingface.co/...` + URL (including a `/tree/` or `/blob//` suffix), and an + explicit `@revision`. +- **The provider is inferred from the repo's files**: `*.gguf` means + `llamacpp`, an MLX repo means `omlx`, plain safetensors means `vllm`. + `--provider`/`-p` overrides it, and a repo that fits none of them fails + saying so rather than guessing. +- **Quantisation selection**: for a GGUF repo, the `:QUANT` suffix (or + `--quant`/`-q`) picks the file; with neither, a preference order picks a + sensible default and the alternatives are listed. An ambiguous or unmatched + quant fails listing what the repo actually offers. +- **The existing Hugging Face cache is used, never bypassed**: `outfit hf` + looks the model up in the local HF cache (`HF_HOME`/`HF_HUB_CACHE`, else + `~/.cache/huggingface/hub`) and in llama.cpp's own cache. A cached GGUF + becomes a `MODEL` pointing at the file on disk, so the engine does not + download a second copy; anything already cached also means the metadata can + be read locally, so a fully cached model resolves with no network at all. +- **Optional Hugging Face token**: a token is resolved from `HF_TOKEN`, then + `HUGGING_FACE_HUB_TOKEN`, then the CLI's stored token file, and sent as a + bearer only when one exists — so gated and private repos work for a logged-in + user, and everyone else is unaffected. The token is never written to disk or + printed. +- **`--output-file`/`-o` writes the Outfit** instead of printing it, refusing + to overwrite an existing file without `--force`. +- **`--apply` dresses the harness straight away**, going through the same path + `outfit apply` uses, so `outfit hf --apply` is the one-liner from model + page to working agent. +- **No weights are downloaded by `outfit`.** It reads the cache and the Hub's + metadata; fetching weights stays the engine's job, as it is today. +- **No new module dependency**: the Hub client is a small leaf package using + the two JSON endpoints and the documented cache layout, in keeping with a CLI + whose whole point is having nothing to install. + +## Capabilities + +### New Capabilities + +- `huggingface-hub`: reading Hugging Face — parsing a model reference in its + several written forms, fetching repo metadata and `config.json` over the + Hub's API, resolving an optional token, locating an already-cached repo in + the HF and llama.cpp caches, and the bounded, quiet failure behaviour that + keeps a slow or unreachable Hub from hanging or breaking a command. +- `huggingface-outfits`: the `outfit hf` command — how a resolved repo becomes + a `Selection` (provider inference, quantisation choice, context and alias + derivation, cached-file preference), the flags that override each inference, + and how the result is printed, written or applied. + +### Modified Capabilities + +_None._ The Outfit file format, `apply`, `serve` and completion all consume the +result exactly as they already do: `outfit hf` emits an ordinary Outfit, and a +cached-file `MODEL` is the local-path form `serve` already understands. + +## Impact + +- New package `internal/hf`: reference parsing, the Hub metadata client, token + resolution, cache lookup, and the inference rules that turn a repo into a + `Selection`. A leaf package — stdlib only, importing nothing of ours but + `internal/contextsize`. +- New `cmd/outfit/hf.go` holding the command and its flag set, kept out of + `main.go` for the same reason `serve.go` is (the dispatch-coverage scan reads + `main.go`'s switch). +- `cmd/outfit/complete.go` gains the command, its flags and provider-name + completion for `-p`; `TestCompletionCoversDispatch` requires it. +- Docs: a new `docs/commands/hf.md`, entries in `docs/README.md` and + `docs/env-vars.md` (`HF_TOKEN`, `HF_HOME`, `HF_HUB_CACHE`, `LLAMA_CACHE`), + a README quickstart line, and an `AGENTS.md` layout entry. +- Network: a second outbound host beyond the AWS control plane and provider + endpoints — `huggingface.co`, over the same best-effort, timeout-bounded + discipline `internal/discovery` already follows. +- No change to any harness adapter, to the catalogue, or to the Outfit format. diff --git a/openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md b/openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md new file mode 100644 index 00000000..5457a3bf --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md @@ -0,0 +1,156 @@ +## Purpose + +Define how outfit reads Hugging Face: the reference forms it accepts, the repo +metadata it fetches, the optional token it sends, the already-downloaded copies +it finds in the local caches, and the bounded, explained failures that keep a +slow or unreachable Hub from hanging a command. + +## ADDED Requirements + +### Requirement: Model reference forms + +A Hugging Face model reference SHALL be accepted in every form a user is likely +to paste: a bare `org/model`, a `hf.co/` or `huggingface.co/` prefixed form, a +full `https://huggingface.co/org/model` URL including a trailing `/tree/` +or `/blob//` path, and any of those with llama.cpp's `:QUANT` +suffix or an `@` suffix. Parsing SHALL yield the repo id, the +revision (defaulting to `main`), and the quantisation or file when one was +named. A reference naming no organisation, or carrying both a `@revision` and a +`/tree/` that disagree, SHALL fail naming what was wrong with it. + +#### Scenario: A pasted model page URL + +- **WHEN** `https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/tree/main` is + parsed +- **THEN** it yields repo `unsloth/Qwen3.6-35B-A3B-GGUF` at revision `main` + +#### Scenario: A quantisation suffix + +- **WHEN** `unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL` is parsed +- **THEN** it yields that repo with the quantisation `UD-Q4_K_XL` + +#### Scenario: A reference that names no repo + +- **WHEN** `qwen3.6` is parsed +- **THEN** it fails saying a reference is `org/model`, and shows an example + +### Requirement: Reading a repo from the Hub + +The system SHALL read a repo's published metadata over the Hub's API: the list +of files it holds, its tags and library, and its `config.json` where one +exists. The endpoint SHALL be overridable by the conventional `HF_ENDPOINT` +environment variable so a mirror or a private deployment can be used. Every +request SHALL carry a bounded timeout, so an unreachable or slow Hub cannot +hang a command. + +Metadata SHALL be read without downloading any weights: a repo's file list and +`config.json` are enough to describe it, and reading a multi-gigabyte file to +learn what it is SHALL never happen. + +#### Scenario: A repo's files and config are read + +- **WHEN** metadata is read for a repo that publishes `config.json` +- **THEN** the file list and the config's contents are available, and no + weights file was fetched + +#### Scenario: A mirror endpoint is honoured + +- **WHEN** `HF_ENDPOINT` names another host +- **THEN** metadata requests go to that host rather than `huggingface.co` + +#### Scenario: An unreachable Hub does not hang + +- **WHEN** the endpoint does not respond within the timeout +- **THEN** the read is abandoned and reported as a failure to reach the Hub + +### Requirement: Optional Hugging Face token + +A token SHALL be resolved, when one exists, from `HF_TOKEN`, then +`HUGGING_FACE_HUB_TOKEN`, then the token file the Hugging Face CLI writes under +its home directory. A resolved token SHALL be sent as the request's bearer +credential; with no token, requests SHALL be made unauthenticated and public +repos SHALL work exactly as before. A token SHALL NOT be written to any file +outfit produces, printed, or included in narration. + +Where a repo is unreadable because it is private or gated, the failure SHALL +say which — and, when no token was resolved, SHALL say that setting `HF_TOKEN` +or logging in with the Hugging Face CLI is what makes it readable — rather than +reporting a bare HTTP status. + +#### Scenario: No token is needed for a public repo + +- **WHEN** metadata is read for a public repo with no token set anywhere +- **THEN** the request is made unauthenticated and succeeds + +#### Scenario: A stored token is used + +- **WHEN** no token variable is set but the Hugging Face CLI's token file holds + one +- **THEN** that token is sent as the request's bearer credential + +#### Scenario: A gated repo without a token + +- **WHEN** a gated repo is read and no token was resolved +- **THEN** the failure says the repo is gated and how to authenticate + +#### Scenario: The token stays out of everything written + +- **WHEN** a command that resolved a token produces its output +- **THEN** neither the output, the written file, nor the narration contains it + +### Requirement: Finding an already-downloaded copy + +Before anything is fetched, the local caches SHALL be consulted for the +reference: the Hugging Face cache (`HF_HUB_CACHE`, else `HF_HOME`'s hub +directory, else the conventional `~/.cache/huggingface/hub`) in the layout the +Hugging Face libraries write, and llama.cpp's own model cache (`LLAMA_CACHE`, +else its conventional location) which is where `llama-server` puts what it +downloads itself. A file found SHALL be reported with its path on disk. + +A cache entry SHALL count only when the file it names is genuinely present and +readable — a snapshot entry whose stored content is missing, and a +partly-downloaded file, SHALL be treated as not cached, so an interrupted +download is never mistaken for a model that is ready. + +Where the cache holds the repo's metadata too, it SHALL be read from there, so +a model that is already downloaded resolves without a network request at all. + +#### Scenario: A downloaded model is found + +- **WHEN** the reference names a repo whose file is already in the Hugging Face + cache +- **THEN** the file's path on disk is reported + +#### Scenario: llama.cpp's own cache counts + +- **WHEN** the model was downloaded by `llama-server` rather than by a Hugging + Face tool +- **THEN** it is found in llama.cpp's cache and its path reported + +#### Scenario: An interrupted download is not a cached model + +- **WHEN** the cache holds only a partial file for the reference +- **THEN** the reference is reported as not cached + +#### Scenario: A cached model needs no network + +- **WHEN** the reference is fully present in the cache, including its metadata +- **THEN** it resolves with no request to the Hub + +### Requirement: Failures name what went wrong + +Reading Hugging Face SHALL fail with a message naming the cause and the repo: +a repo that does not exist, a repo that exists but is gated or private, a +network or timeout failure, and a response that cannot be understood SHALL each +be distinguishable. No failure SHALL surface as a bare status code or a raw +transport error. + +#### Scenario: A repo that does not exist + +- **WHEN** metadata is read for a repo the Hub does not have +- **THEN** the failure names the repo and says it was not found + +#### Scenario: Offline + +- **WHEN** metadata is read with no network available and nothing cached +- **THEN** the failure says the Hub could not be reached, naming the endpoint diff --git a/openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md b/openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md new file mode 100644 index 00000000..8656204c --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md @@ -0,0 +1,221 @@ +## Purpose + +Define `outfit hf`: turning a Hugging Face model reference into a working +Outfit — inferring the engine, the quantisation, the context window and the +name from what the repo actually holds, preferring a copy already on disk, and +printing, writing or applying the result. + +## ADDED Requirements + +### Requirement: Creating an Outfit from a reference + +`outfit hf ` SHALL read the named Hugging Face model, derive a provider +selection from it, and render it as an Outfit on stdout. Rendered output SHALL +be the same canonical form `outfit export` produces, so `outfit hf > +Outfit` yields a file every other command accepts. A missing reference SHALL +fail showing the command's usage. + +What was inferred, and from what, SHALL be reported on stderr — the provider +and why, the quantisation chosen and the alternatives available, the context +window and where it came from, and whether a local copy was used — so stdout +stays a clean Outfit while the reasoning is still visible. + +#### Scenario: A reference becomes an Outfit + +- **WHEN** the user runs `outfit hf unsloth/Qwen3.6-35B-A3B-GGUF` +- **THEN** an Outfit naming a provider, model, alias and context is printed on + stdout, and the reasoning is printed on stderr + +#### Scenario: Redirecting produces a usable file + +- **WHEN** the printed output is redirected to `./Outfit` and `outfit apply` is + run +- **THEN** the harness is configured from it with no editing + +#### Scenario: No reference + +- **WHEN** the user runs `outfit hf` with no argument +- **THEN** it fails showing how the command is called + +### Requirement: The provider is inferred from the repo + +The `PROVIDER` SHALL be inferred from what the repo holds: a repo publishing +GGUF files SHALL yield `llamacpp`; a repo published for MLX SHALL yield `omlx`; +a repo publishing plain safetensors weights SHALL yield `vllm`. A repo matching +none of these SHALL fail saying what it appears to hold and which providers +`outfit hf` can infer, rather than guessing one. + +`--provider`/`-p` SHALL override the inference, and SHALL be accepted even when +inference would have chosen otherwise, so a repo carrying both GGUF and +safetensors can be pointed at either engine. A `-p` naming a provider that is +not a self-hosted engine SHALL still be honoured — the Outfit describes what a +harness talks to, and only `serve` requires a local engine. + +#### Scenario: A GGUF repo + +- **WHEN** the reference names a repo whose files are GGUF +- **THEN** the Outfit says `PROVIDER llamacpp` + +#### Scenario: An MLX repo + +- **WHEN** the reference names a repo published for MLX +- **THEN** the Outfit says `PROVIDER omlx` + +#### Scenario: A safetensors repo + +- **WHEN** the reference names a repo of plain safetensors weights +- **THEN** the Outfit says `PROVIDER vllm` + +#### Scenario: A repo that is not a model + +- **WHEN** the reference names a repo holding none of these +- **THEN** the command fails saying what it found and which providers can be + inferred + +#### Scenario: Overriding the inference + +- **WHEN** the user passes `-p vllm` for a repo carrying both GGUF and + safetensors files +- **THEN** the Outfit says `PROVIDER vllm` + +### Requirement: Choosing a quantisation + +For a repo offering several quantisations, the one served SHALL be chosen in +this order: the reference's own `:QUANT` suffix, then `--quant`/`-q`, then a +deterministic, documented preference order favouring a mid-sized K-quant. The +chosen quantisation SHALL be named in the narration alongside the alternatives +the repo offers, so the default can be seen and overridden. + +A named quantisation that the repo does not offer SHALL fail listing the ones +it does, matched case-insensitively so `q4_k_m` finds `Q4_K_M`. A +quantisation split across several files SHALL be treated as one choice, and the +`MODEL` written for it SHALL be the form the engine loads the whole set from, +never a single shard in isolation. + +#### Scenario: A default is chosen and explained + +- **WHEN** a repo offers `Q4_K_M` and `Q8_0` and neither a suffix nor `-q` was + given +- **THEN** `Q4_K_M` is chosen, and the narration names it and lists `Q8_0` as + an alternative + +#### Scenario: An explicit quantisation wins + +- **WHEN** the reference ends `:Q8_0` +- **THEN** the Outfit's `MODEL` names that quantisation + +#### Scenario: A quantisation the repo does not have + +- **WHEN** the reference ends `:Q3_K_XXL` and the repo has no such file +- **THEN** the command fails listing the quantisations the repo offers + +#### Scenario: A sharded quantisation + +- **WHEN** the chosen quantisation is published as several numbered shards +- **THEN** the `MODEL` written loads the whole set + +### Requirement: Context and name are derived, never invented + +The `CONTEXT` SHALL be the window the model itself declares in its published +configuration, and `--context`/`-c` SHALL override it, parsed by the same +lenient size format every other command uses. Where the model declares no +window, `CONTEXT` SHALL be omitted and the narration SHALL say so, rather than +a plausible number being invented. + +The `ALIAS` SHALL be a short, lower-cased name derived from the repo's own +name with a packaging suffix such as `-GGUF` or `-MLX` removed, and +`--alias`/`-a` SHALL override it. `OUTPUT` SHALL NOT be written: it already +defaults to a quarter of the context when an Outfit is applied. + +#### Scenario: The declared window is used + +- **WHEN** the model's configuration declares a 262144-token window +- **THEN** the Outfit says `CONTEXT 262144` + +#### Scenario: An overridden window + +- **WHEN** the user passes `-c 32k` +- **THEN** the Outfit says `CONTEXT 32000` whatever the model declares + +#### Scenario: No declared window + +- **WHEN** the model publishes no configuration stating a window +- **THEN** the Outfit has no `CONTEXT` line and the narration says why + +#### Scenario: The alias drops the packaging suffix + +- **WHEN** the repo is `unsloth/Qwen3.6-35B-A3B-GGUF` +- **THEN** the `ALIAS` is `qwen3.6-35b-a3b` + +### Requirement: A copy already on disk is preferred + +Where the chosen model is already in a local cache, the `MODEL` written SHALL +name the file on disk rather than the repo, so the engine loads what is there +instead of downloading a second copy, and the narration SHALL say which cache +it came from. Where it is not cached, the `MODEL` SHALL be the repo reference +in the form the engine understands, leaving the download to the engine as it +happens today. + +This SHALL apply only where naming a file is meaningful to the engine: for an +engine that loads a repo or a directory rather than a single weights file, the +`MODEL` SHALL stay the repo reference whether or not a copy is cached. + +Because a path names one machine's disk, `--no-cache` SHALL write the repo +reference even when a copy is cached, so an Outfit meant to be committed and +shared can be produced deliberately. The narration SHALL note when a path was +written for exactly this reason. + +`outfit hf` SHALL NOT download weights under any circumstances. Reading a repo +is metadata only, and a command that describes a model SHALL never begin a +multi-gigabyte transfer as a side effect. + +#### Scenario: A cached GGUF is named directly + +- **WHEN** the chosen quantisation is already in a local cache +- **THEN** the `MODEL` is that file's path, and the narration names the cache + +#### Scenario: An uncached model keeps the repo reference + +- **WHEN** nothing for the reference is cached +- **THEN** the `MODEL` is the repo reference, and nothing is downloaded + +#### Scenario: A portable Outfit is asked for + +- **WHEN** the chosen quantisation is cached and the user passes `--no-cache` +- **THEN** the `MODEL` is the repo reference rather than the local path + +#### Scenario: Describing a model never downloads it + +- **WHEN** `outfit hf` runs for a repo holding many gigabytes of weights +- **THEN** only metadata is fetched and no weights file is transferred + +### Requirement: Printing, writing and applying + +By default the Outfit SHALL be printed to stdout. `--output-file`/`-o` SHALL +write it to the named path instead, reporting where it went; an existing file +SHALL NOT be overwritten unless `--force` is given, so a hand-edited Outfit +cannot be lost to a mistyped command. `--apply` SHALL additionally configure +the active harness from the selection, by the same path `outfit apply` uses and +honouring `--harness`/`-H`, so one command goes from a model page to a dressed +agent. + +#### Scenario: Writing to a file + +- **WHEN** the user runs `outfit hf -o ./Outfit` and no such file exists +- **THEN** the Outfit is written there and the path is reported + +#### Scenario: An existing file is not clobbered + +- **WHEN** `-o` names a file that already exists and `--force` was not given +- **THEN** nothing is written and the command fails saying `--force` overwrites + +#### Scenario: Applying directly + +- **WHEN** the user runs `outfit hf --apply` +- **THEN** the active harness is configured exactly as applying the printed + Outfit would have configured it + +#### Scenario: Applying to a named harness + +- **WHEN** the user runs `outfit hf --apply --harness pi` +- **THEN** the Pi harness is configured rather than the active default diff --git a/openspec/changes/add-hf-outfit-command/tasks.md b/openspec/changes/add-hf-outfit-command/tasks.md new file mode 100644 index 00000000..1d9c7977 --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/tasks.md @@ -0,0 +1,116 @@ +## 1. Reference parsing + +- [ ] 1.1 Create `internal/hf` (stdlib only, plus `internal/contextsize`) with a + `Ref` type holding repo owner, name, revision, and an optional quant or file. +- [ ] 1.2 Implement `ParseRef`: bare `org/model`, `hf.co/`/`huggingface.co/` + prefixes, full `https://huggingface.co/...` URLs with `/tree/` and + `/blob//`, `:QUANT` suffixes, and `@` suffixes. +- [ ] 1.3 Reject a reference naming no organisation, and one whose `@revision` + contradicts a `/tree/`, with messages naming what was wrong. +- [ ] 1.4 Table-driven tests covering every accepted form and each rejection. + +## 2. Hub client + +- [ ] 2.1 Resolve the endpoint from `HF_ENDPOINT`, defaulting to + `https://huggingface.co`; give the client a bounded timeout and no retries, + following `internal/discovery`'s shape. +- [ ] 2.2 Fetch repo metadata (`/api/models/{repo}/revision/{rev}`), reading + `siblings[].rfilename`, `tags`, `library_name` and `sha`. +- [ ] 2.3 Fetch `config.json` via `/{repo}/resolve/{rev}/config.json` and read + `max_position_embeddings`, falling back to + `text_config.max_position_embeddings`; a missing file or field means "no + declared window", not an error. +- [ ] 2.4 Resolve an optional token: `HF_TOKEN`, then `HUGGING_FACE_HUB_TOKEN`, + then `$HF_HOME/token` (else `~/.cache/huggingface/token`); send it as a bearer + only when one was found. +- [ ] 2.5 Map failures to distinct, named errors: not found, gated/private (with + the authenticate-or-set-`HF_TOKEN` hint when no token was resolved), rate + limited, unreachable/timed out, and unparseable — each naming the repo and, + for transport failures, the endpoint. +- [ ] 2.6 Tests against an `httptest` server with `HF_ENDPOINT` pointed at it: + metadata parsing, config parsing and its fallbacks, each failure mapping, the + bearer header sent only when a token resolves, and the token absent from every + produced value. + +## 3. Cache lookup + +- [ ] 3.1 Resolve cache roots in one function — `HF_HUB_CACHE`, else + `$HF_HOME/hub`, else `~/.cache/huggingface/hub`; `LLAMA_CACHE`, else the + platform cache dir — and pass them into every lookup as arguments rather than + reading the environment at the point of use. +- [ ] 3.2 Look a ref up in the Hugging Face cache: + `models--{owner}--{name}/refs/{rev}` for the sha, then + `snapshots/{sha}/{path}`; count an entry only when the link resolves to a + readable regular file, so a dangling link or a `.incomplete` blob reads as not + cached. +- [ ] 3.3 Read the cached `config.json` from the snapshot when present, so a + fully cached model resolves with no network request. +- [ ] 3.4 Look a chosen quant up in llama.cpp's cache: a case-insensitive scan + for a `.gguf` whose name carries the repo owner, the repo name and the quant — + all three, so a near-match never produces a wrong path. +- [ ] 3.5 Tests over temp-dir caches: a hit in each cache, a dangling snapshot + link, an `.incomplete` blob, a near-miss llama.cpp filename that must not + match, and the no-network path for a fully cached model. + +## 4. Inference rules + +- [ ] 4.1 Infer the provider: any `.gguf` → `llamacpp`; else `library_name`/tag + `mlx` → `omlx`; else any `.safetensors` → `vllm`; else an error naming what the + repo appears to hold and the providers that can be inferred. +- [ ] 4.2 Parse quant names from the file list: strip `.gguf` and any + `-000NN-of-000NN` shard suffix, match the quant token + (`IQ*`/`Q*_*`/`UD-*`/`F16`/`BF16`/`F32`/`MXFP4`), prefer a directory level + where the repo uses one, and group shards into one choice. +- [ ] 4.3 Select the quant: reference suffix, then `-q`, then the documented + preference order (`Q4_K_M`, `UD-Q4_K_XL`, `Q4_K_S`, `Q5_K_M`, `Q6_K`, `Q8_0`), + then the smallest non-full-precision group with the name as tie-break. Match + case-insensitively; an unmatched name fails listing what the repo offers. +- [ ] 4.4 Derive the alias from the repo name: lower-cased, with a trailing + `-GGUF`/`-MLX` packaging suffix removed. +- [ ] 4.5 Build the `outfit.Selection`: provider, the `MODEL` per the design's + engine table (cached path for `llamacpp`, repo reference otherwise), alias, + context from the declared window, and no `OUTPUT`. +- [ ] 4.6 Return the reasoning alongside the selection (provider and why, quant + chosen and alternatives, context and its source, cache hit and which cache) as + data, so the command formats it and tests assert on it. +- [ ] 4.7 Tests for each inference rule, including a repo carrying both GGUF and + safetensors, a sharded quant, a repo declaring no window, and a repo matching + no provider. + +## 5. The command + +- [ ] 5.1 Add `cmd/outfit/hf.go` with `cmdHF`: flags `-p/--provider`, + `-q/--quant`, `-c/--context`, `-a/--alias`, `-o/--output-file`, `--force`, + `--no-cache`, `--apply`, `-H/--harness`; no output-tokens flag. Register + `case "hf":` in `run`'s switch in `main.go` (the command body stays out of + `main.go` so the dispatch-coverage scan sees only that switch). +- [ ] 5.2 Render the selection with `outfit.Format` to stdout; write the + reasoning to stderr. +- [ ] 5.3 Implement `-o`: write the rendered Outfit to the path, report where it + went, and refuse to overwrite an existing file unless `--force` is given. +- [ ] 5.4 Implement `--apply`: route the resolved selection through + `applySelection`, honouring `--harness`/`-H`. +- [ ] 5.5 Fail with usage when no reference is given. +- [ ] 5.6 Tests in `cmd/outfit/hf_test.go` against a stub Hub and temp caches: + the printed Outfit round-trips through `outfit.Parse`, `-o` writes and refuses + to clobber, `--force` overwrites, `--apply` configures the harness, `--no-cache` + forces the repo form, and stdout stays free of narration. + +## 6. Completion, docs and checks + +- [ ] 6.1 Add `hf` to the `commands` table in `cmd/outfit/complete.go` with its + flags, provider-name candidates for `-p`, and a file directive for `-o`; check + `TestCompletionCoversDispatch` passes. +- [ ] 6.2 Write `docs/commands/hf.md`: the reference forms, each inference and + how to override it, the quant preference order, cache behaviour and + `--no-cache`, token setup, and an explicit note that `-o` here is the output + *file* (unlike `add`/`apply`, where it is max output tokens). +- [ ] 6.3 Link the new page from `docs/README.md`, document `HF_TOKEN`, + `HUGGING_FACE_HUB_TOKEN`, `HF_HOME`, `HF_HUB_CACHE`, `HF_ENDPOINT` and + `LLAMA_CACHE` in `docs/env-vars.md`, and add a quickstart line to the root + `README.md`. +- [ ] 6.4 Add an `internal/hf` entry and a `cmd/outfit/hf.go` note to AGENTS.md's + Layout, plus a Traps entry for the two caches being separate (a model + downloaded by `llama-server` is not in the Hugging Face cache, and vice versa). +- [ ] 6.5 Run `gofmt -w ./...`, `go vet ./...` and `go test ./... -cover`, + keeping total coverage at or above 80%.