Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
//! 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);
}
}