use std::io::{BufRead, IsTerminal};
use crate::{
ApiKey, ConfigEntry, DEFAULT_DIMENSION, DEFAULT_MAX_TOKENS, EmbedDeviceSetParams,
EmbedModelSetParams, EmbedRequest, Entry, EntryValue, Kind, LOCAL_MODEL, LocalModel, Origin,
RemoteModel, Scope, SecretState, SettingDef, Slot, SlotModel,
};
#[derive(Debug, clap::Subcommand)]
pub enum EmbeddingCommand {
Show,
#[command(subcommand)]
Local(LocalCommand),
#[command(subcommand)]
Remote(RemoteCommand),
#[command(subcommand)]
Key(KeyCommand),
}
#[derive(Debug, clap::Subcommand)]
pub enum LocalCommand {
Set {
#[arg(long, default_value_t = DEFAULT_DIMENSION)]
dimension: u32,
#[arg(long, default_value_t = DEFAULT_MAX_TOKENS)]
max_tokens: usize,
},
Clear,
Device {
switch: Switch,
},
}
#[derive(Debug, clap::Subcommand)]
pub enum RemoteCommand {
Set {
#[arg(long)]
endpoint: String,
#[arg(long)]
model: String,
#[arg(long)]
dimension: u32,
},
Clear,
Device {
switch: Switch,
},
}
#[derive(Debug, clap::Subcommand)]
pub enum KeyCommand {
Set,
Clear,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum Switch {
Auto,
On,
Off,
}
impl Switch {
fn enabled(self) -> Option<bool> {
match self {
Switch::Auto => None,
Switch::On => Some(true),
Switch::Off => Some(false),
}
}
}
impl EmbeddingCommand {
pub fn request(self) -> std::io::Result<EmbedRequest> {
let model_set = |slot, model| EmbedRequest::ModelSet(EmbedModelSetParams { slot, model });
let device_set = |slot, switch: Switch| {
EmbedRequest::DeviceSet(EmbedDeviceSetParams {
slot,
enabled: switch.enabled(),
})
};
Ok(match self {
EmbeddingCommand::Show => EmbedRequest::Show,
EmbeddingCommand::Local(LocalCommand::Set {
dimension,
max_tokens,
}) => model_set(
Slot::Local,
Some(SlotModel::Local(LocalModel {
model: LOCAL_MODEL.to_owned(),
dimension,
max_tokens,
})),
),
EmbeddingCommand::Local(LocalCommand::Clear) => model_set(Slot::Local, None),
EmbeddingCommand::Local(LocalCommand::Device { switch }) => {
device_set(Slot::Local, switch)
}
EmbeddingCommand::Remote(RemoteCommand::Set {
endpoint,
model,
dimension,
}) => model_set(
Slot::Remote,
Some(SlotModel::Remote(RemoteModel {
endpoint,
model,
dimension,
})),
),
EmbeddingCommand::Remote(RemoteCommand::Clear) => model_set(Slot::Remote, None),
EmbeddingCommand::Remote(RemoteCommand::Device { switch }) => {
device_set(Slot::Remote, switch)
}
EmbeddingCommand::Key(KeyCommand::Set) => EmbedRequest::KeySet(read_key()?),
EmbeddingCommand::Key(KeyCommand::Clear) => EmbedRequest::KeyClear,
})
}
}
pub fn read_key() -> std::io::Result<ApiKey> {
let key = if std::io::stdin().is_terminal() {
ApiKey::new(rpassword::prompt_password("API key: ")?)
} else {
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
ApiKey::new(line)
};
if key.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no key was given",
));
}
Ok(key)
}
#[derive(Debug, clap::Subcommand)]
pub enum ConfigCommand {
List {
#[command(flatten)]
scope: ScopeFlags,
#[arg(long)]
show_origin: bool,
},
Get {
key: String,
},
Set {
#[arg(required = true)]
entries: Vec<String>,
#[command(flatten)]
scope: ScopeFlags,
#[arg(long)]
stdin: bool,
#[arg(long)]
restart: bool,
},
Unset {
#[arg(required = true)]
keys: Vec<String>,
#[command(flatten)]
scope: ScopeFlags,
#[arg(long)]
restart: bool,
},
}
#[derive(Debug, Clone, Copy, clap::Args)]
pub struct ScopeFlags {
#[arg(long, conflicts_with = "workgroup")]
device: bool,
#[arg(long)]
workgroup: bool,
}
impl ScopeFlags {
pub fn scope(&self) -> Option<Scope> {
if self.device {
Some(Scope::Device)
} else if self.workgroup {
Some(Scope::Workgroup)
} else {
None
}
}
}
pub fn parse_entries(
schema: &[SettingDef],
entries: &[String],
stdin: bool,
) -> Result<Vec<ConfigEntry>, String> {
parse_entries_with(schema, entries, || {
let tty = !stdin && std::io::stdin().is_terminal();
read_secret(std::io::stdin().lock(), tty).map_err(|e| e.to_string())
})
}
fn parse_entries_with(
schema: &[SettingDef],
entries: &[String],
secret: impl FnOnce() -> Result<String, String>,
) -> Result<Vec<ConfigEntry>, String> {
let kind_of = |key: &str| {
schema
.iter()
.find(|d| d.key == key)
.map(|d| &d.kind)
.ok_or_else(|| format!("unknown setting `{key}`"))
};
let mut secret = Some(secret);
let mut parsed = Vec::new();
for entry in entries {
let Some((key, text)) = entry.split_once('=') else {
let key = entry.as_str();
if kind_of(key)? != &Kind::Secret {
return Err(format!("`{key}` needs a value: write `{key}=<value>`"));
}
if entries.len() != 1 {
return Err(format!("the secret `{key}` must be set on its own"));
}
let read = secret.take().expect("one entry");
parsed.push(ConfigEntry {
key: key.to_owned(),
value: crate::Value::String(read()?),
});
continue;
};
let kind = kind_of(key)?;
if kind == &Kind::Secret {
return Err(format!(
"`{key}` is a secret: give the key alone and pass the value with --stdin or at the prompt, not on the command line"
));
}
parsed.push(ConfigEntry {
key: key.to_owned(),
value: kind.parse(text).map_err(|e| format!("{key}: {e}"))?,
});
}
Ok(parsed)
}
pub fn read_secret(mut stdin: impl BufRead, tty: bool) -> std::io::Result<String> {
let mut text = if tty {
rpassword::prompt_password("Value: ")?
} else {
let mut line = String::new();
stdin.read_line(&mut line)?;
line
};
while text.ends_with('\n') || text.ends_with('\r') {
text.pop();
}
if text.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no value was given",
));
}
Ok(text)
}
pub fn describe_entries(entries: &[Entry], show_origin: bool) -> Vec<String> {
let mut lines = Vec::new();
for entry in entries {
let value = match &entry.value {
EntryValue::Value(v) => v.to_string(),
EntryValue::Secret(SecretState::Set) => "<set>".to_owned(),
EntryValue::Secret(SecretState::Unset) | EntryValue::Unset => "<unset>".to_owned(),
};
let mut line = format!("{} = {value}", entry.key);
if show_origin {
match entry.origin {
Origin::Scope(scope) => line.push_str(&format!(" ({scope})")),
Origin::Default => line.push_str(" (default)"),
}
}
if let Some(note) = &entry.note {
line.push_str(&format!(" # {note}"));
}
lines.push(line);
}
let pending: Vec<&str> = entries
.iter()
.filter(|e| e.pending_restart)
.map(|e| e.key.as_str())
.collect();
if !pending.is_empty() {
lines.push(format!("restart required: {}", pending.join(", ")));
}
lines
}
#[derive(Debug, clap::Subcommand)]
pub enum ExternalDeviceCommand {
List,
Add {
name: String,
#[arg(long = "app")]
apps: Vec<String>,
#[arg(long)]
description: Option<String>,
},
Retire {
selector: String,
},
Restore {
selector: String,
},
Rotate {
selector: String,
},
Apps {
selector: String,
apps: Vec<String>,
},
}
impl ExternalDeviceCommand {
pub fn request(self, default_app: Option<&str>) -> crate::ExternalDeviceRequest {
use crate::{ExternalDeviceAddParams, ExternalDeviceRequest, ExternalDeviceSetAppsParams};
match self {
ExternalDeviceCommand::List => ExternalDeviceRequest::List,
ExternalDeviceCommand::Add {
name,
mut apps,
description,
} => {
if apps.is_empty()
&& let Some(app) = default_app
{
apps.push(app.to_owned());
}
ExternalDeviceRequest::Add(ExternalDeviceAddParams {
name,
description,
apps,
})
}
ExternalDeviceCommand::Retire { selector } => ExternalDeviceRequest::Retire(selector),
ExternalDeviceCommand::Restore { selector } => ExternalDeviceRequest::Restore(selector),
ExternalDeviceCommand::Rotate { selector } => ExternalDeviceRequest::Rotate(selector),
ExternalDeviceCommand::Apps { selector, apps } => {
ExternalDeviceRequest::SetApps(ExternalDeviceSetAppsParams { selector, apps })
}
}
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(clap::Parser)]
struct Cli {
#[command(subcommand)]
command: EmbeddingCommand,
}
fn parse(args: &[&str]) -> EmbedRequest {
let mut argv = vec!["x"];
argv.extend_from_slice(args);
Cli::parse_from(argv).command.request().unwrap()
}
#[test]
fn local_set_fills_in_the_defaults() {
let EmbedRequest::ModelSet(p) = parse(&["local", "set", "--dimension", "512"]) else {
panic!("not a model set");
};
assert_eq!(p.slot, Slot::Local);
assert_eq!(
p.model,
Some(SlotModel::Local(LocalModel {
dimension: 512,
..LocalModel::default()
}))
);
}
#[test]
fn remote_set_needs_every_field() {
let argv = [
"x",
"remote",
"set",
"--endpoint",
"https://e",
"--model",
"m",
];
assert!(Cli::try_parse_from(argv).is_err());
}
#[test]
fn device_switches_map_to_options() {
let EmbedRequest::DeviceSet(p) = parse(&["remote", "device", "off"]) else {
panic!("not a device set");
};
assert_eq!((p.slot, p.enabled), (Slot::Remote, Some(false)));
let EmbedRequest::DeviceSet(p) = parse(&["local", "device", "auto"]) else {
panic!("not a device set");
};
assert_eq!((p.slot, p.enabled), (Slot::Local, None));
}
#[test]
fn clear_is_a_model_set_without_a_model() {
let EmbedRequest::ModelSet(p) = parse(&["remote", "clear"]) else {
panic!("not a model set");
};
assert_eq!((p.slot, p.model), (Slot::Remote, None));
}
fn def(key: &str, kind: Kind) -> SettingDef {
SettingDef {
key: key.into(),
kind,
scopes: vec![Scope::Device],
overridable: false,
apply: crate::Apply::Live,
default: None,
section: "t".into(),
doc: "t".into(),
process: crate::settings::Process::Bridge,
}
}
fn schema() -> Vec<SettingDef> {
vec![
def("net.discovery", Kind::Bool),
def(
"net.relays",
Kind::List {
item: Box::new(Kind::Url),
},
),
def("embed.remote.key", Kind::Secret),
]
}
fn words(w: &[&str]) -> Vec<String> {
w.iter().map(|s| s.to_string()).collect()
}
fn no_secret() -> Result<String, String> {
panic!("no secret expected")
}
#[test]
fn entries_parse_by_kind() {
use crate::Value;
let got = parse_entries_with(
&schema(),
&words(&["net.discovery=off", "net.relays=https://a,https://b"]),
no_secret,
)
.unwrap();
assert_eq!(got[0].value, Value::Bool(false));
assert_eq!(
got[1].value,
Value::List(vec![
Value::String("https://a".into()),
Value::String("https://b".into())
])
);
}
#[test]
fn a_secret_value_on_the_command_line_is_refused() {
let err = parse_entries(&schema(), &words(&["embed.remote.key=sk"]), true).unwrap_err();
assert!(err.contains("--stdin"), "{err}");
}
#[test]
fn a_secret_key_must_be_set_alone() {
let err = parse_entries_with(
&schema(),
&words(&["embed.remote.key", "net.discovery=on"]),
no_secret,
)
.unwrap_err();
assert!(err.contains("on its own"), "{err}");
}
#[test]
fn a_lone_secret_key_is_read_not_taken_from_the_line() {
let got = parse_entries_with(&schema(), &words(&["embed.remote.key"]), || Ok("sk".into()))
.unwrap();
assert_eq!(got[0].value, crate::Value::String("sk".into()));
}
#[test]
fn a_lone_plain_key_asks_for_a_value() {
let err = parse_entries_with(&schema(), &words(&["net.discovery"]), no_secret).unwrap_err();
assert!(err.contains("net.discovery=<value>"), "{err}");
}
#[test]
fn an_empty_stdin_secret_is_refused() {
let err = read_secret("\n".as_bytes(), false).unwrap_err();
assert!(err.to_string().contains("no value was given"));
assert_eq!(read_secret("sk-1\r\n".as_bytes(), false).unwrap(), "sk-1");
}
#[test]
fn an_unknown_key_is_named() {
let err = parse_entries_with(&schema(), &words(&["net.nope=1"]), no_secret).unwrap_err();
assert!(err.contains("net.nope"), "{err}");
}
#[test]
fn list_prints_secrets_as_set_or_unset_and_origins_on_request() {
use crate::Value;
let entry = |key: &str, value, origin, pending| Entry {
key: key.into(),
value,
origin,
pending_restart: pending,
note: None,
};
let entries = vec![
entry(
"embed.remote.key",
EntryValue::Secret(SecretState::Set),
Origin::Scope(Scope::Workgroup),
false,
),
entry(
"net.discovery",
EntryValue::Value(Value::Bool(false)),
Origin::Default,
true,
),
entry("net.relays", EntryValue::Unset, Origin::Default, false),
];
assert_eq!(
describe_entries(&entries, false),
[
"embed.remote.key = <set>",
"net.discovery = false",
"net.relays = <unset>",
"restart required: net.discovery"
]
);
let shown = describe_entries(&entries, true);
assert_eq!(shown[0], "embed.remote.key = <set> (workgroup)");
assert_eq!(shown[1], "net.discovery = false (default)");
}
#[test]
fn a_note_is_appended() {
let entries = vec![Entry {
key: "a".into(),
value: EntryValue::Unset,
origin: Origin::Default,
pending_restart: false,
note: Some("hm".into()),
}];
assert_eq!(describe_entries(&entries, false), ["a = <unset> # hm"]);
}
#[derive(clap::Parser)]
struct CCli {
#[command(subcommand)]
command: ConfigCommand,
}
#[test]
fn scope_flags_exclude_each_other() {
let ConfigCommand::List { scope, .. } =
CCli::parse_from(["x", "list", "--workgroup"]).command
else {
panic!()
};
assert_eq!(scope.scope(), Some(Scope::Workgroup));
assert!(CCli::try_parse_from(["x", "list", "--device", "--workgroup"]).is_err());
}
#[derive(clap::Parser)]
struct XCli {
#[command(subcommand)]
command: ExternalDeviceCommand,
}
#[test]
fn add_defaults_to_the_running_app_and_takes_several() {
let parse = |args: &[&str], app| {
let mut argv = vec!["x"];
argv.extend_from_slice(args);
XCli::parse_from(argv).command.request(app)
};
let crate::ExternalDeviceRequest::Add(p) = parse(&["add", "pendant"], Some("agent")) else {
panic!("not an add")
};
assert_eq!(p.apps, vec!["agent"]);
let crate::ExternalDeviceRequest::Add(p) =
parse(&["add", "hook", "--app", "a", "--app", "b"], Some("agent"))
else {
panic!("not an add")
};
assert_eq!(p.apps, vec!["a", "b"], "named apps replace the default");
}
}