use std::ffi::OsString;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use tftio_cli_common::MetaCommand;
use crate::config::{ContextName, DomainName, HarnessName, InvalidName, ModelName};
#[derive(Debug, Parser)]
#[command(name = "clanker")]
#[command(about = env!("CARGO_PKG_DESCRIPTION"))]
#[command(version)]
pub struct Cli {
#[arg(long, value_name = "FILE", global = true)]
pub config: Option<PathBuf>,
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Meta {
#[command(subcommand)]
command: MetaCommand,
},
Doctor,
List,
Current,
Bake {
#[arg(long)]
dry_run: bool,
#[arg(long)]
check: bool,
#[arg(long)]
force: bool,
},
Env {
harness: OsString,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
arguments: Vec<OsString>,
},
#[command(external_subcommand)]
Harness(Vec<OsString>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultArgsMode {
Apply,
Suppress,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchRequest {
pub harness: HarnessName,
pub context: Option<ContextName>,
pub domains: Vec<DomainName>,
pub model: Option<ModelName>,
pub profiles: Vec<String>,
pub sandbox: bool,
pub no_prompt: bool,
pub default_args: DefaultArgsMode,
pub dry_run: bool,
pub passthrough: Vec<OsString>,
}
#[derive(Debug, thiserror::Error)]
pub enum LaunchArgumentError {
#[error("missing harness name")]
MissingHarness,
#[error("{0} requires a value")]
MissingValue(&'static str),
#[error("{0} value is not valid UTF-8")]
NonUtf8Value(&'static str),
#[error("--profile requires a non-empty value")]
EmptyProfile,
#[error(transparent)]
InvalidName(#[from] InvalidName),
}
pub fn parse_external_launch(values: Vec<OsString>) -> Result<LaunchRequest, LaunchArgumentError> {
let mut values = values.into_iter();
let harness = values.next().ok_or(LaunchArgumentError::MissingHarness)?;
parse_launch(harness, values.collect())
}
pub fn parse_launch(
harness: OsString,
arguments: Vec<OsString>,
) -> Result<LaunchRequest, LaunchArgumentError> {
let harness = parse_name(harness, "harness", HarnessName::new)?;
let mut request = LaunchRequest {
harness,
context: None,
domains: Vec::new(),
model: None,
profiles: Vec::new(),
sandbox: false,
no_prompt: false,
default_args: DefaultArgsMode::Apply,
dry_run: false,
passthrough: Vec::new(),
};
let mut arguments = arguments.into_iter();
while let Some(argument) = arguments.next() {
let Some(text) = argument.to_str() else {
request.passthrough.push(argument);
request.passthrough.extend(arguments);
break;
};
match text {
"--" => {
request.passthrough.extend(arguments);
break;
}
"--context" => {
request.context = Some(parse_next_name(
&mut arguments,
"--context",
ContextName::new,
)?);
}
"--domain" => {
request.domains.push(parse_next_name(
&mut arguments,
"--domain",
DomainName::new,
)?);
}
"--model" => {
request.model = Some(parse_next_name(&mut arguments, "--model", ModelName::new)?);
}
"--profile" => {
request
.profiles
.push(parse_profile(next_value(&mut arguments, "--profile")?)?);
}
"--sandbox" => request.sandbox = true,
"--no-prompt" => request.no_prompt = true,
"--no-default-args" => request.default_args = DefaultArgsMode::Suppress,
"--dry-run" => request.dry_run = true,
_ => {
if let Some(value) = text.strip_prefix("--context=") {
request.context = Some(ContextName::new(value)?);
} else if let Some(value) = text.strip_prefix("--domain=") {
request.domains.push(DomainName::new(value)?);
} else if let Some(value) = text.strip_prefix("--model=") {
request.model = Some(ModelName::new(value)?);
} else if let Some(value) = text.strip_prefix("--profile=") {
request.profiles.push(parse_profile(value.to_string())?);
} else {
request.passthrough.push(argument);
request.passthrough.extend(arguments);
break;
}
}
}
}
Ok(request)
}
fn next_value(
arguments: &mut impl Iterator<Item = OsString>,
flag: &'static str,
) -> Result<String, LaunchArgumentError> {
arguments
.next()
.ok_or(LaunchArgumentError::MissingValue(flag))?
.into_string()
.map_err(|_| LaunchArgumentError::NonUtf8Value(flag))
}
fn parse_next_name<T>(
arguments: &mut impl Iterator<Item = OsString>,
flag: &'static str,
parse: impl FnOnce(String) -> Result<T, InvalidName>,
) -> Result<T, LaunchArgumentError> {
parse(next_value(arguments, flag)?).map_err(Into::into)
}
fn parse_name<T>(
value: OsString,
label: &'static str,
parse: impl FnOnce(String) -> Result<T, InvalidName>,
) -> Result<T, LaunchArgumentError> {
let value = value
.into_string()
.map_err(|_| LaunchArgumentError::NonUtf8Value(label))?;
parse(value).map_err(Into::into)
}
fn parse_profile(value: String) -> Result<String, LaunchArgumentError> {
if value.trim().is_empty() {
Err(LaunchArgumentError::EmptyProfile)
} else {
Ok(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn values(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[test]
fn parses_selection_flags_before_passthrough() {
let request = parse_external_launch(values(&[
"claude",
"--domain",
"research",
"--model=deepseek",
"--profile",
"python.full",
"--sandbox",
"prompt",
"--domain",
"ignored",
]))
.unwrap();
assert_eq!(request.harness.as_str(), "claude");
assert_eq!(request.domains[0].as_str(), "research");
assert_eq!(request.model.unwrap().as_str(), "deepseek");
assert_eq!(request.profiles, ["python.full"]);
assert!(request.sandbox);
assert_eq!(
request.passthrough,
values(&["prompt", "--domain", "ignored"])
);
}
#[test]
fn repeated_domains_preserve_request_order() {
let request = parse_external_launch(values(&[
"claude",
"--domain",
"eng",
"--domain=research",
"--dry-run",
]))
.unwrap();
let domains: Vec<_> = request.domains.iter().map(DomainName::as_str).collect();
assert_eq!(domains, ["eng", "research"]);
}
#[test]
fn explicit_separator_is_not_forwarded() {
let request = parse_external_launch(values(&[
"codex",
"--no-prompt",
"--",
"--domain",
"harness-value",
]))
.unwrap();
assert!(request.no_prompt);
assert_eq!(request.passthrough, values(&["--domain", "harness-value"]));
}
#[test]
fn no_default_args_is_consumed_only_before_passthrough() {
let request = parse_external_launch(values(&[
"claude",
"--no-default-args",
"--",
"--no-default-args",
]))
.unwrap();
assert_eq!(request.default_args, DefaultArgsMode::Suppress);
assert_eq!(request.passthrough, values(&["--no-default-args"]));
let request =
parse_external_launch(values(&["claude", "prompt", "--no-default-args"])).unwrap();
assert_eq!(request.default_args, DefaultArgsMode::Apply);
assert_eq!(
request.passthrough,
values(&["prompt", "--no-default-args"])
);
}
#[test]
fn missing_recognized_value_is_an_error() {
let error = parse_external_launch(values(&["claude", "--domain"]))
.unwrap_err()
.to_string();
assert_eq!(error, "--domain requires a value");
}
}