commit - 545a5aa90590e928e51d84609a2c7eb6a3af7fc1
commit + 182e3431233226e0209de750f784d79f6a4dedac
blob - 9e2f5038f693e9b8c62113dfba94d309c68e764d
blob + 0d5452cdf5907dd381bd0e845f8e4e46646098fe
--- Cargo.lock
+++ Cargo.lock
[[package]]
name = "alpaca"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"anyhow",
"assert_cmd",
blob - 7062b3127ad43d75835a3c86b1d24bb453548b85
blob + 5bf1d4db57792e827114536014f7b926f85d7824
--- Cargo.toml
+++ Cargo.toml
[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
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
```sh
$ make
$ make test
-$ sudo make install
+$ make install
```
`PREFIX` defaults to `/usr/local`; `DESTDIR`, `BINDIR`, and `MANDIR` may be
## 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
*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,
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
*--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
-.\" 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
# 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
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.
*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.
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
# 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.
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
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
*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* [_..._]
*--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.
# 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
+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
);
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
//! 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;
/// 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>,
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 {
}
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()),
}
}
-/// 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,
#[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`
.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")
.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
Command::cargo_bin("alpaca")
.unwrap()
.assert()
- .failure()
- .stderr(predicate::str::contains("no messages provided"));
+ .success()
+ .stdout(predicate::str::contains("Usage: alpaca"));
}
#[test]
let cmd = Command::cargo_bin("alpaca")
.unwrap()
+ .args(["-"])
.write_stdin("Hello")
.env("API_ENDPOINT", server.url())
.env("API_KEY", "ABCDE")