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    action: String,
11}
12
13#[derive(Serialize)]
14struct StatusRowJson {
15    path: String,
16    agent: String,
17    state: String,
18}
19
20#[derive(Serialize)]
21struct UninstallRowJson {
22    path: String,
23    /// One of "removed", "refused_modified", "refused_unsafe_path", "absent" —
24    /// kept as an explicit outcome string rather than a boolean so a consumer
25    /// can tell "we refused to touch a local edit" apart from "there was
26    /// nothing there to remove"; collapsing those into one `removed: false`
27    /// used to make an honest "it just wasn't there" render as the same lie
28    /// as a refusal, in both human text and this JSON.
29    outcome: String,
30}
31
32pub fn install(format: Format, agent: Option<&str>, global: bool, force: bool) -> Result<()> {
33    let root = if global {
34        home_dir()?
35    } else {
36        std::env::current_dir().map_err(BbError::Io)?
37    };
38
39    let agents = match agent {
40        Some("all") => skill::Agent::all().to_vec(),
41        Some("agents") => vec![Agent::Agents],
42        Some("claude") => vec![Agent::Claude],
43        Some(other) => {
44            return Err(BbError::Config(format!(
45                "unknown agent `{other}` — expected agents, claude or all"
46            )))
47        }
48        None => {
49            let detected = skill::detect_agents(&root);
50            if detected.is_empty() {
51                // `.agents/skills/` is the portable location Codex, Cursor and
52                // OpenCode all read, so it is the safe default.
53                if !format.is_json() {
54                    output::info(
55                        "no agent directory found — installing to .agents/skills/, which Codex, Cursor and OpenCode read",
56                    );
57                }
58                vec![Agent::Agents]
59            } else {
60                detected
61            }
62        }
63    };
64
65    let outcomes = skill::install(&root, &agents, force)?;
66    let rows: Vec<OutcomeRow> = outcomes
67        .iter()
68        .map(|o| OutcomeRow {
69            path: o.path.display().to_string(),
70            agent: o.agent.clone(),
71            action: o.action.as_str().to_string(),
72        })
73        .collect();
74
75    match format {
76        Format::Json => output::print_json(&rows)?,
77        Format::Human => {
78            for row in &rows {
79                let line = format!("{} {}", row.action, row.path);
80                match row.action.as_str() {
81                    "unchanged" => output::info(&line),
82                    "skipped_modified" => output::warn(&line),
83                    _ => output::success(&line),
84                }
85            }
86        }
87    }
88
89    // A refusal is an error the user must act on, so it sets the exit code —
90    // after the report, so they can see which paths were fine.
91    if outcomes.iter().any(|o| o.action == Action::SkippedModified) {
92        return Err(BbError::Config(
93            "some skills were edited locally and were left alone — pass --force to overwrite"
94                .into(),
95        ));
96    }
97    Ok(())
98}
99
100pub fn status(format: Format) -> Result<()> {
101    let (rows, warning) = skill::status();
102    if let Some(warning) = warning {
103        output::warn(&warning);
104    }
105
106    match format {
107        Format::Json => {
108            let json_rows: Vec<StatusRowJson> = rows
109                .iter()
110                .map(|r| StatusRowJson {
111                    path: r.path.display().to_string(),
112                    agent: r.agent.clone(),
113                    state: r.state.as_str().to_string(),
114                })
115                .collect();
116            output::print_json(&json_rows)?;
117        }
118        Format::Human => {
119            let table_rows: Vec<Vec<String>> = rows
120                .iter()
121                .map(|r| {
122                    vec![
123                        r.path.display().to_string(),
124                        r.agent.clone(),
125                        r.state.as_str().to_string(),
126                    ]
127                })
128                .collect();
129            output::print_table(&["PATH", "AGENT", "STATE"], table_rows);
130            output::info(&format!(
131                "{} tracked skill{}",
132                rows.len(),
133                if rows.len() == 1 { "" } else { "s" }
134            ));
135        }
136    }
137    Ok(())
138}
139
140pub fn uninstall(format: Format, global: bool, force: bool) -> Result<()> {
141    let root = if global {
142        home_dir()?
143    } else {
144        std::env::current_dir().map_err(BbError::Io)?
145    };
146
147    let results = skill::uninstall(Some(&root), force)?;
148
149    match format {
150        Format::Json => {
151            let json_rows: Vec<UninstallRowJson> = results
152                .iter()
153                .map(|(path, outcome)| UninstallRowJson {
154                    path: path.display().to_string(),
155                    outcome: outcome.as_str().to_string(),
156                })
157                .collect();
158            output::print_json(&json_rows)?;
159        }
160        Format::Human => {
161            if results.is_empty() {
162                output::info("nothing to uninstall");
163            }
164            for (path, outcome) in &results {
165                match outcome {
166                    skill::RemovalOutcome::Removed => {
167                        output::success(&format!("removed {}", path.display()))
168                    }
169                    skill::RemovalOutcome::RefusedModified => output::warn(&format!(
170                        "{} was edited locally — left alone (pass --force to remove)",
171                        path.display()
172                    )),
173                    skill::RemovalOutcome::RefusedUnsafePath => output::warn(&format!(
174                        "{} does not look like a skill path bb would have written — left alone",
175                        path.display()
176                    )),
177                    skill::RemovalOutcome::Absent => output::info(&format!(
178                        "{} was already gone — nothing to remove",
179                        path.display()
180                    )),
181                }
182            }
183        }
184    }
185    Ok(())
186}
187
188fn home_dir() -> Result<std::path::PathBuf> {
189    std::env::var_os("HOME")
190        .filter(|h| !h.is_empty())
191        .map(std::path::PathBuf::from)
192        .ok_or_else(|| BbError::Config("HOME is not set, so --global has no target".into()))
193}