diff --git a/README.md b/README.md
index 919a220a..2704accc 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,28 @@ Use Space to select the Agent harness you want to install into, then
press Enter to install the skill. Run `bsk install-skill --list` to see
internal variants and install paths.
+To install your own instructions, use `bsk install-skill --harness cursor --source ./SKILL.md`.
+An explicit `--source` stays custom even if its contents match the bundled skill.
+Existing installations are skipped unless you add `--force`.
+
+Daemon startup, `session start`, and `doctor` automatically update managed skills
+only when their contents still match the last installed version. Local edits are
+preserved and automatic updates pause. An older installation without a content
+baseline is enrolled automatically only if it exactly matches the current bundled
+skill; this writes the source marker without rewriting `SKILL.md`. Explicit custom
+installations stay custom even when their contents match.
+
+For differing historical files, local edits, or an unrecognized source marker,
+`doctor` shows `WARN` with the reason and recovery options. These warnings do not
+make the health check fail (`--json` reports `status: "warn"` and `ok: true`).
+A concurrent install or sync is reported as deferred and retried on a later pass.
+
+To keep your current instructions as an explicit customization, run
+`bsk install-skill --harness cursor --source --force`, replacing
+`` with the path to your existing file. To restore the bundled
+skill and resume automatic updates, run `bsk install-skill --harness cursor --force`
+without `--source`. This second command overwrites the existing instructions.
+
Other shell-capable agent harnesses are supported too. Copy
[`skill/SKILL.md`](skill/SKILL.md) into your harness's skills directory as
`browser-skill/SKILL.md` to install the skill manually. DeepSeek Harness uses a
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2d2bd3c7..fd2664ef 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -113,6 +113,24 @@ bsk install-skill
用 Space 选择需要安装的 Agent harness,然后按 Enter 安装 skill。运行 `bsk install-skill --list` 可查看 internal 变体及安装路径。
+安装自定义指令可运行 `bsk install-skill --harness cursor --source ./SKILL.md`。
+显式指定 `--source` 的安装始终视为自定义,即使内容与内置 skill 相同。
+已有安装默认跳过,添加 `--force` 才会覆盖。
+
+daemon 启动、`session start` 和 `doctor` 会检查已安装的 skill:只有文件内容仍与
+上次安装或同步时的内容一致,才继续自动更新。检测到本地编辑时会保留文件并暂停更新。
+没有内容基线的历史安装,只有与当前内置 skill 字节级一致时才自动纳入管理;此时只补齐
+来源标记,不重写 `SKILL.md`。明确的自定义安装即使内容相同,也不会被自动纳入管理。
+
+对于内容不同的历史文件、本地编辑或无法识别的来源标记,`doctor` 会显示 `WARN`,
+说明暂停原因及恢复方法。这类警告不会让健康检查失败(`--json` 中为 `status: "warn"`、
+`ok: true`)。其他安装或同步正在进行时,本次同步会推迟到后续再试。
+
+如需将当前指令保留为明确的自定义安装,运行
+`bsk install-skill --harness cursor --source --force`,将
+`` 替换为现有文件路径。如需恢复内置 skill 并重新启用自动更新,运行
+`bsk install-skill --harness cursor --force`,不带 `--source`。后一条命令会覆盖现有指令。
+
其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。DeepSeek Harness 走独立插件,见 [DeepSeek Harness 插件](#deepseek-harness-插件)。
diff --git a/crates/bsk-cli/Cargo.toml b/crates/bsk-cli/Cargo.toml
index e24ef887..291901ae 100644
--- a/crates/bsk-cli/Cargo.toml
+++ b/crates/bsk-cli/Cargo.toml
@@ -45,6 +45,7 @@ console = { workspace = true }
dialoguer = { workspace = true }
dirs = { workspace = true }
fs2 = { workspace = true }
+tempfile = { workspace = true }
rand = { workspace = true }
uuid = { workspace = true }
bsk-protocol = { workspace = true }
@@ -68,7 +69,6 @@ windows-sys = { version = "0.59", features = [
] }
[dev-dependencies]
-tempfile = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
diff --git a/crates/bsk-cli/src/cli/doctor.rs b/crates/bsk-cli/src/cli/doctor.rs
index 23b679e3..fcd0b1e6 100644
--- a/crates/bsk-cli/src/cli/doctor.rs
+++ b/crates/bsk-cli/src/cli/doctor.rs
@@ -24,17 +24,15 @@ const EXTENSION_STORE_URL_EDGE: &str = "https://microsoftedge.microsoft.com/addo
/// Store listings highlighted in repair hints, in the order they appear.
const EXTENSION_STORE_URLS: [&str; 2] = [EXTENSION_STORE_URL, EXTENSION_STORE_URL_EDGE];
-/// Status of a single doctor check. `Ok` / `Fail` are the legacy two
-/// states; `NotApplicable` (review M2) is reported as "N/A" in human
-/// output and as `"status": "na"` in `--json` output, so a check that
-/// has nothing to compare against (e.g. browsers protocol-compat with
-/// zero connected browsers) does not falsely report green.
+/// A warning needs attention but does not fail the overall health check.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
#[default]
Ok,
Fail,
+ #[serde(rename = "warn")]
+ Warning,
/// The check could not run because its precondition is absent.
/// Treated as informational and never flips an exit code.
#[serde(rename = "na")]
@@ -44,21 +42,14 @@ pub enum CheckStatus {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CheckResult {
pub name: String,
- /// Pre-M2 boolean status, retained for backwards-compatible JSON
- /// consumers. `true` means the check passed or was not applicable
- /// (i.e. nothing flips the overall doctor verdict red); `false`
- /// means the check actively failed. New consumers should read the
- /// `status` field below for the tri-state (`ok` / `fail` / `na`)
- /// distinction — the legacy boolean intentionally collapses
- /// `ok` and `na` so a doctor run that includes an N/A check does
- /// not regress for callers that still consult `ok` only.
+ /// Backwards-compatible verdict: only a failure is false. Read `status`
+ /// to distinguish a warning or a check that is not applicable.
pub ok: bool,
- /// Tri-state status (review M2): `ok` / `fail` / `na`.
+ /// `ok`, `fail`, `warn`, or `na`.
#[serde(default)]
pub status: CheckStatus,
pub detail: String,
- /// User-facing repair hint (only meaningful when `status` is
- /// `Fail`).
+ /// Actionable guidance for a failure or warning.
pub hint: Option,
}
@@ -83,6 +74,16 @@ impl CheckResult {
}
}
+ fn warn(name: impl Into, detail: impl Into, hint: impl Into) -> Self {
+ Self {
+ name: name.into(),
+ ok: true,
+ status: CheckStatus::Warning,
+ detail: detail.into(),
+ hint: Some(hint.into()),
+ }
+ }
+
fn na(name: impl Into, detail: impl Into) -> Self {
Self {
name: name.into(),
@@ -111,7 +112,7 @@ pub fn run(output: Output) -> Result> {
}
/// Whether the rendered doctor report contains an active failure.
-/// `NotApplicable` remains informational and must not change the exit code.
+/// Warnings and `NotApplicable` remain informational and do not change the exit code.
pub fn has_failures(checks: &[CheckResult]) -> bool {
checks.iter().any(|check| check.status == CheckStatus::Fail)
}
@@ -247,41 +248,61 @@ fn check_skill_up_to_date() -> CheckResult {
};
let report = crate::skill_install::sync::sync_installed_skills(&home);
- if !report.errors.is_empty() {
- let detail = report
- .errors
- .iter()
- .map(|(h, msg)| format!("{}: {msg}", h.cli_name()))
- .collect::>()
- .join("; ");
- return CheckResult::fail(
- name,
- detail,
- "re-run `bsk install-skill --force --harness ` for the failing harness",
- );
- }
+ skill_check_from_report(&report)
+}
- if !report.updated.is_empty() {
- let names = report
- .updated
- .iter()
- .map(|h| h.cli_name())
- .collect::>()
- .join(", ");
- return CheckResult::ok(
- name,
- format!("synced {} harness(es): {names}", report.updated.len()),
- );
+fn skill_check_from_report(report: &crate::skill_install::sync::SyncReport) -> CheckResult {
+ let name = "agent skill up to date";
+ let mut details = Vec::new();
+ for (label, harnesses) in [
+ ("synced", &report.updated),
+ ("up to date", &report.up_to_date),
+ (
+ "custom skill preserved (automatic updates disabled) in",
+ &report.protected,
+ ),
+ ("busy, sync deferred in", &report.busy),
+ ] {
+ if !harnesses.is_empty() {
+ let names = harnesses
+ .iter()
+ .map(|h| h.cli_name())
+ .collect::>()
+ .join(", ");
+ details.push(format!("{label}: {names}"));
+ }
+ }
+ let mut hints = Vec::new();
+ for (harness, reason) in &report.paused {
+ let id = harness.cli_name();
+ details.push(format!(
+ "automatic updates paused for {id}: {}; content preserved",
+ reason.description()
+ ));
+ hints.push(format!(
+ "{id}: keep your instructions with `bsk install-skill --harness {id} --source --force`, or restore the bundled skill with `bsk install-skill --harness {id} --force` (overwrites existing instructions)"
+ ));
}
+ for (harness, message) in &report.errors {
+ details.push(format!("sync failed for {}: {message}", harness.cli_name()));
+ }
+ let detail = details.join("; ");
- if !report.up_to_date.is_empty() {
- return CheckResult::ok(
- name,
- format!("up to date in {} harness(es)", report.up_to_date.len()),
+ if !report.errors.is_empty() {
+ hints.insert(
+ 0,
+ "check filesystem access for the failing harness, then re-run `bsk doctor`".into(),
);
+ CheckResult::fail(name, detail, hints.join("; "))
+ } else if !report.paused.is_empty() {
+ CheckResult::warn(name, detail, hints.join("; "))
+ } else if !report.updated.is_empty() || !report.up_to_date.is_empty() {
+ CheckResult::ok(name, detail)
+ } else if !details.is_empty() {
+ CheckResult::na(name, detail)
+ } else {
+ CheckResult::na(name, "no agent skill installed")
}
-
- CheckResult::na(name, "no agent skill installed")
}
fn check_daemon_running(state: &DaemonState) -> CheckResult {
@@ -456,10 +477,13 @@ fn render_human(checks: &[CheckResult]) {
let mark = match c.status {
CheckStatus::Ok => "ok ",
CheckStatus::Fail => "FAIL",
+ CheckStatus::Warning => "WARN",
CheckStatus::NotApplicable => "N/A ",
};
let detail = match (&c.hint, c.status) {
- (Some(h), CheckStatus::Fail) => format!("{} — hint: {}", c.detail, style_hint(h)),
+ (Some(h), CheckStatus::Fail | CheckStatus::Warning) => {
+ format!("{} — hint: {}", c.detail, style_hint(h))
+ }
_ => c.detail.clone(),
};
let name = &c.name;
@@ -496,6 +520,138 @@ mod m2_tests {
}
}
+ #[test]
+ fn skill_check_reports_protected_harnesses_with_managed_results() {
+ use crate::skill_install::{HarnessId, sync::SyncReport};
+ for updated in [true, false] {
+ let mut report = SyncReport {
+ protected: vec![HarnessId::Cursor],
+ ..Default::default()
+ };
+ if updated {
+ report.updated.push(HarnessId::ClaudeCode);
+ } else {
+ report.up_to_date.push(HarnessId::ClaudeCode);
+ }
+ let check = skill_check_from_report(&report);
+ assert_eq!(check.status, CheckStatus::Ok);
+ assert!(check.detail.contains("claude-code"));
+ assert!(
+ check
+ .detail
+ .contains("preserved (automatic updates disabled) in: cursor")
+ );
+ let json = serde_json::to_value(&check).unwrap();
+ assert_eq!(json["status"], "ok");
+ assert!(
+ json["detail"]
+ .as_str()
+ .unwrap()
+ .contains("preserved (automatic updates disabled) in: cursor")
+ );
+ }
+ }
+
+ #[test]
+ fn skill_check_keeps_all_outcomes_when_another_harness_fails() {
+ use crate::skill_install::{HarnessId, sync::SyncReport};
+ let check = skill_check_from_report(&SyncReport {
+ updated: vec![HarnessId::ClaudeCode],
+ up_to_date: vec![HarnessId::PiAgent],
+ protected: vec![HarnessId::Cursor],
+ busy: vec![HarnessId::Hermes],
+ errors: vec![(HarnessId::Workbuddy, "permission denied".into())],
+ paused: Vec::new(),
+ });
+ assert_eq!(check.status, CheckStatus::Fail);
+ for text in [
+ "synced: claude-code",
+ "up to date: pi",
+ "preserved (automatic updates disabled) in: cursor",
+ "sync deferred in: hermes",
+ "workbuddy: permission denied",
+ ] {
+ assert!(check.detail.contains(text), "{}", check.detail);
+ }
+ assert!(!check.hint.unwrap().contains("--force"));
+ }
+
+ #[test]
+ fn paused_skills_warn_with_actions_even_alongside_successful_updates() {
+ use crate::skill_install::{
+ HarnessId,
+ sync::{PauseReason, SyncReport},
+ };
+ for reason in [
+ PauseReason::Untracked,
+ PauseReason::MissingBaseline,
+ PauseReason::LocalChanges,
+ PauseReason::InvalidMarker,
+ ] {
+ for updated in [false, true] {
+ let mut report = SyncReport {
+ paused: vec![(HarnessId::Cursor, reason)],
+ ..Default::default()
+ };
+ if updated {
+ report.updated.push(HarnessId::ClaudeCode);
+ }
+ let check = skill_check_from_report(&report);
+ assert_eq!(check.status, CheckStatus::Warning);
+ assert!(check.detail.contains("automatic updates paused for cursor"));
+ assert!(check.detail.contains(reason.description()));
+ if updated {
+ assert!(check.detail.contains("synced: claude-code"));
+ }
+ assert!(!has_failures(std::slice::from_ref(&check)));
+ let json = serde_json::to_value(&check).unwrap();
+ assert_eq!(json["status"], "warn");
+ assert_eq!(json["ok"], true);
+ let hint = json["hint"].as_str().unwrap();
+ assert!(hint.contains("--harness cursor --source --force"));
+ assert!(hint.contains("--harness cursor --force"));
+ assert!(hint.contains("overwrites existing instructions"));
+ // An I/O failure takes precedence without hiding paused installations.
+ report
+ .errors
+ .push((HarnessId::PiAgent, "permission denied".into()));
+ let failed = skill_check_from_report(&report);
+ assert_eq!(failed.status, CheckStatus::Fail);
+ assert!(
+ failed
+ .detail
+ .contains("automatic updates paused for cursor")
+ );
+ assert!(failed.hint.as_ref().unwrap().contains("--harness cursor"));
+ assert!(has_failures(&[failed]));
+ }
+ }
+ }
+
+ #[test]
+ fn protected_or_busy_skills_are_informational() {
+ use crate::skill_install::{HarnessId, sync::SyncReport};
+ for report in [
+ SyncReport {
+ protected: vec![HarnessId::Cursor],
+ ..Default::default()
+ },
+ SyncReport {
+ busy: vec![HarnessId::Cursor],
+ ..Default::default()
+ },
+ ] {
+ let check = skill_check_from_report(&report);
+ assert_eq!(check.status, CheckStatus::NotApplicable);
+ assert!(check.detail.contains("cursor"));
+ assert!(!has_failures(std::slice::from_ref(&check)));
+ assert_eq!(serde_json::to_value(&check).unwrap()["status"], "na");
+ }
+ let empty = skill_check_from_report(&SyncReport::default());
+ assert_eq!(empty.status, CheckStatus::NotApplicable);
+ assert_eq!(empty.detail, "no agent skill installed");
+ }
+
#[test]
fn extension_check_includes_store_url_when_no_browser_connected() {
let status = fake_status(Vec::new(), Vec::new());
diff --git a/crates/bsk-cli/src/cli/install_skill.rs b/crates/bsk-cli/src/cli/install_skill.rs
index 96675a42..9c520fab 100644
--- a/crates/bsk-cli/src/cli/install_skill.rs
+++ b/crates/bsk-cli/src/cli/install_skill.rs
@@ -11,7 +11,7 @@ use serde::Serialize;
use crate::cli::error::CliError;
use crate::cli::status::Output;
use crate::skill_install::{
- InstallOptions, all_harness_reports,
+ InstallOptions, SkillSource, all_harness_reports,
harness::{HarnessId, parse_harness_id},
load_source, print_harness_table, run_interactive_prompt,
};
@@ -43,6 +43,16 @@ pub struct InstallSkillArgs {
pub force: bool,
}
+impl InstallSkillArgs {
+ fn source_kind(&self) -> SkillSource {
+ if self.source.is_some() {
+ SkillSource::Custom
+ } else {
+ SkillSource::Bundled
+ }
+ }
+}
+
#[derive(Debug, Serialize)]
struct ListOutput {
harnesses: Vec,
@@ -61,6 +71,7 @@ pub fn dispatch(args: InstallSkillArgs, output: Output) -> Result<(), CliError>
let install_output = crate::skill_install::install_to_harnesses(&InstallOptions {
harnesses: &harnesses,
source: &source,
+ source_kind: args.source_kind(),
force: args.force,
home: None,
});
@@ -202,6 +213,44 @@ mod tests {
}
}
+ #[test]
+ fn identical_explicit_source_remains_custom_after_install_and_upgrade() {
+ use crate::skill_install::{DEFAULT_SKILL_MD, SOURCE_CUSTOM, SOURCE_MARKER_FILE, sync};
+ let home = tempfile::tempdir().unwrap();
+ let path = home.path().join("my-skill.md");
+ std::fs::write(&path, DEFAULT_SKILL_MD).unwrap();
+ let mut args = args(false, false);
+ assert_eq!(args.source_kind(), SkillSource::Bundled);
+ args.source = Some(path);
+ let source = load_source(args.source.as_deref()).unwrap();
+ let result = crate::skill_install::install_to_harnesses(&InstallOptions {
+ harnesses: &[HarnessId::Cursor],
+ source: &source,
+ source_kind: args.source_kind(),
+ force: false,
+ home: Some(home.path()),
+ });
+ assert!(result.success());
+ let dir = HarnessId::Cursor.skill_dest_dir_for_home(home.path());
+ assert_eq!(
+ std::fs::read_to_string(dir.join(SOURCE_MARKER_FILE)).unwrap(),
+ SOURCE_CUSTOM
+ );
+ // Matching bytes do not turn a custom installation into an up-to-date managed one.
+ assert_eq!(
+ sync::sync_installed_skills(home.path()).protected,
+ vec![HarnessId::Cursor]
+ );
+ assert_eq!(
+ sync::sync_with_source(home.path(), "next bundled version").protected,
+ vec![HarnessId::Cursor]
+ );
+ assert_eq!(
+ std::fs::read_to_string(dir.join("SKILL.md")).unwrap(),
+ DEFAULT_SKILL_MD
+ );
+ }
+
#[test]
fn all_targets_detected_harnesses_only() {
let reports = vec![
diff --git a/crates/bsk-cli/src/cli/session.rs b/crates/bsk-cli/src/cli/session.rs
index 5365282c..6e552633 100644
--- a/crates/bsk-cli/src/cli/session.rs
+++ b/crates/bsk-cli/src/cli/session.rs
@@ -542,6 +542,13 @@ fn run_skill_sync_for_session_start(format: Format) {
for harness in &report.updated {
eprintln!("≈ skill updated for {}", harness.cli_name());
}
+ for (harness, reason) in &report.paused {
+ eprintln!(
+ "! skill auto-update paused for {}: {}; run `bsk doctor` for options",
+ harness.cli_name(),
+ reason.description()
+ );
+ }
}
for (harness, msg) in &report.errors {
tracing::warn!(harness = harness.cli_name(), error = %msg, "skill sync failed");
diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs
index 017c55d0..4d584575 100644
--- a/crates/bsk-cli/src/daemon/start.rs
+++ b/crates/bsk-cli/src/daemon/start.rs
@@ -279,6 +279,10 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
for harness in &report.updated {
info!(harness = harness.cli_name(), "skill synced");
}
+ for (harness, reason) in &report.paused {
+ warn!(harness = harness.cli_name(), reason = reason.description(),
+ "skill auto-update paused; content preserved; run `bsk doctor` for options");
+ }
for (harness, msg) in &report.errors {
warn!(harness = harness.cli_name(), error = %msg, "skill sync failed");
}
diff --git a/crates/bsk-cli/src/skill_install/mod.rs b/crates/bsk-cli/src/skill_install/mod.rs
index b52432e3..456fdc9f 100644
--- a/crates/bsk-cli/src/skill_install/mod.rs
+++ b/crates/bsk-cli/src/skill_install/mod.rs
@@ -1,6 +1,8 @@
-//! Install the bundled browser-skill `SKILL.md` into agent harness skill directories.
+//! Install bundled or custom browser-skill instructions into agent skill directories.
pub mod harness;
+mod provenance;
+mod storage;
pub mod sync;
use std::fs;
@@ -9,12 +11,23 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use console::{Style, style};
use dialoguer::{MultiSelect, theme::ColorfulTheme};
-use serde::Serialize;
+use serde::{Deserialize, 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");
+pub const SOURCE_MARKER_FILE: &str = ".bsk-source";
+pub const SOURCE_BUNDLED: &str = "bundled\n";
+pub const SOURCE_CUSTOM: &str = "custom\n";
+
+/// Installation provenance is explicit: even an identical `--source` is custom.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SkillSource {
+ Bundled,
+ Custom,
+}
#[derive(Debug, Clone, Serialize)]
pub struct InstallResult {
@@ -74,6 +87,7 @@ pub struct InstallError {
pub struct InstallOptions<'a> {
pub harnesses: &'a [HarnessId],
pub source: &'a str,
+ pub source_kind: SkillSource,
pub force: bool,
/// When `Some`, installs under this home instead of the real `$HOME`.
pub home: Option<&'a Path>,
@@ -103,7 +117,7 @@ pub fn install_to_harnesses_at_home(home: &Path, opts: &InstallOptions<'_>) -> I
let mut errors = Vec::new();
for harness in opts.harnesses {
- match install_one_at_home(home, *harness, opts.source, opts.force) {
+ match install_one_at_home(home, *harness, opts.source, opts.source_kind, opts.force) {
Ok((path, status)) => results.push(InstallResult {
harness: harness.cli_name().to_string(),
path,
@@ -111,7 +125,7 @@ pub fn install_to_harnesses_at_home(home: &Path, opts: &InstallOptions<'_>) -> I
}),
Err(err) => errors.push(InstallError {
harness: harness.cli_name().to_string(),
- message: err.to_string(),
+ message: format!("{err:#}"),
}),
}
}
@@ -123,18 +137,40 @@ fn install_one_at_home(
home: &Path,
harness: HarnessId,
source: &str,
+ source_kind: SkillSource,
force: bool,
) -> Result<(PathBuf, InstallStatus)> {
let dest_dir = harness.skill_dest_dir_for_home(home);
let dest_file = dest_dir.join("SKILL.md");
+ // A no-op install needs no write access. Recheck under the lock before writing
+ // so two installers that both observed a missing file cannot overwrite it.
+ if dest_file.exists() && !force {
+ return Ok((dest_file, InstallStatus::Skipped));
+ }
+ fs::create_dir_all(&dest_dir).with_context(|| format!("create {}", dest_dir.display()))?;
+ let _lock = storage::SkillLock::acquire(&dest_dir)
+ .with_context(|| format!("lock {}", dest_dir.display()))?;
+
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 marker = dest_dir.join(SOURCE_MARKER_FILE);
+ match source_kind {
+ SkillSource::Custom => {
+ let content = storage::PendingWrite::prepare(&dest_file, source)?;
+ // Protection must be established before any custom content appears.
+ storage::PendingWrite::prepare(&marker, SOURCE_CUSTOM)?.commit()?;
+ content
+ .commit()
+ .context("custom protection recorded, but skill content was not replaced")?;
+ }
+ SkillSource::Bundled => {
+ write_bundled_skill(&dest_file, source)?;
+ }
+ }
let status = if existed {
InstallStatus::Updated
@@ -144,6 +180,22 @@ fn install_one_at_home(
Ok((dest_file, status))
}
+/// Callers hold the skill lock. Publish the baseline only after its content;
+/// a failed marker replacement leaves a conservative, detectable mismatch.
+fn write_bundled_skill(dest: &Path, source: &str) -> Result<()> {
+ let content = storage::PendingWrite::prepare(dest, source)?;
+ let marker = dest
+ .parent()
+ .context("skill destination has no parent")?
+ .join(SOURCE_MARKER_FILE);
+ let metadata = provenance::bundled_marker(source.as_bytes())?;
+ let marker = storage::PendingWrite::prepare(&marker, &metadata)?;
+ content.commit()?;
+ marker
+ .commit()
+ .context("bundled skill content installed, but its source marker was not updated")
+}
+
/// 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()
@@ -310,6 +362,7 @@ mod tests {
&InstallOptions {
harnesses: &[harness],
source: "# test skill\n",
+ source_kind: SkillSource::Custom,
force: false,
home: Some(&home),
},
@@ -317,6 +370,10 @@ mod tests {
assert!(out.errors.is_empty());
assert_eq!(out.results.len(), 1);
assert!(skills.join(SKILL_DIR_NAME).join("SKILL.md").is_file());
+ assert_eq!(
+ fs::read_to_string(skills.join(SKILL_DIR_NAME).join(SOURCE_MARKER_FILE)).unwrap(),
+ SOURCE_CUSTOM
+ );
}
#[test]
@@ -336,6 +393,7 @@ mod tests {
&InstallOptions {
harnesses: &[harness],
source: "new",
+ source_kind: SkillSource::Custom,
force: false,
home: Some(&home),
},
@@ -361,12 +419,278 @@ mod tests {
&InstallOptions {
harnesses: &[harness],
source: "new",
+ source_kind: SkillSource::Custom,
force: true,
home: Some(&home),
},
);
assert_eq!(out.results[0].status, InstallStatus::Updated);
assert_eq!(fs::read_to_string(&dest).unwrap(), "new");
+ assert_eq!(
+ fs::read_to_string(dest.parent().unwrap().join(SOURCE_MARKER_FILE)).unwrap(),
+ SOURCE_CUSTOM
+ );
+ }
+
+ #[test]
+ fn bundled_install_is_marked_as_managed() {
+ let tmp = TempDir::new().unwrap();
+ let home = tmp.path().to_path_buf();
+ let harness = HarnessId::Cursor;
+
+ let out = install_to_harnesses_at_home(
+ &home,
+ &InstallOptions {
+ harnesses: &[harness],
+ source: DEFAULT_SKILL_MD,
+ source_kind: SkillSource::Bundled,
+ force: false,
+ home: Some(&home),
+ },
+ );
+
+ assert!(out.errors.is_empty());
+ let marker = harness
+ .skill_dest_dir_for_home(&home)
+ .join(SOURCE_MARKER_FILE);
+ assert_eq!(
+ provenance::read(&marker).unwrap(),
+ provenance::Provenance::Bundled {
+ sha256: provenance::digest(DEFAULT_SKILL_MD.as_bytes()),
+ }
+ );
+ }
+
+ #[test]
+ fn custom_install_survives_automatic_bundled_sync() {
+ let tmp = TempDir::new().unwrap();
+ let home = tmp.path().to_path_buf();
+ let harness = HarnessId::Cursor;
+ let dest = harness.skill_dest_dir_for_home(&home).join("SKILL.md");
+
+ let out = install_to_harnesses_at_home(
+ &home,
+ &InstallOptions {
+ harnesses: &[harness],
+ source: "custom instructions",
+ source_kind: SkillSource::Custom,
+ force: false,
+ home: Some(&home),
+ },
+ );
+ assert!(out.errors.is_empty());
+
+ let report = sync::sync_with_source(&home, "new bundled instructions");
+
+ assert_eq!(report.protected, vec![HarnessId::Cursor]);
+ assert_eq!(fs::read_to_string(dest).unwrap(), "custom instructions");
+ }
+
+ #[test]
+ fn skipped_install_does_not_claim_or_change_provenance() {
+ for marker in [None, Some(SOURCE_CUSTOM), Some(SOURCE_BUNDLED)] {
+ let home = TempDir::new().unwrap();
+ let dir = HarnessId::Cursor.skill_dest_dir_for_home(home.path());
+ fs::create_dir_all(&dir).unwrap();
+ fs::write(dir.join("SKILL.md"), "keep").unwrap();
+ if let Some(marker) = marker {
+ fs::write(dir.join(SOURCE_MARKER_FILE), marker).unwrap();
+ }
+ let (_, status) = install_one_at_home(
+ home.path(),
+ HarnessId::Cursor,
+ DEFAULT_SKILL_MD,
+ SkillSource::Bundled,
+ false,
+ )
+ .unwrap();
+ assert_eq!(status, InstallStatus::Skipped);
+ assert_eq!(fs::read_to_string(dir.join("SKILL.md")).unwrap(), "keep");
+ assert!(!dir.join(".bsk.lock").exists());
+ assert_eq!(
+ fs::read_to_string(dir.join(SOURCE_MARKER_FILE))
+ .ok()
+ .as_deref(),
+ marker
+ );
+ }
+ }
+
+ #[test]
+ fn failed_install_keeps_content_and_provenance_safe() {
+ use storage::test_support::{assert_no_temporary_files, with_replace_hook};
+
+ for kind in [SkillSource::Custom, SkillSource::Bundled] {
+ for failed_file in ["SKILL.md", SOURCE_MARKER_FILE] {
+ let home = TempDir::new().unwrap();
+ let dir = HarnessId::Cursor.skill_dest_dir_for_home(home.path());
+ let old_kind = if kind == SkillSource::Custom {
+ SkillSource::Bundled
+ } else {
+ SkillSource::Custom
+ };
+ install_one_at_home(
+ home.path(),
+ HarnessId::Cursor,
+ "old content",
+ old_kind,
+ false,
+ )
+ .unwrap();
+ let original_marker = fs::read_to_string(dir.join(SOURCE_MARKER_FILE)).unwrap();
+ let error = with_replace_hook(
+ move |dest| {
+ if dest.file_name().unwrap() == failed_file {
+ Err(std::io::Error::other("injected replacement failure"))
+ } else {
+ Ok(())
+ }
+ },
+ || {
+ install_one_at_home(
+ home.path(),
+ HarnessId::Cursor,
+ "new content",
+ kind,
+ true,
+ )
+ },
+ )
+ .unwrap_err();
+ assert!(format!("{error:#}").contains("injected replacement failure"));
+ let bundled_content_installed =
+ kind == SkillSource::Bundled && failed_file == SOURCE_MARKER_FILE;
+ let expected_content = if bundled_content_installed {
+ "new content"
+ } else {
+ "old content"
+ };
+ assert_eq!(
+ fs::read_to_string(dir.join("SKILL.md")).unwrap(),
+ expected_content
+ );
+ if bundled_content_installed {
+ assert!(
+ error
+ .to_string()
+ .contains("bundled skill content installed")
+ );
+ }
+ let still_bundled =
+ kind == SkillSource::Custom && failed_file == SOURCE_MARKER_FILE;
+ let expected_marker = if still_bundled {
+ original_marker.as_str()
+ } else {
+ SOURCE_CUSTOM
+ };
+ assert_eq!(
+ fs::read_to_string(dir.join(SOURCE_MARKER_FILE)).unwrap(),
+ expected_marker
+ );
+ assert_no_temporary_files(&dir);
+ let report = sync::sync_with_source(home.path(), "next bundled version");
+ if still_bundled {
+ assert_eq!(report.updated, vec![HarnessId::Cursor]);
+ } else {
+ assert_eq!(report.protected, vec![HarnessId::Cursor]);
+ assert_eq!(
+ fs::read_to_string(dir.join("SKILL.md")).unwrap(),
+ expected_content
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn sync_defers_during_custom_install_then_preserves_it() {
+ use std::sync::mpsc;
+ use std::time::Duration;
+ use storage::test_support::with_replace_hook;
+
+ let home = TempDir::new().unwrap();
+ install_one_at_home(
+ home.path(),
+ HarnessId::Cursor,
+ "old bundled",
+ SkillSource::Bundled,
+ false,
+ )
+ .unwrap();
+ let dir = HarnessId::Cursor.skill_dest_dir_for_home(home.path());
+ let worker_home = home.path().to_path_buf();
+ let (ready_tx, ready_rx) = mpsc::channel();
+ let (resume_tx, resume_rx) = mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ with_replace_hook(
+ move |dest| {
+ if dest.file_name().unwrap() == "SKILL.md" {
+ ready_tx.send(()).unwrap();
+ resume_rx.recv_timeout(Duration::from_secs(10)).unwrap();
+ }
+ Ok(())
+ },
+ || {
+ install_one_at_home(
+ &worker_home,
+ HarnessId::Cursor,
+ "custom",
+ SkillSource::Custom,
+ true,
+ )
+ },
+ )
+ });
+ ready_rx.recv_timeout(Duration::from_secs(10)).unwrap();
+ let during = sync::sync_with_source(home.path(), "new bundled");
+ assert_eq!(during.busy, vec![HarnessId::Cursor]);
+ assert!(during.updated.is_empty());
+ assert!(during.errors.is_empty());
+ assert_eq!(
+ fs::read_to_string(dir.join("SKILL.md")).unwrap(),
+ "old bundled"
+ );
+ assert_eq!(
+ fs::read_to_string(dir.join(SOURCE_MARKER_FILE)).unwrap(),
+ SOURCE_CUSTOM
+ );
+ resume_tx.send(()).unwrap();
+ worker.join().unwrap().unwrap();
+ let after = sync::sync_with_source(home.path(), "new bundled");
+ assert_eq!(after.protected, vec![HarnessId::Cursor]);
+ assert_eq!(fs::read_to_string(dir.join("SKILL.md")).unwrap(), "custom");
+ }
+
+ #[test]
+ fn forced_bundled_install_resumes_management() {
+ let home = TempDir::new().unwrap();
+ install_one_at_home(
+ home.path(),
+ HarnessId::Cursor,
+ "custom",
+ SkillSource::Custom,
+ false,
+ )
+ .unwrap();
+ install_one_at_home(
+ home.path(),
+ HarnessId::Cursor,
+ DEFAULT_SKILL_MD,
+ SkillSource::Bundled,
+ true,
+ )
+ .unwrap();
+ let report = sync::sync_with_source(home.path(), "new bundled");
+ assert_eq!(report.updated, vec![HarnessId::Cursor]);
+ assert_eq!(
+ fs::read_to_string(
+ HarnessId::Cursor
+ .skill_dest_dir_for_home(home.path())
+ .join("SKILL.md")
+ )
+ .unwrap(),
+ "new bundled"
+ );
}
#[test]
diff --git a/crates/bsk-cli/src/skill_install/provenance.rs b/crates/bsk-cli/src/skill_install/provenance.rs
new file mode 100644
index 00000000..991e8d5c
--- /dev/null
+++ b/crates/bsk-cli/src/skill_install/provenance.rs
@@ -0,0 +1,76 @@
+//! Read legacy source markers and record the last managed content in the same
+//! atomically replaced marker. A baseline describes what we wrote, not what the
+//! current binary happens to bundle.
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+use super::{SOURCE_BUNDLED, SOURCE_CUSTOM, SkillSource};
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) enum Provenance {
+ Missing,
+ Custom,
+ LegacyBundled,
+ Bundled { sha256: String },
+ Invalid,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Marker {
+ version: u8,
+ source: SkillSource,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ sha256: Option,
+}
+
+pub(super) fn digest(content: &[u8]) -> String {
+ Sha256::digest(content)
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect()
+}
+
+pub(super) fn bundled_marker(content: &[u8]) -> Result {
+ let marker = Marker {
+ version: 1,
+ source: SkillSource::Bundled,
+ sha256: Some(digest(content)),
+ };
+ Ok(format!("{}\n", serde_json::to_string(&marker)?))
+}
+
+pub(super) fn read(marker: &Path) -> Result {
+ let bytes = match std::fs::read(marker) {
+ Ok(bytes) => bytes,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Provenance::Missing),
+ Err(err) => return Err(err).with_context(|| format!("read {}", marker.display())),
+ };
+ if bytes == SOURCE_CUSTOM.as_bytes() {
+ return Ok(Provenance::Custom);
+ }
+ if bytes == SOURCE_BUNDLED.as_bytes() {
+ return Ok(Provenance::LegacyBundled);
+ }
+ match serde_json::from_slice::(&bytes) {
+ Ok(Marker {
+ version: 1,
+ source: SkillSource::Custom,
+ ..
+ }) => Ok(Provenance::Custom),
+ Ok(Marker {
+ version: 1,
+ source: SkillSource::Bundled,
+ sha256: Some(hash),
+ }) if hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) => {
+ Ok(Provenance::Bundled {
+ sha256: hash.to_ascii_lowercase(),
+ })
+ }
+ _ => Ok(Provenance::Invalid),
+ }
+}
diff --git a/crates/bsk-cli/src/skill_install/storage.rs b/crates/bsk-cli/src/skill_install/storage.rs
new file mode 100644
index 00000000..9caba570
--- /dev/null
+++ b/crates/bsk-cli/src/skill_install/storage.rs
@@ -0,0 +1,156 @@
+//! File operations shared by installation and automatic synchronization.
+
+use std::fs::{File, OpenOptions};
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use fs2::FileExt;
+use tempfile::NamedTempFile;
+
+/// All readers that may replace a skill hold this lock until their writes finish.
+/// Keep the lock file in place: deleting it could let two processes lock different
+/// files at the same path. Closing the handle releases the OS lock.
+pub(super) struct SkillLock {
+ _file: File,
+}
+
+impl SkillLock {
+ fn open(dir: &Path) -> io::Result {
+ OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(dir.join(".bsk.lock"))
+ }
+
+ pub(super) fn acquire(dir: &Path) -> io::Result {
+ let file = Self::open(dir)?;
+ file.lock_exclusive()?;
+ Ok(Self { _file: file })
+ }
+
+ /// Automatic sync is best-effort and must not wait on another installer.
+ pub(super) fn try_acquire(dir: &Path) -> io::Result