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 outcome: String,
34}
35
36fn 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
51fn 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 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
87fn 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 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 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 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}