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 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 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 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}