use std::ffi::OsStr;
use std::process::ExitCode;
use std::str::FromStr as _;
use crate::textfmt::{self, Columns};
use crate::{
AgentSelector, Error, InstallOptions, InstallResult, InstallStatus, Installer, Result, Scope,
State,
};
type ActionRun = fn(&Installer, &InstallOptions) -> (Vec<InstallResult>, Result<()>);
struct Flag {
long: &'static str,
short: Option<char>,
value: Option<&'static str>,
help: &'static str,
}
const FLAGS: [Flag; 6] = [
Flag {
long: "agent",
short: None,
value: Some("AGENT"),
help: "Target agent: {AGENTS}, or all, or detected (repeatable) [default: {DEFAULT_AGENTS}]",
},
Flag {
long: "dir",
short: None,
value: Some("DIR"),
help: "Install to a custom directory (overrides --agent and --scope)",
},
Flag {
long: "scope",
short: None,
value: Some("SCOPE"),
help: "Installation scope: {project|user} [default: {DEFAULT_SCOPE}]",
},
Flag { long: "force", short: Some('f'), value: None, help: "Overwrite existing skills" },
Flag {
long: "dry-run",
short: None,
value: None,
help: "Report what would happen without writing",
},
Flag { long: "help", short: Some('h'), value: None, help: "Show this help" },
];
impl Installer {
pub fn run(&self, args: &[String]) -> Result<()> {
let Some((sub, rest)) = args.split_first() else {
self.print_out(&self.usage())?;
return Err(Error::Help);
};
match sub.as_str() {
"install" => self.run_action(sub, rest, Self::install),
"uninstall" | "remove" => self.run_action("uninstall", rest, Self::uninstall),
"list" | "ls" => self.run_list(rest),
"help" | "-h" | "--help" => {
self.print_out(&self.usage())?;
Err(Error::Help)
}
_ => {
self.print_err(&self.usage())?;
Err(Error::Usage(format!("unknown {} subcommand {sub:?}", self.command_name())))
}
}
}
#[must_use]
pub fn intercept(&self) -> Option<ExitCode> {
self.intercept_args(&std::env::args_os().collect::<Vec<_>>())
}
#[must_use]
pub fn intercept_args<S: AsRef<OsStr>>(&self, argv: &[S]) -> Option<ExitCode> {
if argv.get(1)?.as_ref() != OsStr::new(self.command_name()) {
return None;
}
let mut rest = Vec::with_capacity(argv.len().saturating_sub(2));
for arg in &argv[2..] {
let Some(text) = arg.as_ref().to_str() else {
let _ = self.print_err(&format!(
"{}: {:?} is not UTF-8, so this command cannot read it\n",
self.tool_name(),
arg.as_ref().to_string_lossy()
));
return Some(ExitCode::FAILURE);
};
rest.push(text.to_owned());
}
Some(match self.run(&rest) {
Ok(()) | Err(Error::Help) => ExitCode::SUCCESS,
Err(e) => {
let _ = self.print_err(&format!("{}: {e}\n", self.tool_name()));
ExitCode::FAILURE
}
})
}
fn run_action(&self, sub: &str, args: &[String], run: ActionRun) -> Result<()> {
let options = self.parse(sub, args)?;
let (results, outcome) = run(self, &options);
self.print_out(&render_results(&results, options.dry_run))?;
outcome
}
fn run_list(&self, args: &[String]) -> Result<()> {
let options = self.parse("list", args)?;
let statuses = self.status(&options)?;
self.print_out(&render_status(&statuses))
}
fn print_out(&self, s: &str) -> Result<()> {
self.write_out(s).map_err(|e| Error::io("write the report", e))
}
fn print_err(&self, s: &str) -> Result<()> {
self.write_err(s).map_err(|e| Error::io("write the report", e))
}
fn parse(&self, sub: &str, args: &[String]) -> Result<InstallOptions> {
let mut options =
InstallOptions { scope: Some(self.default_scope()), ..Default::default() };
let mut rest = args.iter();
let mut positional_only = false;
let mut agent_given = false;
while let Some(arg) = rest.next() {
if positional_only || !arg.starts_with('-') || arg == "-" {
options.names.push(arg.clone());
continue;
}
if arg == "--" {
positional_only = true;
continue;
}
let body = arg.trim_start_matches('-');
let (name, inline) = match body.split_once('=') {
Some((name, value)) => (name, Some(value.to_owned())),
None => (body, None),
};
let Some(flag) = FLAGS.iter().find(|f| {
f.long == name || f.short.is_some_and(|s| name.len() == 1 && name.starts_with(s))
}) else {
return self.usage_error(sub, format!("unknown flag {arg:?}"));
};
if flag.value.is_none() && inline.is_some() {
return self.usage_error(sub, format!("--{} takes no value", flag.long));
}
let value = match flag.value {
None => None,
Some(_) => match inline.or_else(|| rest.next().cloned()) {
Some(v) => Some(v),
None => {
return self.usage_error(sub, format!("--{} needs a value", flag.long));
}
},
};
match (flag.long, value) {
("agent", Some(v)) => {
agent_given = true;
options.agents.extend(AgentSelector::parse_list(&v));
}
("dir", Some(v)) if v.is_empty() => {
return self.usage_error(sub, "--dir must name a directory".to_owned());
}
("dir", Some(v)) => options.dir = Some(v.into()),
("scope", Some(v)) => match Scope::from_str(&v) {
Ok(scope) => options.scope = Some(scope),
Err(e) => return self.usage_error(sub, e.to_string()),
},
("force", None) => options.force = true,
("dry-run", None) => options.dry_run = true,
("help", None) => {
self.print_out(&self.usage_for(Some(sub)))?;
return Err(Error::Help);
}
_ => unreachable!("every flag is handled"),
}
}
if agent_given && options.agents.is_empty() {
return self.usage_error(sub, Error::NoAgentSelected.to_string());
}
Ok(options)
}
fn usage_error(&self, sub: &str, message: String) -> Result<InstallOptions> {
self.print_err(&self.usage_for(Some(sub)))?;
Err(Error::Usage(message))
}
}
pub(crate) fn flag_block(agents: &str, default_agents: &str, default_scope: Scope) -> String {
let mut c = Columns::default();
for f in &FLAGS {
let left = match (f.short, f.value) {
(Some(s), Some(v)) => format!(" -{s}, --{} <{v}>", f.long),
(Some(s), None) => format!(" -{s}, --{}", f.long),
(None, Some(v)) => format!(" --{} <{v}>", f.long),
(None, None) => format!(" --{}", f.long),
};
let help = f
.help
.replace("{AGENTS}", agents)
.replace("{DEFAULT_AGENTS}", default_agents)
.replace("{DEFAULT_SCOPE}", default_scope.as_str());
c.push([left, help]);
}
c.render()
}
fn project_line(target: Option<&crate::InstallTarget>) -> String {
match target.and_then(|t| t.root.as_deref()) {
Some(root) => format!("Project root: {}\n\n", root.display()),
None => String::new(),
}
}
#[must_use]
pub fn render_status(statuses: &[InstallStatus]) -> String {
let mut out = project_line(statuses.first().map(|st| &st.target));
let mut seen: Vec<&str> = Vec::new();
for st in statuses {
if st.state == State::Orphaned || seen.contains(&st.skill.as_str()) {
continue;
}
seen.push(&st.skill);
out.push_str(&st.skill);
out.push('\n');
if !st.description.is_empty() {
out.push_str(" ");
out.push_str(&textfmt::first_line(&st.description));
out.push('\n');
}
}
out.push('\n');
let mut c = Columns::default();
c.push(["SKILL".to_owned(), "STATE".to_owned(), "PATH".to_owned()]);
for st in statuses {
c.push([st.skill.clone(), st.state.to_string(), st.path.display().to_string()]);
}
out.push_str(&c.render());
textfmt::trim_lines(&out)
}
#[must_use]
pub fn render_results(results: &[InstallResult], dry_run: bool) -> String {
let prefix = if dry_run { "would be " } else { "" };
let mut out = project_line(results.first().map(|r| &r.target));
let mut c = Columns::default();
for r in results {
let note = match &r.reason {
Some(reason) => format!(" ({reason})"),
None => String::new(),
};
c.push([
format!("{prefix}{}", r.action),
r.skill.clone(),
format!("{}{note}", r.path.display()),
]);
}
out.push_str(&c.render());
textfmt::trim_lines(&out)
}