Skip to main content

skill_embed/
cli.rs

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
11/// What install and uninstall have in common: they differ only in what they
12/// call and in nothing else.
13type ActionRun = fn(&Installer, &InstallOptions) -> (Vec<InstallResult>, Result<()>);
14
15/// One flag the built-in command takes.
16struct Flag {
17    long: &'static str,
18    short: Option<char>,
19    /// The placeholder a value goes in, or [`None`] for a switch.
20    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    /// Executes the skill command.
55    ///
56    /// `args` are the arguments after the command name, so `mytool skill
57    /// install --scope user` passes `["install", "--scope", "user"]`.
58    ///
59    /// It never exits the process, so a driver keeps control. It is what the
60    /// adapters call underneath, and what [`Installer::intercept`] wraps.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`Error::Help`] when help was asked for, which is not a failure.
65    /// Everything else is.
66    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    /// Runs the skill command when it is the first argument, and reports the
87    /// exit code the process should end with.
88    ///
89    /// [`None`] means the arguments were not for this command and the tool
90    /// should carry on:
91    ///
92    /// ```no_run
93    /// use std::process::ExitCode;
94    /// use std::sync::LazyLock;
95    /// # use skill_embed::Installer;
96    /// # static SKILLS: LazyLock<Installer> = LazyLock::new(|| unimplemented!());
97    ///
98    /// fn main() -> ExitCode {
99    ///     if let Some(code) = SKILLS.intercept() {
100    ///         return code;
101    ///     }
102    ///     // the rest of your tool
103    ///     ExitCode::SUCCESS
104    /// }
105    /// ```
106    ///
107    /// The guard is the first argument and nothing else. `mytool skill install`
108    /// reaches it. `mytool -v skill install` does not. Put your own flags after
109    /// the subcommand, or before a normal run.
110    #[must_use]
111    pub fn intercept(&self) -> Option<ExitCode> {
112        // `args_os`, not `args`. The latter panics on an argument that is not
113        // UTF-8, and this runs before the tool has looked at its own command
114        // line, so a file name the tool would have handled took the whole
115        // process down.
116        self.intercept_args(&std::env::args_os().collect::<Vec<_>>())
117    }
118
119    /// [`Installer::intercept`] against arguments the caller supplies, starting
120    /// with the program name.
121    #[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        // Past the guard, the arguments are this command's own. One that is not
127        // UTF-8 is refused rather than replaced: a lossy `--dir` names a
128        // different directory, and two distinct names can collide on one
129        // replacement character. The library still takes any `PathBuf`.
130        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        // Report first. Install describes everything it did before the error,
154        // and a run that wrote three skills and refused a fourth has to say so.
155        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    /// Reads one subcommand's arguments.
175    ///
176    /// Flags are accepted anywhere, so `skill install demo --dry-run` means
177    /// what it looks like. A `--` ends them, for a skill whose name starts with
178    /// a dash.
179    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                    // A value that names nothing is a mistake, and an empty
220                    // `agents` reads as "use the default" everywhere below.
221                    // Only the front end knows the flag was given at all.
222                    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
254/// Renders the flag block, which is written here rather than taken from a
255/// framework, because the core parses the command line itself.
256pub(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
275/// Names the project a run resolved to.
276///
277/// The destinations in each row say it too, but a reader has to infer it from a
278/// leaf path, and a run from a subdirectory can resolve somewhere they did not
279/// expect. Naming the decision is cheaper than noticing it.
280fn 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/// Everything `list` prints: each skill once with its description, then a row
288/// per destination.
289///
290/// It is the whole of the subcommand's output, not a part of it, so that every
291/// front end that calls it says the same thing. The skills come from the
292/// statuses rather than from the set, so a run narrowed by name describes only
293/// what it was asked about.
294#[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        // The heading is what the binary carries. An orphan is on the disk and
301        // not in the binary, so it belongs in the table below and nowhere else.
302        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/// Lists what install or uninstall did at each destination.
326///
327/// The adapters use it so that every front end reports the same way.
328#[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}