use super::{AddProfileOptions, Credential, Headspace, OptionalProfileField, ProfileEdit};
use crate::out::Out;
use anyhow::{anyhow, bail, Result};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
use zeroize::Zeroizing;
#[derive(Parser)]
#[command(
version = crate::GIT_VERSION,
name = "headspace",
bin_name = "headspace",
about = "Manage fork-visible Headspace configuration and model profiles."
)]
pub struct Cli {
#[arg(long, env = "PILE")]
pub(super) pile: PathBuf,
#[arg(long, env = "TRIBLESPACE_KEY")]
pub(super) key: Option<PathBuf>,
#[command(subcommand)]
pub(super) command: Option<Command>,
}
#[derive(Subcommand)]
pub(super) enum Command {
Show {
#[arg(long, default_value_t = false)]
show_secrets: bool,
},
List,
Use {
#[arg(value_name = "PROFILE")]
profile: String,
},
Add(AddArgs),
Set {
#[arg(value_enum, value_name = "FIELD")]
field: SetField,
#[arg(value_name = "VALUE", help = "Literal value, @path, or @- for stdin.")]
value: String,
},
Unset {
#[arg(value_enum, value_name = "FIELD")]
field: UnsetField,
},
Secret {
#[arg(value_enum)]
role: SecretRole,
#[command(subcommand)]
command: SecretCommand,
},
Reconcile {
#[arg(value_name = "SNAPSHOT")]
snapshot: String,
},
}
#[derive(Args)]
pub(super) struct AddArgs {
#[arg(value_name = "NAME")]
pub(super) name: String,
#[arg(long)]
pub(super) model: Option<String>,
#[arg(long = "base-url")]
pub(super) base_url: Option<String>,
#[arg(long)]
pub(super) model_secret_version: Option<String>,
#[arg(long = "reasoning-effort")]
pub(super) reasoning_effort: Option<String>,
#[arg(long)]
pub(super) stream: Option<bool>,
#[arg(long = "context-window-tokens")]
pub(super) context_window_tokens: Option<u64>,
#[arg(long = "max-output-tokens")]
pub(super) max_output_tokens: Option<u64>,
#[arg(long = "prompt-safety-margin-tokens")]
pub(super) context_safety_margin_tokens: Option<u64>,
#[arg(long = "prompt-chars-per-token")]
pub(super) chars_per_token: Option<u64>,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
#[value(rename_all = "kebab-case")]
pub(super) enum SetField {
Model,
BaseUrl,
ReasoningEffort,
Stream,
ContextWindowTokens,
MaxOutputTokens,
PromptSafetyMarginTokens,
PromptCharsPerToken,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
#[value(rename_all = "kebab-case")]
pub(super) enum UnsetField {
ReasoningEffort,
}
#[derive(ValueEnum, Debug, Clone, Copy, Eq, PartialEq)]
#[value(rename_all = "kebab-case")]
pub(super) enum SecretRole {
Model,
Tavily,
Exa,
}
#[derive(Subcommand)]
pub(super) enum SecretCommand {
Set(SecretSetArgs),
Unset,
}
#[derive(Args)]
pub(super) struct SecretSetArgs {
#[arg(long, conflicts_with = "version", required_unless_present = "version")]
pub(super) value: Option<String>,
#[arg(long, conflicts_with = "value", required_unless_present = "value")]
pub(super) version: Option<String>,
}
impl From<SecretRole> for super::SecretRole {
fn from(role: SecretRole) -> Self {
match role {
SecretRole::Model => Self::Model,
SecretRole::Tavily => Self::Tavily,
SecretRole::Exa => Self::Exa,
}
}
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
crate::cli::with_output("headspace", |out| execute(cli, out))
}
pub fn execute(cli: Cli, out: &mut Out<'_>) -> Result<()> {
let Some(command) = cli.command else {
return out.line(Cli::command().render_long_help().to_string());
};
let headspace = Headspace::new(cli.pile, cli.key);
match command {
Command::Show { show_secrets } => headspace.show(show_secrets, out),
Command::List => headspace.list(out),
Command::Use { profile } => headspace.use_profile(&profile, out),
Command::Add(args) => {
let literal = |value: Option<String>, label: &str| {
value
.map(|value| crate::text_arg(&value, label))
.transpose()
};
let options = AddProfileOptions {
name: crate::text_arg(&args.name, "profile name")?,
model: literal(args.model, "model name")?,
base_url: literal(args.base_url, "model base URL")?,
model_secret_version: args.model_secret_version,
reasoning_effort: literal(args.reasoning_effort, "reasoning effort")?,
stream: args.stream,
context_window_tokens: args.context_window_tokens,
max_output_tokens: args.max_output_tokens,
context_safety_margin_tokens: args.context_safety_margin_tokens,
chars_per_token: args.chars_per_token,
};
headspace.add(&options, out).map(|_| ())
}
Command::Set { field, value } => {
let edit = match field {
SetField::Model => ProfileEdit::Model(crate::text_arg(&value, "model name")?),
SetField::BaseUrl => {
ProfileEdit::BaseUrl(crate::text_arg(&value, "model base URL")?)
}
SetField::ReasoningEffort => {
ProfileEdit::ReasoningEffort(crate::text_arg(&value, "model reasoning effort")?)
}
SetField::Stream => ProfileEdit::Stream(parse_bool(&value, "model_stream")?),
SetField::ContextWindowTokens => ProfileEdit::ContextWindowTokens(parse_u64(
&value,
"model_context_window_tokens",
)?),
SetField::MaxOutputTokens => {
ProfileEdit::MaxOutputTokens(parse_u64(&value, "model_max_output_tokens")?)
}
SetField::PromptSafetyMarginTokens => ProfileEdit::PromptSafetyMarginTokens(
parse_u64(&value, "model_context_safety_margin_tokens")?,
),
SetField::PromptCharsPerToken => {
ProfileEdit::PromptCharsPerToken(parse_u64(&value, "model_chars_per_token")?)
}
};
headspace.set(&edit, out)
}
Command::Unset {
field: UnsetField::ReasoningEffort,
} => headspace.unset(OptionalProfileField::ReasoningEffort, out),
Command::Secret { role, command } => match command {
SecretCommand::Set(args) => {
let credential = match (args.value, args.version) {
(Some(value), None) => Credential::Plaintext(Zeroizing::new(crate::text_arg(
&value,
"credential",
)?)),
(None, Some(version)) => Credential::Version(version),
_ => bail!("exactly one of --value or --version is required"),
};
headspace
.secret_set(role.into(), credential, out)
.map(|_| ())
}
SecretCommand::Unset => headspace.secret_unset(role.into(), out),
},
Command::Reconcile { snapshot } => headspace.reconcile(&snapshot, out),
}
}
fn parse_u64(raw: &str, label: &str) -> Result<u64> {
raw.parse::<u64>()
.map_err(|_| anyhow!("invalid {label} {raw}"))
}
fn parse_bool(raw: &str, label: &str) -> Result<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => bail!("invalid {label} {raw} (expected true/false)"),
}
}