From 95499057855ffccbbc94657b9c1d5977fefb287d Mon Sep 17 00:00:00 2001 From: shehab299 Date: Sun, 1 Feb 2026 21:15:21 +0200 Subject: [PATCH 01/10] add tab for browser control as plugins --- cli/src/commands/mod.rs | 11 ++ cli/src/commands/tab.rs | 413 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 cli/src/commands/tab.rs diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 87793fab..60cb4ce3 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod auth; pub mod auto_update; pub mod board; pub mod mcp; +pub mod tab; pub mod warden; pub use auth::AuthCommands; @@ -163,6 +164,13 @@ pub enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Browser automation CLI - control a real browser from the command line + /// Run `stakpak tab --help` for available commands. + Tab { + /// Arguments to pass to the tab plugin + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Update Stakpak Agent to the latest version Update, } @@ -447,6 +455,9 @@ impl Commands { Commands::Board { args } => { board::run_board(args).await?; } + Commands::Tab { args } => { + tab::run_tab(args).await?; + } Commands::Update => { auto_update::run_auto_update().await?; } diff --git a/cli/src/commands/tab.rs b/cli/src/commands/tab.rs new file mode 100644 index 00000000..c78e4f45 --- /dev/null +++ b/cli/src/commands/tab.rs @@ -0,0 +1,413 @@ +use std::process::{Command, Stdio}; + +const TAB_BINARY: &str = if cfg!(windows) { + "agent-tab.exe" +} else { + "agent-tab" +}; +const DAEMON_BINARY: &str = if cfg!(windows) { + "agent-tab-daemon.exe" +} else { + "agent-tab-daemon" +}; + +/// Pass-through to agent-tab plugin. All args after 'tab' are forwarded directly. +/// Run `stakpak tab --help` for available commands. +pub async fn run_tab(args: Vec) -> Result<(), String> { + let tab_path = get_tab_plugin_path().await; + let mut cmd = Command::new(&tab_path); + cmd.args(&args); + + cmd.stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdin(Stdio::inherit()); + + let status = cmd + .status() + .map_err(|e| format!("Failed to run agent-tab: {}", e))?; + + std::process::exit(status.code().unwrap_or(1)); +} + +// ============================================================================ +// Path and platform utilities +// ============================================================================ + +fn get_home_dir() -> Result { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map_err(|_| "HOME/USERPROFILE environment variable not set".to_string()) +} + +fn get_plugins_dir() -> Result { + let home_dir = get_home_dir()?; + Ok(std::path::PathBuf::from(&home_dir) + .join(".stakpak") + .join("plugins")) +} + +fn get_platform_suffix() -> Result<(&'static str, &'static str), String> { + let platform = match std::env::consts::OS { + "linux" => "linux", + "macos" => "darwin", + "windows" => "windows", + os => return Err(format!("Unsupported OS: {}", os)), + }; + + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + arch => return Err(format!("Unsupported architecture: {}", arch)), + }; + + Ok((platform, arch)) +} + +fn get_existing_path(binary_name: &str) -> Result { + let binary_path = get_plugins_dir()?.join(binary_name); + if binary_path.exists() { + Ok(binary_path.to_string_lossy().to_string()) + } else { + Err(format!("{} not found", binary_name)) + } +} + +fn is_version_match(current: &str, target: &str) -> bool { + let current_clean = current.strip_prefix('v').unwrap_or(current); + let target_clean = target.strip_prefix('v').unwrap_or(target); + current_clean == target_clean +} + +// ============================================================================ +// Version checking +// ============================================================================ + +fn get_tab_version(path: &str) -> Result { + let output = std::process::Command::new(path) + .arg("version") + .output() + .map_err(|e| format!("Failed to run agent-tab version: {}", e))?; + + if !output.status.success() { + return Err("agent-tab version command failed".to_string()); + } + + let version_output = String::from_utf8_lossy(&output.stdout); + let trimmed = version_output.trim(); + + // Parse version from output like "agent-tab v0.1.0" or just "v0.1.0" + if let Some(v) = trimmed.split_whitespace().find(|s| { + s.starts_with('v') + || s.chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + }) { + Ok(v.to_string()) + } else { + Ok(trimmed.to_string()) + } +} + +fn get_daemon_version(path: &str) -> Result { + let output = std::process::Command::new(path) + .arg("--version") + .output() + .map_err(|e| format!("Failed to run agent-tab-daemon: {}", e))?; + + if !output.status.success() { + return Err("agent-tab-daemon version command failed".to_string()); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +async fn get_latest_github_release_version() -> Result { + use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; + + let client = create_tls_client(TlsClientConfig::default())?; + + let response = client + .get("https://api.github.com/repos/stakpak/tab/releases") + .header("User-Agent", "stakpak-cli") + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!("GitHub API returned: {}", response.status())); + } + + let releases: Vec = response + .json() + .await + .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; + + for release in releases { + if let Some(tag_name) = release["tag_name"].as_str() { + if let Some(version) = tag_name.strip_prefix('v') { + return Ok(version.to_string()); + } + } + } + + Err("No release found with prefix 'v'".to_string()) +} + +// ============================================================================ +// Download and extraction +// ============================================================================ + +async fn download_and_extract( + artifact_prefix: &str, + binary_name: &str, + version: Option<&str>, +) -> Result { + use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; + + let plugins_dir = get_plugins_dir()?; + std::fs::create_dir_all(&plugins_dir) + .map_err(|e| format!("Failed to create plugins directory: {}", e))?; + + let (platform, arch) = get_platform_suffix()?; + let artifact_name = format!("{}-{}-{}", artifact_prefix, platform, arch); + let extension = if cfg!(windows) { "zip" } else { "tar.gz" }; + + let download_url = match version { + Some(v) => format!( + "https://github.com/stakpak/tab/releases/download/v{}/{}.{}", + v, artifact_name, extension + ), + None => format!( + "https://github.com/stakpak/tab/releases/latest/download/{}.{}", + artifact_name, extension + ), + }; + + eprintln!("{}", download_url); + println!("Downloading {} binary...", artifact_prefix); + + let client = create_tls_client(TlsClientConfig::default())?; + let response = client + .get(&download_url) + .send() + .await + .map_err(|e| format!("Failed to download {}: {}", artifact_prefix, e))?; + + if !response.status().is_success() { + return Err(format!("Download failed: HTTP {}", response.status())); + } + + let archive_bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read download: {}", e))?; + + let binary_path = plugins_dir.join(binary_name); + + if cfg!(windows) { + extract_zip(&archive_bytes, &plugins_dir, binary_name)?; + } else { + extract_tar_gz(&archive_bytes, &plugins_dir, binary_name)?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&binary_path) + .map_err(|e| format!("Failed to get binary metadata: {}", e))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&binary_path, perms) + .map_err(|e| format!("Failed to set binary permissions: {}", e))?; + } + + Ok(binary_path.to_string_lossy().to_string()) +} + +fn extract_tar_gz( + data: &[u8], + dest_dir: &std::path::Path, + binary_name: &str, +) -> Result<(), String> { + use flate2::read::GzDecoder; + use std::io::Cursor; + use tar::Archive; + + let cursor = Cursor::new(data); + let tar = GzDecoder::new(cursor); + let mut archive = Archive::new(tar); + + for entry in archive + .entries() + .map_err(|e| format!("Failed to read archive: {}", e))? + { + let mut entry = entry.map_err(|e| format!("Failed to read archive entry: {}", e))?; + let path = entry + .path() + .map_err(|e| format!("Failed to get entry path: {}", e))?; + + if let Some(file_name) = path.file_name() { + if file_name == binary_name { + let dest_path = dest_dir.join(file_name); + entry + .unpack(&dest_path) + .map_err(|e| format!("Failed to extract binary: {}", e))?; + return Ok(()); + } + } + } + + Err("Binary not found in archive".to_string()) +} + +#[cfg(windows)] +fn extract_zip(data: &[u8], dest_dir: &std::path::Path, binary_name: &str) -> Result<(), String> { + use std::io::Cursor; + use zip::ZipArchive; + + let cursor = Cursor::new(data); + let mut archive = + ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to read zip entry: {}", e))?; + + if file.name().ends_with(binary_name) { + let dest_path = dest_dir.join(binary_name); + let mut outfile = std::fs::File::create(&dest_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + std::io::copy(&mut file, &mut outfile) + .map_err(|e| format!("Failed to write binary: {}", e))?; + return Ok(()); + } + } + + Err("Binary not found in archive".to_string()) +} + +#[cfg(not(windows))] +fn extract_zip( + _data: &[u8], + _dest_dir: &std::path::Path, + _binary_name: &str, +) -> Result<(), String> { + Err("ZIP extraction not supported on this platform".to_string()) +} + +// ============================================================================ +// Daemon management +// ============================================================================ + +async fn ensure_daemon_downloaded(version: Option<&str>) { + if let Ok(existing_path) = get_existing_path(DAEMON_BINARY) { + if let Some(target) = version { + if let Ok(current) = get_daemon_version(&existing_path) { + if is_version_match(¤t, target) { + return; + } + println!( + "agent-tab-daemon {} is outdated (target: {}), updating...", + current, target + ); + } + } else { + return; // No version to check, binary exists + } + } + + match download_and_extract("agent-tab-daemon", DAEMON_BINARY, version).await { + Ok(path) => { + let version_str = version.map(|v| format!("{} ", v)).unwrap_or_default(); + println!( + "Successfully installed agent-tab-daemon {}-> {}", + version_str, path + ); + } + Err(e) => eprintln!("Failed to download agent-tab-daemon: {}", e), + } +} + +// ============================================================================ +// Main plugin path resolution +// ============================================================================ + +async fn get_tab_plugin_path() -> String { + let existing = get_existing_path(TAB_BINARY).ok(); + let current_version = existing.as_ref().and_then(|p| get_tab_version(p).ok()); + + if let Some(ref path) = existing { + match get_latest_github_release_version().await { + Ok(target_version) => { + if let Some(ref current) = current_version { + if is_version_match(current, &target_version) { + ensure_daemon_downloaded(Some(&target_version)).await; + return path.clone(); + } + println!( + "agent-tab {} is outdated (target: {}), updating...", + current, target_version + ); + } + + match download_and_extract("agent-tab", TAB_BINARY, Some(&target_version)).await { + Ok(new_path) => { + println!( + "Successfully installed agent-tab {} -> {}", + target_version, new_path + ); + ensure_daemon_downloaded(Some(&target_version)).await; + return new_path; + } + Err(e) => { + eprintln!("Failed to update agent-tab: {}", e); + eprintln!("Using existing version"); + ensure_daemon_downloaded(Some(&target_version)).await; + return path.clone(); + } + } + } + Err(_) => { + ensure_daemon_downloaded(None).await; + return path.clone(); + } + } + } + + // No existing installation - must download + match get_latest_github_release_version().await { + Ok(target_version) => { + match download_and_extract("agent-tab", TAB_BINARY, Some(&target_version)).await { + Ok(path) => { + println!( + "Successfully installed agent-tab {} -> {}", + target_version, path + ); + ensure_daemon_downloaded(Some(&target_version)).await; + path + } + Err(e) => { + eprintln!("Failed to download agent-tab: {}", e); + "agent-tab".to_string() + } + } + } + Err(e) => { + eprintln!("Warning: Failed to check version: {}", e); + match download_and_extract("agent-tab", TAB_BINARY, None).await { + Ok(path) => { + println!("Successfully installed agent-tab -> {}", path); + ensure_daemon_downloaded(None).await; + path + } + Err(e) => { + eprintln!("Failed to download agent-tab: {}", e); + "agent-tab".to_string() + } + } + } + } +} From 73602e6b37ec498d568590effc245436bfbf96c9 Mon Sep 17 00:00:00 2001 From: shehab299 Date: Sun, 1 Feb 2026 21:50:18 +0200 Subject: [PATCH 02/10] (fix) run clippy and fmt --- cli/src/commands/tab.rs | 24 ++++++++++++------------ libs/api/src/stakpak/models.rs | 16 ++++------------ 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/cli/src/commands/tab.rs b/cli/src/commands/tab.rs index c78e4f45..2e05f913 100644 --- a/cli/src/commands/tab.rs +++ b/cli/src/commands/tab.rs @@ -145,10 +145,10 @@ async fn get_latest_github_release_version() -> Result { .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; for release in releases { - if let Some(tag_name) = release["tag_name"].as_str() { - if let Some(version) = tag_name.strip_prefix('v') { - return Ok(version.to_string()); - } + if let Some(tag_name) = release["tag_name"].as_str() + && let Some(version) = tag_name.strip_prefix('v') + { + return Ok(version.to_string()); } } @@ -248,14 +248,14 @@ fn extract_tar_gz( .path() .map_err(|e| format!("Failed to get entry path: {}", e))?; - if let Some(file_name) = path.file_name() { - if file_name == binary_name { - let dest_path = dest_dir.join(file_name); - entry - .unpack(&dest_path) - .map_err(|e| format!("Failed to extract binary: {}", e))?; - return Ok(()); - } + if let Some(file_name) = path.file_name() + && file_name == binary_name + { + let dest_path = dest_dir.join(file_name); + entry + .unpack(&dest_path) + .map_err(|e| format!("Failed to extract binary: {}", e))?; + return Ok(()); } } diff --git a/libs/api/src/stakpak/models.rs b/libs/api/src/stakpak/models.rs index 9eabfd51..ef091ca9 100644 --- a/libs/api/src/stakpak/models.rs +++ b/libs/api/src/stakpak/models.rs @@ -14,31 +14,23 @@ use uuid::Uuid; /// Session visibility #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "UPPERCASE")] +#[derive(Default)] pub enum SessionVisibility { + #[default] Private, Public, } -impl Default for SessionVisibility { - fn default() -> Self { - Self::Private - } -} - /// Session status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "UPPERCASE")] +#[derive(Default)] pub enum SessionStatus { + #[default] Active, Deleted, } -impl Default for SessionStatus { - fn default() -> Self { - Self::Active - } -} - /// Full session with active checkpoint #[derive(Debug, Clone, Deserialize)] pub struct Session { From d889d57fafc98a788ad0f010574fb7af7d7d736e Mon Sep 17 00:00:00 2001 From: shehab299 Date: Mon, 2 Feb 2026 16:34:30 +0200 Subject: [PATCH 03/10] rename tab subcommand to browser --- cli/src/commands/{tab.rs => browser.rs} | 2 -- cli/src/commands/mod.rs | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) rename cli/src/commands/{tab.rs => browser.rs} (99%) diff --git a/cli/src/commands/tab.rs b/cli/src/commands/browser.rs similarity index 99% rename from cli/src/commands/tab.rs rename to cli/src/commands/browser.rs index 2e05f913..089f2c2a 100644 --- a/cli/src/commands/tab.rs +++ b/cli/src/commands/browser.rs @@ -11,8 +11,6 @@ const DAEMON_BINARY: &str = if cfg!(windows) { "agent-tab-daemon" }; -/// Pass-through to agent-tab plugin. All args after 'tab' are forwarded directly. -/// Run `stakpak tab --help` for available commands. pub async fn run_tab(args: Vec) -> Result<(), String> { let tab_path = get_tab_plugin_path().await; let mut cmd = Command::new(&tab_path); diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 60cb4ce3..efbbcde9 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -12,8 +12,8 @@ pub mod agent; pub mod auth; pub mod auto_update; pub mod board; +pub mod browser; pub mod mcp; -pub mod tab; pub mod warden; pub use auth::AuthCommands; @@ -165,9 +165,9 @@ pub enum Commands { args: Vec, }, /// Browser automation CLI - control a real browser from the command line - /// Run `stakpak tab --help` for available commands. - Tab { - /// Arguments to pass to the tab plugin + /// Run `stakpak browser --help` for available commands. + Browser { + /// Arguments to pass to the browser plugin #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, @@ -455,8 +455,8 @@ impl Commands { Commands::Board { args } => { board::run_board(args).await?; } - Commands::Tab { args } => { - tab::run_tab(args).await?; + Commands::Browser { args } => { + browser::run_tab(args).await?; } Commands::Update => { auto_update::run_auto_update().await?; From 253ec162377e809140c5030dfc42964d438e565e Mon Sep 17 00:00:00 2001 From: shehab299 Date: Wed, 4 Feb 2026 13:15:15 +0200 Subject: [PATCH 04/10] (refactor) remove duplicated plugin code into plugin_utils --- cli/src/commands/board.rs | 238 ++++++------------- cli/src/commands/browser.rs | 392 ++++++++----------------------- cli/src/commands/mod.rs | 3 +- cli/src/commands/plugin_utils.rs | 236 +++++++++++++++++++ cli/src/utils/plugins.rs | 16 +- 5 files changed, 422 insertions(+), 463 deletions(-) create mode 100644 cli/src/commands/plugin_utils.rs diff --git a/cli/src/commands/board.rs b/cli/src/commands/board.rs index c7a7d27e..52023448 100644 --- a/cli/src/commands/board.rs +++ b/cli/src/commands/board.rs @@ -1,4 +1,20 @@ -use std::process::{Command, Stdio}; +use crate::commands::plugin_utils::{ + Plugin, download_plugin_from_github, execute_plugin_command, get_latest_github_release_version, + get_plugin_existing_path, is_version_match, +}; +use std::process::Command; + +const BOARD: Plugin = Plugin { + plugin_name: "agent-board", + repo_owner: "stakpak", + repo_name: "agent-board", + artifact_prefix: "agent-board", + binary_name: if cfg!(windows) { + "agent-board.exe" + } else { + "agent-board" + }, +}; /// Pass-through to agent-board plugin. All args after 'board' are forwarded directly. /// Run `stakpak board --help` for available commands. @@ -6,20 +22,29 @@ pub async fn run_board(args: Vec) -> Result<(), String> { let board_path = get_board_plugin_path().await; let mut cmd = Command::new(board_path); cmd.args(&args); - execute_board_command(cmd) + execute_plugin_command(cmd, BOARD.plugin_name.to_string()) } async fn get_board_plugin_path() -> String { // Check if we have an existing installation first - let existing = get_existing_board_path().ok(); + let existing = get_plugin_existing_path(BOARD.binary_name.to_string()) + .await + .ok(); + let current_version = existing .as_ref() .and_then(|path| get_board_version(path).ok()); + let latest_version = get_latest_github_release_version( + BOARD.repo_owner.to_string(), + BOARD.repo_name.to_string(), + ) + .await; + // If we have an existing installation, check if update needed if let Some(ref path) = existing { // Try to get latest version from GitHub API - match get_latest_github_release_version().await { + match latest_version { Ok(target_version) => { if let Some(ref current) = current_version { if is_version_match(current, &target_version) { @@ -27,16 +52,25 @@ async fn get_board_plugin_path() -> String { return path.clone(); } println!( - "agent-board {} is outdated (target: {}), updating...", - current, target_version + "{} {} is outdated (target: {}), updating...", + BOARD.plugin_name, current, target_version ); } + // Need to update - download new version - match download_board_plugin().await { + match download_plugin_from_github( + BOARD.repo_owner, + BOARD.repo_name, + BOARD.artifact_prefix, + BOARD.binary_name, + None, + ) + .await + { Ok(new_path) => { println!( - "Successfully installed agent-board {} -> {}", - target_version, new_path + "Successfully installed {} {} -> {}", + BOARD.plugin_name, target_version, new_path ); return new_path; } @@ -55,24 +89,42 @@ async fn get_board_plugin_path() -> String { } // No existing installation - must download - match get_latest_github_release_version().await { - Ok(target_version) => match download_board_plugin().await { - Ok(path) => { - println!( - "Successfully installed agent-board {} -> {}", - target_version, path - ); - path - } - Err(e) => { - eprintln!("Failed to download agent-board: {}", e); - "agent-board".to_string() + match latest_version { + Ok(target_version) => { + match download_plugin_from_github( + BOARD.repo_owner, + BOARD.repo_name, + BOARD.artifact_prefix, + BOARD.binary_name, + None, + ) + .await + { + Ok(path) => { + println!( + "Successfully installed agent-board {} -> {}", + target_version, path + ); + path + } + Err(e) => { + eprintln!("Failed to download agent-board: {}", e); + "agent-board".to_string() + } } - }, + } Err(e) => { // Try download anyway (uses /latest/ URL) eprintln!("Warning: Failed to check version: {}", e); - match download_board_plugin().await { + match download_plugin_from_github( + BOARD.repo_owner, + BOARD.repo_name, + BOARD.artifact_prefix, + BOARD.binary_name, + None, + ) + .await + { Ok(path) => { println!("Successfully installed agent-board -> {}", path); path @@ -86,49 +138,6 @@ async fn get_board_plugin_path() -> String { } } -async fn get_latest_github_release_version() -> Result { - use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; - - let client = create_tls_client(TlsClientConfig::default())?; - let response = client - .get("https://api.github.com/repos/stakpak/agent-board/releases/latest") - .header("User-Agent", "stakpak-cli") - .header("Accept", "application/vnd.github.v3+json") - .send() - .await - .map_err(|e| format!("Failed to fetch latest release: {}", e))?; - - if !response.status().is_success() { - return Err(format!("GitHub API returned: {}", response.status())); - } - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; - - json["tag_name"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "No tag_name in release".to_string()) -} - -fn get_existing_board_path() -> Result { - let home_dir = - std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; - - let plugin_path = std::path::PathBuf::from(&home_dir) - .join(".stakpak") - .join("plugins") - .join("agent-board"); - - if plugin_path.exists() { - Ok(plugin_path.to_string_lossy().to_string()) - } else { - Err("agent-board not found in plugins directory".to_string()) - } -} - fn get_board_version(path: &str) -> Result { let output = std::process::Command::new(path) .arg("version") @@ -154,100 +163,3 @@ fn get_board_version(path: &str) -> Result { Ok(trimmed.to_string()) } } - -fn is_version_match(current: &str, target: &str) -> bool { - let current_clean = current.strip_prefix('v').unwrap_or(current); - let target_clean = target.strip_prefix('v').unwrap_or(target); - current_clean == target_clean -} - -async fn download_board_plugin() -> Result { - use flate2::read::GzDecoder; - use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; - use std::io::Cursor; - use tar::Archive; - - let home_dir = - std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; - - let plugins_dir = std::path::PathBuf::from(&home_dir) - .join(".stakpak") - .join("plugins"); - - std::fs::create_dir_all(&plugins_dir) - .map_err(|e| format!("Failed to create plugins directory: {}", e))?; - - // Determine platform - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - - let target = match (os, arch) { - ("linux", "x86_64") => "linux-x86_64", - ("linux", "aarch64") => "linux-aarch64", - ("macos", "x86_64") => "darwin-x86_64", - ("macos", "aarch64") => "darwin-aarch64", - _ => return Err(format!("Unsupported platform: {} {}", os, arch)), - }; - - let download_url = format!( - "https://github.com/stakpak/agent-board/releases/latest/download/agent-board-{}.tar.gz", - target - ); - - println!("Downloading agent-board plugin..."); - - let client = create_tls_client(TlsClientConfig::default())?; - let response = client - .get(&download_url) - .send() - .await - .map_err(|e| format!("Failed to download agent-board: {}", e))?; - - if !response.status().is_success() { - return Err(format!("Download failed: HTTP {}", response.status())); - } - - let archive_bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read download: {}", e))?; - - // Extract tar.gz - let cursor = Cursor::new(archive_bytes.as_ref()); - let tar = GzDecoder::new(cursor); - let mut archive = Archive::new(tar); - - archive - .unpack(&plugins_dir) - .map_err(|e| format!("Failed to extract archive: {}", e))?; - - let plugin_path = plugins_dir.join("agent-board"); - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(&plugin_path) - .map_err(|e| format!("Failed to get file metadata: {}", e))? - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&plugin_path, permissions) - .map_err(|e| format!("Failed to set executable permissions: {}", e))?; - } - - Ok(plugin_path.to_string_lossy().to_string()) -} - -fn execute_board_command(mut cmd: Command) -> Result<(), String> { - // Pass through stdio directly - no buffering needed - cmd.stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .stdin(Stdio::inherit()); - - let status = cmd - .status() - .map_err(|e| format!("Failed to run agent-board: {}", e))?; - - // Exit with the same code as the plugin - std::process::exit(status.code().unwrap_or(1)); -} diff --git a/cli/src/commands/browser.rs b/cli/src/commands/browser.rs index 089f2c2a..b471e571 100644 --- a/cli/src/commands/browser.rs +++ b/cli/src/commands/browser.rs @@ -1,99 +1,53 @@ -use std::process::{Command, Stdio}; - -const TAB_BINARY: &str = if cfg!(windows) { - "agent-tab.exe" -} else { - "agent-tab" +use crate::commands::plugin_utils::{ + Plugin, download_plugin_from_github, execute_plugin_command, get_latest_github_release_version, + get_plugin_existing_path, is_version_match, }; -const DAEMON_BINARY: &str = if cfg!(windows) { - "agent-tab-daemon.exe" -} else { - "agent-tab-daemon" +use std::process::Command; + +const BROWSER: Plugin = Plugin { + plugin_name: "agent-tab", + repo_owner: "stakpak", + repo_name: "tab", + artifact_prefix: "agent-tab", + binary_name: if cfg!(windows) { + "agent-tab.exe" + } else { + "agent-tab" + }, }; -pub async fn run_tab(args: Vec) -> Result<(), String> { - let tab_path = get_tab_plugin_path().await; - let mut cmd = Command::new(&tab_path); - cmd.args(&args); - - cmd.stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .stdin(Stdio::inherit()); - - let status = cmd - .status() - .map_err(|e| format!("Failed to run agent-tab: {}", e))?; - - std::process::exit(status.code().unwrap_or(1)); -} - -// ============================================================================ -// Path and platform utilities -// ============================================================================ - -fn get_home_dir() -> Result { - std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map_err(|_| "HOME/USERPROFILE environment variable not set".to_string()) -} - -fn get_plugins_dir() -> Result { - let home_dir = get_home_dir()?; - Ok(std::path::PathBuf::from(&home_dir) - .join(".stakpak") - .join("plugins")) -} - -fn get_platform_suffix() -> Result<(&'static str, &'static str), String> { - let platform = match std::env::consts::OS { - "linux" => "linux", - "macos" => "darwin", - "windows" => "windows", - os => return Err(format!("Unsupported OS: {}", os)), - }; - - let arch = match std::env::consts::ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - arch => return Err(format!("Unsupported architecture: {}", arch)), - }; - - Ok((platform, arch)) -} - -fn get_existing_path(binary_name: &str) -> Result { - let binary_path = get_plugins_dir()?.join(binary_name); - if binary_path.exists() { - Ok(binary_path.to_string_lossy().to_string()) +const DAEMON: Plugin = Plugin { + plugin_name: "agent-tab-daemon", + repo_owner: "stakpak", + repo_name: "tab", + artifact_prefix: "agent-tab-daemon", + binary_name: if cfg!(windows) { + "agent-tab-daemon.exe" } else { - Err(format!("{} not found", binary_name)) - } -} + "agent-tab-daemon" + }, +}; -fn is_version_match(current: &str, target: &str) -> bool { - let current_clean = current.strip_prefix('v').unwrap_or(current); - let target_clean = target.strip_prefix('v').unwrap_or(target); - current_clean == target_clean +pub async fn run_browser(args: Vec) -> Result<(), String> { + let browser_path = get_browser_plugin_path().await; + let mut cmd = Command::new(&browser_path); + cmd.args(&args); + execute_plugin_command(cmd, BROWSER.binary_name.to_string()) } -// ============================================================================ -// Version checking -// ============================================================================ - -fn get_tab_version(path: &str) -> Result { +fn get_browser_version(path: &str) -> Result { let output = std::process::Command::new(path) .arg("version") .output() - .map_err(|e| format!("Failed to run agent-tab version: {}", e))?; + .map_err(|e| format!("Failed to run {} version: {}", BROWSER.binary_name, e))?; if !output.status.success() { - return Err("agent-tab version command failed".to_string()); + return Err(format!("{} version command failed", BROWSER.binary_name)); } let version_output = String::from_utf8_lossy(&output.stdout); let trimmed = version_output.trim(); - // Parse version from output like "agent-tab v0.1.0" or just "v0.1.0" if let Some(v) = trimmed.split_whitespace().find(|s| { s.starts_with('v') || s.chars() @@ -111,205 +65,25 @@ fn get_daemon_version(path: &str) -> Result { let output = std::process::Command::new(path) .arg("--version") .output() - .map_err(|e| format!("Failed to run agent-tab-daemon: {}", e))?; + .map_err(|e| format!("Failed to run {}: {}", DAEMON.plugin_name, e))?; if !output.status.success() { - return Err("agent-tab-daemon version command failed".to_string()); + return Err(format!("{} version command failed", DAEMON.plugin_name)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } -async fn get_latest_github_release_version() -> Result { - use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; - - let client = create_tls_client(TlsClientConfig::default())?; - - let response = client - .get("https://api.github.com/repos/stakpak/tab/releases") - .header("User-Agent", "stakpak-cli") - .header("Accept", "application/vnd.github.v3+json") - .send() - .await - .map_err(|e| format!("Failed to fetch releases: {}", e))?; - - if !response.status().is_success() { - return Err(format!("GitHub API returned: {}", response.status())); - } - - let releases: Vec = response - .json() - .await - .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; - - for release in releases { - if let Some(tag_name) = release["tag_name"].as_str() - && let Some(version) = tag_name.strip_prefix('v') - { - return Ok(version.to_string()); - } - } - - Err("No release found with prefix 'v'".to_string()) -} - -// ============================================================================ -// Download and extraction -// ============================================================================ - -async fn download_and_extract( - artifact_prefix: &str, - binary_name: &str, - version: Option<&str>, -) -> Result { - use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; - - let plugins_dir = get_plugins_dir()?; - std::fs::create_dir_all(&plugins_dir) - .map_err(|e| format!("Failed to create plugins directory: {}", e))?; - - let (platform, arch) = get_platform_suffix()?; - let artifact_name = format!("{}-{}-{}", artifact_prefix, platform, arch); - let extension = if cfg!(windows) { "zip" } else { "tar.gz" }; - - let download_url = match version { - Some(v) => format!( - "https://github.com/stakpak/tab/releases/download/v{}/{}.{}", - v, artifact_name, extension - ), - None => format!( - "https://github.com/stakpak/tab/releases/latest/download/{}.{}", - artifact_name, extension - ), - }; - - eprintln!("{}", download_url); - println!("Downloading {} binary...", artifact_prefix); - - let client = create_tls_client(TlsClientConfig::default())?; - let response = client - .get(&download_url) - .send() - .await - .map_err(|e| format!("Failed to download {}: {}", artifact_prefix, e))?; - - if !response.status().is_success() { - return Err(format!("Download failed: HTTP {}", response.status())); - } - - let archive_bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read download: {}", e))?; - - let binary_path = plugins_dir.join(binary_name); - - if cfg!(windows) { - extract_zip(&archive_bytes, &plugins_dir, binary_name)?; - } else { - extract_tar_gz(&archive_bytes, &plugins_dir, binary_name)?; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&binary_path) - .map_err(|e| format!("Failed to get binary metadata: {}", e))? - .permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&binary_path, perms) - .map_err(|e| format!("Failed to set binary permissions: {}", e))?; - } - - Ok(binary_path.to_string_lossy().to_string()) -} - -fn extract_tar_gz( - data: &[u8], - dest_dir: &std::path::Path, - binary_name: &str, -) -> Result<(), String> { - use flate2::read::GzDecoder; - use std::io::Cursor; - use tar::Archive; - - let cursor = Cursor::new(data); - let tar = GzDecoder::new(cursor); - let mut archive = Archive::new(tar); - - for entry in archive - .entries() - .map_err(|e| format!("Failed to read archive: {}", e))? - { - let mut entry = entry.map_err(|e| format!("Failed to read archive entry: {}", e))?; - let path = entry - .path() - .map_err(|e| format!("Failed to get entry path: {}", e))?; - - if let Some(file_name) = path.file_name() - && file_name == binary_name - { - let dest_path = dest_dir.join(file_name); - entry - .unpack(&dest_path) - .map_err(|e| format!("Failed to extract binary: {}", e))?; - return Ok(()); - } - } - - Err("Binary not found in archive".to_string()) -} - -#[cfg(windows)] -fn extract_zip(data: &[u8], dest_dir: &std::path::Path, binary_name: &str) -> Result<(), String> { - use std::io::Cursor; - use zip::ZipArchive; - - let cursor = Cursor::new(data); - let mut archive = - ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; - - for i in 0..archive.len() { - let mut file = archive - .by_index(i) - .map_err(|e| format!("Failed to read zip entry: {}", e))?; - - if file.name().ends_with(binary_name) { - let dest_path = dest_dir.join(binary_name); - let mut outfile = std::fs::File::create(&dest_path) - .map_err(|e| format!("Failed to create output file: {}", e))?; - std::io::copy(&mut file, &mut outfile) - .map_err(|e| format!("Failed to write binary: {}", e))?; - return Ok(()); - } - } - - Err("Binary not found in archive".to_string()) -} - -#[cfg(not(windows))] -fn extract_zip( - _data: &[u8], - _dest_dir: &std::path::Path, - _binary_name: &str, -) -> Result<(), String> { - Err("ZIP extraction not supported on this platform".to_string()) -} - -// ============================================================================ -// Daemon management -// ============================================================================ - async fn ensure_daemon_downloaded(version: Option<&str>) { - if let Ok(existing_path) = get_existing_path(DAEMON_BINARY) { + if let Ok(existing_path) = get_plugin_existing_path(DAEMON.binary_name.to_string()).await { if let Some(target) = version { if let Ok(current) = get_daemon_version(&existing_path) { if is_version_match(¤t, target) { return; } println!( - "agent-tab-daemon {} is outdated (target: {}), updating...", - current, target + "{} {} is outdated (target: {}), updating...", + DAEMON.binary_name, current, target ); } } else { @@ -317,28 +91,40 @@ async fn ensure_daemon_downloaded(version: Option<&str>) { } } - match download_and_extract("agent-tab-daemon", DAEMON_BINARY, version).await { + match download_plugin_from_github( + DAEMON.repo_owner, + DAEMON.repo_name, + DAEMON.artifact_prefix, + DAEMON.binary_name, + version, + ) + .await + { Ok(path) => { let version_str = version.map(|v| format!("{} ", v)).unwrap_or_default(); println!( - "Successfully installed agent-tab-daemon {}-> {}", - version_str, path + "Successfully installed {} {}-> {}", + DAEMON.binary_name, version_str, path ); } - Err(e) => eprintln!("Failed to download agent-tab-daemon: {}", e), + Err(e) => eprintln!("Failed to download {}: {}", DAEMON.binary_name, e), } } -// ============================================================================ -// Main plugin path resolution -// ============================================================================ +async fn get_browser_plugin_path() -> String { + let existing = get_plugin_existing_path(BROWSER.binary_name.to_string()) + .await + .ok(); + let current_version = existing.as_ref().and_then(|p| get_browser_version(p).ok()); -async fn get_tab_plugin_path() -> String { - let existing = get_existing_path(TAB_BINARY).ok(); - let current_version = existing.as_ref().and_then(|p| get_tab_version(p).ok()); + let latest_version = get_latest_github_release_version( + BROWSER.repo_owner.to_string(), + BROWSER.repo_name.to_string(), + ) + .await; if let Some(ref path) = existing { - match get_latest_github_release_version().await { + match latest_version { Ok(target_version) => { if let Some(ref current) = current_version { if is_version_match(current, &target_version) { @@ -346,22 +132,30 @@ async fn get_tab_plugin_path() -> String { return path.clone(); } println!( - "agent-tab {} is outdated (target: {}), updating...", - current, target_version + "{} {} is outdated (target: {}), updating...", + BROWSER.binary_name, current, target_version ); } - match download_and_extract("agent-tab", TAB_BINARY, Some(&target_version)).await { + match download_plugin_from_github( + BROWSER.repo_owner, + BROWSER.repo_name, + BROWSER.artifact_prefix, + BROWSER.binary_name, + Some(&target_version), + ) + .await + { Ok(new_path) => { println!( - "Successfully installed agent-tab {} -> {}", - target_version, new_path + "Successfully installed {} {} -> {}", + BROWSER.binary_name, target_version, new_path ); ensure_daemon_downloaded(Some(&target_version)).await; return new_path; } Err(e) => { - eprintln!("Failed to update agent-tab: {}", e); + eprintln!("Failed to update {}: {}", BROWSER.binary_name, e); eprintln!("Using existing version"); ensure_daemon_downloaded(Some(&target_version)).await; return path.clone(); @@ -376,34 +170,50 @@ async fn get_tab_plugin_path() -> String { } // No existing installation - must download - match get_latest_github_release_version().await { + match latest_version { Ok(target_version) => { - match download_and_extract("agent-tab", TAB_BINARY, Some(&target_version)).await { + match download_plugin_from_github( + BROWSER.repo_owner, + BROWSER.repo_name, + BROWSER.artifact_prefix, + BROWSER.binary_name, + Some(&target_version), + ) + .await + { Ok(path) => { println!( - "Successfully installed agent-tab {} -> {}", - target_version, path + "Successfully installed {} {} -> {}", + BROWSER.plugin_name, target_version, path ); ensure_daemon_downloaded(Some(&target_version)).await; path } Err(e) => { - eprintln!("Failed to download agent-tab: {}", e); - "agent-tab".to_string() + eprintln!("Failed to download {}: {}", BROWSER.plugin_name, e); + BROWSER.binary_name.to_string() } } } Err(e) => { eprintln!("Warning: Failed to check version: {}", e); - match download_and_extract("agent-tab", TAB_BINARY, None).await { + match download_plugin_from_github( + BROWSER.repo_owner, + BROWSER.repo_name, + BROWSER.artifact_prefix, + BROWSER.binary_name, + None, + ) + .await + { Ok(path) => { - println!("Successfully installed agent-tab -> {}", path); + println!("Successfully installed {} -> {}", BROWSER.plugin_name, path); ensure_daemon_downloaded(None).await; path } Err(e) => { - eprintln!("Failed to download agent-tab: {}", e); - "agent-tab".to_string() + eprintln!("Failed to download {}: {}", BROWSER.plugin_name, e); + BROWSER.binary_name.to_string() } } } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index efbbcde9..bc1833ea 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod auto_update; pub mod board; pub mod browser; pub mod mcp; +pub mod plugin_utils; pub mod warden; pub use auth::AuthCommands; @@ -456,7 +457,7 @@ impl Commands { board::run_board(args).await?; } Commands::Browser { args } => { - browser::run_tab(args).await?; + browser::run_browser(args).await?; } Commands::Update => { auto_update::run_auto_update().await?; diff --git a/cli/src/commands/plugin_utils.rs b/cli/src/commands/plugin_utils.rs new file mode 100644 index 00000000..af88f4a1 --- /dev/null +++ b/cli/src/commands/plugin_utils.rs @@ -0,0 +1,236 @@ +use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; +use std::process::{Command, Stdio}; + +pub struct Plugin { + pub plugin_name: &'static str, + pub repo_owner: &'static str, + pub repo_name: &'static str, + pub artifact_prefix: &'static str, + pub binary_name: &'static str, +} + +pub async fn get_latest_github_release_version( + owner: String, + repo: String, +) -> Result { + let client = create_tls_client(TlsClientConfig::default()); + let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest"); + + let response = client? + .get(url) + .header("User-Agent", "stakpak-cli") + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .map_err(|e| format!("Failed to fetch latest release version: {}", e))?; + + if !response.status().is_success() { + return Err(format!("GitHub API returned: {}", response.status())); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; + + json["tag_name"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "No tag_name in release".to_string()) +} + +pub async fn get_plugin_existing_path(plugin_binary: String) -> Result { + let home_dir = + std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; + + let plugin_path = std::path::PathBuf::from(&home_dir) + .join(".stakpak") + .join("plugins") + .join(&plugin_binary); + + if plugin_path.exists() { + Ok(plugin_path.to_string_lossy().to_string()) + } else { + Err("Plugin not found".to_string()) + } +} + +pub fn is_version_match(current: &str, target: &str) -> bool { + let current_clean = current.strip_prefix('v').unwrap_or(current); + let target_clean = target.strip_prefix('v').unwrap_or(target); + current_clean == target_clean +} + +pub fn execute_plugin_command(mut cmd: Command, plugin_name: String) -> Result<(), String> { + cmd.stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdin(Stdio::inherit()); + + let status = cmd + .status() + .map_err(|e| format!("Failed to execute {} command: {}", plugin_name, e))?; + + if !status.success() { + return Err(format!( + "{} command failed with status: {}", + plugin_name, status + )); + } + + std::process::exit(status.code().unwrap_or(1)); +} + +pub fn get_home_dir() -> Result { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map_err(|_| "HOME/USERPROFILE environment variable not set".to_string()) +} + +pub fn get_plugins_dir() -> Result { + let home_dir = get_home_dir()?; + Ok(std::path::PathBuf::from(&home_dir) + .join(".stakpak") + .join("plugins")) +} + +pub fn get_platform_suffix() -> Result<(&'static str, &'static str), String> { + let platform = match std::env::consts::OS { + "linux" => "linux", + "macos" => "darwin", + "windows" => "windows", + os => return Err(format!("Unsupported OS: {}", os)), + }; + + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + arch => return Err(format!("Unsupported architecture: {}", arch)), + }; + + Ok((platform, arch)) +} + +pub fn extract_tar_gz( + data: &[u8], + dest_dir: &std::path::Path, + binary_name: &str, +) -> Result<(), String> { + use flate2::read::GzDecoder; + use std::io::Cursor; + use tar::Archive; + + let cursor = Cursor::new(data); + let tar = GzDecoder::new(cursor); + let mut archive = Archive::new(tar); + + for entry in archive + .entries() + .map_err(|e| format!("Failed to read archive: {}", e))? + { + let mut entry = entry.map_err(|e| format!("Failed to read archive entry: {}", e))?; + let path = entry + .path() + .map_err(|e| format!("Failed to get entry path: {}", e))?; + + if let Some(file_name) = path.file_name() + && file_name == binary_name + { + let dest_path = dest_dir.join(file_name); + entry + .unpack(&dest_path) + .map_err(|e| format!("Failed to extract binary: {}", e))?; + return Ok(()); + } + } + + Err("Binary not found in archive".to_string()) +} + +pub fn extract_zip( + data: &[u8], + dest_dir: &std::path::Path, + binary_name: &str, +) -> Result<(), String> { + use std::io::Cursor; + use zip::ZipArchive; + + let cursor = Cursor::new(data); + let mut archive = + ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to read zip entry: {}", e))?; + + if file.name().ends_with(binary_name) { + let dest_path = dest_dir.join(binary_name); + let mut outfile = std::fs::File::create(&dest_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + std::io::copy(&mut file, &mut outfile) + .map_err(|e| format!("Failed to write binary: {}", e))?; + return Ok(()); + } + } + + Err("Binary not found in archive".to_string()) +} + +pub async fn download_plugin_from_github( + owner: &str, + repo: &str, + artifact_prefix: &str, + binary_name: &str, + version: Option<&str>, +) -> Result { + let plugins_dir = get_plugins_dir()?; + std::fs::create_dir_all(&plugins_dir) + .map_err(|e| format!("Failed to create plugins directory: {}", e))?; + + let plugin_path = plugins_dir.join(binary_name); + if plugin_path.exists() { + return Ok(plugin_path.to_string_lossy().to_string()); + } + + let (platform, arch) = get_platform_suffix()?; + let extension = if cfg!(windows) { "zip" } else { "tar.gz" }; + let artifact_name = format!("{}-{}-{}.{}", artifact_prefix, platform, arch, extension); + + let download_url = match version { + Some(v) => format!( + "https://github.com/{}/{}/releases/download/{}/{}", + owner, repo, v, artifact_name + ), + None => format!( + "https://github.com/{}/{}/releases/latest/download/{}", + owner, repo, artifact_name + ), + }; + + eprintln!("{}", download_url); + println!("Downloading {} binary...", artifact_prefix); + + let client = create_tls_client(TlsClientConfig::default())?; + let response = client + .get(&download_url) + .send() + .await + .map_err(|e| format!("Failed to download {}: {}", artifact_prefix, e))?; + + if !response.status().is_success() { + return Err(format!("Download failed: HTTP {}", response.status())); + } + + let archive_bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read download: {}", e))?; + + if extension == "zip" { + extract_zip(&archive_bytes, &plugins_dir, binary_name)?; + } else { + extract_tar_gz(&archive_bytes, &plugins_dir, binary_name)?; + } + + Ok(plugin_path.to_string_lossy().to_string()) +} diff --git a/cli/src/utils/plugins.rs b/cli/src/utils/plugins.rs index 8adb4338..d3c0b882 100644 --- a/cli/src/utils/plugins.rs +++ b/cli/src/utils/plugins.rs @@ -1,11 +1,11 @@ -use flate2::read::GzDecoder; -use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; -use std::fs; -use std::io::Cursor; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tar::Archive; -use zip::ZipArchive; +pub use flate2::read::GzDecoder; +pub use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; +pub use std::fs; +pub use std::io::Cursor; +pub use std::path::{Path, PathBuf}; +pub use std::process::{Command, Stdio}; +pub use tar::Archive; +pub use zip::ZipArchive; /// Configuration for a plugin download pub struct PluginConfig { From 874c4a3f16cfc08b9a9c3d6854584579476d3333 Mon Sep 17 00:00:00 2001 From: shehab299 Date: Wed, 4 Feb 2026 15:57:02 +0200 Subject: [PATCH 05/10] (refactor) reuse plugins utilities --- .txt | 1 + cli/src/commands/auto_update.rs | 2 + cli/src/commands/board.rs | 114 ++++++--------- cli/src/commands/browser.rs | 175 ++++++++++------------- cli/src/commands/mod.rs | 1 - cli/src/commands/plugin_utils.rs | 236 ------------------------------- cli/src/commands/warden.rs | 2 + cli/src/utils/plugins.rs | 164 +++++++++++++++------ 8 files changed, 254 insertions(+), 441 deletions(-) create mode 100644 .txt delete mode 100644 cli/src/commands/plugin_utils.rs diff --git a/.txt b/.txt new file mode 100644 index 00000000..461576ff --- /dev/null +++ b/.txt @@ -0,0 +1 @@ +https://github.com/stakpak/tab/releases/download/v0.1.3/agent-tab-linux-x86_64.tar.gz \ No newline at end of file diff --git a/cli/src/commands/auto_update.rs b/cli/src/commands/auto_update.rs index eb4b8575..a0a9f58c 100644 --- a/cli/src/commands/auto_update.rs +++ b/cli/src/commands/auto_update.rs @@ -257,6 +257,8 @@ async fn update_binary_atomic(os: &str, arch: &str, version: Option) -> base_url: base_url.to_string(), targets: vec![target.to_string()], version: Some(version.clone()), + repo: Some("agent".to_string()), + owner: Some("stakpak".to_string()), }; // 3. Get current executable path diff --git a/cli/src/commands/board.rs b/cli/src/commands/board.rs index 52023448..e8d8fe06 100644 --- a/cli/src/commands/board.rs +++ b/cli/src/commands/board.rs @@ -1,43 +1,47 @@ -use crate::commands::plugin_utils::{ - Plugin, download_plugin_from_github, execute_plugin_command, get_latest_github_release_version, - get_plugin_existing_path, is_version_match, +use crate::utils::plugins::{ + PluginConfig, download_and_install_plugin, execute_plugin_command, get_existing_plugin_path, + get_latest_github_release_version, is_same_version, }; use std::process::Command; -const BOARD: Plugin = Plugin { - plugin_name: "agent-board", - repo_owner: "stakpak", - repo_name: "agent-board", - artifact_prefix: "agent-board", - binary_name: if cfg!(windows) { - "agent-board.exe" - } else { - "agent-board" - }, -}; +fn get_board_plugin_config() -> PluginConfig { + PluginConfig { + name: "agent-board".to_string(), + base_url: "https://github.com/stakpak/agent-board".to_string(), + targets: vec![ + "linux-x86_64".to_string(), + "windows-x86_64".to_string(), + "darwin-x86_64".to_string(), + "darwin-aarch64".to_string(), + ], + version: None, + repo: Some("agent-board".to_string()), + owner: Some("stakpak".to_string()), + } +} /// Pass-through to agent-board plugin. All args after 'board' are forwarded directly. /// Run `stakpak board --help` for available commands. pub async fn run_board(args: Vec) -> Result<(), String> { + let plugin_config = get_board_plugin_config(); + let board_path = get_board_plugin_path().await; let mut cmd = Command::new(board_path); cmd.args(&args); - execute_plugin_command(cmd, BOARD.plugin_name.to_string()) + execute_plugin_command(cmd, plugin_config.name) } async fn get_board_plugin_path() -> String { - // Check if we have an existing installation first - let existing = get_plugin_existing_path(BOARD.binary_name.to_string()) - .await - .ok(); + let config = get_board_plugin_config(); + let existing = get_existing_plugin_path(&config.name).ok(); let current_version = existing .as_ref() .and_then(|path| get_board_version(path).ok()); let latest_version = get_latest_github_release_version( - BOARD.repo_owner.to_string(), - BOARD.repo_name.to_string(), + config.owner.as_deref().unwrap_or_default(), + config.repo.as_deref().unwrap_or_default(), ) .await; @@ -47,30 +51,22 @@ async fn get_board_plugin_path() -> String { match latest_version { Ok(target_version) => { if let Some(ref current) = current_version { - if is_version_match(current, &target_version) { + if is_same_version(current, &target_version) { // Already up to date, use existing return path.clone(); } println!( "{} {} is outdated (target: {}), updating...", - BOARD.plugin_name, current, target_version + &config.name, current, target_version ); } // Need to update - download new version - match download_plugin_from_github( - BOARD.repo_owner, - BOARD.repo_name, - BOARD.artifact_prefix, - BOARD.binary_name, - None, - ) - .await - { + match download_and_install_plugin(&config).await { Ok(new_path) => { println!( "Successfully installed {} {} -> {}", - BOARD.plugin_name, target_version, new_path + config.name, target_version, new_path ); return new_path; } @@ -90,48 +86,30 @@ async fn get_board_plugin_path() -> String { // No existing installation - must download match latest_version { - Ok(target_version) => { - match download_plugin_from_github( - BOARD.repo_owner, - BOARD.repo_name, - BOARD.artifact_prefix, - BOARD.binary_name, - None, - ) - .await - { - Ok(path) => { - println!( - "Successfully installed agent-board {} -> {}", - target_version, path - ); - path - } - Err(e) => { - eprintln!("Failed to download agent-board: {}", e); - "agent-board".to_string() - } + Ok(target_version) => match download_and_install_plugin(&config).await { + Ok(path) => { + println!( + "Successfully installed agent-board {} -> {}", + target_version, path + ); + path } - } + Err(e) => { + eprintln!("Failed to download agent-board: {}", e); + "agent-board".to_string() + } + }, Err(e) => { // Try download anyway (uses /latest/ URL) eprintln!("Warning: Failed to check version: {}", e); - match download_plugin_from_github( - BOARD.repo_owner, - BOARD.repo_name, - BOARD.artifact_prefix, - BOARD.binary_name, - None, - ) - .await - { + match download_and_install_plugin(&config).await { Ok(path) => { println!("Successfully installed agent-board -> {}", path); path } Err(e) => { eprintln!("Failed to download agent-board: {}", e); - "agent-board".to_string() + config.name.clone() } } } @@ -139,13 +117,15 @@ async fn get_board_plugin_path() -> String { } fn get_board_version(path: &str) -> Result { + let config = get_board_plugin_config(); + let output = std::process::Command::new(path) .arg("version") .output() - .map_err(|e| format!("Failed to run agent-board version: {}", e))?; + .map_err(|e| format!("Failed to run {} version: {}", config.name, e))?; if !output.status.success() { - return Err("agent-board version command failed".to_string()); + return Err(format!("{} version command failed", config.name)); } let version_output = String::from_utf8_lossy(&output.stdout); diff --git a/cli/src/commands/browser.rs b/cli/src/commands/browser.rs index b471e571..60c30b89 100644 --- a/cli/src/commands/browser.rs +++ b/cli/src/commands/browser.rs @@ -1,48 +1,60 @@ -use crate::commands::plugin_utils::{ - Plugin, download_plugin_from_github, execute_plugin_command, get_latest_github_release_version, - get_plugin_existing_path, is_version_match, +use crate::utils::plugins::{ + PluginConfig, download_and_install_plugin, execute_plugin_command, get_existing_plugin_path, + get_latest_github_release_version, is_same_version, }; use std::process::Command; -const BROWSER: Plugin = Plugin { - plugin_name: "agent-tab", - repo_owner: "stakpak", - repo_name: "tab", - artifact_prefix: "agent-tab", - binary_name: if cfg!(windows) { - "agent-tab.exe" - } else { - "agent-tab" - }, -}; +fn get_browser_config() -> PluginConfig { + PluginConfig { + name: "agent-tab".to_string(), + base_url: "https://github.com/stakpak/tab".to_string(), + targets: vec![ + "linux-x86_64".to_string(), + "darwin-x86_64".to_string(), + "darwin-aarch64".to_string(), + "windows-x86_64".to_string(), + ], + version: None, + repo: Some("tab".to_string()), + owner: Some("stakpak".to_string()), + } +} -const DAEMON: Plugin = Plugin { - plugin_name: "agent-tab-daemon", - repo_owner: "stakpak", - repo_name: "tab", - artifact_prefix: "agent-tab-daemon", - binary_name: if cfg!(windows) { - "agent-tab-daemon.exe" - } else { - "agent-tab-daemon" - }, -}; +fn get_daemon_config() -> PluginConfig { + PluginConfig { + name: "agent-tab-daemon".to_string(), + base_url: "https://github.com/stakpak/tab".to_string(), + targets: vec![ + "linux-x86_64".to_string(), + "darwin-x86_64".to_string(), + "darwin-aarch64".to_string(), + "windows-x86_64".to_string(), + ], + version: None, + repo: Some("tab".to_string()), + owner: Some("stakpak".to_string()), + } +} pub async fn run_browser(args: Vec) -> Result<(), String> { + let config = get_browser_config(); + let browser_path = get_browser_plugin_path().await; let mut cmd = Command::new(&browser_path); cmd.args(&args); - execute_plugin_command(cmd, BROWSER.binary_name.to_string()) + execute_plugin_command(cmd, config.name) } fn get_browser_version(path: &str) -> Result { + let config = get_browser_config(); + let output = std::process::Command::new(path) .arg("version") .output() - .map_err(|e| format!("Failed to run {} version: {}", BROWSER.binary_name, e))?; + .map_err(|e| format!("Failed to run {} version: {}", config.name, e))?; if !output.status.success() { - return Err(format!("{} version command failed", BROWSER.binary_name)); + return Err(format!("{} version command failed", config.name)); } let version_output = String::from_utf8_lossy(&output.stdout); @@ -62,28 +74,32 @@ fn get_browser_version(path: &str) -> Result { } fn get_daemon_version(path: &str) -> Result { + let config = get_daemon_config(); + let output = std::process::Command::new(path) .arg("--version") .output() - .map_err(|e| format!("Failed to run {}: {}", DAEMON.plugin_name, e))?; + .map_err(|e| format!("Failed to run {}: {}", config.name, e))?; if !output.status.success() { - return Err(format!("{} version command failed", DAEMON.plugin_name)); + return Err(format!("{} version command failed", config.name)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } async fn ensure_daemon_downloaded(version: Option<&str>) { - if let Ok(existing_path) = get_plugin_existing_path(DAEMON.binary_name.to_string()).await { + let config = get_daemon_config(); + + if let Ok(existing_path) = get_existing_plugin_path(&config.name) { if let Some(target) = version { if let Ok(current) = get_daemon_version(&existing_path) { - if is_version_match(¤t, target) { + if is_same_version(¤t, target) { return; } println!( "{} {} is outdated (target: {}), updating...", - DAEMON.binary_name, current, target + config.name, current, target ); } } else { @@ -91,35 +107,26 @@ async fn ensure_daemon_downloaded(version: Option<&str>) { } } - match download_plugin_from_github( - DAEMON.repo_owner, - DAEMON.repo_name, - DAEMON.artifact_prefix, - DAEMON.binary_name, - version, - ) - .await - { + match download_and_install_plugin(&config).await { Ok(path) => { let version_str = version.map(|v| format!("{} ", v)).unwrap_or_default(); println!( "Successfully installed {} {}-> {}", - DAEMON.binary_name, version_str, path + config.name, version_str, path ); } - Err(e) => eprintln!("Failed to download {}: {}", DAEMON.binary_name, e), + Err(e) => eprintln!("Failed to download {}: {}", config.name, e), } } async fn get_browser_plugin_path() -> String { - let existing = get_plugin_existing_path(BROWSER.binary_name.to_string()) - .await - .ok(); + let config = get_browser_config(); + let existing = get_existing_plugin_path(&config.name).ok(); let current_version = existing.as_ref().and_then(|p| get_browser_version(p).ok()); let latest_version = get_latest_github_release_version( - BROWSER.repo_owner.to_string(), - BROWSER.repo_name.to_string(), + config.owner.as_deref().unwrap_or_default(), + config.repo.as_deref().unwrap_or_default(), ) .await; @@ -127,35 +134,27 @@ async fn get_browser_plugin_path() -> String { match latest_version { Ok(target_version) => { if let Some(ref current) = current_version { - if is_version_match(current, &target_version) { + if is_same_version(current, &target_version) { ensure_daemon_downloaded(Some(&target_version)).await; return path.clone(); } println!( "{} {} is outdated (target: {}), updating...", - BROWSER.binary_name, current, target_version + config.name, current, target_version ); } - match download_plugin_from_github( - BROWSER.repo_owner, - BROWSER.repo_name, - BROWSER.artifact_prefix, - BROWSER.binary_name, - Some(&target_version), - ) - .await - { + match download_and_install_plugin(&config).await { Ok(new_path) => { println!( "Successfully installed {} {} -> {}", - BROWSER.binary_name, target_version, new_path + config.name, target_version, new_path ); ensure_daemon_downloaded(Some(&target_version)).await; return new_path; } Err(e) => { - eprintln!("Failed to update {}: {}", BROWSER.binary_name, e); + eprintln!("Failed to update {}: {}", config.name, e); eprintln!("Using existing version"); ensure_daemon_downloaded(Some(&target_version)).await; return path.clone(); @@ -171,49 +170,31 @@ async fn get_browser_plugin_path() -> String { // No existing installation - must download match latest_version { - Ok(target_version) => { - match download_plugin_from_github( - BROWSER.repo_owner, - BROWSER.repo_name, - BROWSER.artifact_prefix, - BROWSER.binary_name, - Some(&target_version), - ) - .await - { - Ok(path) => { - println!( - "Successfully installed {} {} -> {}", - BROWSER.plugin_name, target_version, path - ); - ensure_daemon_downloaded(Some(&target_version)).await; - path - } - Err(e) => { - eprintln!("Failed to download {}: {}", BROWSER.plugin_name, e); - BROWSER.binary_name.to_string() - } + Ok(target_version) => match download_and_install_plugin(&config).await { + Ok(path) => { + println!( + "Successfully installed {} {} -> {}", + config.name, target_version, path + ); + ensure_daemon_downloaded(Some(&target_version)).await; + path } - } + Err(e) => { + eprintln!("Failed to download {}: {}", config.name, e); + config.name.to_string() + } + }, Err(e) => { eprintln!("Warning: Failed to check version: {}", e); - match download_plugin_from_github( - BROWSER.repo_owner, - BROWSER.repo_name, - BROWSER.artifact_prefix, - BROWSER.binary_name, - None, - ) - .await - { + match download_and_install_plugin(&config).await { Ok(path) => { - println!("Successfully installed {} -> {}", BROWSER.plugin_name, path); + println!("Successfully installed {} -> {}", config.name, path); ensure_daemon_downloaded(None).await; path } Err(e) => { - eprintln!("Failed to download {}: {}", BROWSER.plugin_name, e); - BROWSER.binary_name.to_string() + eprintln!("Failed to download {}: {}", config.name, e); + config.name.to_string() } } } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index bc1833ea..9fe75aa7 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -14,7 +14,6 @@ pub mod auto_update; pub mod board; pub mod browser; pub mod mcp; -pub mod plugin_utils; pub mod warden; pub use auth::AuthCommands; diff --git a/cli/src/commands/plugin_utils.rs b/cli/src/commands/plugin_utils.rs deleted file mode 100644 index af88f4a1..00000000 --- a/cli/src/commands/plugin_utils.rs +++ /dev/null @@ -1,236 +0,0 @@ -use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; -use std::process::{Command, Stdio}; - -pub struct Plugin { - pub plugin_name: &'static str, - pub repo_owner: &'static str, - pub repo_name: &'static str, - pub artifact_prefix: &'static str, - pub binary_name: &'static str, -} - -pub async fn get_latest_github_release_version( - owner: String, - repo: String, -) -> Result { - let client = create_tls_client(TlsClientConfig::default()); - let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest"); - - let response = client? - .get(url) - .header("User-Agent", "stakpak-cli") - .header("Accept", "application/vnd.github.v3+json") - .send() - .await - .map_err(|e| format!("Failed to fetch latest release version: {}", e))?; - - if !response.status().is_success() { - return Err(format!("GitHub API returned: {}", response.status())); - } - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; - - json["tag_name"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "No tag_name in release".to_string()) -} - -pub async fn get_plugin_existing_path(plugin_binary: String) -> Result { - let home_dir = - std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; - - let plugin_path = std::path::PathBuf::from(&home_dir) - .join(".stakpak") - .join("plugins") - .join(&plugin_binary); - - if plugin_path.exists() { - Ok(plugin_path.to_string_lossy().to_string()) - } else { - Err("Plugin not found".to_string()) - } -} - -pub fn is_version_match(current: &str, target: &str) -> bool { - let current_clean = current.strip_prefix('v').unwrap_or(current); - let target_clean = target.strip_prefix('v').unwrap_or(target); - current_clean == target_clean -} - -pub fn execute_plugin_command(mut cmd: Command, plugin_name: String) -> Result<(), String> { - cmd.stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .stdin(Stdio::inherit()); - - let status = cmd - .status() - .map_err(|e| format!("Failed to execute {} command: {}", plugin_name, e))?; - - if !status.success() { - return Err(format!( - "{} command failed with status: {}", - plugin_name, status - )); - } - - std::process::exit(status.code().unwrap_or(1)); -} - -pub fn get_home_dir() -> Result { - std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map_err(|_| "HOME/USERPROFILE environment variable not set".to_string()) -} - -pub fn get_plugins_dir() -> Result { - let home_dir = get_home_dir()?; - Ok(std::path::PathBuf::from(&home_dir) - .join(".stakpak") - .join("plugins")) -} - -pub fn get_platform_suffix() -> Result<(&'static str, &'static str), String> { - let platform = match std::env::consts::OS { - "linux" => "linux", - "macos" => "darwin", - "windows" => "windows", - os => return Err(format!("Unsupported OS: {}", os)), - }; - - let arch = match std::env::consts::ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - arch => return Err(format!("Unsupported architecture: {}", arch)), - }; - - Ok((platform, arch)) -} - -pub fn extract_tar_gz( - data: &[u8], - dest_dir: &std::path::Path, - binary_name: &str, -) -> Result<(), String> { - use flate2::read::GzDecoder; - use std::io::Cursor; - use tar::Archive; - - let cursor = Cursor::new(data); - let tar = GzDecoder::new(cursor); - let mut archive = Archive::new(tar); - - for entry in archive - .entries() - .map_err(|e| format!("Failed to read archive: {}", e))? - { - let mut entry = entry.map_err(|e| format!("Failed to read archive entry: {}", e))?; - let path = entry - .path() - .map_err(|e| format!("Failed to get entry path: {}", e))?; - - if let Some(file_name) = path.file_name() - && file_name == binary_name - { - let dest_path = dest_dir.join(file_name); - entry - .unpack(&dest_path) - .map_err(|e| format!("Failed to extract binary: {}", e))?; - return Ok(()); - } - } - - Err("Binary not found in archive".to_string()) -} - -pub fn extract_zip( - data: &[u8], - dest_dir: &std::path::Path, - binary_name: &str, -) -> Result<(), String> { - use std::io::Cursor; - use zip::ZipArchive; - - let cursor = Cursor::new(data); - let mut archive = - ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; - - for i in 0..archive.len() { - let mut file = archive - .by_index(i) - .map_err(|e| format!("Failed to read zip entry: {}", e))?; - - if file.name().ends_with(binary_name) { - let dest_path = dest_dir.join(binary_name); - let mut outfile = std::fs::File::create(&dest_path) - .map_err(|e| format!("Failed to create output file: {}", e))?; - std::io::copy(&mut file, &mut outfile) - .map_err(|e| format!("Failed to write binary: {}", e))?; - return Ok(()); - } - } - - Err("Binary not found in archive".to_string()) -} - -pub async fn download_plugin_from_github( - owner: &str, - repo: &str, - artifact_prefix: &str, - binary_name: &str, - version: Option<&str>, -) -> Result { - let plugins_dir = get_plugins_dir()?; - std::fs::create_dir_all(&plugins_dir) - .map_err(|e| format!("Failed to create plugins directory: {}", e))?; - - let plugin_path = plugins_dir.join(binary_name); - if plugin_path.exists() { - return Ok(plugin_path.to_string_lossy().to_string()); - } - - let (platform, arch) = get_platform_suffix()?; - let extension = if cfg!(windows) { "zip" } else { "tar.gz" }; - let artifact_name = format!("{}-{}-{}.{}", artifact_prefix, platform, arch, extension); - - let download_url = match version { - Some(v) => format!( - "https://github.com/{}/{}/releases/download/{}/{}", - owner, repo, v, artifact_name - ), - None => format!( - "https://github.com/{}/{}/releases/latest/download/{}", - owner, repo, artifact_name - ), - }; - - eprintln!("{}", download_url); - println!("Downloading {} binary...", artifact_prefix); - - let client = create_tls_client(TlsClientConfig::default())?; - let response = client - .get(&download_url) - .send() - .await - .map_err(|e| format!("Failed to download {}: {}", artifact_prefix, e))?; - - if !response.status().is_success() { - return Err(format!("Download failed: HTTP {}", response.status())); - } - - let archive_bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read download: {}", e))?; - - if extension == "zip" { - extract_zip(&archive_bytes, &plugins_dir, binary_name)?; - } else { - extract_tar_gz(&archive_bytes, &plugins_dir, binary_name)?; - } - - Ok(plugin_path.to_string_lossy().to_string()) -} diff --git a/cli/src/commands/warden.rs b/cli/src/commands/warden.rs index e292c46d..bcdde0fc 100644 --- a/cli/src/commands/warden.rs +++ b/cli/src/commands/warden.rs @@ -141,6 +141,8 @@ async fn get_warden_plugin_path() -> String { "windows-x86_64".to_string(), ], version: None, + repo: Some("warden".to_string()), + owner: Some("stakpak".to_string()), }; get_plugin_path(warden_config).await diff --git a/cli/src/utils/plugins.rs b/cli/src/utils/plugins.rs index d3c0b882..88e9f7a6 100644 --- a/cli/src/utils/plugins.rs +++ b/cli/src/utils/plugins.rs @@ -1,11 +1,11 @@ -pub use flate2::read::GzDecoder; -pub use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; -pub use std::fs; -pub use std::io::Cursor; -pub use std::path::{Path, PathBuf}; -pub use std::process::{Command, Stdio}; -pub use tar::Archive; -pub use zip::ZipArchive; +use flate2::read::GzDecoder; +use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client}; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tar::Archive; +use zip::ZipArchive; /// Configuration for a plugin download pub struct PluginConfig { @@ -13,6 +13,8 @@ pub struct PluginConfig { pub base_url: String, pub targets: Vec, pub version: Option, + pub repo: Option, + pub owner: Option, } /// Get the path to a plugin, downloading it if necessary @@ -22,6 +24,8 @@ pub async fn get_plugin_path(config: PluginConfig) -> String { base_url: config.base_url.trim_end_matches('/').to_string(), // Remove trailing slash targets: config.targets, version: config.version, + repo: config.repo, + owner: config.owner, }; // Get the target version from the server @@ -176,21 +180,45 @@ async fn get_latest_version(config: &PluginConfig) -> Result { Ok(version_text.trim().to_string()) } +/// Fetch the latest version from GitHub releases +pub async fn get_latest_github_release_version(owner: &str, repo: &str) -> Result { + let client = create_tls_client(TlsClientConfig::default())?; + let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest"); + + let response = client + .get(url) + .header("User-Agent", "stakpak-cli") + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .map_err(|e| format!("Failed to fetch latest release version: {}", e))?; + + if !response.status().is_success() { + return Err(format!("GitHub API returned: {}", response.status())); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; + + json["tag_name"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "No tag_name in release".to_string()) +} + /// Compare two version strings -fn is_same_version(current: &str, latest: &str) -> bool { +pub fn is_same_version(current: &str, latest: &str) -> bool { let current_clean = current.strip_prefix('v').unwrap_or(current); let latest_clean = latest.strip_prefix('v').unwrap_or(latest); current_clean == latest_clean } -/// Check if plugin binary already exists in plugins directory and get its version -fn get_existing_plugin_path(plugin_name: &str) -> Result { - let home_dir = - std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; - - let stakpak_dir = PathBuf::from(&home_dir).join(".stakpak"); - let plugins_dir = stakpak_dir.join("plugins"); +/// Check if plugin binary already exists in plugins directory +pub fn get_existing_plugin_path(plugin_name: &str) -> Result { + let plugins_dir = get_plugins_dir()?; // Determine the expected binary name based on OS let binary_name = if cfg!(windows) { @@ -212,12 +240,8 @@ fn get_existing_plugin_path(plugin_name: &str) -> Result { } /// Download and install plugin binary to ~/.stakpak/plugins -async fn download_and_install_plugin(config: &PluginConfig) -> Result { - let home_dir = - std::env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; - - let stakpak_dir = PathBuf::from(&home_dir).join(".stakpak"); - let plugins_dir = stakpak_dir.join("plugins"); +pub async fn download_and_install_plugin(config: &PluginConfig) -> Result { + let plugins_dir = get_plugins_dir()?; // Create directories if they don't exist fs::create_dir_all(&plugins_dir) @@ -228,6 +252,7 @@ async fn download_and_install_plugin(config: &PluginConfig) -> Result Result Result<(String, String, bool), String> { - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; + let (platform, arch) = get_platform_suffix()?; // linux x86_64 // Determine the current platform target - let current_target = match (os, arch) { - ("linux", "x86_64") => "linux-x86_64", - ("macos", "x86_64") => "darwin-x86_64", - ("macos", "aarch64") => "darwin-aarch64", - ("windows", "x86_64") => "windows-x86_64", - _ => return Err(format!("Unsupported platform: {} {}", os, arch)), - }; + let current_target = format!("{}-{}", platform, arch); // linux-x86_64 // Check if this target is supported by the plugin if !config.targets.contains(¤t_target.to_string()) { @@ -302,16 +320,35 @@ pub fn get_download_info(config: &PluginConfig) -> Result<(String, String, bool) (config.name.clone(), false) }; - // Construct download URL let extension = if is_zip { "zip" } else { "tar.gz" }; - let download_url = format!( - "{}/{}/{}-{}.{}", - config.base_url, - config.version.clone().unwrap_or("latest".to_string()), - config.name, - current_target, - extension - ); + + // TODO: remove heuristic once provider/template-based downloads are introduced + let download_url = if config.base_url.contains("github.com") { + if config.version.is_none() { + format!( + "{}//releases/latest/download/{}-{}.{}", + config.base_url, config.name, current_target, extension + ) + } else { + format!( + "{}//releases/download/{}/{}-{}.{}", + config.base_url, + config.version.clone().unwrap(), + config.name, + current_target, + extension + ) + } + } else { + format!( + "{}/{}/{}-{}.{}", + config.base_url, + config.version.clone().unwrap_or("latest".to_string()), + config.name, + current_target, + extension + ) + }; Ok((download_url, binary_name, is_zip)) } @@ -396,3 +433,50 @@ pub fn extract_zip(archive_bytes: &[u8], dest_dir: &Path) -> Result<(), String> Ok(()) } + +pub fn get_home_dir() -> Result { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map_err(|_| "HOME/USERPROFILE environment variable not set".to_string()) +} + +pub fn get_plugins_dir() -> Result { + let home_dir = get_home_dir()?; + Ok(PathBuf::from(&home_dir).join(".stakpak").join("plugins")) +} + +pub fn get_platform_suffix() -> Result<(&'static str, &'static str), String> { + let platform = match std::env::consts::OS { + "linux" => "linux", + "macos" => "darwin", + "windows" => "windows", + os => return Err(format!("Unsupported OS: {}", os)), + }; + + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + arch => return Err(format!("Unsupported architecture: {}", arch)), + }; + + Ok((platform, arch)) +} + +pub fn execute_plugin_command(mut cmd: Command, plugin_name: String) -> Result<(), String> { + cmd.stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdin(Stdio::inherit()); + + let status = cmd + .status() + .map_err(|e| format!("Failed to execute {} command: {}", plugin_name, e))?; + + if !status.success() { + return Err(format!( + "{} command failed with status: {}", + plugin_name, status + )); + } + + std::process::exit(status.code().unwrap_or(1)); +} From 1b65f75830d3c2446d2ee560f5f86216fa405f64 Mon Sep 17 00:00:00 2001 From: shehab299 Date: Wed, 4 Feb 2026 16:12:55 +0200 Subject: [PATCH 06/10] (refactor) reuse plugin utilties in plugins to be minimal --- cli/src/commands/auto_update.rs | 1 + cli/src/commands/board.rs | 125 +---------------------- cli/src/commands/browser.rs | 174 ++------------------------------ cli/src/commands/warden.rs | 1 + cli/src/utils/plugins.rs | 95 +++++++++++------ 5 files changed, 82 insertions(+), 314 deletions(-) diff --git a/cli/src/commands/auto_update.rs b/cli/src/commands/auto_update.rs index a0a9f58c..dcb63776 100644 --- a/cli/src/commands/auto_update.rs +++ b/cli/src/commands/auto_update.rs @@ -259,6 +259,7 @@ async fn update_binary_atomic(os: &str, arch: &str, version: Option) -> version: Some(version.clone()), repo: Some("agent".to_string()), owner: Some("stakpak".to_string()), + version_arg: None, }; // 3. Get current executable path diff --git a/cli/src/commands/board.rs b/cli/src/commands/board.rs index e8d8fe06..e6a7a1c2 100644 --- a/cli/src/commands/board.rs +++ b/cli/src/commands/board.rs @@ -1,7 +1,4 @@ -use crate::utils::plugins::{ - PluginConfig, download_and_install_plugin, execute_plugin_command, get_existing_plugin_path, - get_latest_github_release_version, is_same_version, -}; +use crate::utils::plugins::{PluginConfig, execute_plugin_command, get_plugin_path}; use std::process::Command; fn get_board_plugin_config() -> PluginConfig { @@ -17,129 +14,17 @@ fn get_board_plugin_config() -> PluginConfig { version: None, repo: Some("agent-board".to_string()), owner: Some("stakpak".to_string()), + version_arg: None, } } /// Pass-through to agent-board plugin. All args after 'board' are forwarded directly. /// Run `stakpak board --help` for available commands. pub async fn run_board(args: Vec) -> Result<(), String> { - let plugin_config = get_board_plugin_config(); + let config = get_board_plugin_config(); + let board_path = get_plugin_path(config).await; - let board_path = get_board_plugin_path().await; let mut cmd = Command::new(board_path); cmd.args(&args); - execute_plugin_command(cmd, plugin_config.name) -} - -async fn get_board_plugin_path() -> String { - let config = get_board_plugin_config(); - let existing = get_existing_plugin_path(&config.name).ok(); - - let current_version = existing - .as_ref() - .and_then(|path| get_board_version(path).ok()); - - let latest_version = get_latest_github_release_version( - config.owner.as_deref().unwrap_or_default(), - config.repo.as_deref().unwrap_or_default(), - ) - .await; - - // If we have an existing installation, check if update needed - if let Some(ref path) = existing { - // Try to get latest version from GitHub API - match latest_version { - Ok(target_version) => { - if let Some(ref current) = current_version { - if is_same_version(current, &target_version) { - // Already up to date, use existing - return path.clone(); - } - println!( - "{} {} is outdated (target: {}), updating...", - &config.name, current, target_version - ); - } - - // Need to update - download new version - match download_and_install_plugin(&config).await { - Ok(new_path) => { - println!( - "Successfully installed {} {} -> {}", - config.name, target_version, new_path - ); - return new_path; - } - Err(e) => { - eprintln!("Failed to update agent-board: {}", e); - eprintln!("Using existing version"); - return path.clone(); - } - } - } - Err(_) => { - // Can't check version, use existing installation - return path.clone(); - } - } - } - - // No existing installation - must download - match latest_version { - Ok(target_version) => match download_and_install_plugin(&config).await { - Ok(path) => { - println!( - "Successfully installed agent-board {} -> {}", - target_version, path - ); - path - } - Err(e) => { - eprintln!("Failed to download agent-board: {}", e); - "agent-board".to_string() - } - }, - Err(e) => { - // Try download anyway (uses /latest/ URL) - eprintln!("Warning: Failed to check version: {}", e); - match download_and_install_plugin(&config).await { - Ok(path) => { - println!("Successfully installed agent-board -> {}", path); - path - } - Err(e) => { - eprintln!("Failed to download agent-board: {}", e); - config.name.clone() - } - } - } - } -} - -fn get_board_version(path: &str) -> Result { - let config = get_board_plugin_config(); - - let output = std::process::Command::new(path) - .arg("version") - .output() - .map_err(|e| format!("Failed to run {} version: {}", config.name, e))?; - - if !output.status.success() { - return Err(format!("{} version command failed", config.name)); - } - - let version_output = String::from_utf8_lossy(&output.stdout); - // Parse version from output like "agent-board v0.1.6" or just "v0.1.6" - let trimmed = version_output.trim(); - if let Some(v) = trimmed.split_whitespace().find(|s| { - s.starts_with('v') - || s.chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - }) { - Ok(v.to_string()) - } else { - Ok(trimmed.to_string()) - } + execute_plugin_command(cmd, "agent-board".to_string()) } diff --git a/cli/src/commands/browser.rs b/cli/src/commands/browser.rs index 60c30b89..f72c02f5 100644 --- a/cli/src/commands/browser.rs +++ b/cli/src/commands/browser.rs @@ -1,7 +1,4 @@ -use crate::utils::plugins::{ - PluginConfig, download_and_install_plugin, execute_plugin_command, get_existing_plugin_path, - get_latest_github_release_version, is_same_version, -}; +use crate::utils::plugins::{PluginConfig, execute_plugin_command, get_plugin_path}; use std::process::Command; fn get_browser_config() -> PluginConfig { @@ -17,6 +14,7 @@ fn get_browser_config() -> PluginConfig { version: None, repo: Some("tab".to_string()), owner: Some("stakpak".to_string()), + version_arg: Some("version".to_string()), } } @@ -33,170 +31,20 @@ fn get_daemon_config() -> PluginConfig { version: None, repo: Some("tab".to_string()), owner: Some("stakpak".to_string()), + version_arg: Some("--version".to_string()), } } pub async fn run_browser(args: Vec) -> Result<(), String> { - let config = get_browser_config(); + let browser_config = get_browser_config(); + let daemon_config = get_daemon_config(); - let browser_path = get_browser_plugin_path().await; + // Ensure daemon is available (downloaded if needed) + get_plugin_path(daemon_config).await; + + // Get browser path and run it + let browser_path = get_plugin_path(browser_config).await; let mut cmd = Command::new(&browser_path); cmd.args(&args); - execute_plugin_command(cmd, config.name) -} - -fn get_browser_version(path: &str) -> Result { - let config = get_browser_config(); - - let output = std::process::Command::new(path) - .arg("version") - .output() - .map_err(|e| format!("Failed to run {} version: {}", config.name, e))?; - - if !output.status.success() { - return Err(format!("{} version command failed", config.name)); - } - - let version_output = String::from_utf8_lossy(&output.stdout); - let trimmed = version_output.trim(); - - if let Some(v) = trimmed.split_whitespace().find(|s| { - s.starts_with('v') - || s.chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - }) { - Ok(v.to_string()) - } else { - Ok(trimmed.to_string()) - } -} - -fn get_daemon_version(path: &str) -> Result { - let config = get_daemon_config(); - - let output = std::process::Command::new(path) - .arg("--version") - .output() - .map_err(|e| format!("Failed to run {}: {}", config.name, e))?; - - if !output.status.success() { - return Err(format!("{} version command failed", config.name)); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -async fn ensure_daemon_downloaded(version: Option<&str>) { - let config = get_daemon_config(); - - if let Ok(existing_path) = get_existing_plugin_path(&config.name) { - if let Some(target) = version { - if let Ok(current) = get_daemon_version(&existing_path) { - if is_same_version(¤t, target) { - return; - } - println!( - "{} {} is outdated (target: {}), updating...", - config.name, current, target - ); - } - } else { - return; // No version to check, binary exists - } - } - - match download_and_install_plugin(&config).await { - Ok(path) => { - let version_str = version.map(|v| format!("{} ", v)).unwrap_or_default(); - println!( - "Successfully installed {} {}-> {}", - config.name, version_str, path - ); - } - Err(e) => eprintln!("Failed to download {}: {}", config.name, e), - } -} - -async fn get_browser_plugin_path() -> String { - let config = get_browser_config(); - let existing = get_existing_plugin_path(&config.name).ok(); - let current_version = existing.as_ref().and_then(|p| get_browser_version(p).ok()); - - let latest_version = get_latest_github_release_version( - config.owner.as_deref().unwrap_or_default(), - config.repo.as_deref().unwrap_or_default(), - ) - .await; - - if let Some(ref path) = existing { - match latest_version { - Ok(target_version) => { - if let Some(ref current) = current_version { - if is_same_version(current, &target_version) { - ensure_daemon_downloaded(Some(&target_version)).await; - return path.clone(); - } - println!( - "{} {} is outdated (target: {}), updating...", - config.name, current, target_version - ); - } - - match download_and_install_plugin(&config).await { - Ok(new_path) => { - println!( - "Successfully installed {} {} -> {}", - config.name, target_version, new_path - ); - ensure_daemon_downloaded(Some(&target_version)).await; - return new_path; - } - Err(e) => { - eprintln!("Failed to update {}: {}", config.name, e); - eprintln!("Using existing version"); - ensure_daemon_downloaded(Some(&target_version)).await; - return path.clone(); - } - } - } - Err(_) => { - ensure_daemon_downloaded(None).await; - return path.clone(); - } - } - } - - // No existing installation - must download - match latest_version { - Ok(target_version) => match download_and_install_plugin(&config).await { - Ok(path) => { - println!( - "Successfully installed {} {} -> {}", - config.name, target_version, path - ); - ensure_daemon_downloaded(Some(&target_version)).await; - path - } - Err(e) => { - eprintln!("Failed to download {}: {}", config.name, e); - config.name.to_string() - } - }, - Err(e) => { - eprintln!("Warning: Failed to check version: {}", e); - match download_and_install_plugin(&config).await { - Ok(path) => { - println!("Successfully installed {} -> {}", config.name, path); - ensure_daemon_downloaded(None).await; - path - } - Err(e) => { - eprintln!("Failed to download {}: {}", config.name, e); - config.name.to_string() - } - } - } - } + execute_plugin_command(cmd, "agent-tab".to_string()) } diff --git a/cli/src/commands/warden.rs b/cli/src/commands/warden.rs index bcdde0fc..689012e8 100644 --- a/cli/src/commands/warden.rs +++ b/cli/src/commands/warden.rs @@ -143,6 +143,7 @@ async fn get_warden_plugin_path() -> String { version: None, repo: Some("warden".to_string()), owner: Some("stakpak".to_string()), + version_arg: None, }; get_plugin_path(warden_config).await diff --git a/cli/src/utils/plugins.rs b/cli/src/utils/plugins.rs index 88e9f7a6..150711fb 100644 --- a/cli/src/utils/plugins.rs +++ b/cli/src/utils/plugins.rs @@ -15,6 +15,7 @@ pub struct PluginConfig { pub version: Option, pub repo: Option, pub owner: Option, + pub version_arg: Option, } /// Get the path to a plugin, downloading it if necessary @@ -26,31 +27,42 @@ pub async fn get_plugin_path(config: PluginConfig) -> String { version: config.version, repo: config.repo, owner: config.owner, + version_arg: config.version_arg, }; - // Get the target version from the server + // Get the target version from the server or GitHub let target_version = match config.version.clone() { Some(version) => version, - None => match get_latest_version(&config).await { - Ok(version) => version, - Err(e) => { - eprintln!( - "Warning: Failed to check latest version for {}: {}", - config.name, e - ); - // Continue with existing logic if version check fails - return get_plugin_path_without_version_check(&config).await; + None => { + let latest = if let (Some(owner), Some(repo)) = (&config.owner, &config.repo) { + get_latest_github_release_version(owner, repo).await + } else { + get_latest_version(&config).await + }; + + match latest { + Ok(version) => version, + Err(e) => { + eprintln!( + "Warning: Failed to check latest version for {}: {}", + config.name, e + ); + // Continue with existing logic if version check fails + return get_plugin_path_without_version_check(&config).await; + } } - }, + } }; // First check if plugin is available in PATH - if let Ok(system_version) = get_version_from_command(&config.name, &config.name) { + if let Ok(system_version) = + get_version_from_command(&config.name, &config.name, config.version_arg.as_deref()) + { if is_same_version(&system_version, &target_version) { return config.name.clone(); } else { println!( - "{} v{} is outdated (target: v{}), checking plugins directory...", + "{} {} is outdated (target: {}), checking plugins directory...", config.name, system_version, target_version ); } @@ -58,13 +70,14 @@ pub async fn get_plugin_path(config: PluginConfig) -> String { // Check if plugin already exists in plugins directory if let Ok(existing_path) = get_existing_plugin_path(&config.name) - && let Ok(current_version) = get_version_from_command(&existing_path, &config.name) + && let Ok(current_version) = + get_version_from_command(&existing_path, &config.name, config.version_arg.as_deref()) { if is_same_version(¤t_version, &target_version) { return existing_path; } else { println!( - "{} {} is outdated (target: v{}), updating...", + "{} {} is outdated (target: {}), updating...", config.name, current_version, target_version ); } @@ -74,7 +87,7 @@ pub async fn get_plugin_path(config: PluginConfig) -> String { match download_and_install_plugin(&config).await { Ok(path) => { println!( - "Successfully installed {} v{} -> {}", + "Successfully installed {} {} -> {}", config.name, target_version, path ); path @@ -119,14 +132,19 @@ async fn get_plugin_path_without_version_check(config: &PluginConfig) -> String } /// Get version by running a command (can be plugin name or path) -fn get_version_from_command(command: &str, display_name: &str) -> Result { +fn get_version_from_command( + command: &str, + display_name: &str, + version_arg: Option<&str>, +) -> Result { + let arg = version_arg.unwrap_or("version"); let output = Command::new(command) - .arg("version") + .arg(arg) .output() - .map_err(|e| format!("Failed to run {} version command: {}", display_name, e))?; + .map_err(|e| format!("Failed to run {} {} command: {}", display_name, arg, e))?; if !output.status.success() { - return Err(format!("{} version command failed", display_name)); + return Err(format!("{} {} command failed", display_name, arg)); } let version_output = String::from_utf8_lossy(&output.stdout); @@ -137,19 +155,34 @@ fn get_version_from_command(command: &str, display_name: &str) -> Result = full_output.split_whitespace().collect(); - if parts.len() >= 2 { - Ok(parts[1].to_string()) - } else { - // Fallback to full output if parsing fails - Ok(full_output.to_string()) - } + // Split by whitespace and find the part that looks like a version + let version = full_output + .split_whitespace() + .find(|s| { + s.starts_with('v') + || s.chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + }) + .map(|s| s.to_string()) + .or_else(|| { + // Fallback to the second part if none start with 'v' or digit + let parts: Vec<&str> = full_output.split_whitespace().collect(); + if parts.len() >= 2 { + Some(parts[1].to_string()) + } else { + None + } + }) + .unwrap_or_else(|| full_output.to_string()); + + Ok(version) } /// Check if a plugin is available in the system PATH pub fn is_plugin_available(plugin_name: &str) -> bool { - get_version_from_command(plugin_name, plugin_name).is_ok() + get_version_from_command(plugin_name, plugin_name, None).is_ok() } /// Fetch the latest version from the remote server @@ -326,12 +359,12 @@ pub fn get_download_info(config: &PluginConfig) -> Result<(String, String, bool) let download_url = if config.base_url.contains("github.com") { if config.version.is_none() { format!( - "{}//releases/latest/download/{}-{}.{}", + "{}/releases/latest/download/{}-{}.{}", config.base_url, config.name, current_target, extension ) } else { format!( - "{}//releases/download/{}/{}-{}.{}", + "{}/releases/download/{}/{}-{}.{}", config.base_url, config.version.clone().unwrap(), config.name, From d146c256d1ffc976e02e3fc99e26ada53dae3cce Mon Sep 17 00:00:00 2001 From: shehab299 Date: Wed, 4 Feb 2026 16:22:15 +0200 Subject: [PATCH 07/10] (fix) clippy and fmt --- cli/src/utils/plugins.rs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/cli/src/utils/plugins.rs b/cli/src/utils/plugins.rs index 150711fb..b3e89492 100644 --- a/cli/src/utils/plugins.rs +++ b/cli/src/utils/plugins.rs @@ -355,22 +355,16 @@ pub fn get_download_info(config: &PluginConfig) -> Result<(String, String, bool) let extension = if is_zip { "zip" } else { "tar.gz" }; - // TODO: remove heuristic once provider/template-based downloads are introduced let download_url = if config.base_url.contains("github.com") { - if config.version.is_none() { - format!( + match &config.version { + Some(version) => format!( + "{}/releases/download/{}/{}-{}.{}", + config.base_url, version, config.name, current_target, extension + ), + None => format!( "{}/releases/latest/download/{}-{}.{}", config.base_url, config.name, current_target, extension - ) - } else { - format!( - "{}/releases/download/{}/{}-{}.{}", - config.base_url, - config.version.clone().unwrap(), - config.name, - current_target, - extension - ) + ), } } else { format!( From 6dd548dce580b850242761878ea2a1e40b638305 Mon Sep 17 00:00:00 2001 From: shehab khaled <89648315+shehab299@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:58:02 +0200 Subject: [PATCH 08/10] Delete mistakenly committed .txt file --- .txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .txt diff --git a/.txt b/.txt deleted file mode 100644 index 461576ff..00000000 --- a/.txt +++ /dev/null @@ -1 +0,0 @@ -https://github.com/stakpak/tab/releases/download/v0.1.3/agent-tab-linux-x86_64.tar.gz \ No newline at end of file From 134f1f8fa93b0e5f9269371120542502ce1f22fb Mon Sep 17 00:00:00 2001 From: shehab299 Date: Thu, 5 Feb 2026 17:36:16 +0200 Subject: [PATCH 09/10] rename agent-tab to browser and update the system prompt to nudge stakpak to use it --- cli/src/commands/browser.rs | 6 +++--- .../src/local/hooks/task_board_context/system_prompt.txt | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/browser.rs b/cli/src/commands/browser.rs index f72c02f5..354f8a65 100644 --- a/cli/src/commands/browser.rs +++ b/cli/src/commands/browser.rs @@ -3,7 +3,7 @@ use std::process::Command; fn get_browser_config() -> PluginConfig { PluginConfig { - name: "agent-tab".to_string(), + name: "browser".to_string(), base_url: "https://github.com/stakpak/tab".to_string(), targets: vec![ "linux-x86_64".to_string(), @@ -20,7 +20,7 @@ fn get_browser_config() -> PluginConfig { fn get_daemon_config() -> PluginConfig { PluginConfig { - name: "agent-tab-daemon".to_string(), + name: "browser-daemon".to_string(), base_url: "https://github.com/stakpak/tab".to_string(), targets: vec![ "linux-x86_64".to_string(), @@ -46,5 +46,5 @@ pub async fn run_browser(args: Vec) -> Result<(), String> { let browser_path = get_plugin_path(browser_config).await; let mut cmd = Command::new(&browser_path); cmd.args(&args); - execute_plugin_command(cmd, "agent-tab".to_string()) + execute_plugin_command(cmd, "browser".to_string()) } diff --git a/libs/api/src/local/hooks/task_board_context/system_prompt.txt b/libs/api/src/local/hooks/task_board_context/system_prompt.txt index 30a76ba5..cf620340 100644 --- a/libs/api/src/local/hooks/task_board_context/system_prompt.txt +++ b/libs/api/src/local/hooks/task_board_context/system_prompt.txt @@ -250,6 +250,10 @@ User: "Create an eks cluster module in terraform" 5. Run Trivy or Terrascan or Checkov if available (in parallel) 6. Make security recommendations to the user and apply if the user approves +# Browser Usage: `stakapk browser` + +When performing any browser-based actions, use stakpak browser for all navigation, interaction, and scraping. Always consult --help before using stakpak browser. + # Task Management: `stakpak board` From edcb1361f986ff053a12d8605aa59cdb4b7b820a Mon Sep 17 00:00:00 2001 From: shehab299 Date: Thu, 5 Feb 2026 19:16:17 +0200 Subject: [PATCH 10/10] pass down --help to browser and board plugins --- GETTING-STARTED.md | 2 +- cli/src/commands/mod.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index 635147be..8d2f7805 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -1,6 +1,6 @@ # Getting Started with Stakpak CLI -**The most secure agent built for operations & DevOps.** Stakpak CLI is a powerful, security-hardened tool designed for the grittiest parts of software development with enterprise-grade security features. +**The most secure agent built for operations & DevOps** Stakpak CLI is a powerful, security-hardened tool designed for the grittiest parts of software development with enterprise-grade security features. ## 🚀 Quick Start diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 9fe75aa7..7a335993 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -159,6 +159,7 @@ pub enum Commands { }, /// Task board for tracking complex work (cards, checklists, comments) /// Run `stakpak board --help` for available commands. + #[command(disable_help_flag = true)] Board { /// Arguments to pass to the board plugin #[arg(trailing_var_arg = true, allow_hyphen_values = true)] @@ -166,6 +167,7 @@ pub enum Commands { }, /// Browser automation CLI - control a real browser from the command line /// Run `stakpak browser --help` for available commands. + #[command(disable_help_flag = true)] Browser { /// Arguments to pass to the browser plugin #[arg(trailing_var_arg = true, allow_hyphen_values = true)]