Files
BrowserSkill/crates/bsk-cli/src/skill_install/mod.rs
T
2026-06-22 17:36:26 +08:00

409 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<InstallResult>,
pub errors: Vec<InstallError>,
}
/// 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<InstallResult>,
pub errors: Vec<InstallError>,
}
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<Vec<HarnessId>> {
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<Vec<HarnessId>> {
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 <id>` 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<String> = 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<HarnessId> = 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<String> {
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);
}
}