//! Install the bundled browser-skill `SKILL.md` into agent harness skill directories. pub mod harness; pub mod sync; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use console::{Style, style}; use dialoguer::{MultiSelect, theme::ColorfulTheme}; use serde::Serialize; pub use harness::{HarnessId, HarnessReport, all_harness_reports, parse_harness_id}; pub const SKILL_DIR_NAME: &str = "browser-skill"; pub const DEFAULT_SKILL_MD: &str = include_str!("../../skill/SKILL.md"); #[derive(Debug, Clone, Serialize)] pub struct InstallResult { pub harness: String, pub path: PathBuf, pub status: InstallStatus, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum InstallStatus { Installed, Updated, Skipped, } #[derive(Debug, Clone, Serialize)] pub struct InstallSkillOutput { pub results: Vec, pub errors: Vec, } /// JSON envelope for `bsk install-skill --json` (single document, includes exit metadata). #[derive(Debug, Clone, Serialize)] pub struct InstallSkillJsonOutput { pub success: bool, pub exit_code: u8, pub results: Vec, pub errors: Vec, } impl InstallSkillOutput { pub fn success(&self) -> bool { self.errors.is_empty() } pub fn exit_code(&self) -> u8 { u8::from(!self.success()) } pub fn to_json_output(&self) -> InstallSkillJsonOutput { InstallSkillJsonOutput { success: self.success(), exit_code: self.exit_code(), results: self.results.clone(), errors: self.errors.clone(), } } } #[derive(Debug, Clone, Serialize)] pub struct InstallError { pub harness: String, pub message: String, } pub struct InstallOptions<'a> { pub harnesses: &'a [HarnessId], pub source: &'a str, pub force: bool, /// When `Some`, installs under this home instead of the real `$HOME`. pub home: Option<&'a Path>, } pub fn install_to_harnesses(opts: &InstallOptions<'_>) -> InstallSkillOutput { let home = match opts.home { Some(home) => home.to_path_buf(), None => match harness::home_dir() { Ok(home) => home, Err(err) => { return InstallSkillOutput { results: Vec::new(), errors: vec![InstallError { harness: "*".to_string(), message: err.to_string(), }], }; } }, }; install_to_harnesses_at_home(&home, opts) } pub fn install_to_harnesses_at_home(home: &Path, opts: &InstallOptions<'_>) -> InstallSkillOutput { let mut results = Vec::new(); let mut errors = Vec::new(); for harness in opts.harnesses { match install_one_at_home(home, *harness, opts.source, opts.force) { Ok((path, status)) => results.push(InstallResult { harness: harness.cli_name().to_string(), path, status, }), Err(err) => errors.push(InstallError { harness: harness.cli_name().to_string(), message: err.to_string(), }), } } InstallSkillOutput { results, errors } } fn install_one_at_home( home: &Path, harness: HarnessId, source: &str, force: bool, ) -> Result<(PathBuf, InstallStatus)> { let dest_dir = harness.skill_dest_dir_for_home(home); let dest_file = dest_dir.join("SKILL.md"); if dest_file.exists() && !force { return Ok((dest_file, InstallStatus::Skipped)); } let existed = dest_file.exists(); fs::create_dir_all(&dest_dir).with_context(|| format!("create {}", dest_dir.display()))?; fs::write(&dest_file, source).with_context(|| format!("write {}", dest_file.display()))?; let status = if existed { InstallStatus::Updated } else { InstallStatus::Installed }; Ok((dest_file, status)) } /// Harnesses visible in the interactive installer (detected on this machine only). pub fn interactive_candidates(reports: &[HarnessReport]) -> Vec<&HarnessReport> { reports.iter().filter(|report| report.detected).collect() } pub fn harness_ids_from_indices( reports: &[HarnessReport], indices: &[usize], ) -> Result> { if indices.is_empty() { bail!("no harnesses selected"); } let mut selected = Vec::with_capacity(indices.len()); for &index in indices { let Some(report) = reports.get(index) else { bail!("invalid selection index {index}"); }; selected.push(report.id); } selected.sort_by_key(|id| id.index()); selected.dedup(); Ok(selected) } fn install_skill_theme() -> ColorfulTheme { ColorfulTheme { prompt_style: Style::new().for_stderr().bold().yellow(), prompt_prefix: style("▸".to_string()).for_stderr().yellow(), prompt_suffix: style("›".to_string()).for_stderr().dim(), success_prefix: style("✔".to_string()).for_stderr().green(), hint_style: Style::new().for_stderr().dim(), active_item_style: Style::new().for_stderr().cyan().bold(), inactive_item_style: Style::new().for_stderr().dim(), checked_item_prefix: style("●".to_string()).for_stderr().green(), unchecked_item_prefix: style("○".to_string()).for_stderr().dim(), active_item_prefix: style("❯".to_string()).for_stderr().cyan(), ..ColorfulTheme::default() } } fn format_multi_select_item(report: &HarnessReport) -> String { let name = style(report.id.display_name()).bold().yellow(); let path = style(report.skills_dir.display().to_string()).dim(); let installed = if report.installed { format!(" {}", style("installed").green()) } else { String::new() }; format!("{name} {path}{installed}") } /// Interactive harness picker: vertical list, Space toggles, Enter confirms. pub fn run_interactive_prompt(reports: &[HarnessReport]) -> Result> { let candidates = interactive_candidates(reports); if candidates.is_empty() { bail!( "no Agent harness detected on this machine; use `bsk install-skill --list` to check paths, or use `--harness ` to install manually" ); } eprintln!( "\n{}", style(" browser-skill · Install Agent Skill") .bold() .yellow() ); eprintln!( "{}", style(" shows detected harnesses only · unchecked by default · space to toggle · enter to confirm").dim() ); let items: Vec = candidates .iter() .map(|report| format_multi_select_item(report)) .collect(); let theme = install_skill_theme(); loop { let indices = MultiSelect::with_theme(&theme) .with_prompt("select harnesses to install") .items(&items) .defaults(&vec![false; items.len()]) .interact() .map_err(|err| match err { dialoguer::Error::IO(io_err) if io_err.kind() == std::io::ErrorKind::Interrupted => { anyhow::anyhow!("cancelled") } other => anyhow::anyhow!("interactive prompt failed: {other}"), })?; if indices.is_empty() { eprintln!( "{}", style(" ⚠ nothing selected — use Space to check a harness, then press Enter") .yellow() ); continue; } let mut selected: Vec = indices .into_iter() .map(|index| candidates[index].id) .collect(); selected.sort_by_key(|id| id.index()); selected.dedup(); return Ok(selected); } } pub fn print_harness_table(reports: &[HarnessReport], heading: &str) { eprint!("{heading}"); for (index, report) in reports.iter().enumerate() { let marker = if report.detected { "detected" } else { "—" }; let installed = if report.installed { ", skill installed" } else { "" }; eprintln!( " {}. [{marker:9}] {:18} → {}{installed}", index + 1, report.id.display_name(), report.skills_dir.display(), ); if let Some(detail) = &report.detection_detail { eprintln!(" ({detail})"); } } } pub fn load_source(path: Option<&Path>) -> Result { match path { Some(path) => { let meta = fs::metadata(path) .with_context(|| format!("read skill source {}", path.display()))?; if !meta.is_file() { bail!("skill source must be a file: {}", path.display()); } fs::read_to_string(path) .with_context(|| format!("read skill source {}", path.display())) } None => Ok(DEFAULT_SKILL_MD.to_string()), } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn install_writes_skill_md() { let tmp = TempDir::new().unwrap(); let home = tmp.path().to_path_buf(); let harness = HarnessId::Cursor; let skills = harness.skills_dir_for_home(&home); let report = harness.report_for_home(&home); assert!(report.skills_dir.ends_with("skills")); let out = install_to_harnesses_at_home( &home, &InstallOptions { harnesses: &[harness], source: "# test skill\n", force: false, home: Some(&home), }, ); assert!(out.errors.is_empty()); assert_eq!(out.results.len(), 1); assert!(skills.join(SKILL_DIR_NAME).join("SKILL.md").is_file()); } #[test] fn skipped_when_exists_without_force() { let tmp = TempDir::new().unwrap(); let home = tmp.path().to_path_buf(); let harness = HarnessId::Cursor; let dest = harness .skills_dir_for_home(&home) .join(SKILL_DIR_NAME) .join("SKILL.md"); fs::create_dir_all(dest.parent().unwrap()).unwrap(); fs::write(&dest, "old").unwrap(); let out = install_to_harnesses_at_home( &home, &InstallOptions { harnesses: &[harness], source: "new", force: false, home: Some(&home), }, ); assert_eq!(out.results[0].status, InstallStatus::Skipped); assert_eq!(fs::read_to_string(&dest).unwrap(), "old"); } #[test] fn force_overwrites_existing_skill() { let tmp = TempDir::new().unwrap(); let home = tmp.path().to_path_buf(); let harness = HarnessId::Cursor; let dest = harness .skills_dir_for_home(&home) .join(SKILL_DIR_NAME) .join("SKILL.md"); fs::create_dir_all(dest.parent().unwrap()).unwrap(); fs::write(&dest, "old").unwrap(); let out = install_to_harnesses_at_home( &home, &InstallOptions { harnesses: &[harness], source: "new", force: true, home: Some(&home), }, ); assert_eq!(out.results[0].status, InstallStatus::Updated); assert_eq!(fs::read_to_string(&dest).unwrap(), "new"); } #[test] fn interactive_candidates_omit_undetected() { let reports = vec![ HarnessReport { id: HarnessId::Cursor, skills_dir: PathBuf::from("/tmp/cursor/skills"), detected: true, detection_detail: None, installed: false, }, HarnessReport { id: HarnessId::Codex, skills_dir: PathBuf::from("/tmp/codex/skills"), detected: false, detection_detail: None, installed: false, }, ]; let candidates = interactive_candidates(&reports); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].id, HarnessId::Cursor); } #[test] fn json_output_reports_failure_exit_code() { let output = InstallSkillOutput { results: Vec::new(), errors: vec![InstallError { harness: "cursor".into(), message: "boom".into(), }], }; let json = output.to_json_output(); assert!(!json.success); assert_eq!(json.exit_code, 1); } }