Skip to main content

bb_cli/commands/
skill.rs

1use crate::error::{BbError, Result};
2use crate::output::{self, Format};
3use crate::skill::{self, Action, Agent};
4use serde::Serialize;
5
6#[derive(Serialize)]
7struct OutcomeRow {
8    path: String,
9    agent: String,
10    skill: String,
11    action: String,
12}
13
14#[derive(Serialize)]
15struct StatusRowJson {
16    path: String,
17    agent: String,
18    skill: String,
19    state: String,
20}
21
22#[derive(Serialize)]
23struct UninstallRowJson {
24    path: String,
25    skill: String,
26    /// One of "removed", "refused_modified", "refused_not_written",
27    /// "refused_unsafe_path", "absent" —
28    /// kept as an explicit outcome string rather than a boolean so a consumer
29    /// can tell "we refused to touch a local edit" apart from "there was
30    /// nothing there to remove"; collapsing those into one `removed: false`
31    /// used to make an honest "it just wasn't there" render as the same lie
32    /// as a refusal, in both human text and this JSON.
33    outcome: String,
34}
35
36/// The skills a command acts on. `None` means all of them; an unknown name is a
37/// configuration error naming the valid ones, not a silent no-op.
38fn wanted_skills(name: Option<&str>) -> Result<Vec<&'static skill::Skill>> {
39    match name {
40        None => Ok(skill::SKILLS.iter().collect()),
41        Some(name) => skill::skill_by_name(name).map(|s| vec![s]).ok_or_else(|| {
42            let valid: Vec<&str> = skill::SKILLS.iter().map(|s| s.name).collect();
43            BbError::Config(format!(
44                "unknown skill `{name}` — expected one of {}",
45                valid.join(", ")
46            ))
47        }),
48    }
49}
50
51/// The skills to act on, asking when the choice is genuinely open.
52///
53/// The prompt appears only when no `--skill` was given, `--all` was not passed,
54/// the format is human, and stdin is a terminal. That last condition is
55/// load-bearing three times over: the integration suite drives this binary with
56/// piped stdin, CI has no terminal, and `auto_refresh_skills` runs before every
57/// command's own logic — a prompt on any of those paths hangs rather than fails.
58fn choose_skills(
59    format: Format,
60    skill_name: Option<&str>,
61    all: bool,
62) -> Result<Vec<&'static skill::Skill>> {
63    let wanted = wanted_skills(skill_name)?;
64    let interactive = skill_name.is_none()
65        && !all
66        && !format.is_json()
67        && std::io::IsTerminal::is_terminal(&std::io::stdin());
68    if !interactive {
69        return Ok(wanted);
70    }
71
72    let options: Vec<String> = wanted
73        .iter()
74        .map(|s| format!("{} — {}", s.name, s.summary))
75        .collect();
76    // Everything preselected, so the fast path is one keypress and the
77    // behaviour matches what this command did before the prompt existed.
78    let defaults: Vec<usize> = (0..options.len()).collect();
79    let picked = inquire::MultiSelect::new("Which skills should be installed?", options.clone())
80        .with_default(&defaults)
81        .prompt()
82        .map_err(|_| BbError::Config("install cancelled — nothing was written".to_string()))?;
83
84    Ok(pick_skills(&wanted, &options, &picked))
85}
86
87/// Maps the labels the user picked in the prompt back to the `&'static Skill`
88/// values they came from. Pulled out of `choose_skills` because the prompt
89/// itself only runs behind a terminal, so this is the part of that function a
90/// test can actually reach.
91fn pick_skills(
92    wanted: &[&'static skill::Skill],
93    options: &[String],
94    picked: &[String],
95) -> Vec<&'static skill::Skill> {
96    options
97        .iter()
98        .enumerate()
99        .filter(|(_, label)| picked.contains(label))
100        .map(|(i, _)| wanted[i])
101        .collect()
102}
103
104pub fn install(
105    format: Format,
106    agent: Option<&str>,
107    global: bool,
108    force: bool,
109    skill_name: Option<&str>,
110    all: bool,
111) -> Result<()> {
112    let root = if global {
113        home_dir()?
114    } else {
115        std::env::current_dir().map_err(BbError::Io)?
116    };
117
118    let agents = match agent {
119        Some("all") => skill::Agent::all().to_vec(),
120        Some("agents") => vec![Agent::Agents],
121        Some("claude") => vec![Agent::Claude],
122        Some(other) => {
123            return Err(BbError::Config(format!(
124                "unknown agent `{other}` — expected agents, claude or all"
125            )))
126        }
127        None => {
128            let detected = skill::detect_agents(&root);
129            if detected.is_empty() {
130                // `.agents/skills/` is the portable location Codex, Cursor and
131                // OpenCode all read, so it is the safe default.
132                if !format.is_json() {
133                    output::info(
134                        "no agent directory found — installing to .agents/skills/, which Codex, Cursor and OpenCode read",
135                    );
136                }
137                vec![Agent::Agents]
138            } else {
139                detected
140            }
141        }
142    };
143
144    let skills = choose_skills(format, skill_name, all)?;
145    if skills.is_empty() {
146        if !format.is_json() {
147            output::info("no skills selected — nothing installed");
148        }
149        return Ok(());
150    }
151    let outcomes = skill::install(&root, &agents, &skills, force)?;
152    let rows: Vec<OutcomeRow> = outcomes
153        .iter()
154        .map(|o| OutcomeRow {
155            path: o.path.display().to_string(),
156            agent: o.agent.clone(),
157            skill: o.skill.clone(),
158            action: o.action.as_str().to_string(),
159        })
160        .collect();
161
162    match format {
163        Format::Json => output::print_json(&rows)?,
164        Format::Human => {
165            for row in &rows {
166                let line = format!("{} {}", row.action, row.path);
167                // `skill::install()` (the only producer of these rows) never
168                // emits `Pruned` or `Failed` — those come only from
169                // `refresh_tracked`, reached through `bb update` and the
170                // pre-command auto-refresh, not this command. No arm for them
171                // here, so a future wiring mistake falls through to the
172                // generic success line instead of silently matching nothing.
173                match row.action.as_str() {
174                    "unchanged" => output::info(&line),
175                    "skipped_modified" => output::warn(&line),
176                    _ => output::success(&line),
177                }
178            }
179        }
180    }
181
182    // A refusal is an error the user must act on, so it sets the exit code —
183    // after the report, so they can see which paths were fine.
184    if outcomes.iter().any(|o| o.action == Action::SkippedModified) {
185        return Err(BbError::Config(
186            "some skills were edited locally and were left alone — pass --force to overwrite"
187                .into(),
188        ));
189    }
190    Ok(())
191}
192
193pub fn status(format: Format) -> Result<()> {
194    let (rows, warning) = skill::status();
195    if let Some(warning) = warning {
196        output::warn(&warning);
197    }
198
199    match format {
200        Format::Json => {
201            let json_rows: Vec<StatusRowJson> = rows
202                .iter()
203                .map(|r| StatusRowJson {
204                    path: r.path.display().to_string(),
205                    agent: r.agent.clone(),
206                    skill: r.skill.clone(),
207                    state: r.state.as_str().to_string(),
208                })
209                .collect();
210            output::print_json(&json_rows)?;
211        }
212        Format::Human => {
213            let table_rows: Vec<Vec<String>> = rows
214                .iter()
215                .map(|r| {
216                    vec![
217                        r.path.display().to_string(),
218                        r.skill.clone(),
219                        r.agent.clone(),
220                        r.state.as_str().to_string(),
221                    ]
222                })
223                .collect();
224            output::print_table(&["PATH", "SKILL", "AGENT", "STATE"], table_rows);
225            output::info(&format!(
226                "{} tracked skill{}",
227                rows.len(),
228                if rows.len() == 1 { "" } else { "s" }
229            ));
230        }
231    }
232    Ok(())
233}
234
235pub fn uninstall(
236    format: Format,
237    global: bool,
238    force: bool,
239    skill_name: Option<&str>,
240) -> Result<()> {
241    let root = if global {
242        home_dir()?
243    } else {
244        std::env::current_dir().map_err(BbError::Io)?
245    };
246
247    let skills = wanted_skills(skill_name)?;
248    let results = skill::uninstall(Some(&root), &skills, force)?;
249
250    match format {
251        Format::Json => {
252            let json_rows: Vec<UninstallRowJson> = results
253                .iter()
254                .map(|(path, skill, outcome)| UninstallRowJson {
255                    path: path.display().to_string(),
256                    skill: skill.clone(),
257                    outcome: outcome.as_str().to_string(),
258                })
259                .collect();
260            output::print_json(&json_rows)?;
261        }
262        Format::Human => {
263            if results.is_empty() {
264                output::info("nothing to uninstall");
265            }
266            for (path, _skill, outcome) in &results {
267                match outcome {
268                    skill::RemovalOutcome::Removed => {
269                        output::success(&format!("removed {}", path.display()))
270                    }
271                    skill::RemovalOutcome::RefusedModified => output::warn(&format!(
272                        "{} was edited locally — left alone (pass --force to remove)",
273                        path.display()
274                    )),
275                    skill::RemovalOutcome::RefusedNotWritten => output::warn(&format!(
276                        "{} was not written by bb — left alone (pass --force to remove)",
277                        path.display()
278                    )),
279                    skill::RemovalOutcome::RefusedUnsafePath => output::warn(&format!(
280                        "{} does not look like a skill path bb would have written — left alone",
281                        path.display()
282                    )),
283                    skill::RemovalOutcome::Absent => output::info(&format!(
284                        "{} was already gone — nothing to remove",
285                        path.display()
286                    )),
287                }
288            }
289        }
290    }
291    Ok(())
292}
293
294fn home_dir() -> Result<std::path::PathBuf> {
295    std::env::var_os("HOME")
296        .filter(|h| !h.is_empty())
297        .map(std::path::PathBuf::from)
298        .ok_or_else(|| BbError::Config("HOME is not set, so --global has no target".into()))
299}
300
301#[cfg(test)]
302#[allow(clippy::unwrap_used)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn pick_skills_all_picked() {
308        let wanted: Vec<&'static skill::Skill> = skill::SKILLS.iter().collect();
309        let options: Vec<String> = wanted
310            .iter()
311            .map(|s| format!("{} — {}", s.name, s.summary))
312            .collect();
313        let picked = options.clone();
314
315        let result = pick_skills(&wanted, &options, &picked);
316
317        assert_eq!(result.len(), wanted.len());
318    }
319
320    #[test]
321    fn pick_skills_some_picked() {
322        let wanted: Vec<&'static skill::Skill> = skill::SKILLS.iter().collect();
323        let options: Vec<String> = wanted
324            .iter()
325            .map(|s| format!("{} — {}", s.name, s.summary))
326            .collect();
327        assert!(
328            options.len() >= 2,
329            "test needs at least two skills to pick a subset"
330        );
331        let picked = vec![options[0].clone()];
332
333        let result = pick_skills(&wanted, &options, &picked);
334
335        assert_eq!(result.len(), 1);
336        assert_eq!(result[0].name, wanted[0].name);
337    }
338
339    #[test]
340    fn pick_skills_none_picked() {
341        let wanted: Vec<&'static skill::Skill> = skill::SKILLS.iter().collect();
342        let options: Vec<String> = wanted
343            .iter()
344            .map(|s| format!("{} — {}", s.name, s.summary))
345            .collect();
346        let picked: Vec<String> = Vec::new();
347
348        let result = pick_skills(&wanted, &options, &picked);
349
350        assert!(result.is_empty());
351    }
352}