1use std::ffi::OsStr;
2use std::process::ExitCode;
3use std::str::FromStr as _;
4
5use crate::textfmt::{self, Columns};
6use crate::{
7 AgentSelector, Error, InstallOptions, InstallResult, InstallStatus, Installer, Result, Scope,
8 State,
9};
10
11type ActionRun = fn(&Installer, &InstallOptions) -> (Vec<InstallResult>, Result<()>);
14
15struct Flag {
17 long: &'static str,
18 short: Option<char>,
19 value: Option<&'static str>,
21 help: &'static str,
22}
23
24const FLAGS: [Flag; 6] = [
25 Flag {
26 long: "agent",
27 short: None,
28 value: Some("AGENT"),
29 help: "Target agent: {AGENTS}, or all, or detected (repeatable) [default: {DEFAULT_AGENTS}]",
30 },
31 Flag {
32 long: "dir",
33 short: None,
34 value: Some("DIR"),
35 help: "Install to a custom directory (overrides --agent and --scope)",
36 },
37 Flag {
38 long: "scope",
39 short: None,
40 value: Some("SCOPE"),
41 help: "Installation scope: {project|user} [default: {DEFAULT_SCOPE}]",
42 },
43 Flag { long: "force", short: Some('f'), value: None, help: "Overwrite existing skills" },
44 Flag {
45 long: "dry-run",
46 short: None,
47 value: None,
48 help: "Report what would happen without writing",
49 },
50 Flag { long: "help", short: Some('h'), value: None, help: "Show this help" },
51];
52
53impl Installer {
54 pub fn run(&self, args: &[String]) -> Result<()> {
67 let Some((sub, rest)) = args.split_first() else {
68 self.print_out(&self.usage())?;
69 return Err(Error::Help);
70 };
71 match sub.as_str() {
72 "install" => self.run_action(sub, rest, Self::install),
73 "uninstall" | "remove" => self.run_action("uninstall", rest, Self::uninstall),
74 "list" | "ls" => self.run_list(rest),
75 "help" | "-h" | "--help" => {
76 self.print_out(&self.usage())?;
77 Err(Error::Help)
78 }
79 _ => {
80 self.print_err(&self.usage())?;
81 Err(Error::Usage(format!("unknown {} subcommand {sub:?}", self.command_name())))
82 }
83 }
84 }
85
86 #[must_use]
111 pub fn intercept(&self) -> Option<ExitCode> {
112 self.intercept_args(&std::env::args_os().collect::<Vec<_>>())
117 }
118
119 #[must_use]
122 pub fn intercept_args<S: AsRef<OsStr>>(&self, argv: &[S]) -> Option<ExitCode> {
123 if argv.get(1)?.as_ref() != OsStr::new(self.command_name()) {
124 return None;
125 }
126 let mut rest = Vec::with_capacity(argv.len().saturating_sub(2));
131 for arg in &argv[2..] {
132 let Some(text) = arg.as_ref().to_str() else {
133 let _ = self.print_err(&format!(
134 "{}: {:?} is not UTF-8, so this command cannot read it\n",
135 self.tool_name(),
136 arg.as_ref().to_string_lossy()
137 ));
138 return Some(ExitCode::FAILURE);
139 };
140 rest.push(text.to_owned());
141 }
142 Some(match self.run(&rest) {
143 Ok(()) | Err(Error::Help) => ExitCode::SUCCESS,
144 Err(e) => {
145 let _ = self.print_err(&format!("{}: {e}\n", self.tool_name()));
146 ExitCode::FAILURE
147 }
148 })
149 }
150
151 fn run_action(&self, sub: &str, args: &[String], run: ActionRun) -> Result<()> {
152 let options = self.parse(sub, args)?;
153 let (results, outcome) = run(self, &options);
156 self.print_out(&render_results(&results, options.dry_run))?;
157 outcome
158 }
159
160 fn run_list(&self, args: &[String]) -> Result<()> {
161 let options = self.parse("list", args)?;
162 let statuses = self.status(&options)?;
163 self.print_out(&render_status(&statuses))
164 }
165
166 fn print_out(&self, s: &str) -> Result<()> {
167 self.write_out(s).map_err(|e| Error::io("write the report", e))
168 }
169
170 fn print_err(&self, s: &str) -> Result<()> {
171 self.write_err(s).map_err(|e| Error::io("write the report", e))
172 }
173
174 fn parse(&self, sub: &str, args: &[String]) -> Result<InstallOptions> {
180 let mut options =
181 InstallOptions { scope: Some(self.default_scope()), ..Default::default() };
182 let mut rest = args.iter();
183 let mut positional_only = false;
184 let mut agent_given = false;
185
186 while let Some(arg) = rest.next() {
187 if positional_only || !arg.starts_with('-') || arg == "-" {
188 options.names.push(arg.clone());
189 continue;
190 }
191 if arg == "--" {
192 positional_only = true;
193 continue;
194 }
195 let body = arg.trim_start_matches('-');
196 let (name, inline) = match body.split_once('=') {
197 Some((name, value)) => (name, Some(value.to_owned())),
198 None => (body, None),
199 };
200 let Some(flag) = FLAGS.iter().find(|f| {
201 f.long == name || f.short.is_some_and(|s| name.len() == 1 && name.starts_with(s))
202 }) else {
203 return self.usage_error(sub, format!("unknown flag {arg:?}"));
204 };
205 if flag.value.is_none() && inline.is_some() {
206 return self.usage_error(sub, format!("--{} takes no value", flag.long));
207 }
208 let value = match flag.value {
209 None => None,
210 Some(_) => match inline.or_else(|| rest.next().cloned()) {
211 Some(v) => Some(v),
212 None => {
213 return self.usage_error(sub, format!("--{} needs a value", flag.long));
214 }
215 },
216 };
217 match (flag.long, value) {
218 ("agent", Some(v)) => {
219 agent_given = true;
223 options.agents.extend(AgentSelector::parse_list(&v));
224 }
225 ("dir", Some(v)) if v.is_empty() => {
226 return self.usage_error(sub, "--dir must name a directory".to_owned());
227 }
228 ("dir", Some(v)) => options.dir = Some(v.into()),
229 ("scope", Some(v)) => match Scope::from_str(&v) {
230 Ok(scope) => options.scope = Some(scope),
231 Err(e) => return self.usage_error(sub, e.to_string()),
232 },
233 ("force", None) => options.force = true,
234 ("dry-run", None) => options.dry_run = true,
235 ("help", None) => {
236 self.print_out(&self.usage_for(Some(sub)))?;
237 return Err(Error::Help);
238 }
239 _ => unreachable!("every flag is handled"),
240 }
241 }
242 if agent_given && options.agents.is_empty() {
243 return self.usage_error(sub, Error::NoAgentSelected.to_string());
244 }
245 Ok(options)
246 }
247
248 fn usage_error(&self, sub: &str, message: String) -> Result<InstallOptions> {
249 self.print_err(&self.usage_for(Some(sub)))?;
250 Err(Error::Usage(message))
251 }
252}
253
254pub(crate) fn flag_block(agents: &str, default_agents: &str, default_scope: Scope) -> String {
257 let mut c = Columns::default();
258 for f in &FLAGS {
259 let left = match (f.short, f.value) {
260 (Some(s), Some(v)) => format!(" -{s}, --{} <{v}>", f.long),
261 (Some(s), None) => format!(" -{s}, --{}", f.long),
262 (None, Some(v)) => format!(" --{} <{v}>", f.long),
263 (None, None) => format!(" --{}", f.long),
264 };
265 let help = f
266 .help
267 .replace("{AGENTS}", agents)
268 .replace("{DEFAULT_AGENTS}", default_agents)
269 .replace("{DEFAULT_SCOPE}", default_scope.as_str());
270 c.push([left, help]);
271 }
272 c.render()
273}
274
275fn project_line(target: Option<&crate::InstallTarget>) -> String {
281 match target.and_then(|t| t.root.as_deref()) {
282 Some(root) => format!("Project root: {}\n\n", root.display()),
283 None => String::new(),
284 }
285}
286
287#[must_use]
295pub fn render_status(statuses: &[InstallStatus]) -> String {
296 let mut out = project_line(statuses.first().map(|st| &st.target));
297
298 let mut seen: Vec<&str> = Vec::new();
299 for st in statuses {
300 if st.state == State::Orphaned || seen.contains(&st.skill.as_str()) {
303 continue;
304 }
305 seen.push(&st.skill);
306 out.push_str(&st.skill);
307 out.push('\n');
308 if !st.description.is_empty() {
309 out.push_str(" ");
310 out.push_str(&textfmt::first_line(&st.description));
311 out.push('\n');
312 }
313 }
314 out.push('\n');
315
316 let mut c = Columns::default();
317 c.push(["SKILL".to_owned(), "STATE".to_owned(), "PATH".to_owned()]);
318 for st in statuses {
319 c.push([st.skill.clone(), st.state.to_string(), st.path.display().to_string()]);
320 }
321 out.push_str(&c.render());
322 textfmt::trim_lines(&out)
323}
324
325#[must_use]
329pub fn render_results(results: &[InstallResult], dry_run: bool) -> String {
330 let prefix = if dry_run { "would be " } else { "" };
331 let mut out = project_line(results.first().map(|r| &r.target));
332 let mut c = Columns::default();
333 for r in results {
334 let note = match &r.reason {
335 Some(reason) => format!(" ({reason})"),
336 None => String::new(),
337 };
338 c.push([
339 format!("{prefix}{}", r.action),
340 r.skill.clone(),
341 format!("{}{note}", r.path.display()),
342 ]);
343 }
344 out.push_str(&c.render());
345 textfmt::trim_lines(&out)
346}