Skip to main content

skill_embed_clap/
lib.rs

1//! A [clap] command for the agent skills embedded by [`skill_embed`].
2//!
3//! ```no_run
4//! use clap::Command;
5//! # use std::sync::LazyLock;
6//! # static SKILLS: LazyLock<skill_embed::Installer> = LazyLock::new(|| unimplemented!());
7//!
8//! let cli = Command::new("mytool").subcommand(skill_embed_clap::command(&SKILLS));
9//! let matches = cli.get_matches();
10//! if let Some((name, args)) = matches.subcommand()
11//!     && name == SKILLS.command_name()
12//! {
13//!     skill_embed_clap::run(&SKILLS, args)?;
14//! }
15//! # Ok::<_, skill_embed::Error>(())
16//! ```
17//!
18//! The flags are the ones `gh skill install` defines, so a user who knows that
19//! command already knows this one. What each subcommand prints comes from
20//! [`skill_embed::render_results`] and [`skill_embed::render_status`], and goes
21//! to the writer [`skill_embed::Installer::with_output`] names, so every front
22//! end reports the same way to the same place.
23//!
24//! [clap]: https://docs.rs/clap
25
26use clap::{Arg, ArgAction, ArgMatches, Command};
27use skill_embed::{AgentSelector, Error, InstallOptions, Installer, Result, Scope};
28
29/// The skill command, with `install`, `uninstall` and `list` beneath it.
30///
31/// Its name is the installer's command name, `skill` unless it was changed.
32#[must_use]
33pub fn command(skills: &Installer) -> Command {
34    let tool = skills.tool_name().to_owned();
35    Command::new(skills.command_name().to_owned())
36        .about(format!("Manage the agent skills embedded in {tool}"))
37        .subcommand_required(true)
38        .arg_required_else_help(true)
39        .subcommand(flags(
40            Command::new("install").about(format!(
41                "Install the agent skills embedded in {tool}, \
42                     and remove the ones it no longer carries"
43            )),
44            skills,
45        ))
46        .subcommand(flags(
47            Command::new("uninstall")
48                .visible_alias("remove")
49                .about(format!("Remove the agent skills embedded in {tool}")),
50            skills,
51        ))
52        .subcommand(flags(
53            Command::new("list").visible_alias("ls").about(format!(
54                "Show the agent skills embedded in {tool}, and where each one stands"
55            )),
56            skills,
57        ))
58}
59
60/// The flags every subcommand shares, and the skill names it takes.
61fn flags(command: Command, skills: &Installer) -> Command {
62    let defaults: Vec<String> = skills.default_agents().iter().map(ToString::to_string).collect();
63    command
64        .arg(
65            Arg::new("agent")
66                .long("agent")
67                .value_name("AGENT")
68                .action(ArgAction::Append)
69                .value_delimiter(',')
70                .default_value(defaults.join(","))
71                .help(format!("Target agent: {}, or all, or detected", skills.agent_choices())),
72        )
73        .arg(
74            Arg::new("dir")
75                .long("dir")
76                .value_name("DIR")
77                .help("Install to a custom directory (overrides --agent and --scope)"),
78        )
79        .arg(
80            Arg::new("scope")
81                .long("scope")
82                .value_name("SCOPE")
83                .value_parser(["project", "user"])
84                .default_value(skills.default_scope().as_str())
85                .help("Installation scope"),
86        )
87        .arg(
88            Arg::new("force")
89                .long("force")
90                .short('f')
91                .action(ArgAction::SetTrue)
92                .help("Overwrite existing skills"),
93        )
94        .arg(
95            Arg::new("dry-run")
96                .long("dry-run")
97                .action(ArgAction::SetTrue)
98                .help("Report what would happen without writing"),
99        )
100        .arg(
101            Arg::new("skill")
102                .value_name("SKILL")
103                .action(ArgAction::Append)
104                .help("The skills to act on, or every embedded skill"),
105        )
106}
107
108/// Reads the flags clap parsed.
109///
110/// A front end that builds its own [`Command`] uses this to reach the same
111/// options without repeating the flag names.
112///
113/// # Errors
114///
115/// Fails when `--scope` names neither scope, which a [`Command`] built by
116/// [`command`] refuses before this is reached.
117pub fn options(matches: &ArgMatches) -> Result<InstallOptions> {
118    let given: Vec<&String> = matches.get_many::<String>("agent").into_iter().flatten().collect();
119    let agents: Vec<AgentSelector> =
120        given.iter().flat_map(|v| AgentSelector::parse_list(v).collect::<Vec<_>>()).collect();
121    // A value that names nothing is a mistake, and an empty list reads as "use
122    // the default" everywhere below. Only a front end knows the flag was given.
123    if !given.is_empty() && agents.is_empty() {
124        return Err(Error::NoAgentSelected);
125    }
126    let scope = match matches.get_one::<String>("scope") {
127        Some(s) => Some(s.parse::<Scope>()?),
128        None => None,
129    };
130    Ok(InstallOptions {
131        agents,
132        scope,
133        dir: matches.get_one::<String>("dir").map(Into::into),
134        force: matches.get_flag("force"),
135        dry_run: matches.get_flag("dry-run"),
136        names: matches.get_many::<String>("skill").into_iter().flatten().cloned().collect(),
137        cancel: None,
138    })
139}
140
141/// Runs whichever subcommand clap matched, and prints what it did.
142///
143/// `matches` are the skill command's own, so a tool that nests it deeper passes
144/// the matches from that level.
145///
146/// # Errors
147///
148/// Whatever the run refused, including [`Error::NeedsForce`] when a destination
149/// was left alone. The report is printed before the error is returned, because
150/// a run that wrote three skills and refused a fourth has to say so.
151pub fn run(skills: &Installer, matches: &ArgMatches) -> Result<()> {
152    let Some((name, args)) = matches.subcommand() else {
153        return Err(Error::Usage(format!("{} needs a subcommand", skills.command_name())));
154    };
155    let options = options(args)?;
156    match name {
157        "list" => {
158            let statuses = skills.status(&options)?;
159            skills.write_report(&skill_embed::render_status(&statuses))
160        }
161        "install" | "uninstall" => {
162            let (results, outcome) = if name == "install" {
163                skills.install(&options)
164            } else {
165                skills.uninstall(&options)
166            };
167            // Reported first. The results describe everything that happened
168            // before the error, and a run that wrote three skills and refused a
169            // fourth has to say so.
170            skills.write_report(&skill_embed::render_results(&results, options.dry_run))?;
171            outcome
172        }
173        _ => Err(Error::Usage(format!("unknown {} subcommand {name:?}", skills.command_name()))),
174    }
175}