Compare commits

...
8 Commits
Author SHA1 Message Date
BB-fatandCursor 9c229b2489 chore: bump CLI version to 0.1.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:14:26 +08:00
BB-fatandCursor 26e5f98c16 docs: open extension install page from agent guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 15:55:20 +08:00
BB-fatandCursor dc8a6d38b3 Fix browser wait during daemon startup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:04:20 +08:00
BB-fat ab6aa16dfc feat: add Git Bash PATH support to installer
- Add Add-ToBashProfile helper to append install dir to ~/.bashrc
- Converts Windows paths to Unix-style for Git Bash compatibility
- Integrate into Main install flow alongside Add-ToUserPath
- Update completion message to mention Git Bash
2026-06-23 11:00:47 +08:00
BB-fat 45c85fa64a feat: add PowerShell install script for Windows
- Add install.ps1 mirrored from install.sh patterns
  - Same env var overrides (BSK_REPO, BSK_VERSION, BSK_INSTALL_DIR)
  - Default install dir ~/.local/bin
  - Fetches latest version from version.json via Invoke-RestMethod
  - Downloads, extracts zip, installs bsk.exe
  - Adds to user PATH persistently with idempotent check
  - Supports x64 and ARM64 architecture detection
- Update README.md Windows section with irm | iex one-liner
- Update AGENT_INSTALL.md with Windows PowerShell instructions
2026-06-23 10:45:29 +08:00
BB-fat b8c4be4362 docs: move video link after intro paragraph in both READMEs 2026-06-22 17:55:42 +08:00
BB-fat 335391c2bf docs: sync video link to Chinese README 2026-06-22 17:51:20 +08:00
BB-fat 3cf81d509b chore: add demo video to README 2026-06-22 17:49:52 +08:00
20 changed files with 805 additions and 115 deletions
+17 -4
View File
@@ -6,13 +6,22 @@ extension yourself (Step 3 is the user's job).
## 1. Install the CLI and skill ## 1. Install the CLI and skill
Run the install command from [`README.md`](README.md) (macOS/Linux): Run the install command from [`README.md`](README.md):
**macOS / Linux:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
bsk install-skill --yes bsk install-skill --yes
``` ```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
bsk install-skill --yes
```
## 2. Run `bsk doctor` ## 2. Run `bsk doctor`
```bash ```bash
@@ -22,12 +31,16 @@ bsk doctor
Each `fail` row prints a `hint` — follow it and re-run once. A fresh install Each `fail` row prints a `hint` — follow it and re-run once. A fresh install
where only `extension connected` fails is expected; go to Step 3. where only `extension connected` fails is expected; go to Step 3.
## 3. Ask the user to install the extension ## 3. Open the extension install page
If `extension connected` is `FAIL` (`0 browsers connected`), tell the user: If `extension connected` is `FAIL` (`0 browsers connected`), the user likely
has not installed or enabled the browser extension yet. Open the
[Chrome Web Store install page](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
in the user's browser, then tell the user:
> Install **BrowserSkill** from the > Install **BrowserSkill** from the
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi), > [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi),
> open the popup, and wait until it turns green. Reply when done. > open the popup, and wait until it turns green. Reply when done.
Then run `bsk doctor` once more. All `ok`/`na` → tell the user it's ready. If opening the page fails, give the user the same link. Then run `bsk doctor`
once more. All `ok`/`na` → tell the user it's ready.
Generated
+2 -2
View File
@@ -84,7 +84,7 @@ dependencies = [
[[package]] [[package]]
name = "bsk" name = "bsk"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bsk-protocol", "bsk-protocol",
@@ -113,7 +113,7 @@ dependencies = [
[[package]] [[package]]
name = "bsk-protocol" name = "bsk-protocol"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"schemars", "schemars",
"semver", "semver",
+2 -2
View File
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"] default-members = ["crates/bsk-cli"]
[workspace.package] [workspace.package]
version = "0.1.5" version = "0.1.6"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
license = "MIT" license = "MIT"
@@ -37,7 +37,7 @@ rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents) # Workspace crates (version required for `cargo publish` of dependents)
bsk-protocol = { version = "0.1.5", path = "crates/bsk-protocol" } bsk-protocol = { version = "0.1.6", path = "crates/bsk-protocol" }
[profile.release] [profile.release]
strip = "symbols" strip = "symbols"
+7 -3
View File
@@ -20,6 +20,8 @@ Need the agent to touch a tab you already have open? It must borrow that tab
explicitly, return it when the task is done, and leave the rest of your browser explicitly, return it when the task is done, and leave the rest of your browser
alone. alone.
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
## BrowserSkill Advantages ## BrowserSkill Advantages
- **Reuse real login state**: Agents can work with sites you are already signed - **Reuse real login state**: Agents can work with sites you are already signed
@@ -75,9 +77,11 @@ Install the CLI, then install the extension from the [Chrome Web Store](https://
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
``` ```
**Windows**: download `bsk-v<version>-x86_64-pc-windows-msvc.zip` from the **Windows** (PowerShell — installs to `~/.local/bin`):
[latest CLI release](https://github.com/Tencent/BrowserSkill/releases/latest)
and add the extracted `bsk.exe` to your `PATH`. ```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
```
Verify the binary: Verify the binary:
+2
View File
@@ -16,6 +16,8 @@
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。 需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
## BrowserSkill 的优势 ## BrowserSkill 的优势
- **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。 - **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。
+56
View File
@@ -0,0 +1,56 @@
//! Shared browser-connect wait durations for status/doctor/browsers commands.
use std::time::Duration;
use crate::daemon::browsers::EXTENSION_CONNECT_WAIT;
/// Default wait passed to daemon status/list RPCs when the registry is empty.
pub const DEFAULT_BROWSER_CONNECT_WAIT: Duration = EXTENSION_CONNECT_WAIT;
/// Keep CLI-side waits aligned with the daemon's IPC clamp so a bad env value
/// cannot inflate read timeouts after the daemon has already returned.
const MAX_BROWSER_CONNECT_WAIT: Duration = Duration::from_secs(60);
/// Browser wait for `bsk status` / `bsk browsers`. Override in tests via
/// `BSK_BROWSER_WAIT_MS`.
pub fn browser_connect_wait() -> Duration {
wait_from_env("BSK_BROWSER_WAIT_MS").unwrap_or(DEFAULT_BROWSER_CONNECT_WAIT)
}
/// Browser wait for `bsk doctor`. Falls back to [`browser_connect_wait`] when
/// `BSK_DOCTOR_BROWSER_WAIT_MS` is unset so tests can shorten only doctor.
pub fn doctor_browser_connect_wait() -> Duration {
wait_from_env("BSK_DOCTOR_BROWSER_WAIT_MS").unwrap_or_else(browser_connect_wait)
}
/// Encodes a wait duration for protocol params. A zero duration preserves the
/// legacy immediate-snapshot semantics.
pub fn wait_for_browser_ms(wait: Duration) -> Option<u64> {
let wait = clamp_browser_wait(wait);
if wait.is_zero() {
None
} else {
Some(wait.as_millis() as u64)
}
}
/// IPC call budget: the command's normal timeout, or browser wait plus
/// scheduling slack when a wait is requested.
pub fn browser_query_ipc_timeout(wait: Duration, default_timeout: Duration) -> Duration {
let wait = clamp_browser_wait(wait);
if wait.is_zero() {
default_timeout
} else {
wait.saturating_add(Duration::from_secs(2))
}
}
fn wait_from_env(key: &str) -> Option<Duration> {
std::env::var(key)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(|ms| clamp_browser_wait(Duration::from_millis(ms)))
}
fn clamp_browser_wait(wait: Duration) -> Duration {
wait.min(MAX_BROWSER_CONNECT_WAIT)
}
+16 -3
View File
@@ -5,9 +5,13 @@ use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use bsk_protocol::Method; use bsk_protocol::Method;
use bsk_protocol::system::BrowserListParams;
use bsk_protocol::system::BrowserStatusEntry; use bsk_protocol::system::BrowserStatusEntry;
use serde::Deserialize; use serde::Deserialize;
use crate::cli::browser_wait::{
browser_connect_wait, browser_query_ipc_timeout, wait_for_browser_ms,
};
use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format}; use crate::cli::error::{CliError, Format};
@@ -22,7 +26,12 @@ pub fn dispatch(format: Format) -> Result<(), CliError> {
} }
fn run_list(sock: PathBuf, format: Format) -> Result<(), CliError> { fn run_list(sock: PathBuf, format: Format) -> Result<(), CliError> {
let reply: ListReply = call(sock, Duration::from_secs(5))?; let wait = browser_connect_wait();
let params = BrowserListParams {
wait_for_browser_ms: wait_for_browser_ms(wait),
};
let timeout = browser_query_ipc_timeout(wait, Duration::from_secs(5));
let reply: ListReply = call(sock, params, timeout)?;
match format { match format {
Format::Json => { Format::Json => {
println!( println!(
@@ -92,7 +101,11 @@ fn run_list(sock: PathBuf, format: Format) -> Result<(), CliError> {
Ok(()) Ok(())
} }
fn call(sock: PathBuf, timeout: Duration) -> Result<ListReply, CliError> { fn call(
sock: PathBuf,
params: BrowserListParams,
timeout: Duration,
) -> Result<ListReply, CliError> {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@@ -101,7 +114,7 @@ fn call(sock: PathBuf, timeout: Duration) -> Result<ListReply, CliError> {
rt.block_on(async move { rt.block_on(async move {
let mut client = crate::ipc_client::IpcClient::connect(sock).await?; let mut client = crate::ipc_client::IpcClient::connect(sock).await?;
let outcome = client let outcome = client
.call::<(), ListReply>("browser-list-1", Method::BrowserList, None, timeout) .call("browser-list-1", Method::BrowserList, Some(params), timeout)
.await?; .await?;
outcome.map_err(CliError::from_rpc) outcome.map_err(CliError::from_rpc)
}) })
+35 -67
View File
@@ -1,23 +1,19 @@
//! `bsk doctor` — guided diagnostics + repair hints. //! `bsk doctor` — guided diagnostics + repair hints.
use std::time::{Duration, Instant}; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use bsk_protocol::StatusResult; use bsk_protocol::StatusResult;
use console::style; use console::style;
use serde::Serialize; use serde::Serialize;
use crate::cli::browser_wait::doctor_browser_connect_wait;
use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::status::{self, Output}; use crate::cli::status::{self, Output};
use crate::daemon::browsers::EXTENSION_CONNECT_WAIT;
use crate::daemon::info::{self, DaemonInfo}; use crate::daemon::info::{self, DaemonInfo};
use crate::daemon::paths; use crate::daemon::paths;
use crate::daemon::state::PROTOCOL_VERSION; use crate::daemon::state::PROTOCOL_VERSION;
/// How often `warm_up` polls `system.status` while waiting for the
/// browser extension to complete its WS handshake.
const BROWSER_CONNECT_POLL: Duration = Duration::from_millis(200);
/// Chrome Web Store listing for the browser-skill extension. /// Chrome Web Store listing for the browser-skill extension.
const EXTENSION_STORE_URL: &str = const EXTENSION_STORE_URL: &str =
"https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi"; "https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi";
@@ -99,8 +95,8 @@ impl CheckResult {
} }
pub fn run(output: Output) -> Result<Vec<CheckResult>> { pub fn run(output: Output) -> Result<Vec<CheckResult>> {
warm_up(output); let state = resolve_daemon_state(output);
let checks = collect_checks(); let checks = collect_checks(state);
match output { match output {
Output::Human => render_human(&checks), Output::Human => render_human(&checks),
Output::Json => render_json(&checks)?, Output::Json => render_json(&checks)?,
@@ -108,74 +104,47 @@ pub fn run(output: Output) -> Result<Vec<CheckResult>> {
Ok(checks) Ok(checks)
} }
/// Ensure the daemon is up and give the browser extension time to connect. /// Ensure the daemon is reachable and give the browser extension time to
/// /// connect before checks run. Returns a single [`DaemonState`] snapshot
/// All errors are silently swallowed — failures are reported by the /// for check evaluation.
/// individual checks in `collect_checks()` with actionable hints. fn resolve_daemon_state(output: Output) -> DaemonState {
/// let mut state = current_state(Duration::ZERO);
/// Flow:
/// 1. Read the current daemon state. **Only** proceed for `Missing` and if matches!(state, DaemonState::Missing | DaemonState::StaleDead(_)) && ensure_daemon().is_err()
/// `StaleDead`: broken states (`ReadError`, `IpcUnreachable`, {
/// `PidMismatch`) are real diagnostic findings that `collect_checks()` return current_state(Duration::ZERO);
/// should surface — fixing them here would mask the real problem.
/// 2. Call `ensure_daemon()` to auto-start the daemon (spawns a detached
/// child and waits up to 3 s for it to write a valid `daemon.json`).
/// 3. Poll `system.status` for up to [`EXTENSION_CONNECT_WAIT`] so the MV3
/// service worker has time to wake up, discover the WS port, and finish
/// its handshake before the checks are evaluated.
/// The wait duration can be shortened for tests by setting the env var
/// `BSK_DOCTOR_BROWSER_WAIT_MS` to a millisecond count.
fn warm_up(output: Output) {
// Only spawn for states where the daemon is genuinely absent or dead.
match current_state() {
DaemonState::Missing | DaemonState::StaleDead(_) => {}
_ => return,
} }
let info = match ensure_daemon() { if matches!(state, DaemonState::Missing | DaemonState::StaleDead(_)) {
Ok(info) => info, state = current_state(Duration::ZERO);
Err(_) => return, }
let wait = if needs_browser_wait(&state) {
doctor_browser_connect_wait()
} else {
Duration::ZERO
}; };
// Daemon was freshly spawned. Give the MV3 service worker time to if wait > Duration::ZERO && output == Output::Human {
// wake up, discover the WS port, and complete its handshake.
if output == Output::Human {
eprintln!("waiting for browser extension to connect…"); eprintln!("waiting for browser extension to connect…");
} }
let wait = browser_connect_wait(); if wait.is_zero() {
let deadline = Instant::now() + wait; state
loop { } else {
// Check deadline before sleeping so a zero-duration wait exits current_state(wait)
// immediately and we never start a query_sock call past the deadline.
if Instant::now() >= deadline {
break;
}
std::thread::sleep(BROWSER_CONNECT_POLL);
if Instant::now() >= deadline {
break;
}
if let Ok(s) = status::query_sock(info.sock_path.clone()) {
if !s.browsers.is_empty() {
break;
}
}
} }
} }
/// The browser connect wait duration for `warm_up`. Defaults to fn needs_browser_wait(state: &DaemonState) -> bool {
/// [`EXTENSION_CONNECT_WAIT`]; can be overridden in tests via the match state {
/// `BSK_DOCTOR_BROWSER_WAIT_MS` environment variable. DaemonState::Verified { status } => status.browsers.is_empty(),
fn browser_connect_wait() -> Duration { _ => false,
std::env::var("BSK_DOCTOR_BROWSER_WAIT_MS") }
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(EXTENSION_CONNECT_WAIT)
} }
/// What the disk + IPC says about a possibly-running daemon. Threaded /// What the disk + IPC says about a possibly-running daemon. Threaded
/// through every check so they share one IPC round-trip. /// through every check so they share one snapshot.
enum DaemonState { enum DaemonState {
/// No `daemon.json` at all. /// No `daemon.json` at all.
Missing, Missing,
@@ -208,8 +177,7 @@ impl DaemonState {
} }
} }
fn collect_checks() -> Vec<CheckResult> { fn collect_checks(state: DaemonState) -> Vec<CheckResult> {
let state = current_state();
vec![ vec![
check_home_writable(), check_home_writable(),
check_skill_up_to_date(), check_skill_up_to_date(),
@@ -220,7 +188,7 @@ fn collect_checks() -> Vec<CheckResult> {
] ]
} }
fn current_state() -> DaemonState { fn current_state(browser_wait: Duration) -> DaemonState {
let info = match info::read() { let info = match info::read() {
Ok(Some(info)) => info, Ok(Some(info)) => info,
Ok(None) => return DaemonState::Missing, Ok(None) => return DaemonState::Missing,
@@ -229,7 +197,7 @@ fn current_state() -> DaemonState {
if !crate::daemon::lockfile::pid_alive(info.pid) { if !crate::daemon::lockfile::pid_alive(info.pid) {
return DaemonState::StaleDead(info); return DaemonState::StaleDead(info);
} }
match status::query_sock(info.sock_path.clone()) { match status::query_sock_with_wait(info.sock_path.clone(), browser_wait) {
Ok(status) => { Ok(status) => {
if status.pid == info.pid { if status.pid == info.pid {
DaemonState::Verified { status } DaemonState::Verified { status }
+1
View File
@@ -2,6 +2,7 @@
use std::time::Duration; use std::time::Duration;
pub mod browser_wait;
pub mod browsers; pub mod browsers;
pub mod business_rpc; pub mod business_rpc;
pub mod daemon; pub mod daemon;
+14 -7
View File
@@ -5,6 +5,9 @@ use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use bsk_protocol::{Method, StatusParams, StatusResult}; use bsk_protocol::{Method, StatusParams, StatusResult};
use crate::cli::browser_wait::{
browser_connect_wait, browser_query_ipc_timeout, wait_for_browser_ms,
};
use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::CliError; use crate::cli::error::CliError;
@@ -19,7 +22,8 @@ pub enum Output {
/// reuse it. /// reuse it.
pub fn run(output: Output) -> Result<StatusResult, CliError> { pub fn run(output: Output) -> Result<StatusResult, CliError> {
let info = ensure_daemon().context("ensure daemon is running")?; let info = ensure_daemon().context("ensure daemon is running")?;
let result = query_sock(info.sock_path)?; let wait = browser_connect_wait();
let result = query_sock_with_wait(info.sock_path, wait)?;
match output { match output {
Output::Human => render_human(&result), Output::Human => render_human(&result),
Output::Json => render_json(&result).map_err(CliError::Local)?, Output::Json => render_json(&result).map_err(CliError::Local)?,
@@ -27,7 +31,14 @@ pub fn run(output: Output) -> Result<StatusResult, CliError> {
Ok(result) Ok(result)
} }
pub(crate) fn query_sock(sock: std::path::PathBuf) -> Result<StatusResult, CliError> { pub(crate) fn query_sock_with_wait(
sock: std::path::PathBuf,
wait: Duration,
) -> Result<StatusResult, CliError> {
let params = StatusParams {
wait_for_browser_ms: wait_for_browser_ms(wait),
};
let timeout = browser_query_ipc_timeout(wait, Duration::from_secs(2));
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@@ -36,11 +47,7 @@ pub(crate) fn query_sock(sock: std::path::PathBuf) -> Result<StatusResult, CliEr
rt.block_on(async move { rt.block_on(async move {
let mut client = crate::ipc_client::Client::connect_path(sock).await?; let mut client = crate::ipc_client::Client::connect_path(sock).await?;
let outcome = client let outcome = client
.call::<_, StatusResult>( .call::<_, StatusResult>(Method::SystemStatus, &params, timeout)
Method::SystemStatus,
&StatusParams::default(),
Duration::from_secs(2),
)
.await?; .await?;
outcome.map_err(CliError::from_rpc) outcome.map_err(CliError::from_rpc)
}) })
+37 -11
View File
@@ -214,6 +214,24 @@ impl BrowserRegistry {
self.len() == 0 self.len() == 0
} }
/// Poll until at least one browser is registered or `connect_wait` elapses.
/// Returns immediately when the registry is already non-empty.
pub async fn wait_for_any_connected(&self, connect_wait: Duration) {
if connect_wait.is_zero() || !self.is_empty() {
return;
}
let deadline = Instant::now() + connect_wait;
loop {
if !self.is_empty() {
return;
}
if Instant::now() >= deadline {
return;
}
tokio::time::sleep(EXTENSION_CONNECT_POLL).await;
}
}
/// Pick the only connected browser when exactly one is online, or /// Pick the only connected browser when exactly one is online, or
/// match a specific id / label when supplied. Returns an error code /// match a specific id / label when supplied. Returns an error code
/// aligned with §4.5. /// aligned with §4.5.
@@ -511,21 +529,29 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn select_with_connect_wait_does_not_wait_on_ambiguous_label() { async fn wait_for_any_connected_returns_when_browser_registers_late() {
let reg = std::sync::Arc::new(BrowserRegistry::new());
let reg_bg = reg.clone();
let waiter = tokio::spawn(async move {
reg_bg
.wait_for_any_connected(Duration::from_millis(500))
.await;
});
tokio::time::sleep(Duration::from_millis(80)).await;
reg.insert(fake_client("late", ""));
waiter.await.expect("join");
assert_eq!(reg.len(), 1);
}
#[tokio::test]
async fn wait_for_any_connected_skips_when_already_populated() {
let reg = BrowserRegistry::new(); let reg = BrowserRegistry::new();
reg.insert(fake_client("alpha", "Shared")); reg.insert(fake_client("a", ""));
reg.insert(fake_client("beta", "Shared"));
let started = Instant::now(); let started = Instant::now();
match reg reg.wait_for_any_connected(Duration::from_secs(1)).await;
.select_with_connect_wait(Some("Shared"), Duration::from_secs(1))
.await
{
Err(SelectError::AmbiguousLabel { label, .. }) => assert_eq!(label, "Shared"),
other => panic!("expected AmbiguousLabel, got {other:?}"),
}
assert!( assert!(
started.elapsed() < Duration::from_millis(50), started.elapsed() < Duration::from_millis(50),
"ambiguous label should fail immediately" "should return immediately when browsers already connected"
); );
} }
} }
+43 -8
View File
@@ -23,12 +23,14 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use bsk_protocol::system::{ use bsk_protocol::system::{
BrowserStatusEntry, SessionStatusEntry, StatusParams, StatusResult, VersionSkewEntry, BrowserListParams, BrowserStatusEntry, SessionStatusEntry, StatusParams, StatusResult,
VersionSkewEntry,
}; };
use bsk_protocol::tools::{ReturnFailure, WaitMsParams, WaitMsResult}; use bsk_protocol::tools::{ReturnFailure, WaitMsParams, WaitMsResult};
use bsk_protocol::{ use bsk_protocol::{
CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId, CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId,
}; };
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio::sync::Notify; use tokio::sync::Notify;
@@ -57,6 +59,8 @@ pub type RpcHandler = Arc<
>; >;
const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15); const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15);
/// Upper bound on `wait_for_browser_ms` accepted over IPC.
const MAX_BROWSER_WAIT: Duration = Duration::from_secs(60);
// `session.stop` fast-fails while another tool is active; this budget // `session.stop` fast-fails while another tool is active; this budget
// only needs to cover the stop RPC itself and IPC scheduling grace. // only needs to cover the stop RPC itself and IPC scheduling grace.
const DEFAULT_SESSION_STOP_TIMEOUT: Duration = const DEFAULT_SESSION_STOP_TIMEOUT: Duration =
@@ -213,7 +217,10 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
Err(e) => ResponseBody::Err(e), Err(e) => ResponseBody::Err(e),
}, },
Method::SessionList => handle_session_list(&state), Method::SessionList => handle_session_list(&state),
Method::BrowserList => handle_browser_list(&state), Method::BrowserList => match handle_browser_list(&state, params).await {
Ok(v) => ResponseBody::Ok(v),
Err(e) => ResponseBody::Err(e),
},
Method::ToolTabList Method::ToolTabList
| Method::ToolTabCreate | Method::ToolTabCreate
| Method::ToolTabClose | Method::ToolTabClose
@@ -554,14 +561,40 @@ async fn handle_status(
state: &Arc<DaemonState>, state: &Arc<DaemonState>,
params: Value, params: Value,
) -> Result<Value, RpcError> { ) -> Result<Value, RpcError> {
if !params.is_null() { let params: StatusParams = parse_params_or_default(params)?;
let _: StatusParams = serde_json::from_value(params).map_err(|err| RpcError { maybe_wait_for_browser(state, params.wait_for_browser_ms).await;
Ok(serde_json::to_value(status.snapshot_with(state)).unwrap_or(Value::Null))
}
fn parse_params_or_default<T>(params: Value) -> Result<T, RpcError>
where
T: DeserializeOwned + Default,
{
if params.is_null() {
Ok(T::default())
} else {
serde_json::from_value(params).map_err(|err| RpcError {
code: ErrorCode::InvalidParams, code: ErrorCode::InvalidParams,
message: err.to_string(), message: err.to_string(),
data: None, data: None,
})?; })
} }
Ok(serde_json::to_value(status.snapshot_with(state)).unwrap_or(Value::Null)) }
async fn maybe_wait_for_browser(state: &Arc<DaemonState>, wait_ms: Option<u64>) {
if let Some(wait) = clamp_browser_wait(wait_ms) {
state.browsers.wait_for_any_connected(wait).await;
}
}
fn clamp_browser_wait(wait_ms: Option<u64>) -> Option<Duration> {
let ms = wait_ms?;
if ms == 0 {
return None;
}
Some(Duration::from_millis(
ms.min(MAX_BROWSER_WAIT.as_millis() as u64),
))
} }
async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> { async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> {
@@ -788,14 +821,16 @@ fn handle_session_list(state: &Arc<DaemonState>) -> ResponseBody {
ResponseBody::Ok(serde_json::to_value(SessionListResult { sessions }).unwrap_or(Value::Null)) ResponseBody::Ok(serde_json::to_value(SessionListResult { sessions }).unwrap_or(Value::Null))
} }
fn handle_browser_list(state: &Arc<DaemonState>) -> ResponseBody { async fn handle_browser_list(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> {
let params: BrowserListParams = parse_params_or_default(params)?;
maybe_wait_for_browser(state, params.wait_for_browser_ms).await;
// Reuse the same helper that produces the // Reuse the same helper that produces the
// `multiple_browsers_online.error.data.browsers` payload so the // `multiple_browsers_online.error.data.browsers` payload so the
// two surfaces always agree on order — sorted by `connected_at_ms` // two surfaces always agree on order — sorted by `connected_at_ms`
// ascending, with `instance_id` as a deterministic tiebreaker // ascending, with `instance_id` as a deterministic tiebreaker
// (review I1). // (review I1).
let browsers = snapshot_status_entries(&state.browsers, &state.sessions); let browsers = snapshot_status_entries(&state.browsers, &state.sessions);
ResponseBody::Ok(serde_json::to_value(BrowserListResult { browsers }).unwrap_or(Value::Null)) Ok(serde_json::to_value(BrowserListResult { browsers }).unwrap_or(Value::Null))
} }
// ----- Test-helper IpcServer wrapper around the transport layer ----- // ----- Test-helper IpcServer wrapper around the transport layer -----
+321
View File
@@ -0,0 +1,321 @@
//! Integration tests: daemon status/list RPCs wait for a late extension handshake.
#![cfg(unix)]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use bsk::cli::doctor;
use bsk::cli::status::Output;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{BrowserListParams, HandshakeParams, HandshakeResult, StatusParams};
use bsk_protocol::{BrowserPeerInfo, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
static DOCTOR_ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvRestore(Vec<(&'static str, Option<OsString>)>);
impl Drop for EnvRestore {
fn drop(&mut self) {
for (key, value) in self.0.drain(..) {
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
fn with_doctor_env<R>(
bsk_home: &Path,
overrides: &[(&'static str, &'static str)],
f: impl FnOnce() -> R,
) -> R {
let _lock = DOCTOR_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let saved = [
"BSK_HOME",
"BSK_BROWSER_WAIT_MS",
"BSK_DOCTOR_BROWSER_WAIT_MS",
]
.into_iter()
.map(|key| (key, std::env::var_os(key)))
.collect();
let _restore = EnvRestore(saved);
unsafe {
std::env::set_var("BSK_HOME", bsk_home);
std::env::remove_var("BSK_BROWSER_WAIT_MS");
std::env::remove_var("BSK_DOCTOR_BROWSER_WAIT_MS");
for (key, value) in overrides {
std::env::set_var(key, value);
}
}
f()
}
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-wait");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text frame"),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
#[tokio::test]
async fn system_status_waits_for_late_extension_handshake() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
let sock_for_ipc = sock.clone();
let status_task = tokio::spawn(async move {
let mut ipc = IpcClient::connect(&sock_for_ipc).await.unwrap();
ipc.call::<StatusParams, bsk_protocol::StatusResult>(
"status-wait",
Method::SystemStatus,
Some(StatusParams {
wait_for_browser_ms: Some(500),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("system.status should succeed")
});
tokio::time::sleep(Duration::from_millis(80)).await;
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let status = status_task.await.expect("join");
assert_eq!(status.browsers.len(), 1);
assert_eq!(status.browsers[0].instance_id, TEST_EXT_ID);
handle.shutdown().await;
}
#[tokio::test]
async fn browser_list_waits_for_late_extension_handshake() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
#[derive(serde::Deserialize)]
struct ListReply {
browsers: Vec<bsk_protocol::system::BrowserStatusEntry>,
}
let sock_for_ipc = sock.clone();
let list_task = tokio::spawn(async move {
let mut ipc = IpcClient::connect(&sock_for_ipc).await.unwrap();
ipc.call::<BrowserListParams, ListReply>(
"browser-list-wait",
Method::BrowserList,
Some(BrowserListParams {
wait_for_browser_ms: Some(500),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("browser.list should succeed")
});
tokio::time::sleep(Duration::from_millis(80)).await;
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let list = list_task.await.expect("join");
assert_eq!(list.browsers.len(), 1);
assert_eq!(list.browsers[0].instance_id, TEST_EXT_ID);
handle.shutdown().await;
}
#[tokio::test]
async fn system_status_without_wait_returns_immediately_when_empty() {
let (handle, sock) = spawn_daemon().await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let started = std::time::Instant::now();
let status = ipc
.call::<StatusParams, bsk_protocol::StatusResult>(
"status-now",
Method::SystemStatus,
Some(StatusParams::default()),
Duration::from_secs(2),
)
.await
.unwrap()
.expect("system.status should succeed");
assert!(started.elapsed() < Duration::from_millis(200));
assert!(status.browsers.is_empty());
handle.shutdown().await;
}
#[tokio::test]
async fn doctor_reports_extension_connected_after_handshake() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
let home = tempfile::TempDir::new().unwrap();
let bsk_home = home.path().join("bsk");
std::fs::create_dir_all(&bsk_home).unwrap();
let info = serde_json::json!({
"pid": std::process::id(),
"sock_path": sock,
"ws_port": ws_addr.port(),
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
bsk_home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let bsk_home_for_doctor = bsk_home.clone();
let checks = tokio::task::spawn_blocking(move || {
with_doctor_env(
&bsk_home_for_doctor,
&[("BSK_BROWSER_WAIT_MS", "0")],
|| doctor::run(Output::Json),
)
})
.await
.expect("doctor join")
.expect("doctor ok");
let ext = checks
.iter()
.find(|check| check.name == "extension connected")
.expect("extension connected check");
assert_eq!(ext.status, doctor::CheckStatus::Ok);
handle.shutdown().await;
}
#[tokio::test]
async fn doctor_waits_for_late_extension_on_verified_daemon() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
let home = tempfile::TempDir::new().unwrap();
let bsk_home = home.path().join("bsk");
std::fs::create_dir_all(&bsk_home).unwrap();
let info = serde_json::json!({
"pid": std::process::id(),
"sock_path": sock,
"ws_port": ws_addr.port(),
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
bsk_home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let bsk_home_for_doctor = bsk_home.clone();
let doctor_task = tokio::task::spawn_blocking(move || {
with_doctor_env(
&bsk_home_for_doctor,
&[("BSK_DOCTOR_BROWSER_WAIT_MS", "500")],
|| doctor::run(Output::Json),
)
});
tokio::time::sleep(Duration::from_millis(80)).await;
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let checks = doctor_task.await.expect("doctor join").expect("doctor ok");
let ext = checks
.iter()
.find(|check| check.name == "extension connected")
.expect("extension connected check");
assert_eq!(ext.status, doctor::CheckStatus::Ok);
handle.shutdown().await;
}
+1
View File
@@ -44,6 +44,7 @@ fn bsk_status_json_returns_structured_payload() {
let out = Command::new(bsk_bin()) let out = Command::new(bsk_bin())
.args(["--json", "status"]) .args(["--json", "status"])
.env("BSK_HOME", &home) .env("BSK_HOME", &home)
.env("BSK_BROWSER_WAIT_MS", "0")
.env("RUST_LOG", "warn") .env("RUST_LOG", "warn")
.output() .output()
.expect("run bsk status"); .expect("run bsk status");
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "BrowserListParams",
"description": "`browser.list` request payload.",
"type": "object",
"properties": {
"wait_for_browser_ms": {
"description": "When set, the daemon polls the browser registry for up to this many milliseconds before returning a list. Only applies while zero browsers are registered; an existing connection returns immediately.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
}
}
}
@@ -1,6 +1,17 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"title": "StatusParams", "title": "StatusParams",
"description": "`system.status` request payload. Empty.", "description": "`system.status` request payload.",
"type": "object" "type": "object",
"properties": {
"wait_for_browser_ms": {
"description": "When set, the daemon polls the browser registry for up to this many milliseconds before returning a snapshot. Only applies while zero browsers are registered; an existing connection returns immediately.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
}
}
} }
+3 -1
View File
@@ -4,7 +4,8 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use bsk_protocol::system::{ use bsk_protocol::system::{
HandshakeParams, HandshakeResult, PingParams, PingResult, StatusParams, StatusResult, BrowserListParams, HandshakeParams, HandshakeResult, PingParams, PingResult, StatusParams,
StatusResult,
}; };
use bsk_protocol::tools::*; use bsk_protocol::tools::*;
use bsk_protocol::{CancelParams, CancelResult}; use bsk_protocol::{CancelParams, CancelResult};
@@ -34,6 +35,7 @@ fn main() {
dump!(PingResult, "system_ping_result"); dump!(PingResult, "system_ping_result");
dump!(StatusParams, "system_status_params"); dump!(StatusParams, "system_status_params");
dump!(StatusResult, "system_status_result"); dump!(StatusResult, "system_status_result");
dump!(BrowserListParams, "browser_list_params");
dump!(CancelParams, "cancel_params"); dump!(CancelParams, "cancel_params");
dump!(CancelResult, "cancel_result"); dump!(CancelResult, "cancel_result");
+3 -3
View File
@@ -12,8 +12,8 @@ pub use error::{DecodeError, ErrorCode, RpcError};
pub use frame::{EventFrame, EventKind, Frame, RequestFrame, ResponseBody, ResponseFrame, RpcId}; pub use frame::{EventFrame, EventKind, Frame, RequestFrame, ResponseBody, ResponseFrame, RpcId};
pub use method::Method; pub use method::Method;
pub use system::{ pub use system::{
BrowserPeerInfo, BrowserStatusEntry, HandshakeCompat, HandshakeParams, HandshakeResult, BrowserListParams, BrowserPeerInfo, BrowserStatusEntry, HandshakeCompat, HandshakeParams,
PingParams, PingResult, SessionStatusEntry, StatusParams, StatusResult, VersionSkewEntry, HandshakeResult, PingParams, PingResult, SessionStatusEntry, StatusParams, StatusResult,
compare_protocol, evaluate_handshake_compat, VersionSkewEntry, compare_protocol, evaluate_handshake_compat,
}; };
pub use tools::*; pub use tools::*;
+18 -2
View File
@@ -531,9 +531,25 @@ pub struct SessionStatusEntry {
pub created_at_ms: i64, pub created_at_ms: i64,
} }
/// `system.status` request payload. Empty. /// `system.status` request payload.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct StatusParams {} pub struct StatusParams {
/// When set, the daemon polls the browser registry for up to this many
/// milliseconds before returning a snapshot. Only applies while zero
/// browsers are registered; an existing connection returns immediately.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_for_browser_ms: Option<u64>,
}
/// `browser.list` request payload.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct BrowserListParams {
/// When set, the daemon polls the browser registry for up to this many
/// milliseconds before returning a list. Only applies while zero browsers
/// are registered; an existing connection returns immediately.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_for_browser_ms: Option<u64>,
}
/// One entry per connected browser whose protocol version drifts from /// One entry per connected browser whose protocol version drifts from
/// the daemon's (same major). Surfaced in `system.status` for /// the daemon's (same major). Surfaced in `system.status` for
+197
View File
@@ -0,0 +1,197 @@
<#
.SYNOPSIS
install.ps1 — install the bsk CLI on Windows from GitHub Releases.
.DESCRIPTION
Downloads the latest (or pinned) bsk release for Windows x64,
extracts bsk.exe to a user-local directory, and adds it to PATH.
Usage:
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
Environment overrides:
$env:BSK_REPO GitHub owner/repo (default: Tencent/BrowserSkill)
$env:BSK_VERSION Pin CLI version (default: latest from version.json)
$env:BSK_INSTALL_DIR Install directory (default: $HOME\.local\bin)
#>
#Requires -Version 5.1
$ErrorActionPreference = "Stop"
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$GitHub = "https://github.com/${Repo}"
function Write-Log {
param([string]$Message)
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Write-Die {
param([string]$Message)
Write-Host "error: $Message" -ForegroundColor Red
exit 1
}
# ── Platform / architecture detection ─────────────────────────────────────────
function Get-PlatformTriple {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture
switch ($arch) {
"X64" { $archId = "x64" }
"Arm64" { $archId = "arm64" }
default { Write-Die "unsupported architecture: $arch (x64 and ARM64 only)" }
}
$windowsArch = switch ($archId) {
"x64" { "x86_64-pc-windows-msvc" }
"arm64" { "aarch64-pc-windows-msvc" }
}
return @{
ArchId = $archId
TargetTriple = $windowsArch
}
}
# ── Version resolution ────────────────────────────────────────────────────────
function Get-LatestVersion {
$versionJsonUrl = "${GitHub}/releases/latest/download/version.json"
Write-Log "fetching latest version from ${versionJsonUrl}"
try {
$version = (Invoke-RestMethod -Uri $versionJsonUrl).version
if (-not $version) { Write-Die "could not parse version from version.json" }
return $version
}
catch {
Write-Die "failed to fetch version.json: $_"
}
}
# ── PATH helpers ──────────────────────────────────────────────────────────────
function Add-ToUserPath {
param([string]$Dir)
$currentUserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }
if ($currentUserPath -contains $Dir) {
Write-Log "$Dir is already in your user PATH"
return
}
$newUserPath = ($currentUserPath + $Dir) -join ";"
[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")
Write-Log "added ${Dir} to user PATH"
}
function Add-ToSessionPath {
param([string]$Dir)
$pathEntries = $env:PATH -split ";" | Where-Object { $_ }
if ($pathEntries -contains $Dir) {
return
}
$env:PATH = "$Dir;$env:PATH"
}
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
function Add-ToBashProfile {
param([string]$Dir)
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
$unixPath = $Dir -replace '\\', '/'
if ($unixPath -match '^([A-Z]):(.*)$') {
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
}
$bashRc = Join-Path $HOME ".bashrc"
$exportLine = "export PATH=""${unixPath}:`$PATH"" # bsk CLI"
if (Test-Path $bashRc) {
$content = Get-Content $bashRc -Raw -ErrorAction SilentlyContinue
if ($content -match [regex]::Escape($unixPath)) {
Write-Log "$unixPath is already in ~/.bashrc"
return
}
}
Add-Content $bashRc "`n$exportLine" -Encoding ASCII
Write-Log "added ${unixPath} to ~/.bashrc"
}
# ── Main ──────────────────────────────────────────────────────────────────────
function Main {
$platform = Get-PlatformTriple
if ($env:BSK_VERSION) {
$version = $env:BSK_VERSION -replace '^v', ''
Write-Log "using pinned version ${version}"
}
else {
$version = Get-LatestVersion
Write-Log "latest version is ${version}"
}
$tag = "cli-v${version}"
$archiveName = "bsk-v${version}-$($platform.TargetTriple).zip"
$downloadUrl = "${GitHub}/releases/download/${tag}/${archiveName}"
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
$archivePath = Join-Path $tempDir $archiveName
Write-Log "downloading ${downloadUrl}"
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing -ErrorAction Stop
Write-Log "extracting ${archiveName}"
Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force
if (-not (Test-Path (Join-Path $tempDir "bsk.exe"))) {
Write-Die "bsk.exe not found in archive"
}
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
Copy-Item -Path (Join-Path $tempDir "bsk.exe") -Destination (Join-Path $InstallDir "bsk.exe") -Force
Write-Log "installed bsk to $InstallDir\bsk.exe"
# Add to session PATH (current shell)
Add-ToSessionPath $InstallDir
# Add to user PATH (persistent, for PowerShell / cmd)
Add-ToUserPath $InstallDir
# Add to Git Bash PATH (persistent, for bash-based shells / agents)
Add-ToBashProfile $InstallDir
# Verify
$bskPath = Join-Path $InstallDir "bsk.exe"
if (Get-Command bsk -ErrorAction SilentlyContinue) {
& bsk --version
}
else {
Write-Log "verify install: & ""$bskPath"" --version"
}
Write-Log "done"
Write-Host ""
Write-Host "Open a new terminal (PowerShell / Git Bash) for PATH changes to take full effect."
}
finally {
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
}
}
Main