mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
//! Agent harness identifiers, install paths, and local detection heuristics.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::SKILL_DIR_NAME;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum HarnessId {
|
||||
Codex,
|
||||
CodexInternal,
|
||||
ClaudeCode,
|
||||
ClaudeCodeInternal,
|
||||
Cursor,
|
||||
Openclaw,
|
||||
OpenclawInternal,
|
||||
Codebuddy,
|
||||
Workbuddy,
|
||||
PiAgent,
|
||||
Hermes,
|
||||
}
|
||||
|
||||
impl HarnessId {
|
||||
pub const ALL: &'static [HarnessId] = &[
|
||||
HarnessId::Codex,
|
||||
HarnessId::CodexInternal,
|
||||
HarnessId::ClaudeCode,
|
||||
HarnessId::ClaudeCodeInternal,
|
||||
HarnessId::Cursor,
|
||||
HarnessId::Openclaw,
|
||||
HarnessId::OpenclawInternal,
|
||||
HarnessId::Codebuddy,
|
||||
HarnessId::Workbuddy,
|
||||
HarnessId::PiAgent,
|
||||
HarnessId::Hermes,
|
||||
];
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
Self::ALL.iter().position(|h| *h == self).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn cli_name(self) -> &'static str {
|
||||
match self {
|
||||
HarnessId::Codex => "codex",
|
||||
HarnessId::CodexInternal => "codex-internal",
|
||||
HarnessId::ClaudeCode => "claude-code",
|
||||
HarnessId::ClaudeCodeInternal => "claudecode-internal",
|
||||
HarnessId::Cursor => "cursor",
|
||||
HarnessId::Openclaw => "openclaw",
|
||||
HarnessId::OpenclawInternal => "openclaw-internal",
|
||||
HarnessId::Codebuddy => "codebuddy",
|
||||
HarnessId::Workbuddy => "workbuddy",
|
||||
HarnessId::PiAgent => "pi",
|
||||
HarnessId::Hermes => "hermes",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
HarnessId::Codex => "Codex",
|
||||
HarnessId::CodexInternal => "Codex (internal)",
|
||||
HarnessId::ClaudeCode => "Claude Code",
|
||||
HarnessId::ClaudeCodeInternal => "Claude Code (internal)",
|
||||
HarnessId::Cursor => "Cursor",
|
||||
HarnessId::Openclaw => "OpenClaw",
|
||||
HarnessId::OpenclawInternal => "OpenClaw (internal)",
|
||||
HarnessId::Codebuddy => "CodeBuddy",
|
||||
HarnessId::Workbuddy => "WorkBuddy",
|
||||
HarnessId::PiAgent => "Pi",
|
||||
HarnessId::Hermes => "Hermes Agent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skills_dir_for_home(self, home: &Path) -> PathBuf {
|
||||
match self {
|
||||
HarnessId::Codex => home.join(".agents").join("skills"),
|
||||
HarnessId::CodexInternal => home.join(".codex-internal").join("skills"),
|
||||
HarnessId::ClaudeCode => home.join(".claude").join("skills"),
|
||||
HarnessId::ClaudeCodeInternal => home.join(".claude-internal").join("skills"),
|
||||
HarnessId::Cursor => home.join(".cursor").join("skills"),
|
||||
HarnessId::Openclaw => home.join(".openclaw").join("skills"),
|
||||
HarnessId::OpenclawInternal => home.join(".openclaw-internal").join("skills"),
|
||||
HarnessId::Codebuddy => home.join(".codebuddy").join("skills"),
|
||||
HarnessId::Workbuddy => home.join(".workbuddy").join("skills"),
|
||||
HarnessId::PiAgent => home.join(".pi").join("agent").join("skills"),
|
||||
HarnessId::Hermes => hermes_home_for_user_home(home).join("skills"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skill_dest_dir_for_home(self, home: &Path) -> PathBuf {
|
||||
self.skills_dir_for_home(home).join(SKILL_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn report_for_home(self, home: &Path) -> HarnessReport {
|
||||
let skills_dir = self.skills_dir_for_home(home);
|
||||
let dest = skills_dir.join(SKILL_DIR_NAME).join("SKILL.md");
|
||||
let (detected, detection_detail) = self.detect_for_home(home);
|
||||
HarnessReport {
|
||||
id: self,
|
||||
skills_dir,
|
||||
detected,
|
||||
detection_detail,
|
||||
installed: dest.is_file(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_for_home(self, home: &Path) -> (bool, Option<String>) {
|
||||
let mut signals: Vec<String> = Vec::new();
|
||||
let mut push = |signal: &str| signals.push(signal.to_string());
|
||||
|
||||
match self {
|
||||
HarnessId::Codex => {
|
||||
if home.join(".codex").is_dir() {
|
||||
push("~/.codex");
|
||||
}
|
||||
if home.join(".agents").join("skills").is_dir() {
|
||||
push("~/.agents/skills");
|
||||
}
|
||||
if command_exists("codex") {
|
||||
push("`codex` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::CodexInternal => {
|
||||
if home.join(".codex-internal").is_dir() {
|
||||
push("~/.codex-internal");
|
||||
}
|
||||
if command_exists("codex-internal") {
|
||||
push("`codex-internal` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::ClaudeCode => {
|
||||
if home.join(".claude").is_dir() {
|
||||
push("~/.claude");
|
||||
}
|
||||
if command_exists("claude") {
|
||||
push("`claude` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::ClaudeCodeInternal => {
|
||||
if home.join(".claude-internal").is_dir() {
|
||||
push("~/.claude-internal");
|
||||
}
|
||||
for cmd in [
|
||||
"claude-internal",
|
||||
"claudecode-internal",
|
||||
"claude-code-internal",
|
||||
] {
|
||||
if command_exists(cmd) {
|
||||
signals.push(format!("`{cmd}` on PATH"));
|
||||
}
|
||||
}
|
||||
}
|
||||
HarnessId::Cursor => {
|
||||
if home.join(".cursor").is_dir() {
|
||||
push("~/.cursor");
|
||||
}
|
||||
if command_exists("cursor") {
|
||||
push("`cursor` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::Openclaw => {
|
||||
if home.join(".openclaw").is_dir() {
|
||||
push("~/.openclaw");
|
||||
}
|
||||
if command_exists("openclaw") {
|
||||
push("`openclaw` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::OpenclawInternal => {
|
||||
if home.join(".openclaw-internal").is_dir() {
|
||||
push("~/.openclaw-internal");
|
||||
}
|
||||
if command_exists("openclaw-internal") {
|
||||
push("`openclaw-internal` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::Codebuddy => {
|
||||
if home.join(".codebuddy").is_dir() {
|
||||
push("~/.codebuddy");
|
||||
}
|
||||
for cmd in ["codebuddy", "codebody"] {
|
||||
if command_exists(cmd) {
|
||||
signals.push(format!("`{cmd}` on PATH"));
|
||||
}
|
||||
}
|
||||
}
|
||||
HarnessId::Workbuddy => {
|
||||
if home.join(".workbuddy").is_dir() {
|
||||
push("~/.workbuddy");
|
||||
}
|
||||
if command_exists("workbuddy") {
|
||||
push("`workbuddy` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::PiAgent => {
|
||||
if home.join(".pi").join("agent").is_dir() {
|
||||
push("~/.pi/agent");
|
||||
} else if home.join(".pi").is_dir() {
|
||||
push("~/.pi");
|
||||
}
|
||||
if command_exists("pi") {
|
||||
push("`pi` on PATH");
|
||||
}
|
||||
}
|
||||
HarnessId::Hermes => {
|
||||
let hermes_home = hermes_home_for_user_home(home);
|
||||
if hermes_home.is_dir() {
|
||||
let label = hermes_home_signal_label(&hermes_home, home);
|
||||
push(&label);
|
||||
}
|
||||
if command_exists("hermes") {
|
||||
push("`hermes` on PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let detected = !signals.is_empty();
|
||||
let detail = if signals.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(signals.join(", "))
|
||||
};
|
||||
(detected, detail)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HarnessReport {
|
||||
pub id: HarnessId,
|
||||
pub skills_dir: PathBuf,
|
||||
pub detected: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detection_detail: Option<String>,
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
pub fn all_harness_reports() -> Result<Vec<HarnessReport>> {
|
||||
let home = home_dir()?;
|
||||
Ok(HarnessId::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|id| id.report_for_home(&home))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn parse_harness_id(raw: &str) -> Result<HarnessId> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"codex" => Ok(HarnessId::Codex),
|
||||
"codex-internal" | "codex_internal" | "codexinternal" => Ok(HarnessId::CodexInternal),
|
||||
"claude-code" | "claude_code" | "claude" | "claudecode" | "cc" => Ok(HarnessId::ClaudeCode),
|
||||
"claudecode-internal"
|
||||
| "claude-code-internal"
|
||||
| "claude_code_internal"
|
||||
| "claude-internal"
|
||||
| "claude_internal" => Ok(HarnessId::ClaudeCodeInternal),
|
||||
"cursor" => Ok(HarnessId::Cursor),
|
||||
"openclaw" | "open-claw" => Ok(HarnessId::Openclaw),
|
||||
"openclaw-internal" | "openclaw_internal" | "open-claw-internal" => {
|
||||
Ok(HarnessId::OpenclawInternal)
|
||||
}
|
||||
"codebuddy" | "code-body" | "codebody" | "code-buddy" => Ok(HarnessId::Codebuddy),
|
||||
"workbuddy" | "work-buddy" => Ok(HarnessId::Workbuddy),
|
||||
"pi" | "pi-agent" | "pi_agent" | "piagent" => Ok(HarnessId::PiAgent),
|
||||
"hermes" | "hermes-agent" | "hermes_agent" | "hermesagent" => Ok(HarnessId::Hermes),
|
||||
other => bail!(
|
||||
"unknown harness '{other}'; expected one of: {}",
|
||||
HarnessId::ALL
|
||||
.iter()
|
||||
.map(|h| h.cli_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
|
||||
}
|
||||
|
||||
/// Mirrors [`get_hermes_home()`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_constants.py):
|
||||
/// `HERMES_HOME` when set, else `%LOCALAPPDATA%\\hermes` on native Windows, else `~/.hermes`.
|
||||
fn hermes_home_for_user_home(home: &Path) -> PathBuf {
|
||||
if let Ok(override_home) = std::env::var("HERMES_HOME") {
|
||||
let trimmed = override_home.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let trimmed = local.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed).join("hermes");
|
||||
}
|
||||
}
|
||||
return home.join("AppData").join("Local").join("hermes");
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
home.join(".hermes")
|
||||
}
|
||||
}
|
||||
|
||||
fn hermes_home_signal_label(hermes_home: &Path, user_home: &Path) -> String {
|
||||
if std::env::var("HERMES_HOME")
|
||||
.ok()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return format!("$HERMES_HOME ({})", hermes_home.display());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let default = user_home.join("AppData").join("Local").join("hermes");
|
||||
if hermes_home == default
|
||||
|| std::env::var("LOCALAPPDATA")
|
||||
.ok()
|
||||
.is_some_and(|local| hermes_home == PathBuf::from(local.trim()).join("hermes"))
|
||||
{
|
||||
return "%LOCALAPPDATA%\\hermes".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if hermes_home == user_home.join(".hermes") {
|
||||
return "~/.hermes".to_string();
|
||||
}
|
||||
|
||||
hermes_home.display().to_string()
|
||||
}
|
||||
|
||||
fn command_exists(name: &str) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("where")
|
||||
.arg(name)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("command -v {name} >/dev/null 2>&1"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_aliases() {
|
||||
assert_eq!(parse_harness_id("codebody").unwrap(), HarnessId::Codebuddy);
|
||||
assert_eq!(
|
||||
parse_harness_id("claude-internal").unwrap(),
|
||||
HarnessId::ClaudeCodeInternal
|
||||
);
|
||||
assert_eq!(parse_harness_id("pi-agent").unwrap(), HarnessId::PiAgent);
|
||||
assert_eq!(parse_harness_id("hermes-agent").unwrap(), HarnessId::Hermes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cursor_from_home_layout() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".cursor")).unwrap();
|
||||
let report = HarnessId::Cursor.report_for_home(home);
|
||||
assert!(report.detected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openclaw_internal_not_detected_from_install_root_only() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".local/lib/openclaw-internal")).unwrap();
|
||||
let report = HarnessId::OpenclawInternal.report_for_home(home);
|
||||
assert!(!report.detected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_dirs_match_harness_spec() {
|
||||
let home = Path::new("/home/user");
|
||||
assert_eq!(
|
||||
HarnessId::Codex.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.agents/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::CodexInternal.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.codex-internal/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::ClaudeCodeInternal.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.claude-internal/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::OpenclawInternal.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.openclaw-internal/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::Codebuddy.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.codebuddy/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::Workbuddy.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.workbuddy/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::PiAgent.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.pi/agent/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessId::Hermes.skills_dir_for_home(home),
|
||||
PathBuf::from("/home/user/.hermes/skills")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_workbuddy_from_home_layout() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".workbuddy")).unwrap();
|
||||
let report = HarnessId::Workbuddy.report_for_home(home);
|
||||
assert!(report.detected);
|
||||
assert_eq!(report.skills_dir, home.join(".workbuddy").join("skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_pi_agent_from_home_layout() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".pi").join("agent")).unwrap();
|
||||
let report = HarnessId::PiAgent.report_for_home(home);
|
||||
assert!(report.detected);
|
||||
assert_eq!(
|
||||
report.skills_dir,
|
||||
home.join(".pi").join("agent").join("skills")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_hermes_from_home_layout() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir_all(home.join(".hermes")).unwrap();
|
||||
let report = HarnessId::Hermes.report_for_home(home);
|
||||
assert!(report.detected);
|
||||
assert_eq!(report.skills_dir, home.join(".hermes").join("skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_skills_dir_honors_hermes_home_env() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let custom = tmp.path().join("custom-hermes");
|
||||
std::fs::create_dir_all(&custom).unwrap();
|
||||
let previous = std::env::var("HERMES_HOME").ok();
|
||||
// SAFETY: harness tests do not run in parallel with other env-mutating tests.
|
||||
unsafe {
|
||||
std::env::set_var("HERMES_HOME", &custom);
|
||||
}
|
||||
let home = Path::new("/home/user");
|
||||
assert_eq!(
|
||||
HarnessId::Hermes.skills_dir_for_home(home),
|
||||
custom.join("skills")
|
||||
);
|
||||
unsafe {
|
||||
match previous {
|
||||
Some(value) => std::env::set_var("HERMES_HOME", value),
|
||||
None => std::env::remove_var("HERMES_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Keep installed `SKILL.md` files in sync with the `bsk` binary's
|
||||
//! bundled copy. Best-effort: I/O errors are recorded, never thrown.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{DEFAULT_SKILL_MD, harness::HarnessId};
|
||||
|
||||
/// Per-harness outcome of a sync pass.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SyncReport {
|
||||
/// Harnesses whose on-disk `SKILL.md` differed and was rewritten.
|
||||
pub updated: Vec<HarnessId>,
|
||||
/// Harnesses whose on-disk `SKILL.md` already matched the bundled
|
||||
/// content; no write happened, mtime preserved.
|
||||
pub up_to_date: Vec<HarnessId>,
|
||||
/// Harnesses that have an installed `SKILL.md` but the sync attempt
|
||||
/// failed with an I/O error. The string is a human-readable detail.
|
||||
pub errors: Vec<(HarnessId, String)>,
|
||||
}
|
||||
|
||||
/// Iterates `HarnessId::ALL`, syncing harnesses with an existing
|
||||
/// `SKILL.md` and leaving the rest untouched.
|
||||
pub fn sync_installed_skills(home: &Path) -> SyncReport {
|
||||
sync_with_source(home, DEFAULT_SKILL_MD)
|
||||
}
|
||||
|
||||
/// Test seam: lets unit tests inject a synthetic "bundled" payload.
|
||||
pub(crate) fn sync_with_source(home: &Path, source: &str) -> SyncReport {
|
||||
let mut report = SyncReport::default();
|
||||
for &harness in HarnessId::ALL {
|
||||
let dest = harness.skill_dest_dir_for_home(home).join("SKILL.md");
|
||||
match sync_one(&dest, source) {
|
||||
SyncOne::Missing => continue,
|
||||
SyncOne::UpToDate => report.up_to_date.push(harness),
|
||||
SyncOne::Updated => report.updated.push(harness),
|
||||
SyncOne::Error(msg) => report.errors.push((harness, msg)),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
enum SyncOne {
|
||||
Missing,
|
||||
UpToDate,
|
||||
Updated,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
fn sync_one(dest: &Path, source: &str) -> SyncOne {
|
||||
if !dest.is_file() {
|
||||
return SyncOne::Missing;
|
||||
}
|
||||
let on_disk = match std::fs::read_to_string(dest) {
|
||||
Ok(s) => s,
|
||||
Err(err) => return SyncOne::Error(format!("read {}: {err}", dest.display())),
|
||||
};
|
||||
if on_disk == source {
|
||||
return SyncOne::UpToDate;
|
||||
}
|
||||
// Atomic replace: write tmp, rename over. Including pid in the
|
||||
// tmp suffix avoids concurrent processes racing on the same path.
|
||||
let tmp = dest.with_extension(format!("md.tmp.{}", std::process::id()));
|
||||
if let Err(err) = std::fs::write(&tmp, source) {
|
||||
// Best-effort cleanup of any partial tmp left by a half-written attempt.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return SyncOne::Error(format!("write {}: {err}", tmp.display()));
|
||||
}
|
||||
if let Err(err) = std::fs::rename(&tmp, dest) {
|
||||
// Best-effort cleanup of the orphan tmp file.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return SyncOne::Error(format!(
|
||||
"rename {} -> {}: {err}",
|
||||
tmp.display(),
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
SyncOne::Updated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn sync_skips_uninstalled_harness() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let report = sync_with_source(tmp.path(), "anything");
|
||||
assert!(report.updated.is_empty());
|
||||
assert!(report.up_to_date.is_empty());
|
||||
assert!(report.errors.is_empty());
|
||||
// Defensive: sync must not silently create files in harnesses that
|
||||
// never had the skill installed. This guards Task 2's real impl.
|
||||
let dest = HarnessId::Cursor
|
||||
.skill_dest_dir_for_home(tmp.path())
|
||||
.join("SKILL.md");
|
||||
assert!(
|
||||
!dest.exists(),
|
||||
"sync should not create files for uninstalled harnesses"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_updates_outdated_skill() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
let dest_dir = HarnessId::Cursor.skill_dest_dir_for_home(home);
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
let dest = dest_dir.join("SKILL.md");
|
||||
std::fs::write(&dest, b"old content").unwrap();
|
||||
|
||||
let report = sync_with_source(home, "fresh content");
|
||||
|
||||
assert_eq!(report.updated, vec![HarnessId::Cursor]);
|
||||
assert!(report.up_to_date.is_empty());
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "fresh content");
|
||||
// Atomicity guard: no orphan tmp files matching SKILL.md.tmp.* should
|
||||
// remain after a successful sync (regardless of pid suffix).
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dest_dir)
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.file_name())
|
||||
.filter(|name| name.to_string_lossy().starts_with("SKILL.md.tmp"))
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"no SKILL.md.tmp.* files should remain after sync, found: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_skips_when_up_to_date() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
let dest_dir = HarnessId::Cursor.skill_dest_dir_for_home(home);
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
let dest = dest_dir.join("SKILL.md");
|
||||
std::fs::write(&dest, "frozen content").unwrap();
|
||||
let mtime_before = std::fs::metadata(&dest).unwrap().modified().unwrap();
|
||||
|
||||
// Sleep enough that any rewrite would visibly change mtime on
|
||||
// platforms with coarse fs timestamps (HFS+ has 1 s granularity).
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
|
||||
let report = sync_with_source(home, "frozen content");
|
||||
|
||||
assert_eq!(report.up_to_date, vec![HarnessId::Cursor]);
|
||||
assert!(report.updated.is_empty());
|
||||
assert!(report.errors.is_empty());
|
||||
let mtime_after = std::fs::metadata(&dest).unwrap().modified().unwrap();
|
||||
assert_eq!(
|
||||
mtime_before, mtime_after,
|
||||
"up-to-date sync should not touch mtime"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_continues_on_partial_error() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
// Cursor: writable, outdated → should be updated.
|
||||
let cursor_dir = HarnessId::Cursor.skill_dest_dir_for_home(home);
|
||||
std::fs::create_dir_all(&cursor_dir).unwrap();
|
||||
std::fs::write(cursor_dir.join("SKILL.md"), "old").unwrap();
|
||||
|
||||
// Codex: parent dir set to r-x. Reads still succeed, but creating
|
||||
// SKILL.md.tmp fails → exercises sync_one's write-tmp error branch.
|
||||
let codex_dir = HarnessId::Codex.skill_dest_dir_for_home(home);
|
||||
std::fs::create_dir_all(&codex_dir).unwrap();
|
||||
std::fs::write(codex_dir.join("SKILL.md"), "old").unwrap();
|
||||
let mut perms = std::fs::metadata(&codex_dir).unwrap().permissions();
|
||||
perms.set_mode(0o500); // r-x: blocks tmp creation in this dir
|
||||
std::fs::set_permissions(&codex_dir, perms).unwrap();
|
||||
|
||||
let report = sync_with_source(home, "fresh");
|
||||
|
||||
// Restore perms so TempDir can clean up.
|
||||
let mut perms = std::fs::metadata(&codex_dir).unwrap().permissions();
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(&codex_dir, perms).unwrap();
|
||||
|
||||
assert_eq!(report.updated, vec![HarnessId::Cursor]);
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
assert_eq!(report.errors[0].0, HarnessId::Codex);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user