use crate::error::{BbError, Result};
use crate::output::{self, Format};
use crate::skill::{self, Action, Agent};
use serde::Serialize;
#[derive(Serialize)]
struct OutcomeRow {
path: String,
agent: String,
skill: String,
action: String,
}
#[derive(Serialize)]
struct StatusRowJson {
path: String,
agent: String,
skill: String,
state: String,
}
#[derive(Serialize)]
struct UninstallRowJson {
path: String,
skill: String,
outcome: String,
}
fn wanted_skills(name: Option<&str>) -> Result<Vec<&'static skill::Skill>> {
match name {
None => Ok(skill::SKILLS.iter().collect()),
Some(name) => skill::skill_by_name(name).map(|s| vec![s]).ok_or_else(|| {
let valid: Vec<&str> = skill::SKILLS.iter().map(|s| s.name).collect();
BbError::Config(format!(
"unknown skill `{name}` — expected one of {}",
valid.join(", ")
))
}),
}
}
fn choose_skills(
format: Format,
skill_name: Option<&str>,
all: bool,
) -> Result<Vec<&'static skill::Skill>> {
let wanted = wanted_skills(skill_name)?;
let interactive = skill_name.is_none()
&& !all
&& !format.is_json()
&& std::io::IsTerminal::is_terminal(&std::io::stdin());
if !interactive {
return Ok(wanted);
}
let options: Vec<String> = wanted
.iter()
.map(|s| format!("{} — {}", s.name, s.summary))
.collect();
let defaults: Vec<usize> = (0..options.len()).collect();
let picked = inquire::MultiSelect::new("Which skills should be installed?", options.clone())
.with_default(&defaults)
.prompt()
.map_err(|_| BbError::Config("install cancelled — nothing was written".to_string()))?;
Ok(pick_skills(&wanted, &options, &picked))
}
fn pick_skills(
wanted: &[&'static skill::Skill],
options: &[String],
picked: &[String],
) -> Vec<&'static skill::Skill> {
options
.iter()
.enumerate()
.filter(|(_, label)| picked.contains(label))
.map(|(i, _)| wanted[i])
.collect()
}
pub fn install(
format: Format,
agent: Option<&str>,
global: bool,
force: bool,
skill_name: Option<&str>,
all: bool,
) -> Result<()> {
let root = if global {
home_dir()?
} else {
std::env::current_dir().map_err(BbError::Io)?
};
let agents = match agent {
Some("all") => skill::Agent::all().to_vec(),
Some("agents") => vec![Agent::Agents],
Some("claude") => vec![Agent::Claude],
Some(other) => {
return Err(BbError::Config(format!(
"unknown agent `{other}` — expected agents, claude or all"
)))
}
None => {
let detected = skill::detect_agents(&root);
if detected.is_empty() {
if !format.is_json() {
output::info(
"no agent directory found — installing to .agents/skills/, which Codex, Cursor and OpenCode read",
);
}
vec![Agent::Agents]
} else {
detected
}
}
};
let skills = choose_skills(format, skill_name, all)?;
if skills.is_empty() {
if !format.is_json() {
output::info("no skills selected — nothing installed");
}
return Ok(());
}
let outcomes = skill::install(&root, &agents, &skills, force)?;
let rows: Vec<OutcomeRow> = outcomes
.iter()
.map(|o| OutcomeRow {
path: o.path.display().to_string(),
agent: o.agent.clone(),
skill: o.skill.clone(),
action: o.action.as_str().to_string(),
})
.collect();
match format {
Format::Json => output::print_json(&rows)?,
Format::Human => {
for row in &rows {
let line = format!("{} {}", row.action, row.path);
match row.action.as_str() {
"unchanged" => output::info(&line),
"skipped_modified" => output::warn(&line),
_ => output::success(&line),
}
}
}
}
if outcomes.iter().any(|o| o.action == Action::SkippedModified) {
return Err(BbError::Config(
"some skills were edited locally and were left alone — pass --force to overwrite"
.into(),
));
}
Ok(())
}
pub fn status(format: Format) -> Result<()> {
let (rows, warning) = skill::status();
if let Some(warning) = warning {
output::warn(&warning);
}
match format {
Format::Json => {
let json_rows: Vec<StatusRowJson> = rows
.iter()
.map(|r| StatusRowJson {
path: r.path.display().to_string(),
agent: r.agent.clone(),
skill: r.skill.clone(),
state: r.state.as_str().to_string(),
})
.collect();
output::print_json(&json_rows)?;
}
Format::Human => {
let table_rows: Vec<Vec<String>> = rows
.iter()
.map(|r| {
vec![
r.path.display().to_string(),
r.skill.clone(),
r.agent.clone(),
r.state.as_str().to_string(),
]
})
.collect();
output::print_table(&["PATH", "SKILL", "AGENT", "STATE"], table_rows);
output::info(&format!(
"{} tracked skill{}",
rows.len(),
if rows.len() == 1 { "" } else { "s" }
));
}
}
Ok(())
}
pub fn uninstall(
format: Format,
global: bool,
force: bool,
skill_name: Option<&str>,
) -> Result<()> {
let root = if global {
home_dir()?
} else {
std::env::current_dir().map_err(BbError::Io)?
};
let skills = wanted_skills(skill_name)?;
let results = skill::uninstall(Some(&root), &skills, force)?;
match format {
Format::Json => {
let json_rows: Vec<UninstallRowJson> = results
.iter()
.map(|(path, skill, outcome)| UninstallRowJson {
path: path.display().to_string(),
skill: skill.clone(),
outcome: outcome.as_str().to_string(),
})
.collect();
output::print_json(&json_rows)?;
}
Format::Human => {
if results.is_empty() {
output::info("nothing to uninstall");
}
for (path, _skill, outcome) in &results {
match outcome {
skill::RemovalOutcome::Removed => {
output::success(&format!("removed {}", path.display()))
}
skill::RemovalOutcome::RefusedModified => output::warn(&format!(
"{} was edited locally — left alone (pass --force to remove)",
path.display()
)),
skill::RemovalOutcome::RefusedUnsafePath => output::warn(&format!(
"{} does not look like a skill path bb would have written — left alone",
path.display()
)),
skill::RemovalOutcome::Absent => output::info(&format!(
"{} was already gone — nothing to remove",
path.display()
)),
}
}
}
}
Ok(())
}
fn home_dir() -> Result<std::path::PathBuf> {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(std::path::PathBuf::from)
.ok_or_else(|| BbError::Config("HOME is not set, so --global has no target".into()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn pick_skills_all_picked() {
let wanted: Vec<&'static skill::Skill> = skill::SKILLS.iter().collect();
let options: Vec<String> = wanted
.iter()
.map(|s| format!("{} — {}", s.name, s.summary))
.collect();
let picked = options.clone();
let result = pick_skills(&wanted, &options, &picked);
assert_eq!(result.len(), wanted.len());
}
#[test]
fn pick_skills_some_picked() {
let wanted: Vec<&'static skill::Skill> = skill::SKILLS.iter().collect();
let options: Vec<String> = wanted
.iter()
.map(|s| format!("{} — {}", s.name, s.summary))
.collect();
assert!(
options.len() >= 2,
"test needs at least two skills to pick a subset"
);
let picked = vec![options[0].clone()];
let result = pick_skills(&wanted, &options, &picked);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, wanted[0].name);
}
#[test]
fn pick_skills_none_picked() {
let wanted: Vec<&'static skill::Skill> = skill::SKILLS.iter().collect();
let options: Vec<String> = wanted
.iter()
.map(|s| format!("{} — {}", s.name, s.summary))
.collect();
let picked: Vec<String> = Vec::new();
let result = pick_skills(&wanted, &options, &picked);
assert!(result.is_empty());
}
}