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_unsafe_path", "absent" —
27    /// kept as an explicit outcome string rather than a boolean so a consumer
28    /// can tell "we refused to touch a local edit" apart from "there was
29    /// nothing there to remove"; collapsing those into one `removed: false`
30    /// used to make an honest "it just wasn't there" render as the same lie
31    /// as a refusal, in both human text and this JSON.
32    outcome: String,
33}
34
35/// The skills a command acts on. `None` means all of them; an unknown name is a
36/// configuration error naming the valid ones, not a silent no-op.
37fn wanted_skills(name: Option<&str>) -> Result<Vec<&'static skill::Skill>> {
38    match name {
39        None => Ok(skill::SKILLS.iter().collect()),
40        Some(name) => skill::skill_by_name(name).map(|s| vec![s]).ok_or_else(|| {
41            let valid: Vec<&str> = skill::SKILLS.iter().map(|s| s.name).collect();
42            BbError::Config(format!(
43                "unknown skill `{name}` — expected one of {}",
44                valid.join(", ")
45            ))
46        }),
47    }
48}
49
50pub fn install(
51    format: Format,
52    agent: Option<&str>,
53    global: bool,
54    force: bool,
55    skill_name: Option<&str>,
56) -> Result<()> {
57    let root = if global {
58        home_dir()?
59    } else {
60        std::env::current_dir().map_err(BbError::Io)?
61    };
62
63    let agents = match agent {
64        Some("all") => skill::Agent::all().to_vec(),
65        Some("agents") => vec![Agent::Agents],
66        Some("claude") => vec![Agent::Claude],
67        Some(other) => {
68            return Err(BbError::Config(format!(
69                "unknown agent `{other}` — expected agents, claude or all"
70            )))
71        }
72        None => {
73            let detected = skill::detect_agents(&root);
74            if detected.is_empty() {
75                // `.agents/skills/` is the portable location Codex, Cursor and
76                // OpenCode all read, so it is the safe default.
77                if !format.is_json() {
78                    output::info(
79                        "no agent directory found — installing to .agents/skills/, which Codex, Cursor and OpenCode read",
80                    );
81                }
82                vec![Agent::Agents]
83            } else {
84                detected
85            }
86        }
87    };
88
89    let skills = wanted_skills(skill_name)?;
90    let outcomes = skill::install(&root, &agents, &skills, force)?;
91    let rows: Vec<OutcomeRow> = outcomes
92        .iter()
93        .map(|o| OutcomeRow {
94            path: o.path.display().to_string(),
95            agent: o.agent.clone(),
96            skill: o.skill.clone(),
97            action: o.action.as_str().to_string(),
98        })
99        .collect();
100
101    match format {
102        Format::Json => output::print_json(&rows)?,
103        Format::Human => {
104            for row in &rows {
105                let line = format!("{} {}", row.action, row.path);
106                // `skill::install()` (the only producer of these rows) never
107                // emits `Pruned` or `Failed` — those come only from
108                // `refresh_tracked`, reached through `bb update` and the
109                // pre-command auto-refresh, not this command. No arm for them
110                // here, so a future wiring mistake falls through to the
111                // generic success line instead of silently matching nothing.
112                match row.action.as_str() {
113                    "unchanged" => output::info(&line),
114                    "skipped_modified" => output::warn(&line),
115                    _ => output::success(&line),
116                }
117            }
118        }
119    }
120
121    // A refusal is an error the user must act on, so it sets the exit code —
122    // after the report, so they can see which paths were fine.
123    if outcomes.iter().any(|o| o.action == Action::SkippedModified) {
124        return Err(BbError::Config(
125            "some skills were edited locally and were left alone — pass --force to overwrite"
126                .into(),
127        ));
128    }
129    Ok(())
130}
131
132pub fn status(format: Format) -> Result<()> {
133    let (rows, warning) = skill::status();
134    if let Some(warning) = warning {
135        output::warn(&warning);
136    }
137
138    match format {
139        Format::Json => {
140            let json_rows: Vec<StatusRowJson> = rows
141                .iter()
142                .map(|r| StatusRowJson {
143                    path: r.path.display().to_string(),
144                    agent: r.agent.clone(),
145                    skill: r.skill.clone(),
146                    state: r.state.as_str().to_string(),
147                })
148                .collect();
149            output::print_json(&json_rows)?;
150        }
151        Format::Human => {
152            let table_rows: Vec<Vec<String>> = rows
153                .iter()
154                .map(|r| {
155                    vec![
156                        r.path.display().to_string(),
157                        r.skill.clone(),
158                        r.agent.clone(),
159                        r.state.as_str().to_string(),
160                    ]
161                })
162                .collect();
163            output::print_table(&["PATH", "SKILL", "AGENT", "STATE"], table_rows);
164            output::info(&format!(
165                "{} tracked skill{}",
166                rows.len(),
167                if rows.len() == 1 { "" } else { "s" }
168            ));
169        }
170    }
171    Ok(())
172}
173
174pub fn uninstall(
175    format: Format,
176    global: bool,
177    force: bool,
178    skill_name: Option<&str>,
179) -> Result<()> {
180    let root = if global {
181        home_dir()?
182    } else {
183        std::env::current_dir().map_err(BbError::Io)?
184    };
185
186    let skills = wanted_skills(skill_name)?;
187    let results = skill::uninstall(Some(&root), &skills, force)?;
188
189    match format {
190        Format::Json => {
191            let json_rows: Vec<UninstallRowJson> = results
192                .iter()
193                .map(|(path, skill, outcome)| UninstallRowJson {
194                    path: path.display().to_string(),
195                    skill: skill.clone(),
196                    outcome: outcome.as_str().to_string(),
197                })
198                .collect();
199            output::print_json(&json_rows)?;
200        }
201        Format::Human => {
202            if results.is_empty() {
203                output::info("nothing to uninstall");
204            }
205            for (path, _skill, outcome) in &results {
206                match outcome {
207                    skill::RemovalOutcome::Removed => {
208                        output::success(&format!("removed {}", path.display()))
209                    }
210                    skill::RemovalOutcome::RefusedModified => output::warn(&format!(
211                        "{} was edited locally — left alone (pass --force to remove)",
212                        path.display()
213                    )),
214                    skill::RemovalOutcome::RefusedUnsafePath => output::warn(&format!(
215                        "{} does not look like a skill path bb would have written — left alone",
216                        path.display()
217                    )),
218                    skill::RemovalOutcome::Absent => output::info(&format!(
219                        "{} was already gone — nothing to remove",
220                        path.display()
221                    )),
222                }
223            }
224        }
225    }
226    Ok(())
227}
228
229fn home_dir() -> Result<std::path::PathBuf> {
230    std::env::var_os("HOME")
231        .filter(|h| !h.is_empty())
232        .map(std::path::PathBuf::from)
233        .ok_or_else(|| BbError::Config("HOME is not set, so --global has no target".into()))
234}