commit 182e3431233226e0209de750f784d79f6a4dedac from: mtmn date: Fri Sep 4 23:01:45 2026 UTC bump, add config, print help by default 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, model: Option, temperature: Option, @@ -39,8 +41,23 @@ struct Config { apikey: Option, base_url: Option, reasoning_effort: Option, + default_profile: Option, } +#[derive(Debug, Default, Clone)] +struct Config { + settings: Settings, + profiles: HashMap, +} + +impl Deref for Config { + type Target = Settings; + + fn deref(&self) -> &Self::Target { + &self.settings + } +} + impl Config { fn load() -> Result { let Some(path) = config_path() else { @@ -60,14 +77,76 @@ impl Config { } fn from_toml(contents: &str) -> Result { - 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 { + 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 { config_path_from( std::env::var_os("XDG_CONFIG_HOME").filter(|path| !path.is_empty()), @@ -216,24 +295,42 @@ impl From> 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 { let args: Vec = 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::("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 { - 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::("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 "Selects a configuration profile") + .global(true) + .required(false) +} + fn api_key_arg(config: &Config) -> Arg { default_value_opt( arg!(api_key: --apikey "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 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")