Commit Diff


commit - b5fc4794c6055d415e4584d6b75b0e5be51daf36
commit + 545a5aa90590e928e51d84609a2c7eb6a3af7fc1
blob - b4050dedcd52a2cc5d85c657d451b8aa1113ce97
blob + c4973ffaf80eca846cbbaa45f34f34c9f5bd5426
--- .gitignore
+++ .gitignore
@@ -1,3 +1,4 @@
 /target
-/alpacu/alpacu
-/alpaci/alpaci
+/man/*.1
+/man/*.5
+/.allium-loop/
blob - fe59632020171cd46b8a52976b1a02e116838afc
blob + 9e2f5038f693e9b8c62113dfba94d309c68e764d
--- Cargo.lock
+++ Cargo.lock
@@ -26,8 +26,10 @@ dependencies = [
  "reqwest",
  "serde",
  "serde_json",
+ "tempfile",
  "thiserror",
  "tokio",
+ "toml",
 ]
 
 [[package]]
@@ -148,9 +150,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0
 
 [[package]]
 name = "aws-lc-rs"
-version = "1.18.0"
+version = "1.18.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
 dependencies = [
  "aws-lc-sys",
  "zeroize",
@@ -158,9 +160,9 @@ dependencies = [
 
 [[package]]
 name = "aws-lc-sys"
-version = "0.44.0"
+version = "0.45.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
 dependencies = [
  "cc",
  "cmake",
@@ -206,9 +208,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da7
 
 [[package]]
 name = "cc"
-version = "1.4.4"
+version = "1.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
 dependencies = [
  "find-msvc-tools",
  "jobserver",
@@ -454,7 +456,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308c
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.4",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -496,9 +498,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97
 
 [[package]]
 name = "find-msvc-tools"
-version = "0.1.11"
+version = "0.1.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
 
 [[package]]
 name = "float-cmp"
@@ -1065,9 +1067,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac
 
 [[package]]
 name = "mio"
-version = "1.2.2"
+version = "1.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
 dependencies = [
  "libc",
  "wasi",
@@ -1650,7 +1652,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b09
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.4",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -1667,6 +1669,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
 name = "serde_urlencoded"
 version = "0.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1724,9 +1735,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479
 
 [[package]]
 name = "smallvec"
-version = "1.15.2"
+version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
 
 [[package]]
 name = "socket2"
@@ -1769,9 +1780,9 @@ dependencies = [
 
 [[package]]
 name = "syn"
-version = "3.0.4"
+version = "3.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1826,6 +1837,7 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
+ "getrandom 0.4.3",
  "once_cell",
  "rustix",
  "windows-sys 0.61.2",
@@ -1854,7 +1866,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.4",
+ "syn 3.0.5",
 ]
 
 [[package]]
@@ -1869,9 +1881,9 @@ dependencies = [
 
 [[package]]
 name = "tinyvec"
-version = "1.12.0"
+version = "1.13.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
 dependencies = [
  "tinyvec_macros",
 ]
@@ -1907,14 +1919,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.4",
+ "syn 3.0.5",
 ]
 
 [[package]]
 name = "tokio-rustls"
-version = "0.26.4"
+version = "0.26.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
 dependencies = [
  "rustls",
  "tokio",
@@ -1935,6 +1947,45 @@ dependencies = [
 ]
 
 [[package]]
+name = "toml"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
 name = "tower"
 version = "0.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2329,6 +2380,12 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
 [[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
 name = "wit-bindgen"
 version = "0.57.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2440,7 +2497,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e6
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 3.0.4",
+ "syn 3.0.5",
 ]
 
 [[package]]
blob - 2b58a49e4ffe847e61148e9646708dcd2907b00f
blob + 7062b3127ad43d75835a3c86b1d24bb453548b85
--- Cargo.toml
+++ Cargo.toml
@@ -11,13 +11,15 @@ keywords = ["unix", "cli", "command-line", "ai", "llm"
 [dependencies]
 anyhow = "1.0.104"
 chrono = { version = "0.4.45", features = ["serde"] }
-clap = { version = "4.6.4", features = ["default", "cargo", "env"] }
+clap = { version = "4.6.6", features = ["default", "cargo", "env", "string"] }
 derive_builder = "0.20.2"
 reqwest = { version = "0.13.4", features = ["json"] }
 serde = { version = "1.0.229", features = ["derive"] }
 serde_json = "1.0.151"
-thiserror = "2.0.18"
+tempfile = "3.27.0"
+thiserror = "2.0.20"
 tokio = { version = "1.53.1", features = ["full"] }
+toml = "1.1.5"
 
 [dev-dependencies]
 assert_cmd = "2.2.2"
blob - 32b13b2ec6d040b6dc591434a8ac38a3323d4b84
blob + 8781b752986756b3bc92eb2cd2d7e19fee945c5d
--- README.md
+++ README.md
@@ -3,8 +3,7 @@
 Unix native interface for interacting with LLMs.
 
 `alpaca` is a fork of [`cogni`](https://github.com/leoshimo/cogni) by
-[leoshimo](https://github.com/leoshimo) that utilizes
-[Ollama Cloud](https://docs.ollama.com/cloud) rather than OpenAI.
+[leoshimo](https://github.com/leoshimo).
 
 ## Focus
 
@@ -13,7 +12,7 @@ environment by focusing on:
 
 - Ergonomics and accessibility in Unix shell
 - Composability and interop with other programs - including `alpaca` itself
-- Ease of language model programming in both ad-hoc and repeatable manner
+- Easy language model programming, both ad-hoc and repeatable
 
 For example, designing for IO redirection (`stdin`, `stdout`) allows `alpaca` to
 work with files, editor buffers, clipboards, syslogs, sockets, and many external
@@ -21,130 +20,104 @@ tools without bespoke integrations.
 
 ## Features
 
-- Unix-minded Design (IO redirection, composability, interop)
-- Ad-hoc Language Model Scripting
-- Flexible input and output formats (Text, JSON, NDJSON, Transcript)
-- Standalone binary - No Python required
-- Repeatable Scripts via Templates
+- Unix-minded design (IO redirection, composability, interop)
+- Ad-hoc language model scripting
+- Flexible input and output formats (text, JSON, transcript)
+- Standalone binary, with no Python required
+- Works against any compatible chat endpoint, hosted or local
+- Editor-backed prompting and provider quota reporting in the same binary
 
-## Non-Features
+## Non-features
 
-- Interactive use - instead, invoke `alpaca` from within interactive environments (REPLs, emacs, etc) 
+- Interactive use. Instead, invoke `alpaca` from within interactive environments, such as REPLs and emacs.
 
 ## Installation
 
+Building requires Rust and `scdoc`:
+
 ```sh
-$ cargo install --path .
+$ make
+$ make test
+$ sudo make install
 ```
 
+`PREFIX` defaults to `/usr/local`; `DESTDIR`, `BINDIR`, and `MANDIR` may be
+overridden for packaging.
+
 ## Setup
 
-`alpaca` talks to the [Ollama Cloud API](https://docs.ollama.com/cloud). It
-expects an Ollama API Key (create one at
-<https://ollama.com/settings/keys>) supplied via the `--apikey` option or more
-conveniently the `OLLAMA_API_KEY` environment variable:
+`alpaca` expects an API key supplied with the `--apikey` option or, more
+conveniently, the `API_KEY` environment variable:
 
 ```sh
 # in shell configuration
-export OLLAMA_API_KEY=your-api-key
+export API_KEY=your-api-key
 ```
 
-By default requests are sent to `https://ollama.com`. To target a different
-host, for example a local Ollama server, set `OLLAMA_API_ENDPOINT`:
+Requests go to `https://ollama.com` unless told otherwise. To target a
+different host, for example a local server, set `API_ENDPOINT` or pass
+`--base-url`:
 
 ```sh
-export OLLAMA_API_ENDPOINT=http://localhost:11434
+export API_ENDPOINT=http://localhost:11434
 ```
 
-Pick a model with `-m/--model` (default `gpt-oss:120b`). See
-<https://ollama.com/library> for available model identifiers.
+The endpoint's dialect follows from the base URL. A base URL ending in a
+version segment, such as `https://host/v1`, is sent to `/chat/completions`;
+any other base URL is sent to `/api/chat`. Authorization is only sent when a
+key is known, so endpoints that need no authentication also work.
 
----
+Pick a model with `-m/--model` (default `gpt-oss:120b`). Model identifiers are
+whatever your endpoint serves.
 
-## Extra tools
+Shared settings, honoured by every subcommand:
 
-### alpacu
+| variable | meaning | default |
+| --- | --- | --- |
+| `API_KEY` | API key | none |
+| `API_ENDPOINT` | base URL | `https://ollama.com` |
+| `ALPACA_MODEL` | model | `gpt-oss:120b` |
+| `ALPACA_EFFORT` | reasoning effort | `none` |
+| `ALPACA_TIMEOUT` | request timeout, in seconds | `60` |
 
-`alpacu` is a small Go program in the `alpacu/` directory. It calls the Ollama
-Cloud usage endpoint and prints your session quota (5 hour window) and weekly
-quota (7 day window) as colored bars, with a countdown to the next reset and a
-per-model request table with request counts.
+## Configuration
 
-It shares the same `OLLAMA_API_KEY` setup as `alpaca`. The key can also be
-passed with `-k`. Build and install with make (Go 1.26, no dependencies):
+Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`. Alpaca uses
+`~/.config/alpaca/config.toml` when `XDG_CONFIG_HOME` is not set.
 
-```sh
-$ cd alpacu
-$ make                # build ./alpacu
-$ make vet            # vet
-$ sudo make install   # installs alpacu(1) and alpacu.1 to /usr/local
-```
+Command-line options override environment variables. Environment variables
+override the config file. The config file overrides built-in defaults.
 
-Options:
-
-- `-k key`: API key. Falls back to `OLLAMA_API_KEY`
-- `-b url`: base URL. Defaults to `$OLLAMA_API_ENDPOINT`, then `https://ollama.com`
-- `-j`: print the raw JSON response instead of the rendered output
-- `-h`: show help
-
-See `alpacu.1` for the full man page.
-
-### alpaci
-
-`alpaci` edits prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends saved text
-to Ollama `POST /api/chat`, then writes reply to stdout. Piped stdin
-prefills prompt. Editor uses `/dev/tty`, keeping editor I/O out of pipeline.
-
-Uses `OLLAMA_API_KEY` or `-k`. Build needs Go 1.26:
-
-```sh
-$ cd alpaci
-$ make                # build ./alpaci
-$ make vet            # vet
-$ sudo make install   # installs alpaci(1) and alpaci.1 to /usr/local
+```toml
+output_format = "plaintext"
+model = "gpt-oss:120b"
+temperature = 0.7
+timeout = 60
+system = "Answer concisely."
+assistant = ["An example assistant message"]
+user = ["An example user message"]
+apikey = "your-api-key"
+base_url = "https://ollama.com"
+reasoning_effort = "none"
 ```
 
-```sh
-# Write a prompt in the editor, send it, see the reply
-$ alpaci
+The config file applies to chat, compose and quota where they share an option.
+See `alpaca-config(5)` for all fields and command-specific behavior. Keep the
+file private if it contains `apikey`.
 
-# Pick a model and reasoning effort
-$ alpaci -m gpt-oss:120b -e high
+## Commands
 
-# Set a system prompt
-$ alpaci -s "Answer in one sentence"
-
-# Edit piped input in the editor before sending
-$ git diff --staged | alpaci -s "Write a conventional commit message"
-
-# Sit in the middle of a pipeline: pipe in, edit, pipe out
-$ curl -s "wttr.in/?1" | alpaci -s "Summarize this weather in 1 sentence" | say
 ```
+alpaca [OPTIONS] [FILE]        # chat, the default command
+alpaca chat [OPTIONS] [FILE]   # the same thing, named
+alpaca quota [OPTIONS]         # provider quota usage
+alpaca compose [OPTIONS]       # edit a prompt in $EDITOR, then send it
+```
 
-Options:
+A first argument matching a subcommand name is treated as that subcommand. To
+read messages from a file with such a name, separate it with `--`, as in
+`alpaca -- quota`.
 
-- `-m model`: model to use. Defaults to `$ALPACA_MODEL`, then `glm-5.3-flash`
-- `-e effort`: reasoning effort, one of `low`, `medium`, `high`, `none`. Defaults to `$ALPACA_EFFORT`, then `low`. With `none`, the request omits the think field
-- `-s system`: system prompt
-- `-T secs`: request timeout in seconds. Defaults to 300
-- `-k key`: API key. Falls back to `OLLAMA_API_KEY`
-- `-b url`: base URL. Defaults to `$OLLAMA_API_ENDPOINT`, then `https://ollama.com`
-- `-j`: print the raw JSON response instead of the reply
-- `-h`: show help
-
-`$VISUAL` and `$EDITOR` split on whitespace; no quote or escape parsing.
-Use wrapper script for complex editor command. `-s` sends system message before
-user prompt. Temporary file is removed. `-T` must be positive.
-
-Each successful request is saved as a readable, length-prefixed transcript in
-`$XDG_DATA_HOME/alpaca/` (or `~/.local/share/alpaca/`). Run `alpaci c` to
-continue the newest transcript: its final assistant reply opens in the editor,
-and each saved follow-up is sent with the entire conversation as context. The
-saved model, effort and system prompt are reused unless explicitly overridden.
-Save an empty editor buffer to leave the continuation loop.
-
-See `alpaci.1`.
-
 ---
 
 ## Basic Usage
@@ -188,11 +161,105 @@ $ echo "50 + 50" | alpaca --system "Solve the followin
 
 ---
 
+## alpaca compose
+
+`alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the
+saved text, then writes the reply to stdout. Piped stdin prefills the buffer.
+The editor runs on `/dev/tty`, keeping editor I/O out of the pipeline. Saving
+an empty buffer sends nothing.
+
+```sh
+# Write a prompt in the editor, send it, see the reply
+$ alpaca compose
+
+# Pick a model and reasoning effort
+$ alpaca compose -m gpt-oss:120b --reasoning-effort high
+
+# Set a system prompt
+$ alpaca compose -s "Answer in one sentence"
+
+# Edit piped input in the editor before sending
+$ git diff --staged | alpaca compose -s "Write a conventional commit message"
+
+# Sit in the middle of a pipeline: pipe in, edit, pipe out
+$ curl -s "wttr.in/?1" | alpaca compose -s "Summarize this weather in 1 sentence" | say
+```
+
+Options:
+
+- `-m, --model <MODEL>`: model to use
+- `-s, --system <MSG>`: system prompt
+- `-t, --temperature <TEMP>`: sampling temperature
+- `-T, --timeout <SECS>`: request timeout in seconds
+- `-c, --continue`: continue the newest saved conversation
+- `--reasoning-effort <low|medium|high|none>`: reasoning effort
+- `--apikey <KEY>`, `--base-url <URL>`: endpoint settings
+- `--json`, `--jsonp`: print the response as JSON instead of the reply text
+
+`$VISUAL` and `$EDITOR` split on whitespace; no quote or escape parsing. Use a
+wrapper script for complex editor commands. The temporary file is always
+removed. An editor that exits non-zero sets alpaca's own exit status.
+
+Each successful request is saved as a JSON transcript in `$XDG_DATA_HOME/alpaca`
+(or `~/.local/share/alpaca`), with mode 0600 in a directory with mode 0700. Run
+`alpaca compose -c` to continue the newest transcript: its final reply opens in
+the editor, and each follow-up is sent with the whole conversation as context.
+The saved model, effort and system prompt are reused unless given on the command
+line. Save an empty buffer to leave the loop.
+
+See `man/alpaca-compose.1`.
+
+---
+
+## alpaca quota
+
+`alpaca quota` asks a provider how much of your quota you have used. It prints
+each quota window as a bar showing the used share, with a countdown to the next
+reset. Where the provider reports per-model request counts, it prints those as a
+table.
+
+Pick the provider with `-p` (default `ollama`):
+
+```sh
+$ alpaca quota                 # Ollama Cloud
+$ alpaca quota -p anthropic    # Claude Code
+$ alpaca quota -p openai       # Codex CLI
+```
+
+Each provider has its own default credential and base URL:
+
+| provider | credential | base URL |
+| --- | --- | --- |
+| `ollama` | `$OLLAMA_API_KEY` | `$OLLAMA_API_ENDPOINT`, then `https://ollama.com` |
+| `anthropic` | `$CLAUDE_CODE_OAUTH_TOKEN`, then `~/.claude/.credentials.json` | `https://api.anthropic.com` |
+| `openai` | `$CODEX_ACCESS_TOKEN`, then `~/.codex/auth.json` | `https://chatgpt.com` |
+
+The last two read the credential file the vendor CLI writes when you log in.
+They work as soon as you have logged into that CLI. Those endpoints serve the
+vendor CLIs and are not documented public APIs, so they may change without
+notice. Both expect a short lived OAuth token: if the credential file is stale,
+you get an authentication error until the vendor CLI refreshes it.
+
+Options:
+
+- `-p, --provider <PROVIDER>`: one of `ollama`, `anthropic`, `openai`
+- `-T, --timeout <SECS>`: request timeout in seconds
+- `--apikey <KEY>`: API key or OAuth token, replacing the provider default
+- `--base-url <URL>`: base URL, replacing the provider default
+- `--raw`: print the provider response body verbatim
+- `--json`, `--jsonp`: print the normalised view as JSON
+- `--color <auto|always|never>`: colour policy. `auto` colours only when stdout
+  is a terminal and `NO_COLOR` is unset
+
+See `man/alpaca-quota.1`.
+
+---
+
 ## Tour of alpaca
 
-An gallery of examples to get the inspiration flowing
+Examples to get you started.
 
-> :warning: `alpaca` uses the [Ollama Cloud API](https://docs.ollama.com/cloud), thus *any data fed into program will be sent to their servers* (unless you point `OLLAMA_API_ENDPOINT` at a local server).
+> :warning: Whatever you feed `alpaca` is sent to the endpoint you configure, so point `API_ENDPOINT` at a local server if the data should not leave your machine.
 
 ### In the Shell
 
blob - /dev/null
blob + c3793e637044e49f4d39587c1a04fe7293ca7661 (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,37 @@
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+MANDIR ?= $(PREFIX)/share/man
+DESTDIR ?=
+
+SUDO ?= sudo
+CARGO ?= cargo
+INSTALL ?= install
+CARGO_TARGET_DIR ?= target
+
+PROGRAM := alpaca
+MAN1PAGES := $(patsubst %.scd,%,$(wildcard man/*.1.scd))
+MAN5PAGES := $(patsubst %.scd,%,$(wildcard man/*.5.scd))
+MANPAGES := $(MAN1PAGES) $(MAN5PAGES)
+
+.PHONY: all build test install clean
+
+all: build
+
+build: $(MANPAGES)
+	$(CARGO) build --release --locked
+
+man/%: man/%.scd
+	scdoc < "$<" > "$@"
+
+test:
+	$(CARGO) test --locked
+
+install: build
+	$(SUDO) $(INSTALL) -d "$(DESTDIR)$(BINDIR)" "$(DESTDIR)$(MANDIR)/man1" "$(DESTDIR)$(MANDIR)/man5"
+	$(SUDO) $(INSTALL) -m 0755 "$(CARGO_TARGET_DIR)/release/$(PROGRAM)" "$(DESTDIR)$(BINDIR)/$(PROGRAM)"
+	$(SUDO) $(INSTALL) -m 0644 $(MAN1PAGES) "$(DESTDIR)$(MANDIR)/man1/"
+	$(SUDO) $(INSTALL) -m 0644 $(MAN5PAGES) "$(DESTDIR)$(MANDIR)/man5/"
+
+clean:
+	$(CARGO) clean
+	$(RM) $(MANPAGES)
blob - a4767e340e731b6243f69e1f64ca0489bf00e828 (mode 644)
blob + /dev/null
--- alpaci/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/alpaci
-*.log
blob - ebbb6f56f1f80870e7cad47707cc8d77033dbd15 (mode 644)
blob + /dev/null
--- alpaci/Makefile
+++ /dev/null
@@ -1,38 +0,0 @@
-.POSIX:
-
-SHELL = /bin/sh
-GO ?= go
-GOCACHE ?= $(HOME)/.cache/go-build
-BIN ?= alpaci
-PREFIX ?= /usr/local
-BINDIR ?= $(PREFIX)/bin
-MANDIR ?= $(PREFIX)/share/man
-DESTDIR ?=
-DOAS ?= sudo
-GOFLAGS ?=
-
-.PHONY: all build clean install uninstall vet test
-
-all: build
-
-build:
-	GOFLAGS='$(GOFLAGS)' CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) build -trimpath -buildvcs=false -o $(BIN) .
-
-vet:
-	CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./...
-
-test:
-	CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) test ./...
-
-install: build
-	$(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
-	$(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
-	$(DOAS) chmod 0755 $(DESTDIR)$(BINDIR)/$(BIN)
-	$(DOAS) cp $(BIN).1 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-	$(DOAS) chmod 0644 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-uninstall:
-	$(DOAS) rm -f $(DESTDIR)$(BINDIR)/$(BIN) $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-clean:
-	rm -f $(BIN)
blob - 511661c65cce7b14f0f4c58f6fb801f19d5c3939 (mode 644)
blob + /dev/null
--- alpaci/alpaci.1
+++ /dev/null
@@ -1,167 +0,0 @@
-.\" alpaci . edit a prompt, then send it to the Ollama chat API
-.Dd September 2, 2025
-.Dt ALPACI 1
-.Os
-.Sh NAME
-.Nm alpaci
-.Nd edit a prompt in the editor, then send it to the Ollama chat API
-.Sh SYNOPSIS
-.Nm
-.Op Fl m Ar model
-.Op Fl e Ar effort
-.Op Fl s Ar system
-.Op Fl T Ar secs
-.Op Fl k Ar key
-.Op Fl b Ar url
-.Op Fl j
-.Op Fl h
-.Op Cm c
-.Sh DESCRIPTION
-.Nm
-edits a temporary prompt file, sends saved text as user prompt to
-.Pa {base}/api/chat ,
-and writes reply to standard output.
-Piped standard input prefills prompt file.
-Each successful request is saved as a length-prefixed transcript in
-.Pa $XDG_DATA_HOME/alpaca
-or, when that variable is unset,
-.Pa ~/.local/share/alpaca .
-.Pp
-With operand
-.Cm c ,
-.Nm
-loads the newest transcript, opens its final assistant reply in the editor,
-and continues prompting until the edited buffer is empty. Each continuation
-request includes the recorded conversation as context. The saved model, effort,
-and system prompt are reused unless their options are explicitly supplied.
-Editor always uses
-.Pa /dev/tty .
-Editor input and output never enter pipeline.
-.Nm
-fails when no controlling terminal exists.
-.Pp
-Temporary file is removed on exit.
-Request is non-streaming
-.Pq Cm stream=false .
-.Sh OPTIONS
-.Bl -tag -width Ds
-.It Fl m Ar model
-Model to use.
-Defaults to
-.Ev ALPACA_MODEL ,
-then
-.Cm glm-5.3-flash .
-.It Fl e Ar effort
-Reasoning effort, one of
-.Cm low ,
-.Cm medium ,
-.Cm high ,
-or
-.Cm none .
-Defaults to
-.Ev ALPACA_EFFORT ,
-then
-.Cm low .
-With
-.Cm none ,
-the request omits the
-.Sy think
-field.
-.It Fl s Ar system
-System prompt.
-.It Fl T Ar secs
-Request timeout in seconds.
-Defaults to 300.
-.It Fl k Ar key
-API key.
-Falls back to
-.Ev OLLAMA_API_KEY .
-May be omitted for unauthenticated local endpoints.
-.It Fl b Ar url
-Base URL.
-Defaults to
-.Ev OLLAMA_API_ENDPOINT ,
-then
-.Cm https://ollama.com .
-Useful for local or staging instances.
-.It Fl j
-Print the raw JSON response instead of the reply.
-.It Cm c
-Continue the newest saved conversation.
-.It Fl h
-Show help and exit.
-.El
-.Sh ENVIRONMENT
-.Bl -tag -width Ds
-.It Ev OLLAMA_API_KEY
-API key used when
-.Fl k
-is not given.
-.It Ev OLLAMA_API_ENDPOINT
-Base URL used when
-.Fl b
-is not given.
-.It Ev ALPACA_MODEL
-Default model when
-.Fl m
-is not given.
-.It Ev ALPACA_EFFORT
-Default reasoning effort when
-.Fl e
-is not given.
-.It Ev XDG_DATA_HOME
-Base directory for saved transcripts. When unset, transcripts use
-.Pa ~/.local/share/alpaca .
-.It Ev VISUAL , Ev EDITOR
-Editor command for prompt buffer.
-Values split on whitespace; quotes and escapes are not parsed.
-Use wrapper script for complex commands.
-Defaults to
-.Xr vim 1 .
-.El
-.Sh EXIT STATUS
-.Ex -std
-.Nm
-exits 2 for invalid options, missing API key, non-positive timeout, or
-operands.
-It exits with editor exit status when editor fails.
-.Sh EXAMPLES
-Write a prompt in the editor, send it, see the reply:
-.Bd -literal -offset indent
-$ alpaci
-.Ed
-.Pp
-Pick a model and reasoning effort:
-.Bd -literal -offset indent
-$ alpaci -m gpt-oss:120b -e high
-.Ed
-.Pp
-Set a system prompt:
-.Bd -literal -offset indent
-$ git diff --staged | alpaci -s \&"Write a conventional commit message\&"
-.Ed
-.Pp
-Sit in the middle of a pipeline:
-.Bd -literal -offset indent
-$ curl -s \&"wttr.in/?1\&" | alpaci -s \&"Summarize this weather in 1 sentence\&" | say
-.Ed
-.Pp
-Print the raw JSON response:
-.Bd -literal -offset indent
-$ alpaci -j
-.Ed
-.Pp
-Continue the newest saved conversation:
-.Bd -literal -offset indent
-$ alpaci c
-.Ed
-.Sh SEE ALSO
-.Xr alpaca 1 ,
-.Xr alpacu 1 ,
-.Xr vim 1
-.Pp
-.Lk https://docs.ollama.com/api/chat Ollama Chat API
-.Lk https://ollama.com/library Ollama model library
-.Sh AUTHORS
-.An m
-.Aq Mt miro@local
\ No newline at end of file
blob - 68e9a41d837a763b332460756a1d591590912e66 (mode 644)
blob + /dev/null
--- alpaci/go.mod
+++ /dev/null
@@ -1,3 +0,0 @@
-module alpaci
-
-go 1.22
blob - 2fdc1645a62c645ec01cc6a1def7cb9fc5e5b823 (mode 644)
blob + /dev/null
--- alpaci/main.go
+++ /dev/null
@@ -1,540 +0,0 @@
-// alpaci - edit a prompt in the editor, then send it to the Ollama chat API
-package main
-
-import (
-	"bufio"
-	"bytes"
-	"context"
-	"encoding/json"
-	"errors"
-	"flag"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"sort"
-	"strconv"
-	"strings"
-	"time"
-)
-
-const usage = `usage: alpaci [-m model] [-e effort] [-s system] [-T secs] [-k key] [-b url] [-j] [-h] [c]
-
-	-m model    model to use (else $ALPACA_MODEL, else glm-5.3-flash)
-	-e effort   reasoning effort: low, medium, high or none
-	            (else $ALPACA_EFFORT, else low)
-	-s system   system prompt
-	-T secs     request timeout in seconds (default 300)
-	-k key      API key (else $OLLAMA_API_KEY; omit for unauthenticated endpoints)
-	-b url      base URL (else $OLLAMA_API_ENDPOINT, else https://ollama.com)
-	-j          print the raw JSON response instead of the reply
-	-h          show help
-	c           continue the newest saved conversation
-
-Edit a prompt in the editor, then send it to the Ollama chat API and
-print the reply. Piped stdin prefills the editor buffer first, so alpaci
-can sit in the middle of a pipeline: pipe data in, edit it, pipe the
-reply onward.
-
-With c, alpaci opens the previous assistant reply in the editor and keeps
-prompting. Conversations are saved under $XDG_DATA_HOME/alpaca (or
-~/.local/share/alpaca) and sent as context on each continuation turn.
-`
-
-const (
-	defaultBase     = "https://ollama.com"
-	maxErrorBody    = 64 << 10
-	maxTimeoutSec   = int64(1<<63-1) / int64(time.Second)
-	transcriptMagic = "alpaci-transcript-v1"
-)
-
-type httpClient interface {
-	Do(*http.Request) (*http.Response, error)
-}
-
-// client is the default HTTP client. Each request carries its own context
-// timeout via -T, so no global Timeout is set. We do set a timeout for
-// reading response headers so a stalled connection fails quickly.
-var client httpClient = &http.Client{
-	Transport: &http.Transport{
-		ResponseHeaderTimeout: 30 * time.Second,
-	},
-}
-
-type message struct {
-	Role    string `json:"role"`
-	Content string `json:"content"`
-}
-
-type chatReq struct {
-	Model    string    `json:"model"`
-	Messages []message `json:"messages"`
-	Stream   bool      `json:"stream"`
-	Think    string    `json:"think,omitempty"`
-}
-
-type transcript struct {
-	Model    string
-	Effort   string
-	System   string
-	Messages []message
-}
-
-// validEffort reports whether an -e value is accepted. "none" means the
-// request omits the think field.
-func validEffort(e string) bool {
-	switch e {
-	case "low", "medium", "high", "none":
-		return true
-	}
-	return false
-}
-
-// buildReq assembles the chat request. A non-empty system prompt becomes
-// a system message before the user prompt; "none" drops the think field.
-func buildReq(model, effort, system, prompt string) chatReq {
-	return buildReqMessages(model, effort, system, []message{{Role: "user", Content: prompt}})
-}
-
-func buildReqMessages(model, effort, system string, messages []message) chatReq {
-	var m []message
-	if system != "" {
-		m = append(m, message{Role: "system", Content: system})
-	}
-	m = append(m, messages...)
-	think := effort
-	if effort == "none" {
-		think = ""
-	}
-	return chatReq{Model: model, Messages: m, Stream: false, Think: think}
-}
-
-func dataDir() (string, error) {
-	if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
-		return filepath.Join(dir, "alpaca"), nil
-	}
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return "", fmt.Errorf("find home directory: %w", err)
-	}
-	return filepath.Join(home, ".local", "share", "alpaca"), nil
-}
-
-// writeField writes a length-prefixed field so arbitrary message content is safe.
-func writeField(w io.Writer, name, value string) error {
-	_, err := fmt.Fprintf(w, "%s %d\n%s\n", name, len(value), value)
-	return err
-}
-
-func writeTranscript(path string, t transcript) error {
-	var b bytes.Buffer
-	b.WriteString(transcriptMagic + "\n")
-	for _, f := range []struct{ name, value string }{{"model", t.Model}, {"effort", t.Effort}, {"system", t.System}} {
-		if err := writeField(&b, f.name, f.value); err != nil {
-			return err
-		}
-	}
-	for _, m := range t.Messages {
-		if m.Role != "user" && m.Role != "assistant" {
-			return fmt.Errorf("invalid transcript role %q", m.Role)
-		}
-		if err := writeField(&b, "message-"+m.Role, m.Content); err != nil {
-			return err
-		}
-	}
-	return os.WriteFile(path, b.Bytes(), 0600)
-}
-
-func readField(r *bufio.Reader) (string, string, error) {
-	line, err := r.ReadString('\n')
-	if err != nil {
-		return "", "", err
-	}
-	parts := strings.Split(strings.TrimSuffix(line, "\n"), " ")
-	if len(parts) != 2 || parts[0] == "" {
-		return "", "", errors.New("invalid field header")
-	}
-	n, err := strconv.Atoi(parts[1])
-	if err != nil || n < 0 {
-		return "", "", errors.New("invalid field length")
-	}
-	data := make([]byte, n)
-	if _, err := io.ReadFull(r, data); err != nil {
-		return "", "", err
-	}
-	if end, err := r.ReadByte(); err != nil || end != '\n' {
-		return "", "", errors.New("missing field terminator")
-	}
-	return parts[0], string(data), nil
-}
-
-func readTranscript(path string) (transcript, error) {
-	f, err := os.Open(path)
-	if err != nil {
-		return transcript{}, err
-	}
-	defer f.Close()
-	r := bufio.NewReader(f)
-	magic, err := r.ReadString('\n')
-	if err != nil || strings.TrimSuffix(magic, "\n") != transcriptMagic {
-		return transcript{}, errors.New("not an alpaci transcript")
-	}
-	var t transcript
-	for _, expected := range []string{"model", "effort", "system"} {
-		name, value, err := readField(r)
-		if err != nil || name != expected {
-			return transcript{}, fmt.Errorf("invalid %s field", expected)
-		}
-		switch name {
-		case "model":
-			t.Model = value
-		case "effort":
-			t.Effort = value
-		case "system":
-			t.System = value
-		}
-	}
-	for {
-		name, value, err := readField(r)
-		if errors.Is(err, io.EOF) {
-			break
-		}
-		if err != nil || (name != "message-user" && name != "message-assistant") {
-			return transcript{}, errors.New("invalid message field")
-		}
-		t.Messages = append(t.Messages, message{Role: strings.TrimPrefix(name, "message-"), Content: value})
-	}
-	if len(t.Messages) == 0 || len(t.Messages)%2 != 0 {
-		return transcript{}, errors.New("transcript must end with an assistant reply")
-	}
-	for i, m := range t.Messages {
-		want := "user"
-		if i%2 == 1 {
-			want = "assistant"
-		}
-		if m.Role != want {
-			return transcript{}, errors.New("transcript messages are out of order")
-		}
-	}
-	return t, nil
-}
-
-func newTranscript(t transcript) (string, error) {
-	dir, err := dataDir()
-	if err != nil {
-		return "", err
-	}
-	if err := os.MkdirAll(dir, 0700); err != nil {
-		return "", err
-	}
-	f, err := os.CreateTemp(dir, strconv.FormatInt(time.Now().UnixNano(), 10)+"-*.log")
-	if err != nil {
-		return "", err
-	}
-	f.Close()
-	path := f.Name()
-	return path, writeTranscript(path, t)
-}
-
-func newestTranscript() (string, transcript, error) {
-	dir, err := dataDir()
-	if err != nil {
-		return "", transcript{}, err
-	}
-	entries, err := os.ReadDir(dir)
-	if errors.Is(err, os.ErrNotExist) {
-		return "", transcript{}, errors.New("no saved conversations")
-	}
-	if err != nil {
-		return "", transcript{}, err
-	}
-	sort.Slice(entries, func(i, j int) bool {
-		a, _ := entries[i].Info()
-		b, _ := entries[j].Info()
-		return a.ModTime().After(b.ModTime())
-	})
-	var lastErr error
-	for _, entry := range entries {
-		if entry.IsDir() || filepath.Ext(entry.Name()) != ".log" {
-			continue
-		}
-		path := filepath.Join(dir, entry.Name())
-		t, err := readTranscript(path)
-		if err != nil {
-			lastErr = fmt.Errorf("read conversation %s: %w", entry.Name(), err)
-			continue
-		}
-		return path, t, nil
-	}
-	if lastErr != nil {
-		return "", transcript{}, fmt.Errorf("no usable conversation: %w", lastErr)
-	}
-	return "", transcript{}, errors.New("no saved conversations")
-}
-
-// parseChatRes extracts the reply text from the chat response body.
-func parseChatRes(data []byte) (string, error) {
-	var r struct {
-		Message *struct {
-			Content string `json:"content"`
-		} `json:"message"`
-		DoneReason string `json:"done_reason"`
-	}
-	if err := json.Unmarshal(data, &r); err != nil {
-		return "", err
-	}
-	if r.Message == nil {
-		return "", errors.New("response missing message field")
-	}
-	return r.Message.Content, nil
-}
-
-func errBody(data []byte) (string, error) {
-	var e struct {
-		Error string `json:"error"`
-	}
-	if err := json.Unmarshal(data, &e); err != nil {
-		return "", err
-	}
-	return e.Error, nil
-}
-
-// send posts the request to base/api/chat. Authorization is sent only when
-// key is non-empty, so local endpoints without authentication still work.
-func send(ctx context.Context, base, key string, r chatReq) ([]byte, error) {
-	body, err := json.Marshal(r)
-	if err != nil {
-		return nil, err
-	}
-	url := strings.TrimRight(base, "/") + "/api/chat"
-	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-	if key != "" {
-		req.Header.Set("Authorization", "Bearer "+key)
-	}
-	req.Header.Set("Content-Type", "application/json")
-	res, err := client.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer res.Body.Close()
-	if res.StatusCode != http.StatusOK {
-		data, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBody))
-		if err != nil {
-			return nil, err
-		}
-		msg, err := errBody(data)
-		if err != nil {
-			msg = strings.TrimSpace(string(data))
-		}
-		return nil, fmt.Errorf("status %d: %s", res.StatusCode, msg)
-	}
-	return io.ReadAll(res.Body)
-}
-
-func envOr(name, def string) string {
-	if v := os.Getenv(name); v != "" {
-		return v
-	}
-	return def
-}
-
-// editorCmd splits $VISUAL or $EDITOR on whitespace. VISUAL wins.
-// Quotes and escapes are not parsed; use a wrapper script for them.
-func editorCmd() []string {
-	for _, name := range []string{"VISUAL", "EDITOR"} {
-		if v := os.Getenv(name); v != "" {
-			if fields := strings.Fields(v); len(fields) > 0 {
-				return fields
-			}
-		}
-	}
-	return []string{"vim"}
-}
-
-// runEditor runs the editor on controlling terminal. This keeps editor
-// input and screen output out of stdin/stdout pipelines.
-func runEditor(file string) error {
-	ed := editorCmd()
-	if len(ed) == 0 {
-		return errors.New("empty editor command")
-	}
-	tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
-	if err != nil {
-		return fmt.Errorf("open controlling terminal: %w", err)
-	}
-	defer tty.Close()
-	cmd := exec.Command(ed[0], append(ed[1:], file)...)
-	cmd.Stdin, cmd.Stdout, cmd.Stderr = tty, tty, tty
-	return cmd.Run()
-}
-
-// editPrompt writes the initial text (possibly empty) into a temp file,
-// opens the editor on it, and returns the saved contents. The file is
-// always removed. An editor exit error is returned as *exec.ExitError so
-// the caller can exit with the same status without bypassing cleanup.
-func editPrompt(initial string) (string, error) {
-	tmp, err := os.CreateTemp("", "alpaci-")
-	if err != nil {
-		return "", err
-	}
-	name := tmp.Name()
-	defer os.Remove(name)
-	if initial != "" {
-		if _, err := tmp.WriteString(initial); err != nil {
-			tmp.Close()
-			return "", err
-		}
-	}
-	tmp.Close()
-
-	err = runEditor(name)
-	var ee *exec.ExitError
-	if errors.As(err, &ee) {
-		return "", ee
-	}
-	if err != nil {
-		return "", err
-	}
-
-	data, err := os.ReadFile(name)
-	if err != nil {
-		return "", err
-	}
-	return string(data), nil
-}
-
-func must(err error) {
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "alpaci: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-func usageError(format string, args ...any) {
-	fmt.Fprintf(os.Stderr, "alpaci: "+format+"\n", args...)
-	fmt.Fprint(os.Stderr, usage)
-	os.Exit(2)
-}
-
-func flagWasSet(name string) bool {
-	set := false
-	flag.Visit(func(f *flag.Flag) {
-		if f.Name == name {
-			set = true
-		}
-	})
-	return set
-}
-
-func printResponse(body []byte, raw bool) {
-	if raw {
-		if !json.Valid(body) {
-			must(fmt.Errorf("response is not valid JSON"))
-		}
-		var out bytes.Buffer
-		must(json.Indent(&out, body, "", "  "))
-		out.WriteByte('\n')
-		_, _ = os.Stdout.Write(out.Bytes())
-		return
-	}
-	reply, err := parseChatRes(body)
-	must(err)
-	fmt.Print(reply)
-}
-
-func sendTurn(model, effort, system, base, key string, timeout int, t *transcript, prompt string) (string, []byte) {
-	t.Messages = append(t.Messages, message{Role: "user", Content: prompt})
-	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
-	defer cancel()
-	body, err := send(ctx, base, key, buildReqMessages(model, effort, system, t.Messages))
-	must(err)
-	reply, err := parseChatRes(body)
-	must(err)
-	t.Messages = append(t.Messages, message{Role: "assistant", Content: reply})
-	return reply, body
-}
-
-func main() {
-	model := flag.String("m", envOr("ALPACA_MODEL", "glm-5.3-flash"), "model")
-	effort := flag.String("e", envOr("ALPACA_EFFORT", "low"), "reasoning effort")
-	system := flag.String("s", "", "system prompt")
-	timeout := flag.Int("T", 300, "timeout seconds")
-	key := flag.String("k", os.Getenv("OLLAMA_API_KEY"), "API key")
-	base := flag.String("b", envOr("OLLAMA_API_ENDPOINT", defaultBase), "base URL")
-	jsn := flag.Bool("j", false, "raw JSON")
-	help := flag.Bool("h", false, "help")
-	flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
-	flag.Parse()
-	if *help {
-		fmt.Print(usage)
-		return
-	}
-	continueMode := flag.NArg() == 1 && flag.Arg(0) == "c"
-	if flag.NArg() != 0 && !continueMode {
-		usageError("unexpected argument %q", flag.Arg(0))
-	}
-	if *timeout <= 0 || int64(*timeout) > maxTimeoutSec {
-		usageError("timeout must be between 1 and %d seconds", maxTimeoutSec)
-	}
-
-	if continueMode {
-		path, t, err := newestTranscript()
-		must(err)
-		if !flagWasSet("m") {
-			*model = t.Model
-		}
-		if !flagWasSet("e") {
-			*effort = t.Effort
-		}
-		if !flagWasSet("s") {
-			*system = t.System
-		}
-		if !validEffort(*effort) {
-			usageError("invalid effort %q", *effort)
-		}
-		initial := t.Messages[len(t.Messages)-1].Content
-		for {
-			prompt, err := editPrompt(initial)
-			var ee *exec.ExitError
-			if errors.As(err, &ee) {
-				os.Exit(ee.ExitCode())
-			}
-			must(err)
-			if prompt == "" {
-				return
-			}
-			reply, body := sendTurn(*model, *effort, *system, *base, *key, *timeout, &t, prompt)
-			must(writeTranscript(path, t))
-			printResponse(body, *jsn)
-			initial = reply
-		}
-	}
-	if !validEffort(*effort) {
-		usageError("invalid effort %q", *effort)
-	}
-	initial := ""
-	if st, statErr := os.Stdin.Stat(); statErr == nil && st.Mode()&os.ModeCharDevice == 0 {
-		b, err := io.ReadAll(os.Stdin)
-		must(err)
-		initial = string(b)
-	}
-	prompt, err := editPrompt(initial)
-	var ee *exec.ExitError
-	if errors.As(err, &ee) {
-		os.Exit(ee.ExitCode())
-	}
-	must(err)
-	if prompt == "" {
-		return
-	}
-	t := transcript{Model: *model, Effort: *effort, System: *system}
-	_, body := sendTurn(*model, *effort, *system, *base, *key, *timeout, &t, prompt)
-	_, err = newTranscript(t)
-	must(err)
-	printResponse(body, *jsn)
-}
blob - f5b85699b33d174f53d9eaeb3e76e5a815d41a63 (mode 644)
blob + /dev/null
--- alpaci/main_test.go
+++ /dev/null
@@ -1,287 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"reflect"
-	"strings"
-	"testing"
-	"time"
-)
-
-func TestTranscriptRoundTrip(t *testing.T) {
-	path := filepath.Join(t.TempDir(), "session.log")
-	want := transcript{
-		Model: "model", Effort: "none", System: "system\ntext",
-		Messages: []message{{Role: "user", Content: "hello\nmessage-user 4\nevil"}, {Role: "assistant", Content: "reply"}},
-	}
-	if err := writeTranscript(path, want); err != nil {
-		t.Fatal(err)
-	}
-	got, err := readTranscript(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if !reflect.DeepEqual(got, want) {
-		t.Fatalf("got %#v, want %#v", got, want)
-	}
-}
-
-func TestNewestTranscript(t *testing.T) {
-	t.Setenv("XDG_DATA_HOME", t.TempDir())
-	dir, err := dataDir()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if err := os.MkdirAll(dir, 0700); err != nil {
-		t.Fatal(err)
-	}
-	old := filepath.Join(dir, "old.log")
-	newer := filepath.Join(dir, "new.log")
-	if err := writeTranscript(old, transcript{Model: "old", Effort: "low", Messages: []message{{Role: "user", Content: "old question"}, {Role: "assistant", Content: "old"}}}); err != nil {
-		t.Fatal(err)
-	}
-	if err := writeTranscript(newer, transcript{Model: "new", Effort: "high", Messages: []message{{Role: "user", Content: "new question"}, {Role: "assistant", Content: "new"}}}); err != nil {
-		t.Fatal(err)
-	}
-	oldTime := time.Now().Add(-time.Second)
-	if err := os.Chtimes(old, oldTime, oldTime); err != nil {
-		t.Fatal(err)
-	}
-	path, got, err := newestTranscript()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if path != newer || got.Model != "new" {
-		t.Fatalf("got %q %#v", path, got)
-	}
-}
-
-func TestContinuationRequestIncludesHistory(t *testing.T) {
-	req := buildReqMessages("m", "low", "s", []message{{Role: "user", Content: "first"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "next"}})
-	want := []message{{Role: "system", Content: "s"}, {Role: "user", Content: "first"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "next"}}
-	if !reflect.DeepEqual(req.Messages, want) {
-		t.Fatalf("got %#v, want %#v", req.Messages, want)
-	}
-}
-
-func TestNewestTranscriptWithoutHistory(t *testing.T) {
-	t.Setenv("XDG_DATA_HOME", t.TempDir())
-	if _, _, err := newestTranscript(); err == nil {
-		t.Fatal("expected no-history error")
-	}
-}
-
-func TestParseChatRes(t *testing.T) {
-	cases := []struct {
-		name    string
-		body    string
-		want    string
-		wantErr bool
-	}{
-		{"reply", `{"message":{"content":"hello"},"done_reason":"stop"}`, "hello", false},
-		{"empty reply", `{"message":{"content":""},"done_reason":"stop"}`, "", false},
-		{"missing message", `{"done_reason":"stop"}`, "", true},
-		{"not json", `not json`, "", true},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			got, err := parseChatRes([]byte(tc.body))
-			if (err != nil) != tc.wantErr {
-				t.Fatalf("err=%v, wantErr=%v", err, tc.wantErr)
-			}
-			if got != tc.want {
-				t.Fatalf("got %q, want %q", got, tc.want)
-			}
-		})
-	}
-}
-
-func TestErrBody(t *testing.T) {
-	msg, err := errBody([]byte(`{"error":"bad request"}`))
-	if err != nil {
-		t.Fatal(err)
-	}
-	if msg != "bad request" {
-		t.Fatalf("got %q, want %q", msg, "bad request")
-	}
-	if _, err := errBody([]byte(`not json`)); err == nil {
-		t.Fatal("expected error for non-JSON")
-	}
-}
-
-type fakeClient struct {
-	req *http.Request
-	res *http.Response
-	err error
-}
-
-func (c *fakeClient) Do(req *http.Request) (*http.Response, error) {
-	c.req = req
-	if c.err != nil {
-		return nil, c.err
-	}
-	return c.res, nil
-}
-
-func TestSendSuccess(t *testing.T) {
-	body := []byte(`{"message":{"content":"hi"},"done_reason":"stop"}`)
-	old := client
-	defer func() { client = old }()
-	fc := &fakeClient{res: &http.Response{
-		StatusCode: http.StatusOK,
-		Body:       io.NopCloser(bytes.NewReader(body)),
-		Header:     http.Header{"Content-Type": []string{"application/json"}},
-	}}
-	client = fc
-
-	got, err := send(context.Background(), "https://example.com", "secret", buildReq("m", "low", "s", "p"))
-	if err != nil {
-		t.Fatal(err)
-	}
-	if !bytes.Equal(got, body) {
-		t.Fatalf("got %s, want %s", got, body)
-	}
-	if got := fc.req.Header.Get("Authorization"); got != "Bearer secret" {
-		t.Fatalf("Authorization=%q, want Bearer secret", got)
-	}
-	if !strings.HasSuffix(fc.req.URL.Path, "/api/chat") {
-		t.Fatalf("URL path=%q", fc.req.URL.Path)
-	}
-}
-
-func TestSendOmitsAuthWithoutKey(t *testing.T) {
-	body := []byte(`{"message":{"content":"hi"},"done_reason":"stop"}`)
-	old := client
-	defer func() { client = old }()
-	fc := &fakeClient{res: &http.Response{
-		StatusCode: http.StatusOK,
-		Body:       io.NopCloser(bytes.NewReader(body)),
-	}}
-	client = fc
-
-	if _, err := send(context.Background(), "http://localhost", "", buildReq("m", "low", "", "p")); err != nil {
-		t.Fatal(err)
-	}
-	if fc.req.Header.Get("Authorization") != "" {
-		t.Fatalf("Authorization header sent unexpectedly")
-	}
-}
-
-func TestSendErrorStatus(t *testing.T) {
-	old := client
-	defer func() { client = old }()
-	fc := &fakeClient{res: &http.Response{
-		StatusCode: http.StatusBadRequest,
-		Body:       io.NopCloser(bytes.NewReader([]byte(`{"error":"nope"}`))),
-	}}
-	client = fc
-
-	_, err := send(context.Background(), "https://example.com", "secret", buildReq("m", "low", "", "p"))
-	if err == nil {
-		t.Fatal("expected error")
-	}
-	if !strings.Contains(err.Error(), "nope") {
-		t.Fatalf("error=%q, want it to contain nope", err)
-	}
-}
-
-func TestInvalidTranscript(t *testing.T) {
-	cases := []struct {
-		name  string
-		write func(string) error
-	}{
-		{"bad magic", func(p string) error { return os.WriteFile(p, []byte("wrong\n"), 0600) }},
-		{"odd message count", func(p string) error {
-			return writeTranscript(p, transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}}})
-		}},
-		{"out of order", func(p string) error {
-			return writeTranscript(p, transcript{Model: "m", Effort: "low", Messages: []message{{Role: "assistant", Content: "x"}, {Role: "user", Content: "y"}}})
-		}},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			p := filepath.Join(t.TempDir(), "bad.log")
-			if err := tc.write(p); err != nil {
-				t.Fatal(err)
-			}
-			if _, err := readTranscript(p); err == nil {
-				t.Fatal("expected error")
-			}
-		})
-	}
-}
-
-func TestNewestTranscriptSkipsCorrupt(t *testing.T) {
-	t.Setenv("XDG_DATA_HOME", t.TempDir())
-	dir, err := dataDir()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if err := os.MkdirAll(dir, 0700); err != nil {
-		t.Fatal(err)
-	}
-	corrupt := filepath.Join(dir, "corrupt.log")
-	good := filepath.Join(dir, "good.log")
-	if err := os.WriteFile(corrupt, []byte("bad\n"), 0600); err != nil {
-		t.Fatal(err)
-	}
-	if err := writeTranscript(good, transcript{Model: "good", Effort: "low", Messages: []message{{Role: "user", Content: "q"}, {Role: "assistant", Content: "a"}}}); err != nil {
-		t.Fatal(err)
-	}
-	goodTime := time.Now().Add(-time.Second)
-	if err := os.Chtimes(good, goodTime, goodTime); err != nil {
-		t.Fatal(err)
-	}
-	path, got, err := newestTranscript()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if path != good || got.Model != "good" {
-		t.Fatalf("got %q %#v", path, got)
-	}
-}
-
-func TestRawJSONRequiresValidJSON(t *testing.T) {
-	// Capture must's os.Exit by running in a subprocess would be heavy; instead
-	// exercise parseChatRes path separately and trust raw mode validates first.
-	if json.Valid([]byte(`not json`)) {
-		t.Fatal("test setup wrong")
-	}
-	// The printResponse raw branch uses json.Valid before Indent; parsing the
-	// same body via parseChatRes also fails, which is the non-raw path.
-	if _, err := parseChatRes([]byte(`not json`)); err == nil {
-		t.Fatal("expected parse error for non-JSON")
-	}
-}
-
-func TestBuildReqOmitsThinkForNone(t *testing.T) {
-	req := buildReq("m", "none", "", "p")
-	if req.Think != "" {
-		t.Fatalf("Think=%q, want empty", req.Think)
-	}
-	req = buildReq("m", "high", "", "p")
-	if req.Think != "high" {
-		t.Fatalf("Think=%q, want high", req.Think)
-	}
-}
-
-func TestNewTranscriptUniquePaths(t *testing.T) {
-	t.Setenv("XDG_DATA_HOME", t.TempDir())
-	p1, err := newTranscript(transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}})
-	if err != nil {
-		t.Fatal(err)
-	}
-	p2, err := newTranscript(transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}})
-	if err != nil {
-		t.Fatal(err)
-	}
-	if p1 == p2 {
-		t.Fatal("newTranscript produced duplicate paths")
-	}
-}
blob - 1023dee492803d6cdb3c8bbef6f5114b3a9f7f85 (mode 644)
blob + /dev/null
--- alpacu/Makefile
+++ /dev/null
@@ -1,35 +0,0 @@
-.POSIX:
-
-SHELL = /bin/sh
-GO ?= go
-GOCACHE ?= $(HOME)/.cache/go-build
-BIN ?= alpacu
-PREFIX ?= /usr/local
-BINDIR ?= $(PREFIX)/bin
-MANDIR ?= $(PREFIX)/share/man
-DESTDIR ?=
-DOAS ?= sudo
-GOFLAGS ?=
-
-.PHONY: all build clean install uninstall vet
-
-all: build
-
-build:
-	GOFLAGS='$(GOFLAGS)' CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) build -trimpath -buildvcs=false -o $(BIN) .
-
-vet:
-	CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./...
-
-install: build
-	$(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
-	$(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
-	$(DOAS) chmod 0755 $(DESTDIR)$(BINDIR)/$(BIN)
-	$(DOAS) cp $(BIN).1 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-	$(DOAS) chmod 0644 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-uninstall:
-	$(DOAS) rm -f $(DESTDIR)$(BINDIR)/$(BIN) $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-clean:
-	rm -f $(BIN)
blob - 0af891cd07617f550c4dc94f4997139702d6160f (mode 644)
blob + /dev/null
--- alpacu/alpacu.1
+++ /dev/null
@@ -1,87 +0,0 @@
-.\" alpacu . show Ollama Cloud account usage
-.Dd August 31, 2026
-.Dt ALPACU 1
-.Os
-.Sh NAME
-.Nm alpacu
-.Nd show Ollama Cloud session and weekly usage in the terminal
-.Sh SYNOPSIS
-.Nm
-.Op Fl k Ar key
-.Op Fl b Ar url
-.Op Fl j
-.Op Fl h
-.Sh DESCRIPTION
-.Nm
-calls the Ollama Cloud usage endpoint with your API key.
-It then prints your session quota (5 hour window) and weekly quota
-(7 day window) as ANSI bars showing the used share, a live countdown
-to the next reset, and a per-model request table.
-.Pp
-API key comes from
-.Fl k
-or
-.Ev OLLAMA_API_KEY .
-Base URL comes from
-.Fl b
-or
-.Ev OLLAMA_API_ENDPOINT .
-.Pp
-The reset countdown is computed locally from the current UTC time.
-Session resets align to multiples of 5 hours from the Unix epoch.
-Weekly resets are offset by 4 days from the epoch so all accounts share
-the same boundary.
-This matches the formula published in the
-.Lk https://github.com/ollama/ollama/issues/12532 ollama/ollama issue 12532 .
-.Sh OPTIONS
-.Bl -tag -width Ds
-.It Fl k Ar key
-API key.
-Falls back to
-.Ev OLLAMA_API_KEY .
-.It Fl b Ar url
-Base URL.
-Defaults to
-.Ev OLLAMA_API_ENDPOINT ,
-then
-.Cm https://ollama.com .
-Useful for local or staging instances.
-.It Fl j
-Print the raw JSON response instead of the rendered output.
-.It Fl h
-Show help and exit.
-.El
-.Sh ENVIRONMENT
-.Bl -tag -width Ds
-.It Ev OLLAMA_API_KEY
-API key used when
-.Fl k
-is not given.
-.It Ev OLLAMA_API_ENDPOINT
-Base URL used when
-.Fl b
-is not given.
-.El
-.Sh EXIT STATUS
-.Ex -std
-.Nm
-exits 2 for invalid options, missing API key, or operands.
-.Sh EXAMPLES
-Print the current usage:
-.Bd -literal -offset indent
-alpacu -k '$OLLAMA_API_KEY'
-.Ed
-.Pp
-Print the raw JSON:
-.Bd -literal -offset indent
-alpacu -j
-.Ed
-.Sh SEE ALSO
-.Xr curl 1
-.Pp
-.Lk https://docs.ollama.com/api/introduction Ollama API introduction
-.Lk https://docs.ollama.com/api/usage Ollama Cloud usage endpoint
-.Lk https://ollama.com/pricing Ollama Cloud pricing and reset windows
-.Sh AUTHORS
-.An m
-.Aq Mt miro@local
\ No newline at end of file
blob - 483cb7744fb59ddbc2fea3e42d1eaaabff3c5ff1 (mode 644)
blob + /dev/null
--- alpacu/go.mod
+++ /dev/null
@@ -1,3 +0,0 @@
-module alpacu
-
-go 1.26
blob - 5cef3c11a7e246561d6ddc5c081a845f23925afc (mode 644)
blob + /dev/null
--- alpacu/main.go
+++ /dev/null
@@ -1,243 +0,0 @@
-package main
-
-import (
-	"cmp"
-	"encoding/json"
-	"flag"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"slices"
-	"strings"
-	"time"
-)
-
-const (
-	maxErrorBody = 64 << 10
-	usage        = `usage: alpacu [-k key] [-b url] [-j] [-h]
-
-	-k key    API key (else $OLLAMA_API_KEY)
-	-b url    base URL (default https://ollama.com)
-	-j        print raw JSON
-	-h        help
-`
-)
-
-var client = &http.Client{Timeout: 15 * time.Second}
-
-type Mcount struct {
-	Name string `json:"name"`
-	N    int    `json:"request_count"`
-}
-
-type Limit struct {
-	Usage  float64  `json:"usage"`
-	Models []Mcount `json:"models"`
-}
-
-type Resp struct {
-	Activity struct {
-		Cost   string `json:"cost"`
-		Period struct {
-			Type string `json:"type"`
-			From string `json:"starting_at"`
-			To   string `json:"ending_at"`
-		} `json:"period"`
-	} `json:"activity"`
-	Limits struct {
-		Session Limit `json:"session"`
-		Weekly  Limit `json:"weekly"`
-	} `json:"limits"`
-}
-
-func main() {
-	key := flag.String("k", os.Getenv("OLLAMA_API_KEY"), "API key")
-	base := flag.String("b", envOr("OLLAMA_API_ENDPOINT", "https://ollama.com"), "base URL")
-	jsn := flag.Bool("j", false, "raw JSON")
-	help := flag.Bool("h", false, "help")
-	flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
-	flag.Parse()
-	if *help {
-		fmt.Print(usage)
-		return
-	}
-	if flag.NArg() != 0 {
-		usageError("unexpected argument %q", flag.Arg(0))
-	}
-	if *key == "" {
-		usageError("no API key")
-	}
-
-	r, err := fetch(*base, *key)
-	must(err)
-
-	if *jsn {
-		b, err := json.MarshalIndent(r, "", "  ")
-		must(err)
-		fmt.Println(string(b))
-		return
-	}
-
-	render(r)
-}
-
-func fetch(base, key string) (*Resp, error) {
-	req, err := http.NewRequest("GET", strings.TrimRight(base, "/")+"/api/usage", nil)
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Authorization", key)
-	req.Header.Set("Accept", "application/json")
-	res, err := client.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer res.Body.Close()
-	if res.StatusCode != http.StatusOK {
-		b, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBody))
-		if err != nil {
-			return nil, err
-		}
-		return nil, fmt.Errorf("status %d: %s", res.StatusCode, strings.TrimSpace(string(b)))
-	}
-	var r Resp
-	if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
-		return nil, err
-	}
-	return &r, nil
-}
-
-func envOr(name, def string) string {
-	if v := os.Getenv(name); v != "" {
-		return v
-	}
-	return def
-}
-
-func must(err error) {
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "alpacu: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-func usageError(format string, args ...any) {
-	fmt.Fprintf(os.Stderr, "alpacu: "+format+"\n", args...)
-	fmt.Fprint(os.Stderr, usage)
-	os.Exit(2)
-}
-
-// ANSI TUI
-
-const (
-	clr  = "\x1b[0m"
-	dim  = "\x1b[2m"
-	bold = "\x1b[1m"
-	red  = "\x1b[31m"
-	ylw  = "\x1b[33m"
-	grn  = "\x1b[32m"
-	cyn  = "\x1b[36m"
-	clrW = "\x1b[38;5;255m"
-	bkW  = "\x1b[48;5;237m"
-	bkD  = "\x1b[48;5;235m"
-)
-
-// clrPct colors a used fraction: green when light, red when nearly spent.
-func clrPct(f float64) string {
-	switch {
-	case f >= 1.0:
-		return red + bold
-	case f >= 0.9:
-		return ylw + bold
-	case f >= 0.5:
-		return cyn
-	default:
-		return grn
-	}
-}
-
-// countdown returns "resets in Xh Ym" for the next reset of a window
-// whose length is period seconds. Session resets align to UTC multiples
-// of 18000s (5h) from epoch. Weekly resets are offset by 4 days from
-// epoch (so all accounts share the same boundary). Matches the formula
-// in ollama/ollama issue #12532.
-func countdown(period int64, offsetSec int64) string {
-	now := time.Now().UTC().Unix()
-	left := period - ((now - offsetSec) % period)
-	if left <= 0 {
-		left = period
-	}
-	return fmt.Sprintf("resets in %dh %02dm", left/3600, (left%3600)/60)
-}
-
-func bar(f float64, w int) string {
-	fill := int(float64(w) * f)
-	if fill < 0 {
-		fill = 0
-	}
-	if fill > w {
-		fill = w
-	}
-	return strings.Repeat("█", fill) + strings.Repeat("░", w-fill)
-}
-
-func sorted(m []Mcount) []Mcount {
-	s := slices.Clone(m)
-	slices.SortFunc(s, func(a, b Mcount) int { return cmp.Compare(b.N, a.N) })
-	return s
-}
-
-// row prints one quota bar showing the used share of the window.
-func row(label string, used float64, w int, note string) string {
-	c := clrPct(used)
-	pct := fmt.Sprintf("%5.1f%%", used*100)
-	b := bar(used, w)
-	return fmt.Sprintf("  %s%-7s%s  %s%s%s  %s%s%s  %s\n",
-		dim, label, clr, c, b, clr, c, pct, clr, note)
-}
-
-func render(r *Resp) {
-	// header
-	per := r.Activity.Period
-	hdr := fmt.Sprintf("%sollama cloud usage%s  %s%s . %s to %s%s",
-		bold+cyn, clr, dim, per.Type, per.From, per.To, clr)
-	if r.Activity.Cost != "" {
-		hdr += fmt.Sprintf("  %sextra %s USD%s", dim, r.Activity.Cost, clr)
-	}
-
-	fmt.Println(hdr)
-	fmt.Println()
-	fmt.Println(row("session", r.Limits.Session.Usage, 40, dim+countdown(5*3600, 0)+clr))
-	fmt.Println(row("weekly ", r.Limits.Weekly.Usage, 40, dim+countdown(7*86400, 4*86400)+clr))
-
-	// models side-by-side
-	sm := sorted(r.Limits.Session.Models)
-	wm := sorted(r.Limits.Weekly.Models)
-	h := len(sm)
-	if len(wm) > h {
-		h = len(wm)
-	}
-	if h == 0 {
-		return
-	}
-	fmt.Println()
-	fmt.Printf("  %s%s%-20s %6s   %-20s %6s%s\n",
-		bold, dim, "session models", "reqs", "weekly models", "reqs", clr)
-	for i := 0; i < h; i++ {
-		sl, sr := "", ""
-		wl, wr := "", ""
-		if i < len(sm) {
-			sl, sr = sm[i].Name, fmt.Sprint(sm[i].N)
-		}
-		if i < len(wm) {
-			wl, wr = wm[i].Name, fmt.Sprint(wm[i].N)
-		}
-		fmt.Printf("  %s%-20s%s %s%6s%s   %s%-20s%s %s%6s%s\n",
-			clrW+bkW, sl, clr,
-			bold, sr, clr,
-			clrW+bkD, wl, clr,
-			bold, wr, clr,
-		)
-	}
-}
blob - /dev/null
blob + eb90d48c8d007760adabffc4c782c1355d769c68 (mode 644)
--- /dev/null
+++ man/alpaca-compose.1.scd
@@ -0,0 +1,98 @@
+alpaca-compose(1)
+
+# NAME
+
+alpaca compose - edit a prompt in the editor, then send it to the chat API
+
+# SYNOPSIS
+
+*alpaca compose* [*-m* _model_] [*-s* _msg_] [*-t* _temp_]
+\[*-T* _secs_] [*-c*] [*--reasoning-effort* _effort_]
+\[*--apikey* _key_] [*--base-url* _url_] [*--json* | *--jsonp*]
+
+# DESCRIPTION
+
+*alpaca compose* opens a temporary file in the editor, sends the saved text as
+a user prompt, and writes the reply to standard output. Piped standard input
+prefills the buffer first, so *alpaca compose* can sit in the middle of a
+pipeline: pipe data in, edit it, pipe the reply onward.
+
+The editor runs on the controlling terminal, which keeps editor input and
+screen output out of the pipeline. Where no controlling terminal exists, the
+editor inherits this process's own streams instead. Saving an empty buffer
+sends nothing and exits 0.
+
+Each successful request is saved as a JSON transcript in
+_$XDG_DATA_HOME/alpaca_, or, when that variable is unset,
+_~/.local/share/alpaca_. Transcripts are written with mode 0600 in a directory
+with mode 0700.
+
+With *-c*, *alpaca compose* opens the newest saved conversation's final reply
+in the editor and keeps prompting, sending the whole conversation as context
+on each turn. The saved model, reasoning effort and system prompt are reused
+unless given on the command line. Save an empty buffer to leave the loop.
+
+# OPTIONS
+
+*-m*, *--model* _model_
+	Model to use. When continuing, defaults to the saved model, else
+	*ALPACA_MODEL*, else *gpt-oss:120b*.
+
+*-s*, *--system* _msg_
+	System prompt, sent before the conversation.
+
+*-t*, *--temperature* _temp_
+	Sampling temperature.
+
+*-T*, *--timeout* _secs_
+	Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
+
+*-c*, *--continue*
+	Continue the newest saved conversation.
+
+*--reasoning-effort* _effort_
+	One of *low*, *medium*, *high* or *none*.
+
+*--apikey* _key_
+	API key. Defaults to *API_KEY*.
+
+*--base-url* _url_
+	Base URL of the API endpoint. Defaults to *API_ENDPOINT*.
+
+*--json*, *--jsonp*
+	Print the response as JSON instead of the reply text.
+
+# ENVIRONMENT
+
+*VISUAL*, *EDITOR*
+	Editor command, split on whitespace. *VISUAL* wins. Quotes and escapes are
+	not parsed; use a wrapper script for editor commands that need them. Without
+	either variable, *vim*(1) is used.
+
+*XDG_DATA_HOME*
+	Base directory for saved conversations.
+
+# CONFIGURATION
+
+This command uses applicable settings from the Alpaca config file. A config
+setting overrides a value saved in a transcript. Command-line options and
+environment variables take priority. See *alpaca-config*(5).
+
+# EXIT STATUS
+
+An editor that exits non-zero sets the exit status of *alpaca compose*, so
+aborting an edit aborts the request. Usage errors exit 2.
+
+# EXAMPLES
+
+Edit piped input before sending it:
+
+	$ git diff --staged | alpaca compose -s "Write a conventional commit message"
+
+Continue the newest conversation:
+
+	$ alpaca compose -c
+
+# SEE ALSO
+
+*alpaca*(1), *alpaca-quota*(1), *alpaca-config*(5)
blob - /dev/null
blob + 040356730015975f570aeb43c15d42fd6b03398e (mode 644)
--- /dev/null
+++ man/alpaca-config.5
@@ -0,0 +1,121 @@
+.\" Generated by scdoc  1.11.4
+.\" Complete documentation for this program is not available as a GNU info page
+.ie \n(.g .ds Aq \(aq
+.el       .ds Aq '
+.nh
+.ad l
+.\" Begin generated content:
+.TH "alpaca-config" "5" "2026-09-04"
+.PP
+.SH NAME
+.PP
+alpaca-config - configure default alpaca options
+.PP
+.SH DESCRIPTION
+.PP
+Alpaca reads shared defaults from
+\fI$XDG_CONFIG_HOME/alpaca/config.\&toml\fR.\& When \fBXDG_CONFIG_HOME\fR is not set,
+Alpaca reads \fI\(ti/.\&config/alpaca/config.\&toml\fR.\&
+.PP
+The file is optional.\& Alpaca exits with an error if the file exists but cannot
+be read or parsed.\&
+.PP
+Command-line options override environment variables.\& Environment variables
+override this file.\& This file overrides built-in defaults.\&
+.PP
+.SH FORMAT
+.PP
+The file uses TOML.\& Unknown keys and invalid values are errors.\&
+.PP
+\fBoutput_format\fR
+.RS 4
+Output format.\& Use \fBplaintext\fR, \fBjson\fR or \fBjsonpretty\fR.\&
+.PP
+.RE
+\fBmodel\fR
+.RS 4
+Model identifier served by the configured endpoint.\&
+.PP
+.RE
+\fBtemperature\fR
+.RS 4
+Sampling temperature as a number.\&
+.PP
+.RE
+\fBtimeout\fR
+.RS 4
+Request timeout in seconds.\& The value must be greater than zero.\&
+.PP
+.RE
+\fBsystem\fR
+.RS 4
+System message.\&
+.PP
+.RE
+\fBassistant\fR
+.RS 4
+Array of assistant messages.\& These messages come before configured user
+messages and command-line messages.\&
+.PP
+.RE
+\fBuser\fR
+.RS 4
+Array of user messages.\& These messages come after configured assistant
+messages and before command-line messages.\&
+.PP
+.RE
+\fBapikey\fR
+.RS 4
+API key.\& Protect the file from other users if you set this value.\&
+.PP
+.RE
+\fBbase_url\fR
+.RS 4
+Base URL of the API endpoint.\&
+.PP
+.RE
+\fBreasoning_effort\fR
+.RS 4
+Reasoning effort.\& Use \fBnone\fR, \fBlow\fR, \fBmedium\fR or \fBhigh\fR.\&
+.PP
+.RE
+.SH COMMANDS
+.PP
+\fBalpaca\fR and \fBalpaca chat\fR use every setting.\&
+.PP
+\fBalpaca compose\fR uses \fBoutput_format\fR, \fBmodel\fR, \fBtemperature\fR, \fBtimeout\fR,
+\fBsystem\fR, \fBapikey\fR, \fBbase_url\fR and \fBreasoning_effort\fR.\& A config setting
+overrides a value saved in a transcript.\&
+.PP
+\fBalpaca quota\fR uses \fBoutput_format\fR, \fBtimeout\fR, \fBapikey\fR and \fBbase_url\fR.\&
+.PP
+.SH EXAMPLE
+.PP
+.RS 4
+output_format = "plaintext"
+model = "gpt-oss:120b"
+temperature = 0.\&7
+timeout = 60
+system = "Answer concisely.\&"
+assistant = ["An example assistant message"]
+user = ["An example user message"]
+apikey = "your-api-key"
+base_url = "https://ollama.\&com"
+reasoning_effort = "none"
+.PP
+.RE
+.SH FILES
+.PP
+\fI$XDG_CONFIG_HOME/alpaca/config.\&toml\fR
+.RS 4
+Config file when \fBXDG_CONFIG_HOME\fR is set.\&
+.PP
+.RE
+\fI\(ti/.\&config/alpaca/config.\&toml\fR
+.RS 4
+Config file when \fBXDG_CONFIG_HOME\fR is not set.\&
+.PP
+.RE
+.SH SEE ALSO
+.PP
+\fBalpaca\fR(1), \fBalpaca-compose\fR(1), \fBalpaca-quota\fR(1)
blob - /dev/null
blob + a75302c9de031d09cda53bcaba2368e8cce1d6cd (mode 644)
--- /dev/null
+++ man/alpaca-config.5.scd
@@ -0,0 +1,88 @@
+alpaca-config(5)
+
+# NAME
+
+alpaca-config - configure default alpaca options
+
+# DESCRIPTION
+
+Alpaca reads shared defaults from
+_$XDG_CONFIG_HOME/alpaca/config.toml_. When *XDG_CONFIG_HOME* is not set,
+Alpaca reads _~/.config/alpaca/config.toml_.
+
+The file is optional. Alpaca exits with an error if the file exists but cannot
+be read or parsed.
+
+Command-line options override environment variables. Environment variables
+override this file. This file overrides built-in defaults.
+
+# FORMAT
+
+The file uses TOML. Unknown keys and invalid values are errors.
+
+*output_format*
+	Output format. Use *plaintext*, *json* or *jsonpretty*.
+
+*model*
+	Model identifier served by the configured endpoint.
+
+*temperature*
+	Sampling temperature as a number.
+
+*timeout*
+	Request timeout in seconds. The value must be greater than zero.
+
+*system*
+	System message.
+
+*assistant*
+	Array of assistant messages. These messages come before configured user
+	messages and command-line messages.
+
+*user*
+	Array of user messages. These messages come after configured assistant
+	messages and before command-line messages.
+
+*apikey*
+	API key. Protect the file from other users if you set this value.
+
+*base_url*
+	Base URL of the API endpoint.
+
+*reasoning_effort*
+	Reasoning effort. Use *none*, *low*, *medium* or *high*.
+
+# COMMANDS
+
+*alpaca* and *alpaca chat* use every setting.
+
+*alpaca compose* uses *output_format*, *model*, *temperature*, *timeout*,
+*system*, *apikey*, *base_url* and *reasoning_effort*. A config setting
+overrides a value saved in a transcript.
+
+*alpaca quota* uses *output_format*, *timeout*, *apikey* and *base_url*.
+
+# EXAMPLE
+
+	output_format = "plaintext"
+	model = "gpt-oss:120b"
+	temperature = 0.7
+	timeout = 60
+	system = "Answer concisely."
+	assistant = ["An example assistant message"]
+	user = ["An example user message"]
+	apikey = "your-api-key"
+	base_url = "https://ollama.com"
+	reasoning_effort = "none"
+
+# FILES
+
+_$XDG_CONFIG_HOME/alpaca/config.toml_
+	Config file when *XDG_CONFIG_HOME* is set.
+
+_~/.config/alpaca/config.toml_
+	Config file when *XDG_CONFIG_HOME* is not set.
+
+# SEE ALSO
+
+*alpaca*(1), *alpaca-compose*(1), *alpaca-quota*(1)
blob - /dev/null
blob + c4abd648e99375f0c06686a610a3c0f5e142308c (mode 644)
--- /dev/null
+++ man/alpaca-quota.1.scd
@@ -0,0 +1,81 @@
+alpaca-quota(1)
+
+# NAME
+
+alpaca quota - show provider session and weekly usage in the terminal
+
+# SYNOPSIS
+
+*alpaca quota* [*-p* _provider_] [*-T* _secs_] [*--apikey* _key_]
+\[*--base-url* _url_] [*--raw*] [*--json* | *--jsonp*] [*--color* _when_]
+
+# DESCRIPTION
+
+*alpaca quota* calls the usage endpoint of one provider with your credential.
+It prints each quota window as a bar showing the used share, a countdown to the
+next reset, and, where the provider reports them, a per-model request table.
+
+The provider is chosen with *-p*, and defaults to *ollama*. Each provider has
+its own default credential and base URL, either of which can be overridden.
+
+*ollama*
+	Ollama Cloud. Key from *OLLAMA_API_KEY*, base URL from
+	*OLLAMA_API_ENDPOINT*, else *https://ollama.com*. Prints the session
+	(5 hour) and weekly (7 day) quotas and a per-model request table for each.
+	The endpoint reports no reset time, so the countdowns are computed locally:
+	session windows align to UTC multiples of 5 hours from the epoch, and weekly
+	windows use the same rule with a 4 day offset, so all accounts share the same
+	boundary.
+
+*anthropic*
+	Claude Code. Token from *CLAUDE_CODE_OAUTH_TOKEN*, else
+	_~/.claude/.credentials.json_. Base URL *https://api.anthropic.com*.
+	Prints whichever windows the account has, and extra spend when it is enabled.
+
+*openai*
+	Codex CLI. Token from *CODEX_ACCESS_TOKEN*, else _~/.codex/auth.json_. Base
+	URL *https://chatgpt.com*. Prints the plan, its rate limit windows, and the
+	credit balance.
+
+The *anthropic* and *openai* endpoints serve the vendor CLIs. They are not
+documented public APIs, so they may change without notice. Both expect a short
+lived OAuth token: when the credential file is stale, expect an authentication
+error until the vendor CLI refreshes it.
+
+# OPTIONS
+
+*-p*, *--provider* _provider_
+	One of *ollama*, *anthropic* or *openai*.
+
+*-T*, *--timeout* _secs_
+	Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
+
+*--apikey* _key_
+	API key or OAuth token, replacing the provider default.
+
+*--base-url* _url_
+	Base URL, replacing the provider default.
+
+*--raw*
+	Print the provider response body verbatim.
+
+*--json*, *--jsonp*
+	Print the normalised view as JSON, compact or indented.
+
+*--color* _when_
+	One of *auto*, *always* or *never*. Defaults to *auto*, which colours only
+	when standard output is a terminal and *NO_COLOR* is unset.
+
+# CONFIGURATION
+
+This command uses *output_format*, *timeout*, *apikey* and *base_url* from the
+Alpaca config file. Command-line options and environment variables take
+priority. See *alpaca-config*(5).
+
+# EXIT STATUS
+
+Usage errors exit 2.
+
+# SEE ALSO
+
+*alpaca*(1), *alpaca-compose*(1), *alpaca-config*(5)
blob - /dev/null
blob + 4dff79521121c2cd41d1f2512d254a6e19375479 (mode 644)
--- /dev/null
+++ man/alpaca.1.scd
@@ -0,0 +1,129 @@
+alpaca(1)
+
+# NAME
+
+alpaca - Unix native interface for LLMs
+
+# SYNOPSIS
+
+*alpaca* [*-m* _model_] [*-t* _temp_] [*-T* _secs_] [*-s* _msg_]
+\[*-u* _msg_] [*-a* _msg_] [*--reasoning-effort* _effort_]
+\[*--apikey* _key_] [*--base-url* _url_] [*--json* | *--jsonp*] [_file_]
+
+*alpaca chat* [_..._]
+
+*alpaca quota* [_..._]
+
+*alpaca compose* [_..._]
+
+# DESCRIPTION
+
+*alpaca* sends chat requests to a model and writes the reply to standard
+output. Without a subcommand it chats, so *alpaca* composes with pipes, files,
+editor buffers and other programs.
+
+Three subcommands are available:
+
+*chat*
+	Send a chat request. This is the default, so *alpaca -u "hello"* and
+	*alpaca chat -u "hello"* are the same command. See the options below.
+
+*quota*
+	Report how much provider quota is used. See *alpaca-quota*(1).
+
+*compose*
+	Edit a prompt in the editor, then send it. See *alpaca-compose*(1).
+
+A first argument matching a subcommand name is treated as that subcommand. To
+read messages from a file with such a name, separate it with *--*, as in
+*alpaca -- quota*.
+
+# OPTIONS
+
+*-m*, *--model* _model_
+	Model to use. Defaults to *ALPACA_MODEL*, else *gpt-oss:120b*.
+
+*-t*, *--temperature* _temp_
+	Sampling temperature.
+
+*-T*, *--timeout* _secs_
+	Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
+
+*-s*, *--system* _msg_
+	System prompt. Always sent first.
+
+*-u*, *--user* _msg_
+	Append a user message. May be repeated.
+
+*-a*, *--assistant* _msg_
+	Append an assistant message. May be repeated. Repeated *-u* and *-a*
+	messages keep their command line order, which suits few-shot prompting.
+
+*--reasoning-effort* _effort_
+	One of *low*, *medium*, *high* or *none*. Defaults to *ALPACA_EFFORT*,
+	else *none*, which omits the field from the request.
+
+*--apikey* _key_
+	API key. Defaults to *API_KEY*. Authorization is only sent when a key is
+	known, so endpoints that need no authentication also work.
+
+*--base-url* _url_
+	Base URL of the API endpoint. Defaults to *API_ENDPOINT*, else
+	*https://ollama.com*. A base URL ending in a version segment, such as
+	_/v1_, addresses a chat completions endpoint; any other base URL addresses
+	_{base}/api/chat_.
+
+*--output-format* _format_
+	One of *plaintext*, *json* or *jsonpretty*.
+
+*--json*, *--jsonp*
+	Shorthands for the two JSON formats.
+
+# OPERANDS
+
+_file_
+	File providing a message to append to the chat log. Defaults to *-*, which
+	reads non-tty standard input.
+
+# CONFIGURATION
+
+Use _$XDG_CONFIG_HOME/alpaca/config.toml_ for shared defaults. Alpaca uses
+_~/.config/alpaca/config.toml_ when *XDG_CONFIG_HOME* is not set.
+
+Command-line options override environment variables. Environment variables
+override the config file. See *alpaca-config*(5) for the file format.
+
+# ENVIRONMENT
+
+*API_KEY*
+	Default API key.
+
+*API_ENDPOINT*
+	Default base URL.
+
+*ALPACA_MODEL*
+	Default model.
+
+*ALPACA_EFFORT*
+	Default reasoning effort.
+
+*ALPACA_TIMEOUT*
+	Default request timeout, in seconds.
+
+# EXIT STATUS
+
+Usage errors exit 2.
+
+# EXAMPLES
+
+Answer a question from standard input:
+
+	$ echo "What is 50 + 50?" | alpaca
+
+Write a commit message for staged changes:
+
+	$ git diff --staged | alpaca -s "Write a conventional commit message" | git commit -F -
+
+# SEE ALSO
+
+*alpaca-compose*(1), *alpaca-quota*(1), *alpaca-config*(5)
blob - 2fd12d6e6ab5743b8a83fbb57d80fd7a9a2a0ef1
blob + e62d0ec386b951e70ab6bb729059f565bb7bd780
--- src/cli.rs
+++ src/cli.rs
@@ -1,17 +1,111 @@
 //! Command line interface for alpaca
 
+use std::ffi::OsString;
+use std::fs;
 use std::path::PathBuf;
 use std::time::Duration;
 
-use crate::ollama::{Message, ReasoningEffort};
+use crate::api::{Message, ReasoningEffort};
+use crate::usage::Provider;
+use anyhow::{Context, Result, bail};
 use clap::{
-    ArgGroup, ArgMatches, Command, ValueEnum, arg, builder::PossibleValue, command, value_parser,
+    Arg, ArgGroup, ArgMatches, Command, ValueEnum, arg, builder::PossibleValue, command,
+    value_parser,
 };
 use derive_builder::Builder;
+use serde::Deserialize;
 
-/// CLI invocations that can be launched
+/// Default model used by every subcommand that talks to a model
+pub const DEFAULT_MODEL: &str = "gpt-oss:120b";
+/// Default request timeout, in seconds
+pub const DEFAULT_TIMEOUT_SECS: &str = "60";
+/// Default API endpoint
+///
+/// Any host serving a compatible chat endpoint works, so this is only a
+/// starting point: set `API_ENDPOINT` or `--base-url` to point elsewhere.
+pub const DEFAULT_BASE_URL: &str = "https://ollama.com";
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Config {
+    output_format: Option<OutputFormat>,
+    model: Option<String>,
+    temperature: Option<f32>,
+    timeout: Option<u64>,
+    system: Option<String>,
+    #[serde(default)]
+    assistant: Vec<String>,
+    #[serde(default)]
+    user: Vec<String>,
+    apikey: Option<String>,
+    base_url: Option<String>,
+    reasoning_effort: Option<Effort>,
+}
+
+impl Config {
+    fn load() -> Result<Self> {
+        let Some(path) = config_path() else {
+            return Ok(Self::default());
+        };
+
+        let contents = match fs::read_to_string(&path) {
+            Ok(contents) => contents,
+            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
+            Err(err) => {
+                return Err(err).with_context(|| format!("failed to read {}", path.display()));
+            }
+        };
+
+        Self::from_toml(&contents)
+            .with_context(|| format!("invalid configuration {}", path.display()))
+    }
+
+    fn from_toml(contents: &str) -> Result<Self> {
+        let config: Self = toml::from_str(contents).context("failed to parse TOML")?;
+        if config.timeout == Some(0) {
+            bail!("timeout must be greater than zero");
+        }
+        Ok(config)
+    }
+}
+
+fn config_path() -> Option<PathBuf> {
+    config_path_from(
+        std::env::var_os("XDG_CONFIG_HOME").filter(|path| !path.is_empty()),
+        crate::home_dir().ok(),
+    )
+}
+
+fn config_path_from(xdg_config_home: Option<OsString>, home: Option<PathBuf>) -> Option<PathBuf> {
+    xdg_config_home
+        .map(PathBuf::from)
+        .or_else(|| home.map(|path| path.join(".config")))
+        .map(|path| path.join("alpaca").join("config.toml"))
+}
+
+/// Applies `value` as a clap default only when it is set, leaving the argument
+/// optional otherwise.
+fn default_value_opt(arg: Arg, value: Option<String>) -> Arg {
+    if let Some(value) = value {
+        arg.default_value(value)
+    } else {
+        arg
+    }
+}
+
+/// A parsed command line invocation
+#[derive(Debug)]
+pub enum Invocation {
+    /// Send a chat request built from flags, a file or stdin
+    Chat(ChatArgs),
+    /// Report provider quota usage
+    Quota(QuotaArgs),
+    /// Edit a prompt in `$EDITOR`, then send it
+    Compose(ComposeArgs),
+}
+
+/// Arguments for the chat subcommand, which is also the default command
 #[derive(Debug, Default, Builder)]
-pub struct Invocation {
+pub struct ChatArgs {
     pub(crate) api_key: Option<String>,
     pub(crate) messages: Vec<Message>,
     pub(crate) model: String,
@@ -22,10 +116,55 @@ pub struct Invocation {
     pub(crate) timeout: Duration,
     #[builder(default)]
     pub(crate) reasoning_effort: Option<ReasoningEffort>,
+    #[builder(default)]
+    pub(crate) base_url: Option<String>,
 }
 
+/// Arguments for the quota subcommand
+#[derive(Debug, Default, Builder)]
+pub struct QuotaArgs {
+    pub(crate) provider: Provider,
+    #[builder(default)]
+    pub(crate) api_key: Option<String>,
+    #[builder(default)]
+    pub(crate) base_url: Option<String>,
+    pub(crate) timeout: Duration,
+    pub(crate) output_format: OutputFormat,
+    /// Print the provider response body verbatim
+    #[builder(default)]
+    pub(crate) raw: bool,
+    pub(crate) color: ColorChoice,
+}
+
+/// Arguments for the compose subcommand
+///
+/// Model, effort and system prompt are optional here: when a saved
+/// conversation is continued, values left unset are taken from the transcript
+/// rather than from the defaults.
+#[derive(Debug, Default, Builder)]
+pub struct ComposeArgs {
+    #[builder(default)]
+    pub(crate) api_key: Option<String>,
+    #[builder(default)]
+    pub(crate) model: Option<String>,
+    #[builder(default)]
+    pub(crate) system: Option<String>,
+    #[builder(default)]
+    pub(crate) temperature: Option<f32>,
+    pub(crate) timeout: Duration,
+    #[builder(default)]
+    pub(crate) reasoning_effort: Option<Effort>,
+    pub(crate) output_format: OutputFormat,
+    #[builder(default)]
+    pub(crate) base_url: Option<String>,
+    /// Continue the newest saved conversation
+    #[builder(default)]
+    pub(crate) continue_conversation: bool,
+}
+
 /// The format that invocation's results are in
-#[derive(Debug, Default, PartialEq, Clone, Copy)]
+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Deserialize)]
+#[serde(rename_all = "lowercase")]
 pub enum OutputFormat {
     #[default]
     Plaintext,
@@ -33,139 +172,411 @@ pub enum OutputFormat {
     JSONPretty,
 }
 
+/// When to emit ANSI colour
+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
+pub enum ColorChoice {
+    /// Colour when stdout is a terminal and `NO_COLOR` is unset
+    #[default]
+    Auto,
+    Always,
+    Never,
+}
+
+/// Reasoning effort as accepted on the command line, where `none` omits the
+/// `think` field from the request
+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Effort {
+    #[default]
+    None,
+    Low,
+    Medium,
+    High,
+}
+
+impl From<Effort> for Option<ReasoningEffort> {
+    fn from(effort: Effort) -> Self {
+        match effort {
+            Effort::None => None,
+            Effort::Low => Some(ReasoningEffort::Low),
+            Effort::Medium => Some(ReasoningEffort::Medium),
+            Effort::High => Some(ReasoningEffort::High),
+        }
+    }
+}
+
+impl From<Option<ReasoningEffort>> for Effort {
+    fn from(effort: Option<ReasoningEffort>) -> Self {
+        match effort {
+            None => Effort::None,
+            Some(ReasoningEffort::Low) => Effort::Low,
+            Some(ReasoningEffort::Medium) => Effort::Medium,
+            Some(ReasoningEffort::High) => Effort::High,
+        }
+    }
+}
+
 /// Parse commandline arguments into `Invocation`. May exit with help or error message
-#[must_use]
-pub fn parse() -> Invocation {
-    cli().get_matches().into()
+pub fn parse() -> Result<Invocation> {
+    let args: Vec<OsString> = std::env::args_os().collect();
+
+    // Preserve clap's help, version and usage-error exits without consulting a
+    // potentially malformed optional configuration file.
+    cli(&Config::default())
+        .try_get_matches_from(&args)
+        .unwrap_or_else(|err| err.exit());
+
+    let config = Config::load()?;
+    let matches = cli(&config).get_matches_from(args);
+    Ok(invocation_from_matches(matches, &config))
 }
 
-/// Top-level command
-fn cli() -> Command {
-    command!()
-        .arg(arg!(model: -m --model <MODEL> "Sets model. See https://ollama.com/library for model identifiers.").default_value("gpt-oss:120b"))
-        .arg(
+/// Top-level command. Without a subcommand, alpaca chats
+fn cli(config: &Config) -> Command {
+    chat_args(command!(), config)
+        .subcommand(chat_args(
+            Command::new("chat").about("Send a chat request (default command)"),
+            config,
+        ))
+        .subcommand(quota_command(config))
+        .subcommand(compose_command(config))
+        .args_conflicts_with_subcommands(true)
+        .subcommand_negates_reqs(true)
+}
+
+#[cfg(test)]
+fn parse_args_with_config(args: &[&str], config: &Config) -> Result<Invocation> {
+    let matches = cli(config).try_get_matches_from(args)?;
+    Ok(invocation_from_matches(matches, config))
+}
+
+/// Arguments shared by the default command and `alpaca chat`
+fn chat_args(cmd: Command, config: &Config) -> Command {
+    output_format_args(cmd, config)
+        .arg(model_arg().default_value(
+            config.model.clone().unwrap_or_else(|| DEFAULT_MODEL.to_string()),
+        ))
+        .arg(default_value_opt(
             arg!(temperature: -t --temperature <TEMP> "Sets temperature")
                 .value_parser(value_parser!(f32)),
-        )
+            config.temperature.map(|value| value.to_string()),
+        ))
+        .arg(timeout_arg(config))
+        .arg(default_value_opt(
+            arg!(system_message: -s --system <MSG> "Sets system prompt").required(false),
+            config.system.clone(),
+        ))
         .arg(
-            arg!(timeout: -T --timeout <DURATION> "Sets timeout duration in seconds")
-                .value_parser(value_parser!(u64))
-                .default_value("60")
-        )
-        .arg(arg!(system_message: -s --system <MSG> "Sets system prompt").required(false))
-        .arg(
             arg!(assistant_messages: -a --assistant <MSG> ... "Appends assistant message")
                 .required(false),
         )
         .arg(arg!(user_messages: -u --user <MSG> ... "Appends user message").required(false))
-        .arg(
-            arg!(api_key: --apikey <API_KEY> "Sets API Key to use")
-                .env("OLLAMA_API_KEY")
-                .hide_env_values(true),
-        )
-        .arg(
-            arg!(output_format: --output_format <FORMAT> "Sets output format")
-                .value_parser(value_parser!(OutputFormat))
-                .conflicts_with("output_format_short")
-                .default_value_ifs([
-                    ("json", "true", Some("json")),
-                    ("jsonp", "true", Some("jsonpretty")),
-                ])
-                .default_value("plaintext"),
-        )
-        .arg(
-            arg!(reasoning_effort: --"reasoning-effort" <EFFORT> "Sets reasoning effort (low, medium, high)")
-                .value_parser(value_parser!(ReasoningEffort))
-                .required(false),
-        )
-        .arg(arg!(--json "Shorthand for --output_format json"))
-        .arg(arg!(--jsonp "Shorthand for --output_format jsonpretty"))
-        .group(ArgGroup::new("output_format_short").args(["json", "jsonp"]))
+        .arg(api_key_arg(config))
+        .arg(base_url_arg(config))
+        .arg(default_value_opt(
+            effort_arg(),
+            config.reasoning_effort.map(effort_name),
+        ))
         .arg(arg!(file: [FILE] "File providing messages to append to chat log. If \"-\", reads from non-tty stdin").default_value("-"))
 }
 
+fn quota_command(config: &Config) -> Command {
+    output_format_args(
+        Command::new("quota").about("Report how much provider quota is used"),
+        config,
+    )
+    .arg(
+        arg!(provider: -p --provider <PROVIDER> "Sets usage provider")
+            .value_parser(value_parser!(Provider))
+            .default_value("ollama"),
+    )
+    .arg(api_key_arg(config))
+    .arg(base_url_arg(config))
+    .arg(timeout_arg(config))
+    .arg(arg!(raw: --raw "Prints the provider response body verbatim"))
+    .arg(
+        arg!(color: --color <WHEN> "Sets when to colour output")
+            .value_parser(value_parser!(ColorChoice))
+            .default_value("auto"),
+    )
+}
+
+fn compose_command(config: &Config) -> Command {
+    output_format_args(
+        Command::new("compose").about("Edit a prompt in $EDITOR, then send it"),
+        config,
+    )
+    .arg(default_value_opt(model_arg(), config.model.clone()))
+    .arg(default_value_opt(
+        arg!(temperature: -t --temperature <TEMP> "Sets temperature")
+            .value_parser(value_parser!(f32)),
+        config.temperature.map(|value| value.to_string()),
+    ))
+    .arg(default_value_opt(
+        arg!(system_message: -s --system <MSG> "Sets system prompt").required(false),
+        config.system.clone(),
+    ))
+    .arg(timeout_arg(config))
+    .arg(api_key_arg(config))
+    .arg(base_url_arg(config))
+    .arg(default_value_opt(
+        effort_arg(),
+        config.reasoning_effort.map(effort_name),
+    ))
+    .arg(arg!(continue_conversation: -c --"continue" "Continues the newest saved conversation"))
+}
+
+fn model_arg() -> Arg {
+    arg!(model: -m --model <MODEL> "Sets model. Identifiers are whatever the endpoint serves.")
+        .env("ALPACA_MODEL")
+}
+
+fn timeout_arg(config: &Config) -> Arg {
+    arg!(timeout: -T --timeout <DURATION> "Sets timeout duration in seconds")
+        .value_parser(value_parser!(u64).range(1..))
+        .env("ALPACA_TIMEOUT")
+        .default_value(config.timeout.map_or_else(
+            || DEFAULT_TIMEOUT_SECS.to_string(),
+            |value| value.to_string(),
+        ))
+}
+
+fn effort_arg() -> Arg {
+    arg!(reasoning_effort: --"reasoning-effort" <EFFORT> "Sets reasoning effort")
+        .value_parser(value_parser!(Effort))
+        .env("ALPACA_EFFORT")
+        .required(false)
+}
+
+fn effort_name(effort: Effort) -> String {
+    effort
+        .to_possible_value()
+        .expect("effort has a value")
+        .get_name()
+        .to_string()
+}
+
+fn api_key_arg(config: &Config) -> Arg {
+    default_value_opt(
+        arg!(api_key: --apikey <API_KEY> "Sets the API key to use")
+            .env("API_KEY")
+            .hide_env_values(true)
+            .hide_default_value(true),
+        config.apikey.clone(),
+    )
+}
+
+fn base_url_arg(config: &Config) -> Arg {
+    default_value_opt(
+        arg!(base_url: --"base-url" <URL> "Sets base URL of the API endpoint")
+            .env("API_ENDPOINT")
+            .required(false),
+        config.base_url.clone(),
+    )
+}
+
+fn output_format_args(cmd: Command, config: &Config) -> Command {
+    let default = config
+        .output_format
+        .unwrap_or_default()
+        .to_possible_value()
+        .expect("output format has a value")
+        .get_name()
+        .to_string();
+
+    cmd.arg(
+        arg!(output_format: --output_format <FORMAT> "Sets output format")
+            .value_parser(value_parser!(OutputFormat))
+            .conflicts_with("output_format_short")
+            .default_value_ifs([
+                ("json", "true", Some("json")),
+                ("jsonp", "true", Some("jsonpretty")),
+            ])
+            .default_value(default),
+    )
+    .arg(arg!(--json "Shorthand for --output_format json"))
+    .arg(arg!(--jsonp "Shorthand for --output_format jsonpretty"))
+    .group(ArgGroup::new("output_format_short").args(["json", "jsonp"]))
+}
+
+fn invocation_from_matches(matches: ArgMatches, config: &Config) -> Invocation {
+    match matches.subcommand() {
+        Some(("quota", sub)) => Invocation::Quota(QuotaArgs::from(sub)),
+        Some(("compose", sub)) => Invocation::Compose(ComposeArgs::from(sub)),
+        Some(("chat", sub)) => Invocation::Chat(chat_args_from(sub, config)),
+        _ => Invocation::Chat(chat_args_from(&matches, config)),
+    }
+}
+
 impl From<ArgMatches> for Invocation {
     fn from(matches: ArgMatches) -> Self {
-        let api_key = matches.get_one::<String>("api_key").cloned();
-        let messages = Invocation::messages_from_matches(&matches);
-        let model = matches
-            .get_one::<String>("model")
-            .expect("Model is required")
-            .clone();
+        invocation_from_matches(matches, &Config::default())
+    }
+}
 
-        let temperature = matches.get_one::<f32>("temperature").copied();
+impl From<&ArgMatches> for ChatArgs {
+    fn from(matches: &ArgMatches) -> Self {
+        chat_args_from(matches, &Config::default())
+    }
+}
 
-        let timeout = matches
-            .get_one::<u64>("timeout")
-            .map(|t| Duration::from_secs(*t))
-            .expect("Timeout is required");
-
-        let output_format = *matches
-            .get_one::<OutputFormat>("output_format")
-            .expect("Output format is required");
-
-        let file = matches
+fn chat_args_from(matches: &ArgMatches, config: &Config) -> ChatArgs {
+    ChatArgs {
+        api_key: api_key_of(matches),
+        messages: messages_from_matches(matches, config),
+        model: model_of(matches),
+        temperature: matches.get_one::<f32>("temperature").copied(),
+        output_format: output_format_of(matches),
+        file: matches
             .get_one::<String>("file")
             .expect("File is required")
-            .clone()
-            .into();
+            .into(),
+        timeout: timeout_of(matches),
+        reasoning_effort: effort_of(matches),
+        base_url: base_url_of(matches),
+    }
+}
 
-        let reasoning_effort = matches
-            .get_one::<ReasoningEffort>("reasoning_effort")
-            .copied();
+impl From<&ArgMatches> for QuotaArgs {
+    fn from(matches: &ArgMatches) -> Self {
+        Self {
+            provider: *matches
+                .get_one::<Provider>("provider")
+                .expect("Provider is required"),
+            // Quota takes no API key from the environment: each provider
+            // resolves its own credential when the flag is absent.
+            api_key: api_key_of(matches),
+            base_url: base_url_of(matches),
+            timeout: timeout_of(matches),
+            output_format: output_format_of(matches),
+            raw: matches.get_flag("raw"),
+            color: *matches
+                .get_one::<ColorChoice>("color")
+                .expect("Color is required"),
+        }
+    }
+}
 
+impl From<&ArgMatches> for ComposeArgs {
+    fn from(matches: &ArgMatches) -> Self {
         Self {
-            api_key,
-            messages,
-            model,
-            temperature,
-            output_format,
-            file,
-            timeout,
-            reasoning_effort,
+            api_key: api_key_of(matches),
+            model: matches.get_one::<String>("model").cloned(),
+            system: matches.get_one::<String>("system_message").cloned(),
+            temperature: matches.get_one::<f32>("temperature").copied(),
+            timeout: timeout_of(matches),
+            reasoning_effort: matches.get_one::<Effort>("reasoning_effort").copied(),
+            output_format: output_format_of(matches),
+            base_url: base_url_of(matches),
+            continue_conversation: matches.get_flag("continue_conversation"),
         }
     }
 }
 
-impl Invocation {
-    /// Builder
-    #[must_use]
-    pub fn builder() -> InvocationBuilder {
-        InvocationBuilder::default()
+fn api_key_of(matches: &ArgMatches) -> Option<String> {
+    matches
+        .get_one::<String>("api_key")
+        .filter(|key| !key.is_empty())
+        .cloned()
+}
+
+fn base_url_of(matches: &ArgMatches) -> Option<String> {
+    matches
+        .get_one::<String>("base_url")
+        .filter(|url| !url.is_empty())
+        .cloned()
+}
+
+fn model_of(matches: &ArgMatches) -> String {
+    matches
+        .get_one::<String>("model")
+        .expect("Model is required")
+        .clone()
+}
+
+fn timeout_of(matches: &ArgMatches) -> Duration {
+    matches
+        .get_one::<u64>("timeout")
+        .copied()
+        .map(Duration::from_secs)
+        .expect("Timeout is required")
+}
+
+fn output_format_of(matches: &ArgMatches) -> OutputFormat {
+    *matches
+        .get_one::<OutputFormat>("output_format")
+        .expect("Output format is required")
+}
+
+fn effort_of(matches: &ArgMatches) -> Option<ReasoningEffort> {
+    matches
+        .get_one::<Effort>("reasoning_effort")
+        .copied()
+        .unwrap_or_default()
+        .into()
+}
+
+/// Given `clap::ArgMatches`, creates a vector of `Message` with assigned roles and ordering
+fn messages_from_matches(matches: &ArgMatches, config: &Config) -> Vec<Message> {
+    let mut messages = config
+        .assistant
+        .iter()
+        .map(|msg| Message::assistant(msg))
+        .chain(config.user.iter().map(|msg| Message::user(msg)))
+        .collect::<Vec<_>>();
+    let mut command_line_messages = vec![];
+
+    if let Some(user_msgs) = matches.get_many::<String>("user_messages") {
+        let indices = matches.indices_of("user_messages").unwrap();
+        command_line_messages.extend(
+            user_msgs
+                .zip(indices)
+                .map(|(c, idx)| (Message::user(c), idx)),
+        );
     }
+    if let Some(asst_msgs) = matches.get_many::<String>("assistant_messages") {
+        let indices = matches.indices_of("assistant_messages").unwrap();
+        command_line_messages.extend(
+            asst_msgs
+                .zip(indices)
+                .map(|(c, idx)| (Message::assistant(c), idx)),
+        );
+    }
+    command_line_messages.sort_by_key(|(_, idx)| *idx);
+    messages.extend(command_line_messages.into_iter().map(|(msg, _)| msg));
 
-    /// Given `clap::ArgMatches`, creates a vector of `Message` with assigned roles and ordering
-    fn messages_from_matches(matches: &ArgMatches) -> Vec<Message> {
-        let mut messages = vec![];
+    // System message is always first
+    if let Some(system_msg) = matches.get_one::<String>("system_message") {
+        messages.insert(0, Message::system(system_msg));
+    }
 
-        if let Some(user_msgs) = matches.get_many::<String>("user_messages") {
-            let indices = matches.indices_of("user_messages").unwrap();
-            messages.extend(
-                user_msgs
-                    .zip(indices)
-                    .map(|(c, idx)| (Message::user(c), idx)),
-            );
-        }
-        if let Some(asst_msgs) = matches.get_many::<String>("assistant_messages") {
-            let indices = matches.indices_of("assistant_messages").unwrap();
-            messages.extend(
-                asst_msgs
-                    .zip(indices)
-                    .map(|(c, idx)| (Message::assistant(c), idx)),
-            );
-        }
-        messages.sort_by_key(|(_, idx)| *idx);
-        let mut messages = messages.into_iter().map(|(msg, _)| msg).collect::<Vec<_>>();
+    messages
+}
 
-        // System message is always first
-        if let Some(system_msg) = matches.get_one::<String>("system_message") {
-            messages.insert(0, Message::system(system_msg));
-        }
+impl ChatArgs {
+    /// Builder
+    #[must_use]
+    pub fn builder() -> ChatArgsBuilder {
+        ChatArgsBuilder::default()
+    }
+}
 
-        messages
+impl QuotaArgs {
+    /// Builder
+    #[must_use]
+    pub fn builder() -> QuotaArgsBuilder {
+        QuotaArgsBuilder::default()
     }
 }
 
+impl ComposeArgs {
+    /// Builder
+    #[must_use]
+    pub fn builder() -> ComposeArgsBuilder {
+        ComposeArgsBuilder::default()
+    }
+}
+
 impl ValueEnum for OutputFormat {
     fn value_variants<'a>() -> &'a [Self] {
         &[Self::Plaintext, Self::JSON, Self::JSONPretty]
@@ -180,6 +591,49 @@ impl ValueEnum for OutputFormat {
     }
 }
 
+impl ValueEnum for ColorChoice {
+    fn value_variants<'a>() -> &'a [Self] {
+        &[Self::Auto, Self::Always, Self::Never]
+    }
+
+    fn to_possible_value(&self) -> Option<PossibleValue> {
+        Some(match self {
+            Self::Auto => PossibleValue::new("auto"),
+            Self::Always => PossibleValue::new("always"),
+            Self::Never => PossibleValue::new("never"),
+        })
+    }
+}
+
+impl ValueEnum for Provider {
+    fn value_variants<'a>() -> &'a [Self] {
+        &[Self::Ollama, Self::Anthropic, Self::OpenAI]
+    }
+
+    fn to_possible_value(&self) -> Option<PossibleValue> {
+        Some(PossibleValue::new(match self {
+            Self::Ollama => "ollama",
+            Self::Anthropic => "anthropic",
+            Self::OpenAI => "openai",
+        }))
+    }
+}
+
+impl ValueEnum for Effort {
+    fn value_variants<'a>() -> &'a [Self] {
+        &[Self::None, Self::Low, Self::Medium, Self::High]
+    }
+
+    fn to_possible_value(&self) -> Option<PossibleValue> {
+        Some(match self {
+            Self::None => PossibleValue::new("none"),
+            Self::Low => PossibleValue::new("low"),
+            Self::Medium => PossibleValue::new("medium"),
+            Self::High => PossibleValue::new("high"),
+        })
+    }
+}
+
 impl ValueEnum for ReasoningEffort {
     fn value_variants<'a>() -> &'a [Self] {
         &[Self::Low, Self::Medium, Self::High]
@@ -195,26 +649,74 @@ impl ValueEnum for ReasoningEffort {
 }
 
 #[cfg(test)]
+mod config_tests;
+
+#[cfg(test)]
 mod test {
     use super::*;
 
     type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
 
+    /// Clears the environment variables the CLI reads, so a developer's own
+    /// ALPACA_* or API_* settings cannot change parsing results. Runs once,
+    /// before any test in this module parses arguments.
+    static CLEAN_ENV: std::sync::LazyLock<()> = std::sync::LazyLock::new(|| {
+        for key in [
+            "ALPACA_MODEL",
+            "ALPACA_EFFORT",
+            "ALPACA_TIMEOUT",
+            "API_KEY",
+            "API_ENDPOINT",
+        ] {
+            // SAFETY: this runs exactly once and every parse in this module
+            // waits on it, so no other thread reads the environment meanwhile.
+            unsafe { std::env::remove_var(key) };
+        }
+    });
+
+    fn parse_args(args: &[&str]) -> Result<Invocation> {
+        std::sync::LazyLock::force(&CLEAN_ENV);
+        Ok(cli(&Config::default())
+            .try_get_matches_from(args)
+            .map(Invocation::from)?)
+    }
+
+    fn chat_args(args: &[&str]) -> Result<ChatArgs> {
+        match parse_args(args)? {
+            Invocation::Chat(args) => Ok(args),
+            other => Err(format!("expected chat invocation, got {other:?}").into()),
+        }
+    }
+
+    fn quota_args(args: &[&str]) -> Result<QuotaArgs> {
+        match parse_args(args)? {
+            Invocation::Quota(args) => Ok(args),
+            other => Err(format!("expected quota invocation, got {other:?}").into()),
+        }
+    }
+
+    fn compose_args(args: &[&str]) -> Result<ComposeArgs> {
+        match parse_args(args)? {
+            Invocation::Compose(args) => Ok(args),
+            other => Err(format!("expected compose invocation, got {other:?}").into()),
+        }
+    }
+
     #[test]
+    fn verify_cli() {
+        cli(&Config::default()).debug_assert();
+    }
+
+    #[test]
     fn chat_one_msgs() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "USER"])
-            .map(Invocation::from)?;
-
+        let args = chat_args(&["alpaca", "-u", "USER"])?;
         assert_eq!(args.messages, vec![Message::user("USER")]);
         Ok(())
     }
 
     #[test]
     fn chat_many_msgs() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "USER1", "-a", "ROBOT", "-u", "USER2"])
-            .map(Invocation::from)?;
+        let args = chat_args(&["alpaca", "-u", "USER1", "-a", "ROBOT", "-u", "USER2"])?;
 
         assert_eq!(
             args.messages,
@@ -230,21 +732,26 @@ mod test {
 
     #[test]
     fn chat_reasoning_effort_flag() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "USER", "--reasoning-effort", "high"])
-            .map(Invocation::from)?;
-
+        let args = chat_args(&["alpaca", "-u", "USER", "--reasoning-effort", "high"])?;
         assert_eq!(args.reasoning_effort, Some(ReasoningEffort::High));
         Ok(())
     }
 
     #[test]
+    fn chat_reasoning_effort_none_is_unset() -> Result<()> {
+        let args = chat_args(&["alpaca", "-u", "USER", "--reasoning-effort", "none"])?;
+        assert_eq!(args.reasoning_effort, None);
+
+        let args = chat_args(&["alpaca", "-u", "USER"])?;
+        assert_eq!(args.reasoning_effort, None, "effort defaults to unset");
+        Ok(())
+    }
+
+    #[test]
     fn chat_many_msgs_with_system_prompt() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec![
-                "alpaca", "-s", "SYSTEM", "-u", "USER1", "-a", "ROBOT", "-u", "USER2",
-            ])
-            .map(Invocation::from)?;
+        let args = chat_args(&[
+            "alpaca", "-s", "SYSTEM", "-u", "USER1", "-a", "ROBOT", "-u", "USER2",
+        ])?;
 
         assert_eq!(
             args.messages,
@@ -263,11 +770,9 @@ mod test {
     fn chat_many_msgs_with_system_prompt_last() -> Result<()> {
         // System message should always be brought to front, even if
         // it appears after user/assistant messages on the command line.
-        let args = cli()
-            .try_get_matches_from(vec![
-                "alpaca", "-u", "USER1", "-a", "ROBOT", "-s", "SYSTEM", "-u", "USER2",
-            ])
-            .map(Invocation::from)?;
+        let args = chat_args(&[
+            "alpaca", "-u", "USER1", "-a", "ROBOT", "-s", "SYSTEM", "-u", "USER2",
+        ])?;
 
         assert_eq!(
             args.messages,
@@ -285,9 +790,7 @@ mod test {
 
     #[test]
     fn chat_output_format_default() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "ABC"])
-            .map(Invocation::from)?;
+        let args = chat_args(&["alpaca", "-u", "ABC"])?;
 
         assert_eq!(
             args.output_format,
@@ -299,51 +802,192 @@ mod test {
 
     #[test]
     fn chat_output_format_explicit_json() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "ABC", "--output_format", "json"])
-            .map(Invocation::from)?;
-
+        let args = chat_args(&["alpaca", "-u", "ABC", "--output_format", "json"])?;
         assert_eq!(args.output_format, OutputFormat::JSON);
         Ok(())
     }
 
     #[test]
     fn chat_output_format_shorthand_json() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "ABC", "--json"])
-            .map(Invocation::from)?;
-
+        let args = chat_args(&["alpaca", "-u", "ABC", "--json"])?;
         assert_eq!(args.output_format, OutputFormat::JSON);
         Ok(())
     }
 
     #[test]
     fn chat_output_format_shorthand_jsonp() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "-u", "ABC", "--jsonp"])
-            .map(Invocation::from)?;
-
+        let args = chat_args(&["alpaca", "-u", "ABC", "--jsonp"])?;
         assert_eq!(args.output_format, OutputFormat::JSONPretty);
         Ok(())
     }
 
     #[test]
     fn chat_file_default() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca"])
-            .map(Invocation::from)?;
-
+        let args = chat_args(&["alpaca"])?;
         assert_eq!(args.file, PathBuf::from("-"));
         Ok(())
     }
 
     #[test]
     fn chat_file_positional() -> Result<()> {
-        let args = cli()
-            .try_get_matches_from(vec!["alpaca", "dialog_log"])
-            .map(Invocation::from)?;
+        let args = chat_args(&["alpaca", "dialog_log"])?;
+        assert_eq!(args.file, PathBuf::from("dialog_log"));
+        Ok(())
+    }
 
+    #[test]
+    fn chat_defaults() -> Result<()> {
+        let args = chat_args(&["alpaca", "-u", "ABC"])?;
+        assert_eq!(args.model, DEFAULT_MODEL);
+        assert_eq!(args.timeout, Duration::from_secs(60));
+        assert_eq!(args.base_url, None);
+        Ok(())
+    }
+
+    #[test]
+    fn chat_explicit_subcommand() -> Result<()> {
+        let args = chat_args(&["alpaca", "chat", "-u", "USER", "dialog_log"])?;
+        assert_eq!(args.messages, vec![Message::user("USER")]);
         assert_eq!(args.file, PathBuf::from("dialog_log"));
         Ok(())
     }
+
+    #[test]
+    fn chat_file_named_like_subcommand_after_escape() -> Result<()> {
+        let args = chat_args(&["alpaca", "--", "quota"])?;
+        assert_eq!(
+            args.file,
+            PathBuf::from("quota"),
+            "after --, a subcommand name is a file"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn quota_defaults() -> Result<()> {
+        let args = quota_args(&["alpaca", "quota"])?;
+        assert_eq!(args.provider, Provider::Ollama);
+        assert_eq!(args.api_key, None);
+        assert_eq!(args.base_url, None);
+        assert_eq!(args.timeout, Duration::from_secs(60));
+        assert_eq!(args.color, ColorChoice::Auto);
+        assert!(!args.raw);
+        assert_eq!(args.output_format, OutputFormat::Plaintext);
+        Ok(())
+    }
+
+    #[test]
+    fn quota_provider_and_flags() -> Result<()> {
+        let args = quota_args(&[
+            "alpaca",
+            "quota",
+            "-p",
+            "openai",
+            "--raw",
+            "--color",
+            "never",
+            "--base-url",
+            "http://example.com",
+            "-T",
+            "5",
+        ])?;
+
+        assert_eq!(args.provider, Provider::OpenAI);
+        assert!(args.raw);
+        assert_eq!(args.color, ColorChoice::Never);
+        assert_eq!(args.base_url.as_deref(), Some("http://example.com"));
+        assert_eq!(args.timeout, Duration::from_secs(5));
+        Ok(())
+    }
+
+    #[test]
+    fn quota_unknown_provider_is_error() {
+        assert!(
+            quota_args(&["alpaca", "quota", "-p", "nope"]).is_err(),
+            "unknown provider should fail to parse"
+        );
+    }
+
+    #[test]
+    fn quota_json_shorthand() -> Result<()> {
+        let args = quota_args(&["alpaca", "quota", "--json"])?;
+        assert_eq!(args.output_format, OutputFormat::JSON);
+        Ok(())
+    }
+
+    #[test]
+    fn compose_leaves_unset_values_open() -> Result<()> {
+        let args = compose_args(&["alpaca", "compose"])?;
+        assert_eq!(
+            args.model, None,
+            "an unset model is taken from the transcript or the default"
+        );
+        assert_eq!(args.timeout, Duration::from_secs(60));
+        assert_eq!(args.reasoning_effort, None);
+        assert_eq!(args.system, None);
+        assert!(!args.continue_conversation);
+        Ok(())
+    }
+
+    #[test]
+    fn compose_takes_model_from_the_command_line() -> Result<()> {
+        let args = compose_args(&["alpaca", "compose", "-m", "other-model"])?;
+        assert_eq!(args.model.as_deref(), Some("other-model"));
+        Ok(())
+    }
+
+    #[test]
+    fn compose_effort_none_is_explicit() -> Result<()> {
+        let args = compose_args(&["alpaca", "compose", "--reasoning-effort", "none"])?;
+        assert_eq!(
+            args.reasoning_effort,
+            Some(Effort::None),
+            "an explicit none overrides a saved effort"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn compose_continue_and_system() -> Result<()> {
+        let args = compose_args(&[
+            "alpaca",
+            "compose",
+            "-c",
+            "-s",
+            "SYSTEM",
+            "--reasoning-effort",
+            "low",
+        ])?;
+        assert!(args.continue_conversation, "-c is short for --continue");
+
+        let args = compose_args(&[
+            "alpaca",
+            "compose",
+            "--continue",
+            "-s",
+            "SYSTEM",
+            "--reasoning-effort",
+            "low",
+        ])?;
+        assert!(args.continue_conversation);
+        assert_eq!(args.system.as_deref(), Some("SYSTEM"));
+        assert_eq!(args.reasoning_effort, Some(Effort::Low));
+        Ok(())
+    }
+
+    #[test]
+    fn compose_invalid_effort_is_error() {
+        assert!(
+            compose_args(&["alpaca", "compose", "--reasoning-effort", "extreme"]).is_err(),
+            "unknown effort should fail to parse"
+        );
+    }
+
+    #[test]
+    fn timeout_must_be_positive() {
+        assert!(
+            chat_args(&["alpaca", "-u", "ABC", "-T", "0"]).is_err(),
+            "zero timeout should fail to parse"
+        );
+    }
 }
blob - /dev/null
blob + c384d14199e039afafa3e4435cbfbc87e3b13351 (mode 644)
--- /dev/null
+++ src/api.rs
@@ -0,0 +1,741 @@
+//! Interactions with the chat API
+//!
+//! Two request and response dialects are supported, so alpaca works against
+//! both the native chat endpoint and any compatible chat completions endpoint.
+//! The dialect follows from the base URL: a base URL whose path ends in a
+//! version segment, such as `https://host/v1`, uses `/chat/completions`, and
+//! anything else uses `/api/chat`.
+
+use std::fmt;
+use std::time::Duration;
+
+use crate::Error;
+use chrono::serde::ts_seconds;
+use chrono::{DateTime, TimeZone, Utc};
+use derive_builder::Builder;
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value, json};
+use thiserror::Error;
+
+/// Convenience client for the chat API
+pub struct Client {
+    /// Inner HTTP client
+    http: reqwest::Client,
+    /// Default API key
+    api_key: Option<String>,
+    /// Base URL for the API endpoint
+    base_url: String,
+    /// Request and response dialect implied by the base URL
+    dialect: Dialect,
+}
+
+/// Wire format a chat endpoint speaks
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Dialect {
+    /// `POST {base}/api/chat`, with a single message in the response
+    Native,
+    /// `POST {base}/chat/completions`, with a list of choices in the response
+    Completions,
+}
+
+/// Requests for the chat API
+#[derive(Builder, Default)]
+pub struct ChatRequest {
+    model: String,
+    messages: Vec<Message>,
+    #[builder(default)]
+    temperature: Option<f32>,
+    timeout: Duration,
+    #[builder(default)]
+    think: Option<ReasoningEffort>,
+}
+
+/// Normalized response surfaced to the rest of the crate
+#[derive(Builder, Default, Debug, Serialize, Deserialize)]
+pub struct Response {
+    #[serde(with = "ts_seconds")]
+    pub created: DateTime<Utc>,
+    pub choices: Vec<Choice>,
+    pub model: String,
+    pub usage: Usage,
+}
+
+/// API errors
+///
+/// Endpoints report errors either as `{"error": "message"}` or as
+/// `{"error": {"message": "..."}}`.
+#[derive(Debug, Deserialize)]
+pub struct APIError {
+    #[serde(rename = "error", deserialize_with = "error_message")]
+    pub message: String,
+}
+
+/// Errors that can occur when converting an API response into a normalized
+/// [`Response`].
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+pub enum ResponseError {
+    #[error("response did not contain a message")]
+    NoMessage,
+    #[error("response message missing text content")]
+    MissingContent,
+    #[error("unsupported finish reason: {0}")]
+    UnsupportedFinishReason(String),
+    #[error("token count overflow")]
+    TokenCountOverflow,
+}
+
+/// Messages in chat API requests and responses
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
+pub struct Message {
+    pub role: Role,
+    pub content: String,
+}
+
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
+#[serde(rename_all = "lowercase")]
+pub enum Role {
+    System,
+    Assistant,
+    User,
+    Tool,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
+pub struct Usage {
+    pub input_tokens: u32,
+    pub output_tokens: u32,
+    pub total_tokens: u32,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ReasoningEffort {
+    Low,
+    Medium,
+    High,
+}
+
+impl fmt::Display for ReasoningEffort {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let effort = match self {
+            ReasoningEffort::Low => "low",
+            ReasoningEffort::Medium => "medium",
+            ReasoningEffort::High => "high",
+        };
+        f.write_str(effort)
+    }
+}
+
+/// Reason generation stopped
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum FinishReason {
+    Stop,
+    Length,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Choice {
+    pub message: Message,
+    pub finish_reason: FinishReason,
+}
+
+impl Dialect {
+    /// The dialect implied by a base URL
+    ///
+    /// A base URL ending in a version segment, such as `/v1`, addresses a chat
+    /// completions endpoint. Everything else addresses the native endpoint.
+    #[must_use]
+    pub fn from_base_url(base_url: &str) -> Self {
+        let is_version = |segment: &str| {
+            let mut chars = segment.chars();
+            chars.next() == Some('v') && chars.all(|c| c.is_ascii_digit())
+        };
+
+        let last = base_url
+            .trim_end_matches('/')
+            .rsplit('/')
+            .next()
+            .unwrap_or_default();
+
+        if is_version(last) && last.len() > 1 {
+            Self::Completions
+        } else {
+            Self::Native
+        }
+    }
+
+    /// Path of the chat endpoint
+    #[must_use]
+    pub fn path(self) -> &'static str {
+        match self {
+            Self::Native => "/api/chat",
+            Self::Completions => "/chat/completions",
+        }
+    }
+}
+
+impl Client {
+    /// Build a client for `base_url`
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the underlying HTTP client cannot be built.
+    pub fn new(api_key: Option<String>, base_url: String) -> Result<Self, Error> {
+        let http = reqwest::Client::builder().build()?;
+        let dialect = Dialect::from_base_url(&base_url);
+        Ok(Self {
+            http,
+            api_key,
+            base_url,
+            dialect,
+        })
+    }
+
+    /// Send a chat request and normalize the reply
+    ///
+    /// Authorization is sent only when an API key is set, so endpoints that
+    /// need no authentication, such as a local server, still work.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the request fails or the response cannot be parsed
+    /// into a [`Response`].
+    pub async fn create_response(&self, request: &ChatRequest) -> Result<Response, Error> {
+        let mut builder = self
+            .http
+            .post(self.chat_endpoint())
+            .timeout(request.timeout)
+            .header("Content-Type", "application/json")
+            .json(&request.to_payload(self.dialect));
+
+        if let Some(api_key) = &self.api_key {
+            builder = builder.bearer_auth(api_key);
+        }
+
+        let resp = builder.send().await?;
+
+        let status = resp.status();
+        if status.is_success() {
+            let body: APIResponse = resp.json().await?;
+            Response::try_from(body).map_err(|e| Error::UnexpectedResponse(e.to_string()))
+        } else {
+            let body = resp.bytes().await?;
+            match serde_json::from_slice::<APIError>(&body) {
+                Ok(error) => Err(Error::API { error }),
+                Err(_) => Err(Error::HttpStatus {
+                    status: status.as_u16(),
+                    message: String::from_utf8_lossy(&body).trim().to_string(),
+                }),
+            }
+        }
+    }
+
+    fn chat_endpoint(&self) -> String {
+        let base = self.base_url.trim_end_matches('/');
+        format!("{base}{}", self.dialect.path())
+    }
+}
+
+impl Message {
+    #[must_use]
+    pub fn system(content: &str) -> Message {
+        Message {
+            role: Role::System,
+            content: content.to_string(),
+        }
+    }
+    #[must_use]
+    pub fn user(content: &str) -> Message {
+        Message {
+            role: Role::User,
+            content: content.to_string(),
+        }
+    }
+    #[must_use]
+    pub fn assistant(content: &str) -> Message {
+        Message {
+            role: Role::Assistant,
+            content: content.to_string(),
+        }
+    }
+}
+
+impl ChatRequest {
+    #[must_use]
+    pub fn builder() -> ChatRequestBuilder {
+        ChatRequestBuilder::default()
+    }
+
+    fn to_payload(&self, dialect: Dialect) -> Value {
+        let messages = self
+            .messages
+            .iter()
+            .map(|m| serde_json::to_value(m).expect("Message always serializes"))
+            .collect::<Vec<_>>();
+
+        let mut payload = Map::new();
+        payload.insert("model".to_string(), json!(self.model));
+        payload.insert("messages".to_string(), json!(messages));
+        payload.insert("stream".to_string(), json!(false));
+
+        match dialect {
+            Dialect::Native => {
+                if let Some(temperature) = self.temperature {
+                    payload.insert("options".to_string(), json!({ "temperature": temperature }));
+                }
+                if let Some(think) = self.think {
+                    payload.insert("think".to_string(), json!(think.to_string()));
+                }
+            }
+            Dialect::Completions => {
+                if let Some(temperature) = self.temperature {
+                    payload.insert("temperature".to_string(), json!(temperature));
+                }
+                if let Some(effort) = self.think {
+                    payload.insert("reasoning_effort".to_string(), json!(effort.to_string()));
+                }
+            }
+        }
+
+        Value::Object(payload)
+    }
+}
+
+impl Response {
+    #[must_use]
+    pub fn builder() -> ResponseBuilder {
+        ResponseBuilder::default()
+    }
+}
+
+/// A response in either dialect
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum APIResponse {
+    Native(NativeResponse),
+    Completions(CompletionsResponse),
+}
+
+#[derive(Debug, Deserialize)]
+struct NativeResponse {
+    created_at: DateTime<Utc>,
+    model: String,
+    message: Option<ResponseMessage>,
+    #[serde(default)]
+    done_reason: Option<String>,
+    #[serde(default)]
+    prompt_eval_count: u32,
+    #[serde(default)]
+    eval_count: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct CompletionsResponse {
+    #[serde(default)]
+    created: i64,
+    #[serde(default)]
+    model: String,
+    #[serde(default)]
+    choices: Vec<CompletionsChoice>,
+    #[serde(default)]
+    usage: CompletionsUsage,
+}
+
+#[derive(Debug, Deserialize)]
+struct CompletionsChoice {
+    message: Option<ResponseMessage>,
+    #[serde(default)]
+    finish_reason: Option<String>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct CompletionsUsage {
+    #[serde(default, rename = "prompt_tokens")]
+    prompt: u32,
+    #[serde(default, rename = "completion_tokens")]
+    completion: u32,
+    #[serde(default, rename = "total_tokens")]
+    total: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct ResponseMessage {
+    #[serde(default = "default_role")]
+    role: Role,
+    #[serde(default)]
+    content: String,
+}
+
+fn default_role() -> Role {
+    Role::Assistant
+}
+
+/// Read an error message from either a bare string or an object with a
+/// `message` field
+fn error_message<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    #[derive(Deserialize)]
+    #[serde(untagged)]
+    enum ErrorBody {
+        Message(String),
+        Object { message: String },
+    }
+
+    Ok(match ErrorBody::deserialize(deserializer)? {
+        ErrorBody::Message(message) | ErrorBody::Object { message } => message,
+    })
+}
+
+/// Interpret a finish reason, defaulting to a clean stop
+fn finish_reason(reason: Option<&str>) -> Result<FinishReason, ResponseError> {
+    match reason {
+        Some("length") => Ok(FinishReason::Length),
+        None | Some("stop") => Ok(FinishReason::Stop),
+        Some(reason) => Err(ResponseError::UnsupportedFinishReason(reason.to_string())),
+    }
+}
+
+fn choice(message: ResponseMessage, reason: Option<&str>) -> Result<Choice, ResponseError> {
+    if message.content.is_empty() {
+        return Err(ResponseError::MissingContent);
+    }
+
+    Ok(Choice {
+        message: Message {
+            role: message.role,
+            content: message.content,
+        },
+        finish_reason: finish_reason(reason)?,
+    })
+}
+
+impl TryFrom<APIResponse> for Response {
+    type Error = ResponseError;
+
+    fn try_from(value: APIResponse) -> Result<Self, Self::Error> {
+        match value {
+            APIResponse::Native(native) => Self::try_from(native),
+            APIResponse::Completions(completions) => Self::try_from(completions),
+        }
+    }
+}
+
+impl TryFrom<NativeResponse> for Response {
+    type Error = ResponseError;
+
+    fn try_from(value: NativeResponse) -> Result<Self, Self::Error> {
+        let message = value.message.ok_or(ResponseError::NoMessage)?;
+        let choice = choice(message, value.done_reason.as_deref())?;
+
+        Ok(Response {
+            created: value.created_at,
+            choices: vec![choice],
+            model: value.model,
+            usage: Usage {
+                input_tokens: value.prompt_eval_count,
+                output_tokens: value.eval_count,
+                total_tokens: value
+                    .prompt_eval_count
+                    .checked_add(value.eval_count)
+                    .ok_or(ResponseError::TokenCountOverflow)?,
+            },
+        })
+    }
+}
+
+impl TryFrom<CompletionsResponse> for Response {
+    type Error = ResponseError;
+
+    fn try_from(value: CompletionsResponse) -> Result<Self, Self::Error> {
+        let choices = value
+            .choices
+            .into_iter()
+            .map(|c| {
+                let message = c.message.ok_or(ResponseError::NoMessage)?;
+                choice(message, c.finish_reason.as_deref())
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+
+        if choices.is_empty() {
+            return Err(ResponseError::NoMessage);
+        }
+
+        let total = if value.usage.total == 0 {
+            value
+                .usage
+                .prompt
+                .checked_add(value.usage.completion)
+                .ok_or(ResponseError::TokenCountOverflow)?
+        } else {
+            value.usage.total
+        };
+
+        Ok(Response {
+            created: Utc
+                .timestamp_opt(value.created, 0)
+                .single()
+                .unwrap_or_default(),
+            choices,
+            model: value.model,
+            usage: Usage {
+                input_tokens: value.usage.prompt,
+                output_tokens: value.usage.completion,
+                total_tokens: total,
+            },
+        })
+    }
+}
+
+#[cfg(test)]
+mod test {
+
+    use super::*;
+    use anyhow::Result;
+    use std::time::Duration;
+
+    #[test]
+    fn parse_response_payload() -> Result<()> {
+        let data = r#"{
+             "model": "gpt-oss:120b",
+             "created_at": "2025-10-17T23:14:07.414671Z",
+             "message": {
+                 "role": "assistant",
+                 "content": "Hello! How can I assist you today?"
+             },
+             "done": true,
+             "done_reason": "stop",
+             "prompt_eval_count": 8,
+             "eval_count": 9
+        }
+        "#;
+
+        let resp = serde_json::from_str::<APIResponse>(data)?;
+        let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
+
+        assert_eq!(
+            resp.choices,
+            vec![Choice {
+                message: Message {
+                    role: Role::Assistant,
+                    content: "Hello! How can I assist you today?".to_string()
+                },
+                finish_reason: FinishReason::Stop
+            }]
+        );
+        assert_eq!(resp.model, "gpt-oss:120b");
+        assert_eq!(
+            resp.usage,
+            Usage {
+                input_tokens: 8,
+                output_tokens: 9,
+                total_tokens: 17,
+            }
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn parse_completions_response_payload() -> Result<()> {
+        let data = r#"{
+             "id": "chatcmpl-1",
+             "created": 1760742847,
+             "model": "some-model",
+             "choices": [{
+                 "index": 0,
+                 "message": {"role": "assistant", "content": "Hello there"},
+                 "finish_reason": "stop"
+             }],
+             "usage": {"prompt_tokens": 8, "completion_tokens": 9, "total_tokens": 17}
+        }
+        "#;
+
+        let resp = serde_json::from_str::<APIResponse>(data)?;
+        let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
+
+        assert_eq!(resp.model, "some-model");
+        assert_eq!(resp.choices.len(), 1);
+        assert_eq!(resp.choices[0].message.content, "Hello there");
+        assert_eq!(resp.choices[0].finish_reason, FinishReason::Stop);
+        assert_eq!(resp.usage.total_tokens, 17);
+        assert_eq!(resp.created.timestamp(), 1_760_742_847);
+        Ok(())
+    }
+
+    #[test]
+    fn completions_length_limit_is_reported() -> Result<()> {
+        let data = r#"{
+             "created": 1,
+             "model": "m",
+             "choices": [{"message": {"content": "cut"}, "finish_reason": "length"}]
+        }
+        "#;
+
+        let resp = Response::try_from(serde_json::from_str::<APIResponse>(data)?)
+            .map_err(|e| anyhow::anyhow!(e))?;
+
+        assert_eq!(resp.choices[0].finish_reason, FinishReason::Length);
+        assert_eq!(
+            resp.usage.total_tokens, 0,
+            "absent usage counts as no tokens"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn dialect_follows_the_base_url() {
+        assert_eq!(
+            Dialect::from_base_url("https://example.com"),
+            Dialect::Native
+        );
+        assert_eq!(
+            Dialect::from_base_url("http://localhost:11434/"),
+            Dialect::Native
+        );
+        assert_eq!(
+            Dialect::from_base_url("https://example.com/v1"),
+            Dialect::Completions
+        );
+        assert_eq!(
+            Dialect::from_base_url("https://example.com/v1/"),
+            Dialect::Completions
+        );
+        assert_eq!(
+            Dialect::from_base_url("https://example.com/api/v2"),
+            Dialect::Completions
+        );
+    }
+
+    #[test]
+    fn endpoints_follow_the_dialect() -> Result<()> {
+        let native = Client::new(None, "https://example.com/".to_string())?;
+        assert_eq!(native.chat_endpoint(), "https://example.com/api/chat");
+
+        let completions = Client::new(None, "https://example.com/v1".to_string())?;
+        assert_eq!(
+            completions.chat_endpoint(),
+            "https://example.com/v1/chat/completions"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn response_payload_includes_think() -> Result<()> {
+        let request = ChatRequest::builder()
+            .model("gpt-oss:120b".to_string())
+            .messages(vec![Message::user("Hello")])
+            .temperature(Some(0.0))
+            .timeout(Duration::from_secs(30))
+            .think(Some(ReasoningEffort::High))
+            .build()
+            .expect("request builds");
+
+        let payload = request.to_payload(Dialect::Native);
+
+        assert_eq!(payload["think"], "high");
+        assert_eq!(payload["model"], "gpt-oss:120b");
+        assert_eq!(payload["options"]["temperature"], 0.0);
+        assert_eq!(payload["stream"], false);
+
+        Ok(())
+    }
+
+    #[test]
+    fn completions_payload_uses_flat_fields() -> Result<()> {
+        let request = ChatRequest::builder()
+            .model("some-model".to_string())
+            .messages(vec![Message::user("Hello")])
+            .temperature(Some(0.5))
+            .timeout(Duration::from_secs(30))
+            .think(Some(ReasoningEffort::Low))
+            .build()
+            .expect("request builds");
+
+        let payload = request.to_payload(Dialect::Completions);
+
+        assert_eq!(payload["reasoning_effort"], "low");
+        assert_eq!(payload["temperature"], 0.5);
+        assert!(payload.get("options").is_none());
+        assert!(payload.get("think").is_none());
+
+        Ok(())
+    }
+
+    #[test]
+    fn response_payload_omits_think_when_not_set() -> Result<()> {
+        let request = ChatRequest::builder()
+            .model("gpt-oss:120b".to_string())
+            .messages(vec![Message::user("Hello")])
+            .timeout(Duration::from_secs(30))
+            .build()
+            .expect("request builds");
+
+        let payload = request.to_payload(Dialect::Native);
+
+        assert!(payload.get("think").is_none());
+        assert!(payload.get("options").is_none());
+        Ok(())
+    }
+
+    #[test]
+    fn response_try_from_errors_without_message() -> Result<()> {
+        let data = r#"{
+             "model": "gpt-oss:120b",
+             "created_at": "2025-10-17T23:14:07.414671Z",
+             "done": true,
+             "done_reason": "stop",
+             "prompt_eval_count": 1,
+             "eval_count": 1
+        }
+        "#;
+
+        let resp = serde_json::from_str::<APIResponse>(data)?;
+        let err = Response::try_from(resp).expect_err("should error");
+
+        assert!(
+            matches!(err, ResponseError::NoMessage),
+            "unexpected error: {err:?}"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn parse_response_error() -> Result<()> {
+        let flat = serde_json::from_str::<APIError>(r#"{"error": "model 'nope' not found"}"#)?;
+        assert_eq!(flat.message, "model 'nope' not found");
+
+        let nested =
+            serde_json::from_str::<APIError>(r#"{"error": {"message": "invalid api key"}}"#)?;
+        assert_eq!(nested.message, "invalid api key");
+
+        Ok(())
+    }
+    #[test]
+    fn rejects_unknown_finish_reasons() -> Result<()> {
+        let data = r#"{"created": 1, "model": "m", "choices": [{"message": {"role": "assistant", "content": "blocked"}, "finish_reason": "content_filter"}]}"#;
+        let response = serde_json::from_str::<APIResponse>(data)?;
+        assert!(matches!(
+            Response::try_from(response),
+            Err(ResponseError::UnsupportedFinishReason(reason)) if reason == "content_filter"
+        ));
+        Ok(())
+    }
+
+    #[test]
+    fn rejects_token_count_overflow() -> Result<()> {
+        let data = format!(
+            r#"{{"created": 1, "model": "m", "choices": [{{"message": {{"role": "assistant", "content": "ok"}}, "finish_reason": "stop"}}], "usage": {{"prompt_tokens": {}, "completion_tokens": 1}}}}"#,
+            u32::MAX
+        );
+        let response = serde_json::from_str::<APIResponse>(&data)?;
+        assert!(matches!(
+            Response::try_from(response),
+            Err(ResponseError::TokenCountOverflow)
+        ));
+        Ok(())
+    }
+}
blob - 0ce669871f6ed18118041e575e2365acbe166cf8
blob + b20af6da546f55a2bfdae7f9966b03b92be31691
--- src/error.rs
+++ src/error.rs
@@ -1,10 +1,9 @@
 //! Errors for alpaca library crate
 
+use std::path::PathBuf;
+
 #[derive(Debug, thiserror::Error)]
 pub enum Error {
-    #[error("no API key provided")]
-    NoAPIKey,
-
     #[error("failed to fetch - {0}")]
     FailedToFetch(#[from] reqwest::Error),
 
@@ -20,6 +19,31 @@ pub enum Error {
     #[error("json serialization error - {0}")]
     JSON(#[from] serde_json::Error),
 
-    #[error("ollama api returned error - {}", .error.message)]
-    OllamaError { error: crate::ollama::APIError },
+    #[error("api returned error - {}", .error.message)]
+    API { error: crate::api::APIError },
+
+    #[error("status {status} - {message}")]
+    HttpStatus { status: u16, message: String },
+
+    #[error("response body exceeds {limit} bytes")]
+    ResponseTooLarge { limit: usize },
+
+    #[error("no credentials for {provider}")]
+    NoCredentials { provider: crate::usage::Provider },
+
+    #[error("editor failed - {0}")]
+    Editor(String),
+
+    /// Editor exited non-zero. The status is forwarded to alpaca's own exit code.
+    #[error("editor exited with status {0}")]
+    EditorExit(i32),
+
+    #[error("no saved conversations")]
+    NoConversations,
+
+    #[error("invalid transcript {} - {message}", .path.display())]
+    InvalidTranscript { path: PathBuf, message: String },
+
+    #[error("no home directory")]
+    NoHomeDir,
 }
blob - /dev/null
blob + 8f636a7ae619df2597f468f358735df3bfe53a04 (mode 644)
--- /dev/null
+++ src/cli/config_tests.rs
@@ -0,0 +1,149 @@
+use super::*;
+
+type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+
+fn configured_chat(toml: &str, args: &[&str]) -> Result<ChatArgs> {
+    let config = Config::from_toml(toml)?;
+    match parse_args_with_config(args, &config)? {
+        Invocation::Chat(args) => Ok(args),
+        other => Err(format!("expected chat invocation, got {other:?}").into()),
+    }
+}
+
+#[test]
+fn config_parses_every_supported_setting() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            output_format = "jsonpretty"
+            model = "configured-model"
+            temperature = 0.25
+            timeout = 17
+            system = "configured system"
+            assistant = ["assistant one", "assistant two"]
+            user = ["user one", "user two"]
+            apikey = "configured-key"
+            base_url = "https://configured.example/v1"
+            reasoning_effort = "high"
+        "#,
+        &["alpaca"],
+    )?;
+
+    assert_eq!(args.output_format, OutputFormat::JSONPretty);
+    assert_eq!(args.model, "configured-model");
+    assert_eq!(args.temperature, Some(0.25));
+    assert_eq!(args.timeout, Duration::from_secs(17));
+    assert_eq!(args.api_key.as_deref(), Some("configured-key"));
+    assert_eq!(
+        args.base_url.as_deref(),
+        Some("https://configured.example/v1")
+    );
+    assert_eq!(args.reasoning_effort, Some(ReasoningEffort::High));
+    assert_eq!(
+        args.messages,
+        vec![
+            Message::system("configured system"),
+            Message::assistant("assistant one"),
+            Message::assistant("assistant two"),
+            Message::user("user one"),
+            Message::user("user two"),
+        ]
+    );
+    Ok(())
+}
+
+#[test]
+fn command_line_overrides_config_and_keeps_message_order() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            output_format = "jsonpretty"
+            model = "configured-model"
+            system = "configured system"
+            assistant = ["configured assistant"]
+            user = ["configured user"]
+        "#,
+        &[
+            "alpaca",
+            "--json",
+            "-m",
+            "cli-model",
+            "-s",
+            "cli system",
+            "-u",
+            "cli user",
+            "-a",
+            "cli assistant",
+        ],
+    )?;
+
+    assert_eq!(args.output_format, OutputFormat::JSON);
+    assert_eq!(args.model, "cli-model");
+    assert_eq!(
+        args.messages,
+        vec![
+            Message::system("cli system"),
+            Message::assistant("configured assistant"),
+            Message::user("configured user"),
+            Message::user("cli user"),
+            Message::assistant("cli assistant"),
+        ]
+    );
+    Ok(())
+}
+
+#[test]
+fn config_applies_to_compose_and_quota() -> Result<()> {
+    let config = Config::from_toml(
+        r#"
+            output_format = "json"
+            model = "configured-model"
+            timeout = 19
+            system = "configured system"
+            apikey = "configured-key"
+            base_url = "https://configured.example"
+            reasoning_effort = "medium"
+        "#,
+    )?;
+
+    let Invocation::Compose(compose) = parse_args_with_config(&["alpaca", "compose"], &config)?
+    else {
+        return Err("expected compose invocation".into());
+    };
+    assert_eq!(compose.model.as_deref(), Some("configured-model"));
+    assert_eq!(compose.system.as_deref(), Some("configured system"));
+    assert_eq!(compose.reasoning_effort, Some(Effort::Medium));
+    assert_eq!(compose.timeout, Duration::from_secs(19));
+
+    let Invocation::Quota(quota) = parse_args_with_config(&["alpaca", "quota"], &config)? else {
+        return Err("expected quota invocation".into());
+    };
+    assert_eq!(quota.output_format, OutputFormat::JSON);
+    assert_eq!(quota.timeout, Duration::from_secs(19));
+    assert_eq!(quota.api_key.as_deref(), Some("configured-key"));
+    assert_eq!(
+        quota.base_url.as_deref(),
+        Some("https://configured.example")
+    );
+    Ok(())
+}
+
+#[test]
+fn config_rejects_unknown_keys_and_non_positive_timeout() {
+    assert!(Config::from_toml("modle = 'typo'").is_err());
+    assert!(Config::from_toml("timeout = 0").is_err());
+}
+
+#[test]
+fn config_path_prefers_xdg_and_falls_back_to_home() {
+    assert_eq!(
+        config_path_from(
+            Some(OsString::from("/xdg")),
+            Some(PathBuf::from("/home/alpaca")),
+        ),
+        Some(PathBuf::from("/xdg/alpaca/config.toml"))
+    );
+    assert_eq!(
+        config_path_from(None, Some(PathBuf::from("/home/alpaca"))),
+        Some(PathBuf::from("/home/alpaca/.config/alpaca/config.toml"))
+    );
+    assert_eq!(config_path_from(None, None), None);
+}
blob - 43b814dc1c239e286cd24712b98a9de8924d150f
blob + 154c6a56e459afd61686001e53fe1234e5113dbb
--- src/exec/chat.rs
+++ src/exec/chat.rs
@@ -1,26 +1,23 @@
 //! Implements chat subcommand
 
 use crate::Error;
-use crate::cli::{Invocation, OutputFormat};
-use crate::ollama::{self, FinishReason, Message, Response};
+use crate::api::{self, Message};
+use crate::cli::ChatArgs;
 use crate::parse;
 
 use anyhow::{Context, Result};
 use std::fs::File;
-use std::io::{self, BufWriter, IsTerminal, Read, Write};
+use std::io::{self, IsTerminal, Read};
 use std::path::Path;
 
-/// Executes `Invocation` via given args
+/// Executes `ChatArgs` via given args
 ///
 /// # Errors
 ///
 /// Returns an error if no messages are provided, the request fails, or the
 /// response cannot be shown.
-pub async fn exec(args: Invocation) -> Result<()> {
-    let base_url =
-        std::env::var("OLLAMA_API_ENDPOINT").unwrap_or_else(|_| "https://ollama.com".to_string());
-
-    let client = ollama::Client::new(args.api_key.clone(), base_url)
+pub async fn exec(args: ChatArgs) -> Result<()> {
+    let client = api::Client::new(args.api_key.clone(), super::base_url(args.base_url.clone()))
         .with_context(|| "failed to create http client")?;
 
     let file_msgs = read_messages_from_file(&args.file)
@@ -36,20 +33,12 @@ pub async fn exec(args: Invocation) -> Result<()> {
         return Err(Error::NoMessagesProvided.into());
     }
 
-    // TODO: Lifetimes for `ChatRequest` fields
-    let mut builder = ollama::ChatRequest::builder();
-
-    builder
+    let request = api::ChatRequest::builder()
         .model(args.model)
         .messages(msgs)
         .temperature(args.temperature)
-        .timeout(args.timeout);
-
-    if let Some(effort) = args.reasoning_effort {
-        builder.think(Some(effort));
-    }
-
-    let request = builder
+        .timeout(args.timeout)
+        .think(args.reasoning_effort)
         .build()
         .with_context(|| "failed to create request")?;
 
@@ -58,7 +47,7 @@ pub async fn exec(args: Invocation) -> Result<()> {
         .await
         .with_context(|| "failed to fetch request")?;
 
-    show_response(io::stdout(), args.output_format, &res)?;
+    super::show_response(io::stdout(), args.output_format, &res)?;
     Ok(())
 }
 
@@ -82,39 +71,6 @@ fn read_messages_from_file(file: &Path) -> Result<Vec<
     }
 }
 
-/// Show formatted output for a Chat API result
-fn show_response(
-    dest: impl Write,
-    output_format: OutputFormat,
-    resp: &Response,
-) -> Result<(), Error> {
-    let mut writer = BufWriter::new(dest);
-    let choice = resp.choices.first().ok_or_else(|| {
-        Error::UnexpectedResponse(format!(
-            "unexpected number of choices in response: {resp:?}"
-        ))
-    })?;
-
-    match choice.finish_reason {
-        FinishReason::Stop => {
-            let output = match output_format {
-                OutputFormat::Plaintext => choice.message.content.clone(),
-                OutputFormat::JSON => serde_json::to_string(resp)?,
-                OutputFormat::JSONPretty => serde_json::to_string_pretty(resp)?,
-            };
-            writeln!(writer, "{output}").map_err(Error::IO)?;
-            writer.flush().map_err(Error::IO)?;
-        }
-        FinishReason::Length => {
-            return Err(Error::UnexpectedResponse(format!(
-                "response truncated due to length limit: {choice:?}"
-            )));
-        }
-    }
-
-    Ok(())
-}
-
 #[cfg(test)]
 mod test {
     use std::time::Duration;
@@ -124,12 +80,11 @@ mod test {
     use predicates::str;
 
     use crate::{
-        cli::{Invocation, InvocationBuilder, OutputFormat},
-        ollama::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage},
+        api::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage},
+        cli::{ChatArgs, ChatArgsBuilder, OutputFormat},
+        exec::show_response,
     };
 
-    use super::*;
-
     use anyhow::Result;
 
     #[test]
@@ -199,8 +154,28 @@ mod test {
         Ok(())
     }
 
-    fn default_args() -> InvocationBuilder {
-        Invocation::builder()
+    #[test]
+    fn show_chat_response_rejects_truncated_reply() -> Result<()> {
+        let mut output = vec![];
+        let resp = default_resp()
+            .choices(vec![Choice {
+                message: Message::assistant("Hello wor"),
+                finish_reason: FinishReason::Length,
+            }])
+            .build()?;
+
+        let err = show_response(&mut output, OutputFormat::Plaintext, &resp)
+            .expect_err("truncated responses should error");
+
+        assert!(
+            err.to_string().contains("truncated"),
+            "unexpected error: {err}"
+        );
+        Ok(())
+    }
+
+    fn default_args() -> ChatArgsBuilder {
+        ChatArgs::builder()
             .api_key(Some(String::default()))
             .messages(vec![])
             .model(String::default())
blob - 75c8c33d2564802d1ec057ab8464e7b6204cd5dc
blob + e518cbee4af064ba36d2679aa89b96131ecb3c17
--- src/exec/mod.rs
+++ src/exec/mod.rs
@@ -1,14 +1,75 @@
 //! Executor for alpaca
 pub mod chat;
+pub mod compose;
+pub mod quota;
 
-use crate::cli::Invocation;
+use std::io::{BufWriter, Write};
+
+use crate::Error;
+use crate::api::{Choice, FinishReason, Response};
+use crate::cli::{Invocation, OutputFormat};
 use anyhow::Result;
 
 /// Execute the invocation
 ///
 /// # Errors
 ///
-/// Returns an error if the chat request fails or its response cannot be shown.
-pub async fn exec(inv: Invocation) -> Result<()> {
-    chat::exec(inv).await
+/// Returns an error if the subcommand fails.
+pub async fn exec(invocation: Invocation) -> Result<()> {
+    match invocation {
+        Invocation::Chat(args) => chat::exec(args).await,
+        Invocation::Quota(args) => quota::exec(args).await,
+        Invocation::Compose(args) => compose::exec(args).await,
+    }
 }
+
+/// Resolve the chat API base URL, falling back to the public endpoint
+pub(crate) fn base_url(configured: Option<String>) -> String {
+    configured.unwrap_or_else(|| crate::cli::DEFAULT_BASE_URL.to_string())
+}
+
+/// Show formatted output for a Chat API result
+///
+/// # Errors
+///
+/// Returns an error if the response holds no usable choice, was truncated, or
+/// cannot be written.
+pub(crate) fn show_response(
+    dest: impl Write,
+    output_format: OutputFormat,
+    resp: &Response,
+) -> Result<(), Error> {
+    let mut writer = BufWriter::new(dest);
+    let choice = reply(resp)?;
+
+    let output = match output_format {
+        OutputFormat::Plaintext => choice.message.content.clone(),
+        OutputFormat::JSON => serde_json::to_string(resp)?,
+        OutputFormat::JSONPretty => serde_json::to_string_pretty(resp)?,
+    };
+    writeln!(writer, "{output}").map_err(Error::IO)?;
+    writer.flush().map_err(Error::IO)?;
+
+    Ok(())
+}
+
+/// The choice carrying the assistant's reply
+///
+/// # Errors
+///
+/// Returns an error if the response holds no choice or was truncated by the
+/// model's length limit.
+pub(crate) fn reply(resp: &Response) -> Result<&Choice, Error> {
+    let choice = resp.choices.first().ok_or_else(|| {
+        Error::UnexpectedResponse(format!(
+            "unexpected number of choices in response: {resp:?}"
+        ))
+    })?;
+
+    match choice.finish_reason {
+        FinishReason::Stop => Ok(choice),
+        FinishReason::Length => Err(Error::UnexpectedResponse(format!(
+            "response truncated due to length limit: {choice:?}"
+        ))),
+    }
+}
blob - /dev/null
blob + ad7dfd7746ce6db96f34473d78e5921c3f24612b (mode 644)
--- /dev/null
+++ src/exec/compose.rs
@@ -0,0 +1,119 @@
+//! Implements compose subcommand
+//!
+//! Edits a prompt in the user's editor, sends it, then saves the conversation
+//! so it can be continued later.
+
+use std::io::{self, IsTerminal, Read};
+
+use anyhow::{Context, Result};
+
+use crate::api::{self, Message, ReasoningEffort};
+use crate::cli::{ComposeArgs, DEFAULT_MODEL};
+use crate::transcript::Transcript;
+use crate::{Error, editor};
+
+/// Executes `ComposeArgs`
+///
+/// # Errors
+///
+/// Returns an error if the editor fails, the request fails, or the
+/// conversation cannot be saved.
+pub async fn exec(args: ComposeArgs) -> Result<()> {
+    let client = api::Client::new(args.api_key.clone(), super::base_url(args.base_url.clone()))
+        .with_context(|| "failed to create http client")?;
+
+    let (mut path, mut transcript, mut prefill) = if args.continue_conversation {
+        let (path, transcript) = Transcript::newest()?;
+        let prefill = transcript.last_reply().unwrap_or_default().to_string();
+        (Some(path), transcript, prefill)
+    } else {
+        (
+            None,
+            Transcript::new(String::new(), None, None),
+            read_stdin()?,
+        )
+    };
+
+    transcript.model = resolve(args.model, &transcript.model, DEFAULT_MODEL.to_string());
+    transcript.effort = args
+        .reasoning_effort
+        .map_or(transcript.effort, Option::<ReasoningEffort>::from);
+    transcript.system = args.system.or(transcript.system);
+
+    loop {
+        let prompt = editor::edit(&prefill)?;
+        if prompt.is_empty() {
+            return Ok(());
+        }
+
+        transcript.messages.push(Message::user(&prompt));
+
+        let request = api::ChatRequest::builder()
+            .model(transcript.model.clone())
+            .messages(transcript.request_messages())
+            .temperature(args.temperature)
+            .timeout(args.timeout)
+            .think(transcript.effort)
+            .build()
+            .with_context(|| "failed to create request")?;
+
+        let response = client
+            .create_response(&request)
+            .await
+            .with_context(|| "failed to fetch request")?;
+
+        let reply = super::reply(&response)?.message.content.clone();
+        transcript.messages.push(Message::assistant(&reply));
+
+        match &path {
+            Some(path) => transcript.save(path)?,
+            None => path = Some(transcript.create()?),
+        }
+
+        super::show_response(io::stdout(), args.output_format, &response)?;
+
+        if !args.continue_conversation {
+            return Ok(());
+        }
+        prefill = reply;
+    }
+}
+
+/// Command line value, else the saved value, else the default
+fn resolve(given: Option<String>, saved: &str, default: String) -> String {
+    given.unwrap_or_else(|| {
+        if saved.is_empty() {
+            default
+        } else {
+            saved.to_string()
+        }
+    })
+}
+
+/// Piped stdin prefills the editor buffer, so compose can sit in the middle of
+/// a pipeline
+fn read_stdin() -> Result<String, Error> {
+    let stdin = io::stdin();
+    if stdin.is_terminal() {
+        return Ok(String::new());
+    }
+
+    let mut buffer = String::new();
+    stdin.lock().read_to_string(&mut buffer)?;
+    Ok(buffer)
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn command_line_value_wins_over_saved_and_default() {
+        assert_eq!(
+            resolve(Some("given".into()), "saved", "default".into()),
+            "given"
+        );
+        assert_eq!(resolve(None, "saved", "default".into()), "saved");
+        assert_eq!(resolve(None, "", "default".into()), "default");
+    }
+}
blob - /dev/null
blob + 0e152265c75285e4811b85aa8115182ecb8c7a35 (mode 644)
--- /dev/null
+++ src/exec/quota.rs
@@ -0,0 +1,50 @@
+//! Implements quota subcommand
+//!
+//! Asks a provider how much of the account's quota is used, then renders each
+//! window as a bar with a countdown to the next reset.
+
+use std::io::{self, IsTerminal, Write};
+
+use anyhow::{Context, Result};
+
+use crate::cli::{OutputFormat, QuotaArgs};
+use crate::usage::{self, render::Style};
+
+/// Executes `QuotaArgs`
+///
+/// # Errors
+///
+/// Returns an error if no credential is found, the request fails, or the
+/// response cannot be shown.
+pub async fn exec(args: QuotaArgs) -> Result<()> {
+    let client = usage::Client::new(args.provider, args.api_key, args.base_url, args.timeout)
+        .with_context(|| format!("failed to create {} client", args.provider))?;
+
+    let body = client
+        .fetch()
+        .await
+        .with_context(|| format!("failed to fetch {} usage", args.provider))?;
+
+    let mut stdout = io::stdout();
+
+    if args.raw {
+        stdout.write_all(&body)?;
+        if !body.ends_with(b"\n") {
+            stdout.write_all(b"\n")?;
+        }
+        return Ok(());
+    }
+
+    let view = args.provider.view(&body)?;
+
+    match args.output_format {
+        OutputFormat::Plaintext => {
+            let style = Style::new(args.color, stdout.is_terminal());
+            usage::render::render(&mut stdout, &view, style)?;
+        }
+        OutputFormat::JSON => writeln!(stdout, "{}", serde_json::to_string(&view)?)?,
+        OutputFormat::JSONPretty => writeln!(stdout, "{}", serde_json::to_string_pretty(&view)?)?,
+    }
+
+    Ok(())
+}
blob - 15cf9e339df89215549608b3604bb8874ccd57e8
blob + 4fa8c978528e18d877efdb464292904c13bc55bb
--- src/lib.rs
+++ src/lib.rs
@@ -1,11 +1,28 @@
+pub mod api;
 pub mod cli;
+pub mod editor;
 pub mod error;
 pub mod exec;
-pub mod ollama;
 pub mod parse;
+pub mod transcript;
+pub mod usage;
 
 pub use error::Error;
 pub use exec::exec;
 pub use parse::parse_messages;
 
 pub type Result<T> = std::result::Result<T, Error>;
+
+/// The current user's home directory
+///
+/// # Errors
+///
+/// Returns an error if `HOME` is unset and the platform cannot report a home
+/// directory.
+pub fn home_dir() -> Result<std::path::PathBuf> {
+    std::env::var_os("HOME")
+        .filter(|home| !home.is_empty())
+        .map(std::path::PathBuf::from)
+        .or_else(std::env::home_dir)
+        .ok_or(Error::NoHomeDir)
+}
blob - /dev/null
blob + 24abb4c683a5ab2b84310b724ab0e9fc552a9334 (mode 644)
--- /dev/null
+++ src/editor.rs
@@ -0,0 +1,107 @@
+//! Editing prompts in the user's editor
+//!
+//! The editor runs on the controlling terminal rather than on the process's
+//! own stdin and stdout, so `alpaca compose` can sit in the middle of a
+//! pipeline: pipe data in, edit it, pipe the reply onward.
+
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+use crate::Error;
+
+/// Editor used when neither `VISUAL` nor `EDITOR` is set
+const FALLBACK_EDITOR: &str = "vim";
+
+/// Open `initial` in the user's editor and return the saved contents
+///
+/// The temporary file is removed on every path, including editor failure.
+///
+/// # Errors
+///
+/// Returns [`Error::EditorExit`] when the editor exits non-zero, and
+/// [`Error::Editor`] when it cannot be started or the terminal cannot be
+/// opened.
+pub fn edit(initial: &str) -> Result<String, Error> {
+    let mut file = tempfile::Builder::new()
+        .prefix("alpaca-")
+        .tempfile()
+        .map_err(Error::IO)?;
+    file.write_all(initial.as_bytes())?;
+    file.flush()?;
+
+    run(file.path())?;
+
+    Ok(fs::read_to_string(file.path())?)
+}
+
+/// The editor command, split on whitespace
+///
+/// `VISUAL` wins over `EDITOR`. Quotes and escapes are not parsed; use a
+/// wrapper script for editor commands that need them.
+#[must_use]
+pub fn command() -> Vec<String> {
+    ["VISUAL", "EDITOR"]
+        .iter()
+        .filter_map(|name| std::env::var(name).ok())
+        .map(|value| split(&value))
+        .find(|words| !words.is_empty())
+        .unwrap_or_else(|| vec![FALLBACK_EDITOR.to_string()])
+}
+
+fn split(value: &str) -> Vec<String> {
+    value.split_whitespace().map(String::from).collect()
+}
+
+/// Run the editor on `path`, wired to the controlling terminal
+///
+/// Without a controlling terminal there is nothing better to attach the editor
+/// to, so it inherits this process's own streams.
+fn run(path: &Path) -> Result<(), Error> {
+    let words = command();
+    let (program, args) = words.split_first().expect("command is never empty");
+
+    let mut editor = Command::new(program);
+    editor.args(args).arg(path);
+
+    match fs::OpenOptions::new()
+        .read(true)
+        .write(true)
+        .open("/dev/tty")
+    {
+        Ok(tty) => {
+            editor
+                .stdin(Stdio::from(tty.try_clone().map_err(Error::IO)?))
+                .stdout(Stdio::from(tty.try_clone().map_err(Error::IO)?))
+                .stderr(Stdio::from(tty));
+        }
+        Err(_) => {
+            editor
+                .stdin(Stdio::inherit())
+                .stdout(Stdio::inherit())
+                .stderr(Stdio::inherit());
+        }
+    }
+
+    let status = editor
+        .status()
+        .map_err(|err| Error::Editor(format!("run {program}: {err}")))?;
+
+    if status.success() {
+        Ok(())
+    } else {
+        Err(Error::EditorExit(status.code().unwrap_or(1)))
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn splits_on_whitespace() {
+        assert_eq!(split("emacsclient  -nw"), vec!["emacsclient", "-nw"]);
+        assert_eq!(split("   "), Vec::<String>::new());
+    }
+}
blob - 59e0a7d39bdce3d2be395b411d5bfd78e79b1368
blob + d8e362f6322c76e64833576733b471154a9964de
--- src/main.rs
+++ src/main.rs
@@ -1,9 +1,23 @@
-use alpaca::cli;
-use anyhow::Result;
+use alpaca::{Error, cli};
 
 #[tokio::main]
-async fn main() -> Result<()> {
-    let invocation = cli::parse();
-    alpaca::exec(invocation).await?;
-    Ok(())
+async fn main() {
+    let invocation = match cli::parse() {
+        Ok(invocation) => invocation,
+        Err(err) => {
+            eprintln!("alpaca: {err:#}");
+            std::process::exit(1);
+        }
+    };
+
+    if let Err(err) = alpaca::exec(invocation).await {
+        // An editor that exits non-zero sets alpaca's own exit status, so
+        // aborting an edit behaves like aborting any other editor session.
+        if let Some(Error::EditorExit(code)) = err.downcast_ref::<Error>() {
+            std::process::exit(*code);
+        }
+
+        eprintln!("alpaca: {err:#}");
+        std::process::exit(1);
+    }
 }
blob - 34137e38327a1922d4715acfe772acec97a76ebf (mode 644)
blob + /dev/null
--- src/ollama.rs
+++ /dev/null
@@ -1,417 +0,0 @@
-//! Interactions with the Ollama Cloud API
-//!
-//! Reference: <https://docs.ollama.com/api/chat>
-
-use std::fmt;
-use std::time::Duration;
-
-use crate::Error;
-use chrono::serde::ts_seconds;
-use chrono::{DateTime, Utc};
-use derive_builder::Builder;
-use reqwest::StatusCode;
-use serde::{Deserialize, Serialize};
-use serde_json::{Map, Value, json};
-use thiserror::Error;
-
-/// Convenience Client for the Ollama Chat API
-pub struct Client {
-    /// Inner HTTP client
-    http: reqwest::Client,
-    /// Default API Key
-    api_key: Option<String>,
-    /// Base URL for API Endpoint
-    base_url: String,
-}
-
-/// Requests for the Chat API
-/// Reference: <https://docs.ollama.com/api/chat>
-#[derive(Builder, Default)]
-pub struct ChatRequest {
-    model: String,
-    messages: Vec<Message>,
-    #[builder(default)]
-    temperature: Option<f32>,
-    timeout: Duration,
-    #[builder(default)]
-    think: Option<ReasoningEffort>,
-}
-
-/// Normalized response surfaced to the rest of the crate
-#[derive(Builder, Default, Debug, Serialize, Deserialize)]
-pub struct Response {
-    #[serde(with = "ts_seconds")]
-    pub created: DateTime<Utc>,
-    pub choices: Vec<Choice>,
-    pub model: String,
-    pub usage: Usage,
-}
-
-/// API Errors from Ollama
-///
-/// Ollama returns errors as `{"error": "message"}`.
-#[derive(Debug, Deserialize)]
-pub struct APIError {
-    #[serde(rename = "error")]
-    pub message: String,
-}
-
-/// Errors that can occur when converting an Ollama API response into a
-/// normalized [`Response`].
-#[derive(Debug, Clone, PartialEq, Eq, Error)]
-pub enum ResponseError {
-    #[error("response did not contain a message")]
-    NoMessage,
-    #[error("response message missing text content")]
-    MissingContent,
-}
-
-/// Messages in Chat API request and response
-#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
-pub struct Message {
-    pub role: Role,
-    pub content: String,
-}
-
-#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
-#[serde(rename_all = "lowercase")]
-pub enum Role {
-    System,
-    Assistant,
-    User,
-    Tool,
-}
-
-#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
-pub struct Usage {
-    pub input_tokens: u32,
-    pub output_tokens: u32,
-    pub total_tokens: u32,
-}
-
-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
-#[serde(rename_all = "lowercase")]
-pub enum ReasoningEffort {
-    Low,
-    Medium,
-    High,
-}
-
-impl fmt::Display for ReasoningEffort {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            ReasoningEffort::Low => write!(f, "low"),
-            ReasoningEffort::Medium => write!(f, "medium"),
-            ReasoningEffort::High => write!(f, "high"),
-        }
-    }
-}
-
-/// Reason generation stopped, derived from Ollama's `done_reason`
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum FinishReason {
-    Stop,
-    Length,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct Choice {
-    pub message: Message,
-    pub finish_reason: FinishReason,
-}
-
-impl Client {
-    /// # Errors
-    ///
-    /// Returns an error if the underlying HTTP client cannot be built.
-    pub fn new(api_key: Option<String>, base_url: String) -> Result<Self, Error> {
-        let http = reqwest::Client::builder().build()?;
-        Ok(Self {
-            http,
-            api_key,
-            base_url,
-        })
-    }
-
-    /// # Errors
-    ///
-    /// Returns an error if no API key is set, the request fails, or the
-    /// response cannot be parsed into a [`Response`].
-    pub async fn create_response(&self, request: &ChatRequest) -> Result<Response, Error> {
-        let api_key = self.api_key.as_ref().ok_or(Error::NoAPIKey)?;
-
-        let resp = self
-            .http
-            .post(self.chat_endpoint())
-            .bearer_auth(api_key)
-            .timeout(request.timeout)
-            .header("Content-Type", "application/json")
-            .json(&request.to_payload())
-            .send()
-            .await?;
-
-        if resp.status() == StatusCode::OK {
-            let chat: ChatAPIResponse = resp.json().await?;
-            Response::try_from(chat)
-                .map_err(|e| Error::UnexpectedResponse(e.to_string()))
-        } else {
-            let error = resp.json::<APIError>().await?;
-            Err(Error::OllamaError { error })
-        }
-    }
-
-    fn chat_endpoint(&self) -> String {
-        let base = self.base_url.trim_end_matches('/');
-        format!("{}/api/chat", base)
-    }
-}
-
-impl Message {
-    #[must_use]
-    pub fn system(content: &str) -> Message {
-        Message {
-            role: Role::System,
-            content: content.to_string(),
-        }
-    }
-    #[must_use]
-    pub fn user(content: &str) -> Message {
-        Message {
-            role: Role::User,
-            content: content.to_string(),
-        }
-    }
-    #[must_use]
-    pub fn assistant(content: &str) -> Message {
-        Message {
-            role: Role::Assistant,
-            content: content.to_string(),
-        }
-    }
-}
-
-impl ChatRequest {
-    #[must_use]
-    pub fn builder() -> ChatRequestBuilder {
-        ChatRequestBuilder::default()
-    }
-
-    fn to_payload(&self) -> Value {
-        let messages = self
-            .messages
-            .iter()
-            .map(|m| serde_json::to_value(m).expect("Message always serializes"))
-            .collect::<Vec<_>>();
-
-        let mut payload = Map::new();
-        payload.insert("model".to_string(), json!(self.model));
-        payload.insert("messages".to_string(), json!(messages));
-        payload.insert("stream".to_string(), json!(false));
-
-        if let Some(temperature) = self.temperature {
-            payload.insert(
-                "options".to_string(),
-                json!({ "temperature": temperature }),
-            );
-        }
-
-        if let Some(think) = self.think {
-            payload.insert("think".to_string(), json!(think.to_string()));
-        }
-
-        Value::Object(payload)
-    }
-}
-
-impl Response {
-    #[must_use]
-    pub fn builder() -> ResponseBuilder {
-        ResponseBuilder::default()
-    }
-}
-
-#[derive(Debug, Deserialize)]
-struct ChatAPIResponse {
-    created_at: DateTime<Utc>,
-    model: String,
-    message: Option<ChatMessage>,
-    #[serde(default)]
-    done_reason: Option<String>,
-    #[serde(default)]
-    prompt_eval_count: u32,
-    #[serde(default)]
-    eval_count: u32,
-}
-
-#[derive(Debug, Deserialize)]
-struct ChatMessage {
-    #[serde(default = "default_role")]
-    role: Role,
-    #[serde(default)]
-    content: String,
-}
-
-fn default_role() -> Role {
-    Role::Assistant
-}
-
-impl TryFrom<ChatAPIResponse> for Response {
-    type Error = ResponseError;
-
-    fn try_from(value: ChatAPIResponse) -> Result<Self, Self::Error> {
-        let message = value.message.ok_or(ResponseError::NoMessage)?;
-
-        if message.content.is_empty() {
-            return Err(ResponseError::MissingContent);
-        }
-
-        let finish_reason = match value.done_reason.as_deref() {
-            Some("length") => FinishReason::Length,
-            _ => FinishReason::Stop,
-        };
-
-        let choice = Choice {
-            message: Message {
-                role: message.role,
-                content: message.content,
-            },
-            finish_reason,
-        };
-
-        Ok(Response {
-            created: value.created_at,
-            choices: vec![choice],
-            model: value.model,
-            usage: Usage {
-                input_tokens: value.prompt_eval_count,
-                output_tokens: value.eval_count,
-                total_tokens: value.prompt_eval_count + value.eval_count,
-            },
-        })
-    }
-}
-
-#[cfg(test)]
-mod test {
-
-    use super::*;
-    use anyhow::Result;
-    use std::time::Duration;
-
-    #[test]
-    fn parse_response_payload() -> Result<()> {
-        let data = r#"{
-             "model": "gpt-oss:120b",
-             "created_at": "2025-10-17T23:14:07.414671Z",
-             "message": {
-                 "role": "assistant",
-                 "content": "Hello! How can I assist you today?"
-             },
-             "done": true,
-             "done_reason": "stop",
-             "prompt_eval_count": 8,
-             "eval_count": 9
-        }
-        "#;
-
-        let resp = serde_json::from_str::<ChatAPIResponse>(data)?;
-        let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
-
-        assert_eq!(
-            resp.choices,
-            vec![Choice {
-                message: Message {
-                    role: Role::Assistant,
-                    content: "Hello! How can I assist you today?".to_string()
-                },
-                finish_reason: FinishReason::Stop
-            }]
-        );
-        assert_eq!(resp.model, "gpt-oss:120b");
-        assert_eq!(
-            resp.usage,
-            Usage {
-                input_tokens: 8,
-                output_tokens: 9,
-                total_tokens: 17,
-            }
-        );
-
-        Ok(())
-    }
-
-    #[test]
-    fn response_payload_includes_think() -> Result<()> {
-        let request = ChatRequest::builder()
-            .model("gpt-oss:120b".to_string())
-            .messages(vec![Message::user("Hello")])
-            .temperature(Some(0.0))
-            .timeout(Duration::from_secs(30))
-            .think(Some(ReasoningEffort::High))
-            .build()
-            .expect("request builds");
-
-        let payload = request.to_payload();
-
-        assert_eq!(payload["think"], "high");
-        assert_eq!(payload["model"], "gpt-oss:120b");
-        assert_eq!(payload["options"]["temperature"], 0.0);
-        assert_eq!(payload["stream"], false);
-
-        Ok(())
-    }
-
-    #[test]
-    fn response_payload_omits_think_when_not_set() -> Result<()> {
-        let request = ChatRequest::builder()
-            .model("gpt-oss:120b".to_string())
-            .messages(vec![Message::user("Hello")])
-            .timeout(Duration::from_secs(30))
-            .build()
-            .expect("request builds");
-
-        let payload = request.to_payload();
-
-        assert!(payload.get("think").is_none());
-        assert!(payload.get("options").is_none());
-        Ok(())
-    }
-
-    #[test]
-    fn response_try_from_errors_without_message() -> Result<()> {
-        let data = r#"{
-             "model": "gpt-oss:120b",
-             "created_at": "2025-10-17T23:14:07.414671Z",
-             "done": true,
-             "done_reason": "stop",
-             "prompt_eval_count": 1,
-             "eval_count": 1
-        }
-        "#;
-
-        let resp = serde_json::from_str::<ChatAPIResponse>(data)?;
-        let err = Response::try_from(resp).expect_err("should error");
-
-        assert!(
-            matches!(err, ResponseError::NoMessage),
-            "unexpected error: {err:?}"
-        );
-
-        Ok(())
-    }
-
-    #[test]
-    fn parse_response_error() -> Result<()> {
-        let data = r#"{
-            "error": "model 'nope' not found"
-        }
-        "#;
-
-        let resp = serde_json::from_str::<APIError>(data)?;
-
-        assert_eq!(resp.message, "model 'nope' not found");
-
-        Ok(())
-    }
-}
blob - 378e86478140600edf16cd0af18cc2c48cb55185
blob + c1604d171e0ee1f15c8c6d7a32981f06bdb0695d
--- src/parse.rs
+++ src/parse.rs
@@ -3,7 +3,7 @@
 use std::io::Read;
 
 use crate::Error;
-use crate::ollama::Message;
+use crate::api::Message;
 
 /// Read from `std::io::Read` into a vector of messages
 ///
blob - /dev/null
blob + d42a73bd6246f28d199db6f8473e2feab8d66b7d (mode 644)
--- /dev/null
+++ src/transcript.rs
@@ -0,0 +1,442 @@
+//! Saved conversations
+//!
+//! Conversations are stored one JSON document per file under
+//! `$XDG_DATA_HOME/alpaca`, falling back to `~/.local/share/alpaca`. Files are
+//! written with mode 0600 and the directory with mode 0700, since prompts and
+//! replies are private.
+
+use std::fs;
+use std::io::Write;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use crate::Error;
+use crate::api::{Message, ReasoningEffort, Role};
+
+/// Version of the on-disk transcript document
+const VERSION: u32 = 1;
+
+/// A saved conversation
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transcript {
+    /// Format version, so future readers can tell formats apart
+    #[serde(default = "default_version")]
+    pub version: u32,
+    pub model: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub effort: Option<ReasoningEffort>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub system: Option<String>,
+    /// Alternating user and assistant messages, starting with a user message
+    pub messages: Vec<Message>,
+}
+
+const fn default_version() -> u32 {
+    VERSION
+}
+
+impl Transcript {
+    /// Start a transcript with no messages yet
+    #[must_use]
+    pub fn new(model: String, effort: Option<ReasoningEffort>, system: Option<String>) -> Self {
+        Self {
+            version: VERSION,
+            model,
+            effort,
+            system,
+            messages: vec![],
+        }
+    }
+
+    /// Messages to send for the next turn, with the system prompt first
+    #[must_use]
+    pub fn request_messages(&self) -> Vec<Message> {
+        let system = self.system.iter().map(|s| Message::system(s));
+        system.chain(self.messages.iter().cloned()).collect()
+    }
+
+    /// The most recent assistant reply, if there is one
+    #[must_use]
+    pub fn last_reply(&self) -> Option<&str> {
+        self.messages
+            .iter()
+            .rev()
+            .find(|m| m.role == Role::Assistant)
+            .map(|m| m.content.as_str())
+    }
+
+    /// Read a transcript from `path`
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the file cannot be read, is not a transcript
+    /// document, or does not hold a complete alternating conversation.
+    pub fn load(path: &Path) -> Result<Self, Error> {
+        let data = fs::read(path)?;
+        let transcript: Self =
+            serde_json::from_slice(&data).map_err(|err| Error::InvalidTranscript {
+                path: path.to_path_buf(),
+                message: err.to_string(),
+            })?;
+        transcript.validate(path)?;
+        Ok(transcript)
+    }
+
+    /// Write the transcript to `path`, replacing any previous contents
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the transcript is malformed or the file cannot be
+    /// written.
+    pub fn save(&self, path: &Path) -> Result<(), Error> {
+        let data = self.serialized(path)?;
+        let parent = path.parent().unwrap_or_else(|| Path::new("."));
+        let mut file = tempfile::NamedTempFile::new_in(parent)?;
+        fs::set_permissions(file.path(), fs::Permissions::from_mode(0o600))?;
+        file.write_all(&data)?;
+        file.write_all(b"\n")?;
+        file.flush()?;
+        file.persist(path).map_err(|err| Error::IO(err.error))?;
+        Ok(())
+    }
+
+    /// Save the transcript to a new file in the transcript directory, returning
+    /// its path
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the directory cannot be created or the file cannot
+    /// be written.
+    pub fn create(&self) -> Result<PathBuf, Error> {
+        let dir = dir()?;
+        fs::create_dir_all(&dir)?;
+        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
+        let data = self.serialized(&dir)?;
+        let mut file = tempfile::NamedTempFile::new_in(&dir)?;
+        fs::set_permissions(file.path(), fs::Permissions::from_mode(0o600))?;
+        file.write_all(&data)?;
+        file.write_all(b"\n")?;
+        file.flush()?;
+
+        let stamp = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map_or(0, |duration| duration.as_nanos());
+        for seq in 0.. {
+            let path = dir.join(format!("{stamp}-{seq}.json"));
+            match file.persist_noclobber(&path) {
+                Ok(_) => return Ok(path),
+                Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => {
+                    file = err.file;
+                }
+                Err(err) => return Err(Error::IO(err.error)),
+            }
+        }
+        unreachable!("the counter yields an unused name")
+    }
+
+    fn serialized(&self, path: &Path) -> Result<Vec<u8>, Error> {
+        self.validate(path)?;
+        Ok(serde_json::to_vec_pretty(self)?)
+    }
+
+    /// Find the most recently modified usable transcript
+    ///
+    /// Unreadable files are skipped. Their error is only reported when no
+    /// transcript in the directory can be read.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`Error::NoConversations`] when the directory holds no
+    /// transcripts, or the last parse error when none of them are usable.
+    pub fn newest() -> Result<(PathBuf, Self), Error> {
+        let dir = dir()?;
+        let entries = match fs::read_dir(&dir) {
+            Ok(entries) => entries,
+            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
+                return Err(Error::NoConversations);
+            }
+            Err(err) => return Err(err.into()),
+        };
+
+        let mut candidates = vec![];
+        for entry in entries {
+            let entry = entry?;
+            let path = entry.path();
+            if path.extension().is_none_or(|ext| ext != "json") {
+                continue;
+            }
+            let metadata = entry.metadata()?;
+            if !metadata.is_file() {
+                continue;
+            }
+            candidates.push((metadata.modified()?, path));
+        }
+
+        candidates.sort_by(|(a, _), (b, _)| b.cmp(a));
+
+        let mut last_error = None;
+        for (_, path) in candidates {
+            match Self::load(&path) {
+                Ok(transcript) => return Ok((path, transcript)),
+                Err(err) => last_error = Some(err),
+            }
+        }
+
+        Err(last_error.unwrap_or(Error::NoConversations))
+    }
+
+    /// Check that messages alternate user, assistant, and end with a reply
+    fn validate(&self, path: &Path) -> Result<(), Error> {
+        let invalid = |message: &str| Error::InvalidTranscript {
+            path: path.to_path_buf(),
+            message: message.to_string(),
+        };
+
+        if self.messages.is_empty() || !self.messages.len().is_multiple_of(2) {
+            return Err(invalid("transcript must end with an assistant reply"));
+        }
+
+        let expected = [Role::User, Role::Assistant];
+        if self
+            .messages
+            .iter()
+            .zip(expected.iter().cycle())
+            .any(|(message, role)| message.role != *role)
+        {
+            return Err(invalid("transcript messages are out of order"));
+        }
+
+        Ok(())
+    }
+}
+
+/// Directory holding saved conversations
+///
+/// # Errors
+///
+/// Returns an error if neither `XDG_DATA_HOME` nor a home directory is known.
+pub fn dir() -> Result<PathBuf, Error> {
+    if let Some(data_home) = std::env::var_os("XDG_DATA_HOME")
+        && !data_home.is_empty()
+    {
+        return Ok(PathBuf::from(data_home).join("alpaca"));
+    }
+    Ok(crate::home_dir()?
+        .join(".local")
+        .join("share")
+        .join("alpaca"))
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use anyhow::Result;
+    use std::time::Duration;
+
+    /// Point the transcript directory at a temporary tree for the duration of
+    /// the guard. Tests that use it run serially, guarded by a mutex, because
+    /// the environment is process wide.
+    struct DataHome {
+        _dir: tempfile::TempDir,
+        _guard: std::sync::MutexGuard<'static, ()>,
+    }
+
+    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+    impl DataHome {
+        fn new() -> Self {
+            let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+            let dir = tempfile::tempdir().expect("temp dir");
+            // SAFETY: the mutex makes this the only thread touching the
+            // environment while the guard is alive.
+            unsafe { std::env::set_var("XDG_DATA_HOME", dir.path()) };
+            Self {
+                _dir: dir,
+                _guard: guard,
+            }
+        }
+    }
+
+    impl Drop for DataHome {
+        fn drop(&mut self) {
+            // SAFETY: see `DataHome::new`.
+            unsafe { std::env::remove_var("XDG_DATA_HOME") };
+        }
+    }
+
+    fn transcript(model: &str) -> Transcript {
+        Transcript {
+            version: VERSION,
+            model: model.to_string(),
+            effort: Some(ReasoningEffort::Low),
+            system: None,
+            messages: vec![Message::user("question"), Message::assistant("reply")],
+        }
+    }
+
+    #[test]
+    fn round_trip_preserves_awkward_content() -> Result<()> {
+        let dir = tempfile::tempdir()?;
+        let path = dir.path().join("session.json");
+        let want = Transcript {
+            version: VERSION,
+            model: "model".to_string(),
+            effort: None,
+            system: Some("system\ntext".to_string()),
+            messages: vec![
+                Message::user("hello\nmessage-user 4\nevil\"quoted\""),
+                Message::assistant("reply"),
+            ],
+        };
+
+        want.save(&path)?;
+        let got = Transcript::load(&path)?;
+
+        assert_eq!(got, want);
+        Ok(())
+    }
+
+    #[test]
+    fn saved_files_are_private() -> Result<()> {
+        let _home = DataHome::new();
+        let path = transcript("m").create()?;
+
+        let file_mode = fs::metadata(&path)?.permissions().mode() & 0o777;
+        let dir_mode = fs::metadata(dir()?)?.permissions().mode() & 0o777;
+
+        assert_eq!(file_mode, 0o600, "transcripts should not be world readable");
+        assert_eq!(dir_mode, 0o700, "transcript directory should be private");
+        Ok(())
+    }
+
+    #[test]
+    fn create_produces_unique_paths() -> Result<()> {
+        let _home = DataHome::new();
+        let first = transcript("m").create()?;
+        let second = transcript("m").create()?;
+
+        assert_ne!(first, second);
+        Ok(())
+    }
+
+    #[test]
+    fn newest_picks_most_recent() -> Result<()> {
+        let _home = DataHome::new();
+        let old = transcript("old").create()?;
+        let new = transcript("new").create()?;
+
+        let past = SystemTime::now() - Duration::from_secs(60);
+        fs::File::open(&old)?.set_modified(past)?;
+
+        let (path, found) = Transcript::newest()?;
+
+        assert_eq!(path, new);
+        assert_eq!(found.model, "new");
+        Ok(())
+    }
+
+    #[test]
+    fn newest_skips_unreadable_transcripts() -> Result<()> {
+        let _home = DataHome::new();
+        let good = transcript("good").create()?;
+        fs::write(dir()?.join("corrupt.json"), b"not json")?;
+
+        let past = SystemTime::now() - Duration::from_secs(60);
+        fs::File::open(&good)?.set_modified(past)?;
+
+        let (path, found) = Transcript::newest()?;
+
+        assert_eq!(path, good);
+        assert_eq!(found.model, "good");
+        Ok(())
+    }
+
+    #[test]
+    fn newest_without_conversations_errors() {
+        let _home = DataHome::new();
+
+        let err = Transcript::newest().expect_err("should have no conversations");
+
+        assert!(
+            matches!(err, Error::NoConversations),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn newest_reports_parse_error_when_nothing_is_usable() -> Result<()> {
+        let _home = DataHome::new();
+        fs::create_dir_all(dir()?)?;
+        fs::write(dir()?.join("corrupt.json"), b"not json")?;
+
+        let err = Transcript::newest().expect_err("should fail");
+
+        assert!(
+            matches!(err, Error::InvalidTranscript { .. }),
+            "unexpected error: {err:?}"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn request_messages_puts_system_first() {
+        let transcript = Transcript {
+            version: VERSION,
+            model: "m".to_string(),
+            effort: None,
+            system: Some("s".to_string()),
+            messages: vec![
+                Message::user("first"),
+                Message::assistant("answer"),
+                Message::user("next"),
+            ],
+        };
+
+        assert_eq!(
+            transcript.request_messages(),
+            vec![
+                Message::system("s"),
+                Message::user("first"),
+                Message::assistant("answer"),
+                Message::user("next"),
+            ]
+        );
+    }
+
+    #[test]
+    fn incomplete_conversations_are_rejected() -> Result<()> {
+        let dir = tempfile::tempdir()?;
+        let path = dir.path().join("bad.json");
+
+        let odd = Transcript {
+            messages: vec![Message::user("x")],
+            ..transcript("m")
+        };
+        assert!(odd.save(&path).is_err(), "odd message count is invalid");
+        assert!(!path.exists(), "invalid save must not leave a file");
+
+        let out_of_order = Transcript {
+            messages: vec![Message::assistant("x"), Message::user("y")],
+            ..transcript("m")
+        };
+        assert!(
+            out_of_order.save(&path).is_err(),
+            "assistant first is invalid"
+        );
+
+        fs::write(&path, br#"{"model":"m","messages":[]}"#)?;
+        assert!(
+            Transcript::load(&path).is_err(),
+            "empty conversation is invalid"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn last_reply_returns_final_assistant_message() {
+        assert_eq!(transcript("m").last_reply(), Some("reply"));
+    }
+}
blob - /dev/null
blob + a8f714a2f7dfc74599cef9235561839588ae51b1 (mode 644)
--- /dev/null
+++ src/usage/anthropic.rs
@@ -0,0 +1,281 @@
+//! Claude Code usage
+//!
+//! The endpoint serves the Claude Code CLI. It is not a documented public API,
+//! so it may change without notice.
+
+use std::fmt;
+
+use serde::Deserialize;
+
+use super::{View, Window, timestamp};
+use crate::Error;
+
+/// One quota window, with utilization reported in percent
+#[derive(Debug, Deserialize)]
+struct Utilization {
+    #[serde(default)]
+    utilization: f64,
+    #[serde(default)]
+    resets_at: Option<String>,
+}
+
+/// One entry of the flat limits list
+#[derive(Debug, Deserialize)]
+struct Limit {
+    #[serde(default)]
+    kind: String,
+    #[serde(default)]
+    percent: f64,
+    #[serde(default)]
+    resets_at: Option<String>,
+}
+
+/// An amount in minor units, as reported in the spend block
+#[derive(Debug, Default, Deserialize)]
+struct Money {
+    #[serde(rename = "amount_minor", default)]
+    minor: i64,
+    #[serde(rename = "currency", default)]
+    currency: String,
+    #[serde(rename = "exponent", default)]
+    exponent: u32,
+}
+
+impl fmt::Display for Money {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let minor = i128::from(self.minor);
+        let Some(scale) = 10_i128.checked_pow(self.exponent) else {
+            return write!(f, "{}e-{} {}", self.minor, self.exponent, self.currency);
+        };
+        let whole = minor / scale;
+        let fraction = minor.abs() % scale;
+        if self.exponent == 0 {
+            write!(f, "{whole} {}", self.currency)
+        } else {
+            let digits = usize::try_from(self.exponent).map_err(|_| fmt::Error)?;
+            if minor < 0 && whole == 0 {
+                write!(f, "-0.{fraction:0digits$} {}", self.currency)
+            } else {
+                write!(f, "{whole}.{fraction:0digits$} {}", self.currency)
+            }
+        }
+    }
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Spend {
+    #[serde(default)]
+    used: Money,
+    #[serde(default)]
+    limit: Money,
+    #[serde(default)]
+    enabled: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct Usage {
+    #[serde(default)]
+    five_hour: Option<Utilization>,
+    #[serde(default)]
+    seven_day: Option<Utilization>,
+    #[serde(default)]
+    limits: Vec<Limit>,
+    #[serde(default)]
+    spend: Spend,
+}
+
+/// The OAuth token Claude Code writes when you log in
+///
+/// # Errors
+///
+/// Returns an error if the credential file exists but cannot be parsed.
+pub fn token_from_file() -> Result<Option<String>, Error> {
+    #[derive(Deserialize)]
+    struct Credentials {
+        #[serde(rename = "claudeAiOauth")]
+        oauth: OAuth,
+    }
+
+    #[derive(Deserialize)]
+    struct OAuth {
+        #[serde(rename = "accessToken")]
+        access_token: String,
+    }
+
+    let path = crate::home_dir()?.join(".claude").join(".credentials.json");
+    let data = match std::fs::read(path) {
+        Ok(data) => data,
+        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+        Err(err) => return Err(err.into()),
+    };
+
+    let credentials: Credentials = serde_json::from_slice(&data)?;
+    Ok(Some(credentials.oauth.access_token).filter(|token| !token.is_empty()))
+}
+
+/// Normalise the Claude Code usage response
+///
+/// The flat `limits` list is preferred, since it names whichever windows the
+/// account has. Accounts without it fall back to the fixed five hour and seven
+/// day fields.
+///
+/// # Errors
+///
+/// Returns an error if the body is not a Claude usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+    let value: serde_json::Value =
+        serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+    if !["five_hour", "seven_day", "limits", "spend"]
+        .iter()
+        .any(|key| value.get(key).is_some())
+    {
+        return Err(Error::UnexpectedResponse(
+            "response contains no anthropic usage fields".to_string(),
+        ));
+    }
+    let usage: Usage =
+        serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+
+    let mut windows: Vec<Window> = usage
+        .limits
+        .iter()
+        .map(|limit| {
+            Window::new(
+                limit.kind.clone(),
+                limit.percent / 100.0,
+                timestamp(limit.resets_at.as_deref()),
+            )
+        })
+        .collect();
+
+    if windows.is_empty() {
+        let fallback = [
+            ("session", usage.five_hour.as_ref()),
+            ("weekly", usage.seven_day.as_ref()),
+        ];
+        windows = fallback
+            .into_iter()
+            .filter_map(|(name, window)| {
+                window.map(|w| {
+                    Window::new(
+                        name,
+                        w.utilization / 100.0,
+                        timestamp(w.resets_at.as_deref()),
+                    )
+                })
+            })
+            .collect();
+    }
+
+    Ok(View {
+        title: "anthropic usage".to_string(),
+        subtitle: None,
+        note: usage
+            .spend
+            .enabled
+            .then(|| format!("extra {} of {}", usage.spend.used, usage.spend.limit)),
+        windows,
+        tables: vec![],
+    })
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use anyhow::Result;
+
+    #[test]
+    fn prefers_the_flat_limits_list() -> Result<()> {
+        let body = br#"{
+            "five_hour": {"utilization": 10, "resets_at": "2030-01-01T00:00:00Z"},
+            "limits": [
+                {"kind": "session", "percent": 42.5, "resets_at": "2030-01-01T00:00:00Z"},
+                {"kind": "opus weekly", "percent": 90}
+            ],
+            "spend": {"enabled": true,
+                      "used": {"amount_minor": 1234, "currency": "USD", "exponent": 2},
+                      "limit": {"amount_minor": 5000, "currency": "USD", "exponent": 2}}
+        }"#;
+
+        let view = view(body)?;
+
+        assert_eq!(view.title, "anthropic usage");
+        assert_eq!(view.windows.len(), 2);
+        assert_eq!(view.windows[0].name, "session");
+        assert!((view.windows[0].used - 0.425).abs() < f64::EPSILON);
+        assert!(view.windows[0].resets_in_secs.is_some());
+        assert_eq!(
+            view.windows[1].resets_in_secs, None,
+            "a missing reset time is not rendered"
+        );
+        assert_eq!(view.note.as_deref(), Some("extra 12.34 USD of 50.00 USD"));
+        Ok(())
+    }
+
+    #[test]
+    fn falls_back_to_fixed_windows() -> Result<()> {
+        let body = br#"{
+            "five_hour": {"utilization": 20, "resets_at": "2030-01-01T00:00:00Z"},
+            "seven_day": {"utilization": 60}
+        }"#;
+
+        let view = view(body)?;
+
+        assert_eq!(view.windows.len(), 2);
+        assert_eq!(view.windows[0].name, "session");
+        assert_eq!(view.windows[1].name, "weekly");
+        assert!((view.windows[1].used - 0.6).abs() < f64::EPSILON);
+        Ok(())
+    }
+
+    #[test]
+    fn omits_spend_when_disabled() -> Result<()> {
+        let body = br#"{"seven_day": {"utilization": 1}, "spend": {"enabled": false}}"#;
+
+        let view = view(body)?;
+
+        assert_eq!(view.note, None);
+        assert_eq!(view.windows.len(), 1);
+        Ok(())
+    }
+
+    #[test]
+    fn rejects_non_usage_bodies() {
+        assert!(view(b"not json").is_err());
+        assert!(view(b"{}").is_err());
+    }
+
+    #[test]
+    fn money_renders_minor_units() {
+        let money = Money {
+            minor: 1234,
+            currency: "USD".to_string(),
+            exponent: 2,
+        };
+        assert_eq!(money.to_string(), "12.34 USD");
+
+        let whole = Money {
+            minor: 7,
+            currency: "JPY".to_string(),
+            exponent: 0,
+        };
+        assert_eq!(whole.to_string(), "7 JPY");
+    }
+
+    #[test]
+    fn money_preserves_negative_fraction_and_large_exponent() {
+        let negative = Money {
+            minor: -5,
+            currency: "USD".to_string(),
+            exponent: 2,
+        };
+        assert_eq!(negative.to_string(), "-0.05 USD");
+
+        let large = Money {
+            minor: 1,
+            currency: "USD".to_string(),
+            exponent: u32::MAX,
+        };
+        assert_eq!(large.to_string(), format!("1e-{} USD", u32::MAX));
+    }
+}
blob - /dev/null
blob + 858707aa6886d8401f46bafc36d226f20f923c4f (mode 644)
--- /dev/null
+++ src/usage/mod.rs
@@ -0,0 +1,345 @@
+//! Provider quota usage
+//!
+//! Each provider exposes a different usage endpoint with a different response
+//! shape. They are normalised into a [`View`] so the renderer only has to know
+//! about quota windows and per-model request counts.
+
+pub mod anthropic;
+pub mod ollama;
+pub mod openai;
+pub mod render;
+
+use std::fmt;
+use std::time::Duration;
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::Error;
+
+/// Largest response body read from a usage endpoint
+const MAX_BODY: usize = 1 << 20;
+
+/// A usage provider
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Provider {
+    #[default]
+    Ollama,
+    Anthropic,
+    OpenAI,
+}
+
+/// Provider usage, normalised for rendering
+#[derive(Debug, Default, Clone, PartialEq, Serialize)]
+pub struct View {
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub subtitle: Option<String>,
+    /// Trailing note, typically extra spend
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub note: Option<String>,
+    pub windows: Vec<Window>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub tables: Vec<Table>,
+}
+
+/// One quota window
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Window {
+    pub name: String,
+    /// Used share of the window, where 1.0 is spent
+    pub used: f64,
+    /// Seconds until the window resets, when the provider reports a reset
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub resets_in_secs: Option<u64>,
+}
+
+/// One table of per-model request counts
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Table {
+    pub heading: String,
+    pub rows: Vec<ModelCount>,
+}
+
+/// Requests made against one model
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ModelCount {
+    pub name: String,
+    #[serde(rename = "request_count")]
+    pub requests: u64,
+}
+
+impl Window {
+    /// A window that resets at `resets_at`, if the provider reports one
+    #[must_use]
+    pub fn new(name: impl Into<String>, used: f64, resets_at: Option<DateTime<Utc>>) -> Self {
+        Self {
+            name: name.into(),
+            used,
+            resets_in_secs: resets_at.map(|at| remaining_secs(at, Utc::now())),
+        }
+    }
+}
+
+/// Whole seconds between `now` and `at`, clamped at zero for past instants
+fn remaining_secs(at: DateTime<Utc>, now: DateTime<Utc>) -> u64 {
+    u64::try_from((at - now).num_seconds()).unwrap_or(0)
+}
+
+impl Provider {
+    /// Default base URL, honouring any environment override
+    #[must_use]
+    pub fn base_url(self) -> String {
+        match self {
+            Self::Ollama => std::env::var("OLLAMA_API_ENDPOINT")
+                .ok()
+                .filter(|url| !url.is_empty())
+                .unwrap_or_else(|| "https://ollama.com".to_string()),
+            Self::Anthropic => "https://api.anthropic.com".to_string(),
+            Self::OpenAI => "https://chatgpt.com".to_string(),
+        }
+    }
+
+    /// Path of the usage endpoint
+    #[must_use]
+    pub fn path(self) -> &'static str {
+        match self {
+            Self::Ollama => "/api/usage",
+            Self::Anthropic => "/api/oauth/usage",
+            Self::OpenAI => "/backend-api/codex/usage",
+        }
+    }
+
+    /// Whether the credential is sent as a bearer token
+    #[must_use]
+    pub fn bearer(self) -> bool {
+        matches!(self, Self::Anthropic | Self::OpenAI)
+    }
+
+    /// The credential this provider uses when none is given on the command line
+    ///
+    /// # Errors
+    ///
+    /// Returns [`Error::NoCredentials`] when no environment variable is set and
+    /// the vendor CLI's credential file is missing or malformed.
+    pub fn credential(self) -> Result<String, Error> {
+        let from_env = |name: &str| std::env::var(name).ok().filter(|key| !key.is_empty());
+
+        let key = match self {
+            Self::Ollama => from_env("OLLAMA_API_KEY"),
+            Self::Anthropic => from_env("CLAUDE_CODE_OAUTH_TOKEN")
+                .or_else(|| anthropic::token_from_file().ok().flatten()),
+            Self::OpenAI => {
+                from_env("CODEX_ACCESS_TOKEN").or_else(|| openai::token_from_file().ok().flatten())
+            }
+        };
+
+        key.ok_or(Error::NoCredentials { provider: self })
+    }
+
+    /// Normalise a usage response body
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the body is not the JSON this provider reports.
+    pub fn view(self, body: &[u8]) -> Result<View, Error> {
+        match self {
+            Self::Ollama => ollama::view(body),
+            Self::Anthropic => anthropic::view(body),
+            Self::OpenAI => openai::view(body),
+        }
+    }
+
+    /// Build the HTTP client this provider needs
+    ///
+    /// The Codex endpoint sits behind a bot filter that answers Go-style and
+    /// modern Rust clients with a challenge page instead of JSON: it rejects
+    /// HTTP/2 and TLS 1.3 handshakes. That client therefore speaks HTTP/1.1
+    /// over TLS 1.2. No compression feature is enabled for reqwest, so no
+    /// `Accept-Encoding` header is sent either, which the same filter requires.
+    fn http_client(self) -> Result<reqwest::Client, Error> {
+        let builder = reqwest::Client::builder().user_agent(user_agent());
+        let builder = match self {
+            Self::OpenAI => builder
+                .http1_only()
+                .tls_version_max(reqwest::tls::Version::TLS_1_2),
+            _ => builder,
+        };
+        Ok(builder.build()?)
+    }
+}
+
+impl fmt::Display for Provider {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let name = match self {
+            Self::Ollama => "ollama",
+            Self::Anthropic => "anthropic",
+            Self::OpenAI => "openai",
+        };
+        f.write_str(name)
+    }
+}
+
+fn user_agent() -> String {
+    format!("alpaca/{}", env!("CARGO_PKG_VERSION"))
+}
+
+/// Client for provider usage endpoints
+pub struct Client {
+    http: reqwest::Client,
+    provider: Provider,
+    base_url: String,
+    api_key: String,
+    timeout: Duration,
+}
+
+impl Client {
+    /// Build a client for `provider`, resolving the credential and base URL
+    /// from the provider defaults when they are not given
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if no credential can be found or the HTTP client
+    /// cannot be built.
+    pub fn new(
+        provider: Provider,
+        api_key: Option<String>,
+        base_url: Option<String>,
+        timeout: Duration,
+    ) -> Result<Self, Error> {
+        let api_key = match api_key {
+            Some(key) => key,
+            None => provider.credential()?,
+        };
+
+        Ok(Self {
+            http: provider.http_client()?,
+            provider,
+            base_url: base_url.unwrap_or_else(|| provider.base_url()),
+            api_key,
+            timeout,
+        })
+    }
+
+    /// Fetch the raw usage response body
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the request fails or the endpoint answers with a
+    /// non-success status.
+    pub async fn fetch(&self) -> Result<Vec<u8>, Error> {
+        let credential = if self.provider.bearer() {
+            format!("Bearer {}", self.api_key)
+        } else {
+            self.api_key.clone()
+        };
+
+        let resp = self
+            .http
+            .get(self.endpoint())
+            .timeout(self.timeout)
+            .header("Authorization", credential)
+            .header("Accept", "application/json")
+            .send()
+            .await?;
+
+        let status = resp.status();
+        let body = read_body(resp).await?;
+
+        if status.is_success() {
+            Ok(body)
+        } else {
+            Err(Error::HttpStatus {
+                status: status.as_u16(),
+                message: String::from_utf8_lossy(&body).trim().to_string(),
+            })
+        }
+    }
+
+    fn endpoint(&self) -> String {
+        format!(
+            "{}{}",
+            self.base_url.trim_end_matches('/'),
+            self.provider.path()
+        )
+    }
+}
+
+/// Read at most [`MAX_BODY`] bytes of a response body
+async fn read_body(mut resp: reqwest::Response) -> Result<Vec<u8>, Error> {
+    let mut body = Vec::new();
+    while let Some(chunk) = resp.chunk().await? {
+        let room = MAX_BODY - body.len();
+        if chunk.len() > room {
+            return Err(Error::ResponseTooLarge { limit: MAX_BODY });
+        }
+        if chunk.len() == room {
+            body.extend_from_slice(&chunk);
+            if resp.chunk().await?.is_some() {
+                return Err(Error::ResponseTooLarge { limit: MAX_BODY });
+            }
+            return Ok(body);
+        }
+        body.extend_from_slice(&chunk);
+    }
+    Ok(body)
+}
+
+/// Parse an RFC 3339 timestamp, ignoring absent or malformed values
+fn timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
+    let value = value?;
+    DateTime::parse_from_rfc3339(value)
+        .ok()
+        .map(|at| at.with_timezone(&Utc))
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn endpoints_join_cleanly() -> Result<(), Error> {
+        let client = Client::new(
+            Provider::Ollama,
+            Some("key".to_string()),
+            Some("http://example.com/".to_string()),
+            Duration::from_secs(1),
+        )?;
+
+        assert_eq!(client.endpoint(), "http://example.com/api/usage");
+        Ok(())
+    }
+
+    #[test]
+    fn provider_display_matches_flag_values() {
+        assert_eq!(Provider::Ollama.to_string(), "ollama");
+        assert_eq!(Provider::Anthropic.to_string(), "anthropic");
+        assert_eq!(Provider::OpenAI.to_string(), "openai");
+    }
+
+    #[test]
+    fn only_oauth_providers_use_bearer() {
+        assert!(!Provider::Ollama.bearer());
+        assert!(Provider::Anthropic.bearer());
+        assert!(Provider::OpenAI.bearer());
+    }
+
+    #[test]
+    fn remaining_is_clamped_at_zero() {
+        let now = Utc::now();
+        assert_eq!(remaining_secs(now - chrono::TimeDelta::hours(1), now), 0);
+        assert_eq!(
+            remaining_secs(now + chrono::TimeDelta::minutes(90), now),
+            5400
+        );
+    }
+
+    #[test]
+    fn timestamps_tolerate_missing_and_malformed_values() {
+        assert!(timestamp(None).is_none());
+        assert!(timestamp(Some("")).is_none());
+        assert!(timestamp(Some("not a time")).is_none());
+        assert!(timestamp(Some("2025-01-01T00:00:00Z")).is_some());
+    }
+}
blob - /dev/null
blob + f430e5638ccc6d2517cdc38eed5d1a76ca3d56f2 (mode 644)
--- /dev/null
+++ src/usage/ollama.rs
@@ -0,0 +1,247 @@
+//! Ollama Cloud usage
+
+use chrono::{DateTime, TimeDelta, Utc};
+use serde::Deserialize;
+
+use super::{ModelCount, Table, View, Window};
+use crate::Error;
+
+/// Length of the session window
+const SESSION: TimeDelta = TimeDelta::hours(5);
+/// Length of the weekly window
+const WEEKLY: TimeDelta = TimeDelta::days(7);
+/// Offset of the weekly boundary from the Unix epoch, so all accounts share it
+const WEEKLY_OFFSET: TimeDelta = TimeDelta::days(4);
+
+#[derive(Debug, Default, Deserialize)]
+struct Limit {
+    #[serde(default)]
+    usage: f64,
+    #[serde(default)]
+    models: Vec<ModelCount>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Period {
+    #[serde(rename = "type", default)]
+    kind: String,
+    #[serde(rename = "starting_at", default)]
+    from: String,
+    #[serde(rename = "ending_at", default)]
+    to: String,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Activity {
+    #[serde(default)]
+    cost: String,
+    #[serde(default)]
+    period: Period,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Limits {
+    #[serde(default)]
+    session: Limit,
+    #[serde(default)]
+    weekly: Limit,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Usage {
+    #[serde(default)]
+    activity: Activity,
+    #[serde(default)]
+    limits: Limits,
+}
+
+/// Normalise the Ollama Cloud usage response
+///
+/// Ollama reports no reset time, so the windows are computed locally: session
+/// resets align to UTC multiples of 5h from the epoch, weekly resets are offset
+/// by 4 days so all accounts share the same boundary. Matches the formula in
+/// ollama/ollama issue #12532.
+///
+/// # Errors
+///
+/// Returns an error if the body is not an Ollama usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+    let value: serde_json::Value =
+        serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+    if !["activity", "limits"]
+        .iter()
+        .any(|key| value.get(key).is_some())
+    {
+        return Err(Error::UnexpectedResponse(
+            "response contains no ollama usage fields".to_string(),
+        ));
+    }
+    let usage: Usage =
+        serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+    Ok(build(&usage, Utc::now()))
+}
+
+fn build(usage: &Usage, now: DateTime<Utc>) -> View {
+    let period = &usage.activity.period;
+
+    View {
+        title: "ollama cloud usage".to_string(),
+        subtitle: Some(format!(
+            "{} . {} to {}",
+            period.kind, period.from, period.to
+        )),
+        note: (!usage.activity.cost.is_empty())
+            .then(|| format!("extra {} USD", usage.activity.cost)),
+        windows: vec![
+            Window::new(
+                "session",
+                usage.limits.session.usage,
+                Some(next_boundary(now, SESSION, TimeDelta::zero())),
+            ),
+            Window::new(
+                "weekly",
+                usage.limits.weekly.usage,
+                Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)),
+            ),
+        ],
+        tables: vec![
+            Table {
+                heading: "session models".to_string(),
+                rows: sorted(&usage.limits.session.models),
+            },
+            Table {
+                heading: "weekly models".to_string(),
+                rows: sorted(&usage.limits.weekly.models),
+            },
+        ],
+    }
+}
+
+/// The next boundary of a window of length `period`, whose boundaries are
+/// offset by `offset` from the Unix epoch
+fn next_boundary(now: DateTime<Utc>, period: TimeDelta, offset: TimeDelta) -> DateTime<Utc> {
+    let period_secs = period.num_seconds();
+    let since_epoch = now.timestamp() - offset.num_seconds();
+    let elapsed = since_epoch.rem_euclid(period_secs);
+    now + TimeDelta::seconds(period_secs - elapsed)
+}
+
+/// Model counts, busiest first
+fn sorted(models: &[ModelCount]) -> Vec<ModelCount> {
+    let mut sorted = models.to_vec();
+    sorted.sort_by_key(|model| std::cmp::Reverse(model.requests));
+    sorted
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use anyhow::Result;
+
+    const BODY: &[u8] = br#"{
+        "activity": {
+            "cost": "1.25",
+            "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"}
+        },
+        "limits": {
+            "session": {"usage": 0.5, "models": [
+                {"name": "small", "request_count": 2},
+                {"name": "big", "request_count": 9}
+            ]},
+            "weekly": {"usage": 0.25, "models": []}
+        }
+    }"#;
+
+    #[test]
+    fn reads_windows_models_and_cost() -> Result<()> {
+        let view = view(BODY)?;
+
+        assert_eq!(view.title, "ollama cloud usage");
+        assert_eq!(
+            view.subtitle.as_deref(),
+            Some("monthly . 2025-10-01 to 2025-10-31")
+        );
+        assert_eq!(view.note.as_deref(), Some("extra 1.25 USD"));
+        assert_eq!(view.windows.len(), 2);
+        assert_eq!(view.windows[0].name, "session");
+        assert!((view.windows[0].used - 0.5).abs() < f64::EPSILON);
+        assert_eq!(view.windows[1].name, "weekly");
+        assert_eq!(
+            view.tables[0].rows,
+            vec![
+                ModelCount {
+                    name: "big".to_string(),
+                    requests: 9
+                },
+                ModelCount {
+                    name: "small".to_string(),
+                    requests: 2
+                }
+            ],
+            "models are sorted busiest first"
+        );
+        assert!(view.tables[1].rows.is_empty());
+        Ok(())
+    }
+
+    #[test]
+    fn omits_note_without_cost() -> Result<()> {
+        let view = view(br#"{"limits": {"session": {"usage": 0}, "weekly": {"usage": 0}}}"#)?;
+
+        assert_eq!(view.note, None);
+        Ok(())
+    }
+
+    #[test]
+    fn rejects_non_usage_bodies() {
+        assert!(view(b"not json").is_err());
+        assert!(view(b"{}").is_err());
+    }
+
+    #[test]
+    fn session_boundaries_are_epoch_aligned() {
+        let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z")
+            .unwrap()
+            .with_timezone(&Utc);
+
+        let next = next_boundary(now, SESSION, TimeDelta::zero());
+
+        assert!(next > now, "boundary is in the future");
+        assert!(next - now <= SESSION, "boundary is within one window");
+        assert_eq!(
+            next.timestamp() % SESSION.num_seconds(),
+            0,
+            "session boundaries are multiples of 5h from the epoch"
+        );
+    }
+
+    #[test]
+    fn weekly_boundaries_use_the_four_day_offset() {
+        let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z")
+            .unwrap()
+            .with_timezone(&Utc);
+
+        let next = next_boundary(now, WEEKLY, WEEKLY_OFFSET);
+
+        assert!(next > now);
+        assert!(next - now <= WEEKLY);
+        assert_eq!(
+            (next.timestamp() - WEEKLY_OFFSET.num_seconds()) % WEEKLY.num_seconds(),
+            0
+        );
+    }
+
+    #[test]
+    fn boundaries_never_land_in_the_past_before_the_epoch() {
+        let before_epoch = DateTime::parse_from_rfc3339("1969-01-01T00:00:00Z")
+            .unwrap()
+            .with_timezone(&Utc);
+
+        let next = next_boundary(before_epoch, SESSION, TimeDelta::zero());
+
+        assert!(
+            next > before_epoch,
+            "negative timestamps still move forward"
+        );
+    }
+}
blob - /dev/null
blob + af79856fd6db58534020742a9dcedcf3f79bdd04 (mode 644)
--- /dev/null
+++ src/usage/openai.rs
@@ -0,0 +1,203 @@
+//! Codex CLI usage
+//!
+//! The endpoint serves the Codex CLI. It is not a documented public API, so it
+//! may change without notice.
+
+use chrono::{DateTime, Utc};
+use serde::Deserialize;
+
+use super::{View, Window};
+use crate::Error;
+
+/// Seconds in the session window Codex reports
+const SESSION_SECS: i64 = 5 * 3600;
+/// Seconds in the weekly window Codex reports
+const WEEKLY_SECS: i64 = 7 * 86400;
+
+/// One rate limit window, with the used share in percent
+#[derive(Debug, Deserialize)]
+struct RateWindow {
+    #[serde(default)]
+    used_percent: f64,
+    #[serde(default)]
+    limit_window_seconds: i64,
+    #[serde(default)]
+    reset_at: i64,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct RateLimit {
+    #[serde(default)]
+    primary_window: Option<RateWindow>,
+    #[serde(default)]
+    secondary_window: Option<RateWindow>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Credits {
+    #[serde(default)]
+    balance: String,
+    #[serde(default)]
+    unlimited: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct Usage {
+    #[serde(rename = "plan_type", default)]
+    plan: String,
+    #[serde(default)]
+    rate_limit: RateLimit,
+    #[serde(default)]
+    credits: Credits,
+}
+
+/// The OAuth token the Codex CLI writes when you log in
+///
+/// # Errors
+///
+/// Returns an error if the credential file exists but cannot be parsed.
+pub fn token_from_file() -> Result<Option<String>, Error> {
+    #[derive(Deserialize)]
+    struct Auth {
+        tokens: Tokens,
+    }
+
+    #[derive(Deserialize)]
+    struct Tokens {
+        access_token: String,
+    }
+
+    let path = crate::home_dir()?.join(".codex").join("auth.json");
+    let data = match std::fs::read(path) {
+        Ok(data) => data,
+        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+        Err(err) => return Err(err.into()),
+    };
+
+    let auth: Auth = serde_json::from_slice(&data)?;
+    Ok(Some(auth.tokens.access_token).filter(|token| !token.is_empty()))
+}
+
+/// Normalise the Codex usage response
+///
+/// # Errors
+///
+/// Returns an error if the body is not a Codex usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+    let value: serde_json::Value =
+        serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+    if !["plan_type", "rate_limit", "credits"]
+        .iter()
+        .any(|key| value.get(key).is_some())
+    {
+        return Err(Error::UnexpectedResponse(
+            "response contains no openai usage fields".to_string(),
+        ));
+    }
+    let usage: Usage =
+        serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+
+    let windows = [
+        usage.rate_limit.primary_window.as_ref(),
+        usage.rate_limit.secondary_window.as_ref(),
+    ]
+    .into_iter()
+    .flatten()
+    .map(|window| {
+        Window::new(
+            window_name(window.limit_window_seconds),
+            window.used_percent / 100.0,
+            DateTime::<Utc>::from_timestamp(window.reset_at, 0),
+        )
+    })
+    .collect();
+
+    let note = if usage.credits.unlimited {
+        Some("credits unlimited".to_string())
+    } else if usage.credits.balance.is_empty() {
+        None
+    } else {
+        Some(format!("credits {}", usage.credits.balance))
+    };
+
+    Ok(View {
+        title: "codex usage".to_string(),
+        subtitle: Some(usage.plan).filter(|plan| !plan.is_empty()),
+        note,
+        windows,
+        tables: vec![],
+    })
+}
+
+/// Name a quota window of `secs` seconds
+fn window_name(secs: i64) -> String {
+    match secs {
+        SESSION_SECS => "session".to_string(),
+        WEEKLY_SECS => "weekly".to_string(),
+        secs if secs >= 86400 => format!("{}d", secs / 86400),
+        secs => format!("{}h", secs / 3600),
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use anyhow::Result;
+
+    #[test]
+    fn reads_windows_and_credits() -> Result<()> {
+        let body = br#"{
+            "plan_type": "pro",
+            "rate_limit": {
+                "primary_window": {"used_percent": 12.5, "limit_window_seconds": 18000, "reset_at": 4102444800},
+                "secondary_window": {"used_percent": 80, "limit_window_seconds": 604800, "reset_at": 4102444800}
+            },
+            "credits": {"balance": "3.50"}
+        }"#;
+
+        let view = view(body)?;
+
+        assert_eq!(view.title, "codex usage");
+        assert_eq!(view.subtitle.as_deref(), Some("pro"));
+        assert_eq!(view.windows.len(), 2);
+        assert_eq!(view.windows[0].name, "session");
+        assert!((view.windows[0].used - 0.125).abs() < f64::EPSILON);
+        assert_eq!(view.windows[1].name, "weekly");
+        assert_eq!(view.note.as_deref(), Some("credits 3.50"));
+        Ok(())
+    }
+
+    #[test]
+    fn unlimited_credits_win_over_a_balance() -> Result<()> {
+        let body = br#"{"credits": {"balance": "3.50", "unlimited": true}}"#;
+
+        let view = view(body)?;
+
+        assert_eq!(view.note.as_deref(), Some("credits unlimited"));
+        assert!(view.windows.is_empty());
+        assert_eq!(view.subtitle, None, "an empty plan is not rendered");
+        Ok(())
+    }
+
+    #[test]
+    fn omits_note_without_credits() -> Result<()> {
+        let view = view(br#"{"plan_type": "plus"}"#)?;
+
+        assert_eq!(view.note, None);
+        Ok(())
+    }
+
+    #[test]
+    fn rejects_non_usage_bodies() {
+        assert!(view(b"not json").is_err());
+        assert!(view(b"{}").is_err());
+    }
+
+    #[test]
+    fn window_names_cover_known_and_unknown_lengths() {
+        assert_eq!(window_name(18000), "session");
+        assert_eq!(window_name(604800), "weekly");
+        assert_eq!(window_name(2 * 86400), "2d");
+        assert_eq!(window_name(3600), "1h");
+    }
+}
blob - /dev/null
blob + 22ae73796ea44d2c93c71930324702ae2e3b28ee (mode 644)
--- /dev/null
+++ src/usage/render.rs
@@ -0,0 +1,323 @@
+//! Rendering usage views for a terminal
+
+use std::io::Write;
+
+use super::{Table, View, Window};
+use crate::Error;
+use crate::cli::ColorChoice;
+
+/// Width of a quota bar, in cells
+const BAR_WIDTH: usize = 40;
+/// Width of a model name column, in cells
+const NAME_WIDTH: usize = 20;
+/// Width of a request count column, in cells
+const COUNT_WIDTH: usize = 6;
+
+const RESET: &str = "\x1b[0m";
+const DIM: &str = "\x1b[2m";
+const BOLD: &str = "\x1b[1m";
+const RED_BOLD: &str = "\x1b[31m\x1b[1m";
+const YELLOW_BOLD: &str = "\x1b[33m\x1b[1m";
+const GREEN: &str = "\x1b[32m";
+const CYAN: &str = "\x1b[36m";
+const WHITE: &str = "\x1b[38;5;255m";
+const BG_LIGHT: &str = "\x1b[48;5;237m";
+const BG_DARK: &str = "\x1b[48;5;235m";
+
+/// Whether to emit ANSI escapes
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Style {
+    color: bool,
+}
+
+impl Style {
+    /// Resolve the colour choice against the terminal and `NO_COLOR`
+    #[must_use]
+    pub fn new(choice: ColorChoice, is_terminal: bool) -> Self {
+        let color = match choice {
+            ColorChoice::Always => true,
+            ColorChoice::Never => false,
+            ColorChoice::Auto => is_terminal && std::env::var_os("NO_COLOR").is_none(),
+        };
+        Self { color }
+    }
+
+    /// Wrap `text` in escape codes, or return it unchanged without colour
+    fn paint(self, codes: &str, text: &str) -> String {
+        if self.color {
+            format!("{codes}{text}{RESET}")
+        } else {
+            text.to_string()
+        }
+    }
+}
+
+/// Write a rendered view
+///
+/// # Errors
+///
+/// Returns an error if writing to `dest` fails.
+pub fn render(mut dest: impl Write, view: &View, style: Style) -> Result<(), Error> {
+    let mut header = style.paint(&format!("{BOLD}{CYAN}"), &view.title);
+    for note in [view.subtitle.as_deref(), view.note.as_deref()]
+        .into_iter()
+        .flatten()
+    {
+        header.push_str("  ");
+        header.push_str(&style.paint(DIM, note));
+    }
+    writeln!(dest, "{header}")?;
+    writeln!(dest)?;
+
+    let label = view
+        .windows
+        .iter()
+        .map(|window| window.name.chars().count())
+        .max()
+        .unwrap_or(0);
+    for window in &view.windows {
+        // A blank line after each bar, matching the spacing of the tool this
+        // subcommand replaces.
+        writeln!(dest, "{}\n", window_row(window, label, style))?;
+    }
+
+    let height = view
+        .tables
+        .iter()
+        .map(|table| table.rows.len())
+        .max()
+        .unwrap_or(0);
+    if height == 0 {
+        return Ok(());
+    }
+
+    writeln!(dest)?;
+    let headings = view
+        .tables
+        .iter()
+        .map(|table| format!("{:<NAME_WIDTH$} {:>COUNT_WIDTH$}", table.heading, "reqs"))
+        .collect::<Vec<_>>()
+        .join("   ");
+    writeln!(
+        dest,
+        "  {}",
+        style.paint(&format!("{BOLD}{DIM}"), &headings)
+    )?;
+
+    for row in 0..height {
+        let cells = view
+            .tables
+            .iter()
+            .enumerate()
+            .map(|(column, table)| table_cell(table, row, column, style))
+            .collect::<Vec<_>>()
+            .join("   ");
+        writeln!(dest, "  {cells}")?;
+    }
+
+    Ok(())
+}
+
+/// One quota bar, showing the used share of a window
+fn window_row(window: &Window, label: usize, style: Style) -> String {
+    let color = used_color(window.used);
+    let name = style.paint(DIM, &format!("{:<label$}", window.name));
+    let bar = style.paint(color, &bar(window.used));
+    let percent = style.paint(color, &format!("{:>5.1}%", window.used * 100.0));
+    let note = match resets_in(window.resets_in_secs) {
+        Some(note) => style.paint(DIM, &note),
+        None => String::new(),
+    };
+
+    // Trailing whitespace is kept out of the line when there is no note.
+    format!("  {name}  {bar}  {percent}  {note}")
+        .trim_end()
+        .to_string()
+}
+
+/// One model table cell, with alternating column backgrounds
+fn table_cell(table: &Table, row: usize, column: usize, style: Style) -> String {
+    let (name, count) = table
+        .rows
+        .get(row)
+        .map_or((String::new(), String::new()), |r| {
+            (r.name.clone(), r.requests.to_string())
+        });
+
+    let background = if column.is_multiple_of(2) {
+        BG_LIGHT
+    } else {
+        BG_DARK
+    };
+    let name = style.paint(
+        &format!("{WHITE}{background}"),
+        &format!("{name:<NAME_WIDTH$}"),
+    );
+    let count = style.paint(BOLD, &format!("{count:>COUNT_WIDTH$}"));
+
+    format!("{name} {count}")
+}
+
+/// Colour a used share: green when light, red when nearly spent
+fn used_color(used: f64) -> &'static str {
+    if used >= 1.0 {
+        RED_BOLD
+    } else if used >= 0.9 {
+        YELLOW_BOLD
+    } else if used >= 0.5 {
+        CYAN
+    } else {
+        GREEN
+    }
+}
+
+fn bar(used: f64) -> String {
+    #[allow(
+        clippy::cast_possible_truncation,
+        clippy::cast_sign_loss,
+        clippy::cast_precision_loss
+    )]
+    // used is a share clamped to [0, 1], so the product always fits in BAR_WIDTH
+    let filled = (BAR_WIDTH as f64 * used.clamp(0.0, 1.0)) as usize;
+    let filled = filled.min(BAR_WIDTH);
+    "█".repeat(filled) + &"░".repeat(BAR_WIDTH - filled)
+}
+
+/// Time left before a window resets
+fn resets_in(secs: Option<u64>) -> Option<String> {
+    let secs = secs?;
+    let (hours, minutes) = (secs / 3600, (secs % 3600) / 60);
+    Some(if secs >= 86400 {
+        format!("resets in {}d {:02}h", secs / 86400, hours % 24)
+    } else {
+        format!("resets in {hours}h {minutes:02}m")
+    })
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use crate::usage::ModelCount;
+    use anyhow::Result;
+
+    fn view() -> View {
+        View {
+            title: "usage".to_string(),
+            subtitle: Some("plan".to_string()),
+            note: Some("credits 1".to_string()),
+            windows: vec![
+                Window {
+                    name: "session".to_string(),
+                    used: 0.5,
+                    resets_in_secs: Some(3660),
+                },
+                Window {
+                    name: "weekly".to_string(),
+                    used: 0.0,
+                    resets_in_secs: None,
+                },
+            ],
+            tables: vec![Table {
+                heading: "session models".to_string(),
+                rows: vec![ModelCount {
+                    name: "big".to_string(),
+                    requests: 9,
+                }],
+            }],
+        }
+    }
+
+    fn rendered(style: Style) -> Result<String> {
+        let mut out = vec![];
+        render(&mut out, &view(), style)?;
+        Ok(String::from_utf8(out)?)
+    }
+
+    #[test]
+    fn plain_output_has_no_escapes() -> Result<()> {
+        let out = rendered(Style::new(ColorChoice::Never, true))?;
+
+        assert!(!out.contains('\x1b'), "plain output should have no escapes");
+        assert!(out.contains("usage  plan  credits 1"));
+        assert!(out.contains("session"));
+        assert!(out.contains("50.0%"));
+        assert!(out.contains("resets in 1h 01m"));
+        assert!(out.contains("big"));
+        assert!(out.contains("reqs"));
+        Ok(())
+    }
+
+    #[test]
+    fn colour_output_has_escapes() -> Result<()> {
+        let out = rendered(Style::new(ColorChoice::Always, false))?;
+
+        assert!(out.contains('\x1b'), "always should colour a non-terminal");
+        Ok(())
+    }
+
+    #[test]
+    fn auto_is_plain_off_a_terminal() -> Result<()> {
+        let out = rendered(Style::new(ColorChoice::Auto, false))?;
+
+        assert!(!out.contains('\x1b'));
+        Ok(())
+    }
+
+    #[test]
+    fn window_names_are_padded_to_the_widest() -> Result<()> {
+        let out = rendered(Style::new(ColorChoice::Never, false))?;
+
+        assert!(
+            out.contains("  session  "),
+            "widest name sets the label width: {out}"
+        );
+        assert!(out.contains("  weekly   "), "shorter names are padded");
+        Ok(())
+    }
+
+    #[test]
+    fn bars_are_clamped() {
+        assert_eq!(bar(-1.0), "░".repeat(BAR_WIDTH));
+        assert_eq!(bar(0.0), "░".repeat(BAR_WIDTH));
+        assert_eq!(bar(2.0), "█".repeat(BAR_WIDTH));
+        assert_eq!(bar(0.5), "█".repeat(20) + &"░".repeat(20));
+    }
+
+    #[test]
+    fn colors_track_thresholds() {
+        assert_eq!(used_color(0.49), GREEN);
+        assert_eq!(used_color(0.5), CYAN);
+        assert_eq!(used_color(0.89), CYAN);
+        assert_eq!(used_color(0.9), YELLOW_BOLD);
+        assert_eq!(used_color(1.0), RED_BOLD);
+        assert_eq!(used_color(1.5), RED_BOLD);
+    }
+
+    #[test]
+    fn resets_switch_to_days() {
+        assert_eq!(resets_in(None), None);
+        assert_eq!(resets_in(Some(0)).as_deref(), Some("resets in 0h 00m"));
+        assert_eq!(resets_in(Some(3660)).as_deref(), Some("resets in 1h 01m"));
+        assert_eq!(
+            resets_in(Some(2 * 86400 + 3 * 3600)).as_deref(),
+            Some("resets in 2d 03h")
+        );
+    }
+
+    #[test]
+    fn tables_are_skipped_when_empty() -> Result<()> {
+        let mut out = vec![];
+        let view = View {
+            tables: vec![Table {
+                heading: "models".to_string(),
+                rows: vec![],
+            }],
+            ..view()
+        };
+        render(&mut out, &view, Style::new(ColorChoice::Never, false))?;
+        let out = String::from_utf8(out)?;
+
+        assert!(!out.contains("reqs"), "no table without rows: {out}");
+        Ok(())
+    }
+}
blob - 2b8ca1f8eb055057810e29221fac12fb38bc6237
blob + d6f294690066d520973b497d65fc2f1ee06a2399
--- tests/chat.rs
+++ tests/chat.rs
@@ -5,7 +5,7 @@ use assert_fs::prelude::*;
 use predicates::prelude::*;
 use serde_json::json;
 
-/// A canned successful Ollama `/api/chat` response body
+/// A canned successful native `/api/chat` response body
 fn ok_body() -> &'static str {
     r#"{
          "model": "gpt-oss:120b",
@@ -64,8 +64,8 @@ fn chat_user_message_from_flag() {
     let cmd = Command::cargo_bin("alpaca")
         .unwrap()
         .args(["-u", "Hello"])
-        .env("OLLAMA_API_ENDPOINT", server.url())
-        .env("OLLAMA_API_KEY", "ABCDE")
+        .env("API_ENDPOINT", server.url())
+        .env("API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -96,8 +96,8 @@ fn chat_user_message_from_stdin() {
     let cmd = Command::cargo_bin("alpaca")
         .unwrap()
         .write_stdin("Hello")
-        .env("OLLAMA_API_ENDPOINT", server.url())
-        .env("OLLAMA_API_KEY", "ABCDE")
+        .env("API_ENDPOINT", server.url())
+        .env("API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -128,8 +128,8 @@ fn chat_with_reasoning_effort() {
     let cmd = Command::cargo_bin("alpaca")
         .unwrap()
         .args(["-u", "Hello", "--reasoning-effort", "medium"])
-        .env("OLLAMA_API_ENDPOINT", server.url())
-        .env("OLLAMA_API_KEY", "ABCDE")
+        .env("API_ENDPOINT", server.url())
+        .env("API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -182,8 +182,8 @@ fn chat_multiple_messages() {
             "-s", "SYSTEM", "-u", "USER_1", "-a", "ASSI_1", "-u", "USER_2", "-a", "ASSI_2",
         ])
         .write_stdin("USER_STDIN")
-        .env("OLLAMA_API_ENDPOINT", server.url())
-        .env("OLLAMA_API_KEY", "ABCDE")
+        .env("API_ENDPOINT", server.url())
+        .env("API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -219,8 +219,8 @@ fn chat_api_error() {
         .unwrap()
         .args(["-u", "USER", "-t", "1000"])
         .write_stdin("USER_STDIN")
-        .env("OLLAMA_API_ENDPOINT", server.url())
-        .env("OLLAMA_API_KEY", "ABCDE")
+        .env("API_ENDPOINT", server.url())
+        .env("API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -230,6 +230,59 @@ fn chat_api_error() {
     ));
 }
 
+/// A base URL ending in a version segment speaks the chat completions dialect
+#[test]
+fn chat_against_a_completions_endpoint() {
+    let mut server = mockito::Server::new();
+
+    let mock = server
+        .mock("POST", "/v1/chat/completions")
+        .with_header("content-type", "application/json")
+        .match_body(mockito::Matcher::PartialJson(json!({
+            "model": "gpt-oss:120b",
+            "temperature": 0.5,
+            "reasoning_effort": "high",
+            "messages": [{
+                "role": "user",
+                "content": "Hello"
+            }]
+        })))
+        .with_body(
+            r#"{
+                 "id": "chatcmpl-1",
+                 "created": 1760742847,
+                 "model": "gpt-oss:120b",
+                 "choices": [{
+                     "index": 0,
+                     "message": {"role": "assistant", "content": "ASSISTANT REPLY"},
+                     "finish_reason": "stop"
+                 }],
+                 "usage": {"prompt_tokens": 8, "completion_tokens": 9, "total_tokens": 17}
+            }"#,
+        )
+        .create();
+
+    let cmd = Command::cargo_bin("alpaca")
+        .unwrap()
+        .args([
+            "-u",
+            "Hello",
+            "-t",
+            "0.5",
+            "--reasoning-effort",
+            "high",
+            "--base-url",
+            &format!("{}/v1", server.url()),
+        ])
+        .env("API_KEY", "ABCDE")
+        .assert();
+
+    mock.assert();
+
+    cmd.success()
+        .stdout(predicate::str::contains("ASSISTANT REPLY"));
+}
+
 /// Test messages from file
 #[test]
 fn chat_user_message_from_file() {
@@ -255,8 +308,8 @@ fn chat_user_message_from_file() {
     let cmd = Command::cargo_bin("alpaca")
         .unwrap()
         .args([infile.path().to_str().unwrap()])
-        .env("OLLAMA_API_ENDPOINT", server.url())
-        .env("OLLAMA_API_KEY", "ABCDE")
+        .env("API_ENDPOINT", server.url())
+        .env("API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
blob - /dev/null
blob + 9736052666c25a9bc0b82b75fc7c01fbd428aec4 (mode 644)
--- /dev/null
+++ tests/quota.rs
@@ -0,0 +1,162 @@
+//! Integration tests for quota subcommand
+
+use assert_cmd::Command;
+use predicates::prelude::*;
+
+/// A canned successful Ollama `/api/usage` response body
+fn ok_body() -> &'static str {
+    r#"{
+        "activity": {
+            "cost": "1.25",
+            "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"}
+        },
+        "limits": {
+            "session": {"usage": 0.5, "models": [{"name": "gpt-oss:120b", "request_count": 9}]},
+            "weekly": {"usage": 0.25, "models": []}
+        }
+    }"#
+}
+
+fn alpaca() -> Command {
+    Command::cargo_bin("alpaca").unwrap()
+}
+
+#[test]
+fn quota_renders_windows_and_models() {
+    let mut server = mockito::Server::new();
+
+    let mock = server
+        .mock("GET", "/api/usage")
+        .match_header("authorization", "KEY")
+        .with_body(ok_body())
+        .create();
+
+    let cmd = alpaca()
+        .args(["quota", "--apikey", "KEY", "--base-url", &server.url()])
+        .assert();
+
+    mock.assert();
+
+    cmd.success()
+        .stdout(
+            predicate::str::contains("ollama cloud usage")
+                .and(predicate::str::contains("extra 1.25 USD"))
+                .and(predicate::str::contains("session"))
+                .and(predicate::str::contains("50.0%"))
+                .and(predicate::str::contains("gpt-oss:120b"))
+                .and(predicate::str::contains("reqs")),
+        )
+        .stdout(predicate::str::contains('\x1b').not());
+}
+
+#[test]
+fn quota_raw_prints_the_provider_body() {
+    let mut server = mockito::Server::new();
+
+    let mock = server
+        .mock("GET", "/api/usage")
+        .with_body(ok_body())
+        .create();
+
+    let cmd = alpaca()
+        .args([
+            "quota",
+            "--raw",
+            "--apikey",
+            "KEY",
+            "--base-url",
+            &server.url(),
+        ])
+        .assert();
+
+    mock.assert();
+
+    cmd.success()
+        .stdout(predicate::str::contains(r#""request_count": 9"#));
+}
+
+#[test]
+fn quota_json_emits_the_normalized_view() {
+    let mut server = mockito::Server::new();
+
+    let mock = server
+        .mock("GET", "/api/usage")
+        .with_body(ok_body())
+        .create();
+
+    let cmd = alpaca()
+        .args([
+            "quota",
+            "--json",
+            "--apikey",
+            "KEY",
+            "--base-url",
+            &server.url(),
+        ])
+        .assert();
+
+    mock.assert();
+
+    cmd.success().stdout(
+        predicate::str::starts_with(r#"{"title":"ollama cloud usage""#)
+            .and(predicate::str::contains(r#""name":"session""#))
+            .and(predicate::str::contains(r#""resets_in_secs""#)),
+    );
+}
+
+#[test]
+fn quota_reports_provider_errors() {
+    let mut server = mockito::Server::new();
+
+    let mock = server
+        .mock("GET", "/api/usage")
+        .with_status(401)
+        .with_body(r#"{"error":"unauthorized"}"#)
+        .create();
+
+    let cmd = alpaca()
+        .args(["quota", "--apikey", "BAD", "--base-url", &server.url()])
+        .assert();
+
+    mock.assert();
+
+    cmd.failure().stderr(
+        predicate::str::contains("failed to fetch ollama usage")
+            .and(predicate::str::contains("unauthorized")),
+    );
+}
+
+#[test]
+fn quota_without_credentials_fails() {
+    alpaca()
+        .args(["quota"])
+        .env_remove("OLLAMA_API_KEY")
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("no credentials for ollama"));
+}
+
+#[test]
+fn quota_rejects_oversized_responses() {
+    let mut server = mockito::Server::new();
+    let mock = server
+        .mock("GET", "/api/usage")
+        .with_body(vec![b'x'; (1 << 20) + 1])
+        .create();
+
+    let cmd = alpaca()
+        .args([
+            "quota",
+            "--raw",
+            "--apikey",
+            "KEY",
+            "--base-url",
+            &server.url(),
+        ])
+        .assert();
+
+    mock.assert();
+    cmd.failure().stderr(predicate::str::contains(
+        "response body exceeds 1048576 bytes",
+    ));
+}