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,
33}
34
35fn 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 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 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 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}