Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,28 @@ Use <kbd>Space</kbd> to select the Agent harness you want to install into, then
press <kbd>Enter</kbd> 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 <existing-SKILL.md> --force`, replacing
`<existing-SKILL.md>` 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
Expand Down
18 changes: 18 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ bsk install-skill

用 <kbd>Space</kbd> 选择需要安装的 Agent harness,然后按 <kbd>Enter</kbd> 安装 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 <existing-SKILL.md> --force`,将
`<existing-SKILL.md>` 替换为现有文件路径。如需恢复内置 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-插件)。

</details>
Expand Down
2 changes: 1 addition & 1 deletion crates/bsk-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
252 changes: 204 additions & 48 deletions crates/bsk-cli/src/cli/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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<String>,
}

Expand All @@ -83,6 +74,16 @@ impl CheckResult {
}
}

fn warn(name: impl Into<String>, detail: impl Into<String>, hint: impl Into<String>) -> Self {
Self {
name: name.into(),
ok: true,
status: CheckStatus::Warning,
detail: detail.into(),
hint: Some(hint.into()),
}
}

fn na(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
Expand Down Expand Up @@ -111,7 +112,7 @@ pub fn run(output: Output) -> Result<Vec<CheckResult>> {
}

/// 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)
}
Expand Down Expand Up @@ -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::<Vec<_>>()
.join("; ");
return CheckResult::fail(
name,
detail,
"re-run `bsk install-skill --force --harness <id>` 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::<Vec<_>>()
.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::<Vec<_>>()
.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 <existing-SKILL.md> --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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <existing-SKILL.md> --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());
Expand Down
Loading