Commit Diff


commit - 545a5aa90590e928e51d84609a2c7eb6a3af7fc1
commit + 182e3431233226e0209de750f784d79f6a4dedac
blob - 9e2f5038f693e9b8c62113dfba94d309c68e764d
blob + 0d5452cdf5907dd381bd0e845f8e4e46646098fe
--- Cargo.lock
+++ Cargo.lock
@@ -13,7 +13,7 @@ dependencies = [
 
 [[package]]
 name = "alpaca"
-version = "0.3.0"
+version = "0.5.0"
 dependencies = [
  "anyhow",
  "assert_cmd",
blob - 7062b3127ad43d75835a3c86b1d24bb453548b85
blob + 5bf1d4db57792e827114536014f7b926f85d7824
--- Cargo.toml
+++ Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "alpaca"
 authors = ["leoshimo", "mtmn"]
-version = "0.3.0"
+version = "0.5.0"
 edition = "2024"
 description = "Unix native interface for LLMs"
 repository = "https://github.com/leoshimo/cogni"
blob - c3793e637044e49f4d39587c1a04fe7293ca7661
blob + eb5843e4b733c95556585e0c855b2fd12c90b2b2
--- Makefile
+++ Makefile
@@ -26,6 +26,10 @@ man/%: man/%.scd
 test:
 	$(CARGO) test --locked
 
+check:
+	$(CARGO) check
+	$(CARGO) clippy -- -W clippy::pedantic
+
 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)"
blob - 8781b752986756b3bc92eb2cd2d7e19fee945c5d
blob + 11a471e935699a617c2d5715c19cb4b7c0fb58f8
--- README.md
+++ README.md
@@ -38,7 +38,7 @@ Building requires Rust and `scdoc`:
 ```sh
 $ make
 $ make test
-$ sudo make install
+$ make install
 ```
 
 `PREFIX` defaults to `/usr/local`; `DESTDIR`, `BINDIR`, and `MANDIR` may be
@@ -82,28 +82,39 @@ Shared settings, honoured by every subcommand:
 
 ## Configuration
 
-Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`. Alpaca uses
-`~/.config/alpaca/config.toml` when `XDG_CONFIG_HOME` is not set.
+Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`. If you do not set
+`XDG_CONFIG_HOME`, Alpaca uses `~/.config/alpaca/config.toml`.
 
-Command-line options override environment variables. Environment variables
-override the config file. The config file overrides built-in defaults.
+Settings take priority in this order:
+- command-line options
+- environment variables
+- the config file
+- built-in defaults
 
+See `config.example.toml` for an example with every supported key. See
+`alpaca-config(5)` for all fields and how each command uses them. Keep the file
+private if it contains an `apikey`.
+
+Use a `[profile-name]` table to define a profile. Profiles inherit top-level
+settings and override only the keys they set. Set `default_profile` to activate
+a profile automatically. Use `--profile` to override it:
+
 ```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"
+default_profile = "ollama-cloud"
+
+[ollama-cloud]
+apikey = "your-ollama-cloud-key"
+base_url = "https://ollama.cloud"
+
+[deepinfra]
+model = "llama-3.1-70b"
 ```
 
-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`.
+```sh
+alpaca -u "Hello"
+```
 
 ## Commands
 
blob - eb90d48c8d007760adabffc4c782c1355d769c68
blob + a7044c99e7cbb9321178cfefd77a5024cbd0189d
--- man/alpaca-compose.1.scd
+++ man/alpaca-compose.1.scd
@@ -8,19 +8,20 @@ alpaca compose - edit a prompt in the editor, then sen
 
 *alpaca compose* [*-m* _model_] [*-s* _msg_] [*-t* _temp_]
 \[*-T* _secs_] [*-c*] [*--reasoning-effort* _effort_]
-\[*--apikey* _key_] [*--base-url* _url_] [*--json* | *--jsonp*]
+\[*--apikey* _key_] [*--base-url* _url_] [*--profile* _name_]
+\[*--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
+*alpaca compose* opens a temporary file in the editor and sends the saved text
+as a user prompt. It writes the reply to standard output. Piped standard input
+prefills the buffer first, so you can use *alpaca compose* 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.
+The editor runs on the controlling terminal. This keeps editor input and screen
+output out of the pipeline. If there is no controlling terminal, the editor
+inherits this process's streams. 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,
@@ -28,8 +29,8 @@ _~/.local/share/alpaca_. Transcripts are written with 
 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
+in the editor. It 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
@@ -62,6 +63,9 @@ unless given on the command line. Save an empty buffer
 *--json*, *--jsonp*
 	Print the response as JSON instead of the reply text.
 
+*--profile* _name_
+	Select a configuration profile. See *alpaca-config*(5).
+
 # ENVIRONMENT
 
 *VISUAL*, *EDITOR*
blob - 040356730015975f570aeb43c15d42fd6b03398e (mode 644)
blob + /dev/null
--- man/alpaca-config.5
+++ /dev/null
@@ -1,121 +0,0 @@
-.\" 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 - a75302c9de031d09cda53bcaba2368e8cce1d6cd
blob + 6f75d4b9e0d69798da77a91723c0e625cc1db52c
--- man/alpaca-config.5.scd
+++ man/alpaca-config.5.scd
@@ -6,15 +6,14 @@ 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_.
+Alpaca reads shared defaults from _$XDG_CONFIG_HOME/alpaca/config.toml_. If you
+do not set *XDG_CONFIG_HOME*, 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.
+Settings take priority in this order: command-line options, then environment
+variables, then this file, then built-in defaults.
 
 # FORMAT
 
@@ -44,7 +43,7 @@ The file uses TOML. Unknown keys and invalid values ar
 	messages and before command-line messages.
 
 *apikey*
-	API key. Protect the file from other users if you set this value.
+	API key. Keep the file private if you set this value.
 
 *base_url*
 	Base URL of the API endpoint.
@@ -52,6 +51,15 @@ The file uses TOML. Unknown keys and invalid values ar
 *reasoning_effort*
 	Reasoning effort. Use *none*, *low*, *medium* or *high*.
 
+*default_profile*
+	Profile to activate automatically when *--profile* is not given.
+
+# PROFILES
+
+A *[profile-name]* table defines a profile. Profiles inherit every top-level
+setting and override only the keys they set. Use *--profile* _profile-name_ to
+activate a profile, or set *default_profile* to activate one automatically.
+
 # COMMANDS
 
 *alpaca* and *alpaca chat* use every setting.
@@ -74,6 +82,7 @@ overrides a value saved in a transcript.
 	apikey = "your-api-key"
 	base_url = "https://ollama.com"
 	reasoning_effort = "none"
+	default_profile = "ollama-cloud"
 
 # FILES
 
blob - c4abd648e99375f0c06686a610a3c0f5e142308c
blob + 1b9ef5568282a43dd6f351a1f94621076fe17e20
--- man/alpaca-quota.1.scd
+++ man/alpaca-quota.1.scd
@@ -7,13 +7,14 @@ alpaca quota - show provider session and weekly usage 
 # SYNOPSIS
 
 *alpaca quota* [*-p* _provider_] [*-T* _secs_] [*--apikey* _key_]
-\[*--base-url* _url_] [*--raw*] [*--json* | *--jsonp*] [*--color* _when_]
+\[*--base-url* _url_] [*--profile* _name_] [*--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.
+It prints each quota window as a bar showing the used share and a countdown to
+the next reset. Some providers also report 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.
@@ -37,10 +38,10 @@ its own default credential and base URL, either of whi
 	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.
+The *anthropic* and *openai* endpoints are not documented public APIs, so they
+may change without notice. Both need a short-lived OAuth token. If the
+credential file is stale, you will get an authentication error until the vendor
+CLI refreshes it.
 
 # OPTIONS
 
@@ -66,6 +67,9 @@ error until the vendor CLI refreshes it.
 	One of *auto*, *always* or *never*. Defaults to *auto*, which colours only
 	when standard output is a terminal and *NO_COLOR* is unset.
 
+*--profile* _name_
+	Select a configuration profile. See *alpaca-config*(5).
+
 # CONFIGURATION
 
 This command uses *output_format*, *timeout*, *apikey* and *base_url* from the
blob - 4dff79521121c2cd41d1f2512d254a6e19375479
blob + 41405dfe334337b1f94fd1f189b57687c2dd218a
--- man/alpaca.1.scd
+++ man/alpaca.1.scd
@@ -8,7 +8,8 @@ alpaca - Unix native interface for LLMs
 
 *alpaca* [*-m* _model_] [*-t* _temp_] [*-T* _secs_] [*-s* _msg_]
 \[*-u* _msg_] [*-a* _msg_] [*--reasoning-effort* _effort_]
-\[*--apikey* _key_] [*--base-url* _url_] [*--json* | *--jsonp*] [_file_]
+\[*--apikey* _key_] [*--base-url* _url_] [*--profile* _name_]
+\[*--json* | *--jsonp*] [_file_]
 
 *alpaca chat* [_..._]
 
@@ -76,6 +77,9 @@ read messages from a file with such a name, separate i
 *--output-format* _format_
 	One of *plaintext*, *json* or *jsonpretty*.
 
+*--profile* _name_
+	Select a configuration profile. See *alpaca-config*(5).
+
 *--json*, *--jsonp*
 	Shorthands for the two JSON formats.
 
@@ -87,11 +91,12 @@ _file_
 
 # CONFIGURATION
 
-Use _$XDG_CONFIG_HOME/alpaca/config.toml_ for shared defaults. Alpaca uses
-_~/.config/alpaca/config.toml_ when *XDG_CONFIG_HOME* is not set.
+Use _$XDG_CONFIG_HOME/alpaca/config.toml_ for shared defaults. If you do not
+set *XDG_CONFIG_HOME*, Alpaca uses _~/.config/alpaca/config.toml_.
 
-Command-line options override environment variables. Environment variables
-override the config file. See *alpaca-config*(5) for the file format.
+Settings take priority in this order: command-line options, then environment
+variables, then the config file, then built-in defaults. See *alpaca-config*(5)
+for the file format.
 
 # ENVIRONMENT
 
blob - /dev/null
blob + 371fda1c82d78e9dc50af7835131473d8c4c2f8a (mode 644)
--- /dev/null
+++ config.example.toml
@@ -0,0 +1,18 @@
+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"
+default_profile = "ollama-cloud"
+
+[ollama-cloud]
+apikey = "your-ollama-cloud-key"
+base_url = "https://ollama.cloud"
+
+[deepinfra]
+model = "llama-3.1-70b"
blob - 8f636a7ae619df2597f468f358735df3bfe53a04
blob + 56dfa03213b316112651c6e8ed43997494aaac7f
--- src/cli/config_tests.rs
+++ src/cli/config_tests.rs
@@ -147,3 +147,172 @@ fn config_path_prefers_xdg_and_falls_back_to_home() {
     );
     assert_eq!(config_path_from(None, None), None);
 }
+
+#[test]
+fn profile_overrides_base_settings() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            model = "base-model"
+            apikey = "base-key"
+            base_url = "https://base.example"
+            temperature = 0.5
+
+            [ollama-cloud]
+            apikey = "cloud-key"
+            base_url = "https://cloud.example"
+
+            [deepinfra]
+            model = "deepinfra-model"
+        "#,
+        &["alpaca", "--profile", "ollama-cloud", "-u", "USER"],
+    )?;
+
+    assert_eq!(args.model, "base-model");
+    assert_eq!(args.api_key.as_deref(), Some("cloud-key"));
+    assert_eq!(args.base_url.as_deref(), Some("https://cloud.example"));
+    assert_eq!(args.temperature, Some(0.5));
+    Ok(())
+}
+
+#[test]
+fn profile_overrides_model() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            model = "base-model"
+
+            [deepinfra]
+            model = "deepinfra-model"
+        "#,
+        &["alpaca", "--profile", "deepinfra", "-u", "USER"],
+    )?;
+
+    assert_eq!(args.model, "deepinfra-model");
+    Ok(())
+}
+
+#[test]
+fn profile_inherits_unset_base_fields() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            model = "base-model"
+            timeout = 30
+            system = "base system"
+            assistant = ["base assistant"]
+            user = ["base user"]
+
+            [custom]
+            temperature = 0.9
+        "#,
+        &["alpaca", "--profile", "custom", "-u", "USER"],
+    )?;
+
+    assert_eq!(args.model, "base-model");
+    assert_eq!(args.timeout, Duration::from_secs(30));
+    assert_eq!(
+        args.messages,
+        vec![
+            Message::system("base system"),
+            Message::assistant("base assistant"),
+            Message::user("base user"),
+            Message::user("USER"),
+        ]
+    );
+    assert_eq!(args.temperature, Some(0.9));
+    Ok(())
+}
+
+#[test]
+fn unknown_profile_is_error() {
+    assert!(
+        Config::from_toml(
+            r#"
+                model = "base-model"
+
+                [known]
+                model = "known-model"
+            "#,
+        )
+        .unwrap()
+        .with_profile("missing")
+        .is_err()
+    );
+}
+
+#[test]
+fn profile_rejects_unknown_keys() {
+    assert!(
+        Config::from_toml(
+            r#"
+            [bad]
+            modle = "typo"
+        "#,
+        )
+        .is_err()
+    );
+}
+
+#[test]
+fn profile_rejects_non_positive_timeout() {
+    assert!(
+        Config::from_toml(
+            r#"
+            [bad]
+            timeout = 0
+        "#,
+        )
+        .is_err()
+    );
+}
+
+#[test]
+fn default_profile_activates_without_a_flag() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            model = "base-model"
+            default_profile = "deepinfra"
+
+            [deepinfra]
+            model = "deepinfra-model"
+        "#,
+        &["alpaca", "-u", "USER"],
+    )?;
+
+    assert_eq!(args.model, "deepinfra-model");
+    Ok(())
+}
+
+#[test]
+fn command_line_profile_overrides_default_profile() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            model = "base-model"
+            default_profile = "deepinfra"
+
+            [deepinfra]
+            model = "deepinfra-model"
+
+            [ollama-cloud]
+            model = "cloud-model"
+        "#,
+        &["alpaca", "--profile", "ollama-cloud", "-u", "USER"],
+    )?;
+
+    assert_eq!(args.model, "cloud-model");
+    Ok(())
+}
+
+#[test]
+fn no_default_profile_uses_base_settings() -> Result<()> {
+    let args = configured_chat(
+        r#"
+            model = "base-model"
+
+            [deepinfra]
+            model = "deepinfra-model"
+        "#,
+        &["alpaca", "-u", "USER"],
+    )?;
+
+    assert_eq!(args.model, "base-model");
+    Ok(())
+}
blob - e62d0ec386b951e70ab6bb729059f565bb7bd780
blob + d2e1fba388bd246eb53dc21bc31e52e2e00efa22
--- src/cli.rs
+++ src/cli.rs
@@ -1,7 +1,9 @@
 //! Command line interface for alpaca
 
+use std::collections::HashMap;
 use std::ffi::OsString;
 use std::fs;
+use std::ops::Deref;
 use std::path::PathBuf;
 use std::time::Duration;
 
@@ -24,9 +26,9 @@ pub const DEFAULT_TIMEOUT_SECS: &str = "60";
 /// 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)]
+#[derive(Debug, Default, Deserialize, Clone)]
 #[serde(deny_unknown_fields)]
-struct Config {
+struct Settings {
     output_format: Option<OutputFormat>,
     model: Option<String>,
     temperature: Option<f32>,
@@ -39,8 +41,23 @@ struct Config {
     apikey: Option<String>,
     base_url: Option<String>,
     reasoning_effort: Option<Effort>,
+    default_profile: Option<String>,
 }
 
+#[derive(Debug, Default, Clone)]
+struct Config {
+    settings: Settings,
+    profiles: HashMap<String, Settings>,
+}
+
+impl Deref for Config {
+    type Target = Settings;
+
+    fn deref(&self) -> &Self::Target {
+        &self.settings
+    }
+}
+
 impl Config {
     fn load() -> Result<Self> {
         let Some(path) = config_path() else {
@@ -60,14 +77,76 @@ impl Config {
     }
 
     fn from_toml(contents: &str) -> Result<Self> {
-        let config: Self = toml::from_str(contents).context("failed to parse TOML")?;
-        if config.timeout == Some(0) {
+        let value: toml::Value = toml::from_str(contents).context("failed to parse TOML")?;
+        let table = value.as_table().context("configuration must be a table")?;
+
+        let mut base = toml::map::Map::new();
+        let mut profiles = HashMap::new();
+
+        for (key, value) in table {
+            if let toml::Value::Table(profile_table) = value {
+                let profile: Settings =
+                    Settings::deserialize(toml::Value::Table(profile_table.clone()))
+                        .with_context(|| format!("invalid profile '{key}'"))?;
+                if profile.timeout == Some(0) {
+                    bail!("timeout must be greater than zero in profile '{key}'");
+                }
+                profiles.insert(key.clone(), profile);
+            } else {
+                base.insert(key.clone(), value.clone());
+            }
+        }
+
+        let settings: Settings =
+            Settings::deserialize(toml::Value::Table(base)).context("invalid configuration")?;
+        if settings.timeout == Some(0) {
             bail!("timeout must be greater than zero");
         }
-        Ok(config)
+
+        Ok(Self { settings, profiles })
     }
+
+    fn with_profile(&self, name: &str) -> Result<Self> {
+        let profile = self
+            .profiles
+            .get(name)
+            .with_context(|| format!("unknown profile '{name}'"))?;
+        Ok(Self {
+            settings: self.settings.merge(profile),
+            profiles: HashMap::new(),
+        })
+    }
 }
 
+impl Settings {
+    fn merge(&self, other: &Self) -> Self {
+        Self {
+            output_format: other.output_format.or(self.output_format),
+            model: other.model.clone().or_else(|| self.model.clone()),
+            temperature: other.temperature.or(self.temperature),
+            timeout: other.timeout.or(self.timeout),
+            system: other.system.clone().or_else(|| self.system.clone()),
+            assistant: if other.assistant.is_empty() {
+                self.assistant.clone()
+            } else {
+                other.assistant.clone()
+            },
+            user: if other.user.is_empty() {
+                self.user.clone()
+            } else {
+                other.user.clone()
+            },
+            apikey: other.apikey.clone().or_else(|| self.apikey.clone()),
+            base_url: other.base_url.clone().or_else(|| self.base_url.clone()),
+            reasoning_effort: other.reasoning_effort.or(self.reasoning_effort),
+            default_profile: other
+                .default_profile
+                .clone()
+                .or_else(|| self.default_profile.clone()),
+        }
+    }
+}
+
 fn config_path() -> Option<PathBuf> {
     config_path_from(
         std::env::var_os("XDG_CONFIG_HOME").filter(|path| !path.is_empty()),
@@ -216,24 +295,42 @@ impl From<Option<ReasoningEffort>> for Effort {
     }
 }
 
-/// Parse commandline arguments into `Invocation`. May exit with help or error message
+/// Parse commandline arguments into `Invocation`. May exit with help or error message.
+///
+/// # Errors
+///
+/// Returns an error if the configuration file cannot be loaded or parsed.
 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())
+    // Show help when invoked with no arguments
+    if args.len() <= 1 {
+        cli(&Config::default()).print_help()?;
+        std::process::exit(0);
+    }
+
+    // Preserve clap's help, version and usage-error exits without loading a
+    // malformed config file. This pass also captures any --profile flag.
+    let pre_matches = cli(&Config::default())
         .try_get_matches_from(&args)
         .unwrap_or_else(|err| err.exit());
+    let profile = pre_matches.get_one::<String>("profile").cloned();
 
     let config = Config::load()?;
+    let profile = profile.or_else(|| config.default_profile.clone());
+    let config = match profile {
+        Some(name) => config.with_profile(&name)?,
+        None => config,
+    };
+
     let matches = cli(&config).get_matches_from(args);
-    Ok(invocation_from_matches(matches, &config))
+    Ok(invocation_from_matches(&matches, &config))
 }
 
 /// Top-level command. Without a subcommand, alpaca chats
 fn cli(config: &Config) -> Command {
     chat_args(command!(), config)
+        .arg(profile_arg())
         .subcommand(chat_args(
             Command::new("chat").about("Send a chat request (default command)"),
             config,
@@ -246,8 +343,15 @@ fn cli(config: &Config) -> Command {
 
 #[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))
+    let pre_matches = cli(config).try_get_matches_from(args)?;
+    let profile = pre_matches.get_one::<String>("profile").cloned();
+    let profile = profile.or_else(|| config.default_profile.clone());
+    let config = match profile {
+        Some(name) => config.with_profile(&name)?,
+        None => config.clone(),
+    };
+    let matches = cli(&config).try_get_matches_from(args)?;
+    Ok(invocation_from_matches(&matches, &config))
 }
 
 /// Arguments shared by the default command and `alpaca chat`
@@ -356,6 +460,12 @@ fn effort_name(effort: Effort) -> String {
         .to_string()
 }
 
+fn profile_arg() -> Arg {
+    arg!(profile: --profile <PROFILE> "Selects a configuration profile")
+        .global(true)
+        .required(false)
+}
+
 fn api_key_arg(config: &Config) -> Arg {
     default_value_opt(
         arg!(api_key: --apikey <API_KEY> "Sets the API key to use")
@@ -399,18 +509,18 @@ fn output_format_args(cmd: Command, config: &Config) -
     .group(ArgGroup::new("output_format_short").args(["json", "jsonp"]))
 }
 
-fn invocation_from_matches(matches: ArgMatches, config: &Config) -> Invocation {
+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)),
+        _ => Invocation::Chat(chat_args_from(matches, config)),
     }
 }
 
 impl From<ArgMatches> for Invocation {
     fn from(matches: ArgMatches) -> Self {
-        invocation_from_matches(matches, &Config::default())
+        invocation_from_matches(&matches, &Config::default())
     }
 }
 
blob - d6f294690066d520973b497d65fc2f1ee06a2399
blob + 1cd9a68a621bd35fa406dfaa708eeb65132392e0
--- tests/chat.rs
+++ tests/chat.rs
@@ -26,8 +26,8 @@ fn chat_no_message() {
     Command::cargo_bin("alpaca")
         .unwrap()
         .assert()
-        .failure()
-        .stderr(predicate::str::contains("no messages provided"));
+        .success()
+        .stdout(predicate::str::contains("Usage: alpaca"));
 }
 
 #[test]
@@ -95,6 +95,7 @@ fn chat_user_message_from_stdin() {
 
     let cmd = Command::cargo_bin("alpaca")
         .unwrap()
+        .args(["-"])
         .write_stdin("Hello")
         .env("API_ENDPOINT", server.url())
         .env("API_KEY", "ABCDE")