From 19c147af57e8042f49367babce631add6eb7827c Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Tue, 7 Jul 2026 13:03:32 +0530 Subject: [PATCH 1/3] feat: auth vault phases 3+4 - failover, isolation, encryption (#23) auth run: wrap CLI with auto-failover on rate limit (5 retries). auth isolate add/ls/delete: isolated HOME profiles for parallel sessions. auth exec: run command with isolated profile HOME. auth login: login flow with auto-backup on success. auth_crypt: AES-256-GCM vault encryption with PBKDF2 key derivation. Copilot added to auth catalog. Daemon detection helpers for codex app-server. --- Cargo.lock | 93 +++++++++++++++++++ Cargo.toml | 2 + src/auth.rs | 225 ++++++++++++++++++++++++++++++++++++++++++++- src/auth_crypt.rs | 82 +++++++++++++++++ src/auth_runner.rs | 180 ++++++++++++++++++++++++++++++++++++ src/main.rs | 65 +++++++++++++ 6 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 src/auth_crypt.rs create mode 100644 src/auth_runner.rs diff --git a/Cargo.lock b/Cargo.lock index eef20cbd..5b47c472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -19,14 +29,30 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "agentflare" version = "1.1.0" dependencies = [ + "aes-gcm", "chrono", "clap", "dirs", "flate2", + "pbkdf2", "rand", "rmcp", "rusqlite", @@ -130,6 +156,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" @@ -328,9 +360,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.23.0" @@ -640,6 +682,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -975,12 +1027,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pastey" version = "0.2.3" @@ -995,6 +1064,8 @@ checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", "hmac", + "password-hash", + "sha2", ] [[package]] @@ -1015,6 +1086,18 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1574,6 +1657,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index a11ffa39..cd7ca0c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,8 @@ tar = "0.4" zip = "2" rusqlite = { version = "0.40", features = ["bundled"] } rand = "0.8" +aes-gcm = "0.10" +pbkdf2 = { version = "0.12", features = ["simple"] } [profile.release] opt-level = 3 diff --git a/src/auth.rs b/src/auth.rs index 4dfc532f..20c270f4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,4 @@ +use crate::auth_crypt; use crate::auth_db::{self, CooldownRow, ProfileHealth}; use crate::paths::home; use rusqlite::Connection; @@ -49,6 +50,12 @@ static CATALOG: &[AuthCatalog] = &[ ".opencode/auth.json", ], }, + AuthCatalog { + agent_key: "copilot", + files: &[ + ".copilot/auth.json", + ], + }, ]; fn catalog_for(agent: &str) -> Option<&'static AuthCatalog> { @@ -84,11 +91,18 @@ pub fn backup(agent: &str, profile: &str, json: bool) { let mut backed = 0; let mut skipped = 0; + let passphrase = auth_crypt::get_passphrase(); for &rel in cat.files { let src = home().join(rel); let dest = vault.join(rel.rsplit('/').next().unwrap_or(rel)); if src.exists() { - fs::copy(&src, &dest).expect("copy"); + let data = fs::read(&src).expect("read"); + if let Some(ref pw) = passphrase { + let encrypted = auth_crypt::encrypt(&data, pw).expect("encrypt"); + fs::write(&dest, encrypted).expect("write"); + } else { + fs::write(&dest, data).expect("write"); + } backed += 1; } else { skipped += 1; @@ -155,6 +169,7 @@ pub fn activate(agent: &str, profile: &str, json: bool) { } let mut restored = 0; + let passphrase = auth_crypt::get_passphrase(); for &rel in cat.files { let src = vault.join( rel.split('/').next_back().unwrap_or(rel) @@ -164,7 +179,17 @@ pub fn activate(agent: &str, profile: &str, json: bool) { if let Some(parent) = dest.parent() { fs::create_dir_all(parent).expect("create parent"); } - fs::copy(&src, &dest).expect("copy"); + let data = fs::read(&src).expect("read"); + if let Some(ref pw) = passphrase { + if let Some(decrypted) = auth_crypt::decrypt(&data, pw) { + fs::write(&dest, decrypted).expect("write"); + } else { + eprintln!("warning: cannot decrypt {} — wrong passphrase or corrupted", src.display()); + continue; + } + } else { + fs::write(&dest, data).expect("write"); + } restored += 1; } } @@ -682,6 +707,202 @@ pub fn project_unset(agent: &str, json: bool) { } } +const ISOLATE_DIR: &str = "isolate"; + +fn isolates_dir() -> PathBuf { + home().join(".local").join("share").join("agentflare").join(ISOLATE_DIR) +} + +pub fn isolate_add(agent: &str, profile: &str, json: bool) { + let dir = isolates_dir().join(agent).join(profile); + if dir.exists() { + if json { + println!("{}", serde_json::json!({"error": "isolated profile already exists"})); + } else { + eprintln!("isolated profile '{agent}/{profile}' already exists"); + } + return; + } + fs::create_dir_all(&dir).expect("create isolate dir"); + + // Symlink shared host files + for host_file in &[".ssh", ".gitconfig", ".git-credentials"] { + let src = home().join(host_file); + if src.exists() { + symlink_or_copy(&src, &dir.join(host_file)); + } + } + + // Copy auth files from vault profile + activate_into(agent, profile, &dir); + + if json { + println!("{}", serde_json::json!({"agent": agent, "profile": profile, "isolate_dir": dir.to_string_lossy()})); + } else { + println!("isolated profile created: {agent}/{profile} at {}", dir.display()); + } +} + +pub fn isolate_ls(agent: Option<&str>, json: bool) { + let dir = isolates_dir(); + if !dir.exists() { + if json { + println!("[]"); + } else { + println!("no isolated profiles"); + } + return; + } + let mut results: Vec = Vec::new(); + let agents: Vec = match agent { + Some(a) => vec![a.to_string()], + None => fs::read_dir(&dir).ok().map(|entries| { + entries.flatten().filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.file_name().to_string_lossy().to_string()).collect() + }).unwrap_or_default(), + }; + for a in &agents { + let agent_dir = dir.join(a); + if !agent_dir.exists() { continue; } + if let Ok(entries) = fs::read_dir(&agent_dir) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let p = entry.file_name().to_string_lossy().to_string(); + if json { + results.push(serde_json::json!({"agent": a, "profile": p})); + } else { + println!("{a}/{p}"); + } + } + } + } + } + if json { + println!("{}", serde_json::to_string(&results).unwrap()); + } +} + +pub fn isolate_delete(agent: &str, profile: &str, json: bool) { + let dir = isolates_dir().join(agent).join(profile); + if !dir.exists() { + if json { + println!("{}", serde_json::json!({"error": "not found"})); + } else { + eprintln!("isolated profile '{agent}/{profile}' not found"); + } + return; + } + fs::remove_dir_all(&dir).expect("remove isolate dir"); + if json { + println!("{}", serde_json::json!({"deleted": true, "agent": agent, "profile": profile})); + } else { + println!("deleted isolated profile: {agent}/{profile}"); + } +} + +pub fn auth_exec(agent: &str, profile: &str, args: &[String], json: bool) { + let dir = isolates_dir().join(agent).join(profile); + if !dir.exists() { + if json { + println!("{}", serde_json::json!({"error": "isolated profile not found"})); + } else { + eprintln!("error: isolated profile '{agent}/{profile}' not found — run 'auth isolate add' first"); + } + return; + } + + if args.is_empty() { + eprintln!("error: no command specified after --"); + return; + } + + let binary = &args[0]; + let rest = &args[1..]; + + let status = std::process::Command::new(binary) + .args(rest) + .env("HOME", &dir) + .spawn() + .and_then(|mut c| c.wait()) + .unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } +} + +pub fn auth_login(agent: &str, profile: &str, args: &[String], json: bool) { + let dir = isolates_dir().join(agent).join(profile); + if !dir.exists() { + // Auto-create isolate if it doesn't exist + isolate_add(agent, profile, json); + } + + if args.is_empty() { + eprintln!("error: no login command specified after --"); + return; + } + + let binary = &args[0]; + let rest = &args[1..]; + + let status = std::process::Command::new(binary) + .args(rest) + .env("HOME", &dir) + .spawn() + .and_then(|mut c| c.wait()) + .unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + if status.success() { + // After login, backup the new auth state + let dir = isolates_dir().join(agent).join(profile); + for &rel in CATALOG.iter().find(|c| c.agent_key == agent).map(|c| c.files).unwrap_or(&[]) { + let dest = profile_dir(agent, profile).join(rel.rsplit('/').next().unwrap_or(rel)); + let src = dir.join(rel); + if src.exists() { + fs::create_dir_all(dest.parent().unwrap()).ok(); + fs::copy(&src, &dest).ok(); + } + } + if !json { + println!("login complete — auth backed up to vault profile '{agent}/{profile}'"); + } + } else { + std::process::exit(status.code().unwrap_or(1)); + } +} + +fn activate_into(agent: &str, profile: &str, target_dir: &std::path::Path) { + let cat = match catalog_for(agent) { Some(c) => c, None => { return; } }; + let vault = profile_dir(agent, profile); + for &rel in cat.files { + let src = vault.join(rel.rsplit('/').next().unwrap_or(rel)); + if src.exists() { + let dest = target_dir.join(rel); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).ok(); + } + fs::copy(&src, &dest).ok(); + } + } +} + +#[cfg(not(windows))] +fn symlink_or_copy(src: &std::path::Path, dest: &std::path::Path) { + std::os::unix::fs::symlink(src, dest).ok(); +} + +#[cfg(windows)] +fn symlink_or_copy(src: &std::path::Path, dest: &std::path::Path) { + fs::copy(src, dest).ok(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/auth_crypt.rs b/src/auth_crypt.rs new file mode 100644 index 00000000..3851edc2 --- /dev/null +++ b/src/auth_crypt.rs @@ -0,0 +1,82 @@ +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use pbkdf2::pbkdf2_hmac_array; +use rand::RngCore; +use sha2::Sha256; +use std::io::Write; + +const SALT: &[u8] = b"agentflare-vault-salt-v1"; +const NONCE_SIZE: usize = 12; + +pub fn get_passphrase() -> Option { + if let Ok(pw) = std::env::var("AGENTFLARE_VAULT_PASSPHRASE") { + if !pw.is_empty() { + return Some(pw); + } + } + prompt_passphrase() +} + +fn prompt_passphrase() -> Option { + print!("vault passphrase: "); + std::io::stdout().flush().ok(); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).ok()?; + let pw = input.trim().to_string(); + if pw.is_empty() { None } else { Some(pw) } +} + +fn derive_key(passphrase: &str) -> [u8; 32] { + pbkdf2_hmac_array::(passphrase.as_bytes(), SALT, 100_000) +} + +pub fn encrypt(plaintext: &[u8], passphrase: &str) -> Option> { + let key = derive_key(passphrase); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher.encrypt(nonce, plaintext).ok()?; + // Prepend nonce to ciphertext + let mut result = nonce_bytes.to_vec(); + result.extend(ciphertext); + Some(result) +} + +pub fn decrypt(data: &[u8], passphrase: &str) -> Option> { + if data.len() < NONCE_SIZE + 16 { + return None; // too short for nonce + auth tag + } + let (nonce_bytes, ciphertext) = data.split_at(NONCE_SIZE); + let key = derive_key(passphrase); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let nonce = Nonce::from_slice(nonce_bytes); + cipher.decrypt(nonce, ciphertext).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let pw = "test-passphrase"; + let plaintext = b"hello world"; + let encrypted = encrypt(plaintext, pw).unwrap(); + let decrypted = decrypt(&encrypted, pw).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn wrong_passphrase_fails() { + let encrypted = encrypt(b"secret", "correct").unwrap(); + assert!(decrypt(&encrypted, "wrong").is_none()); + } + + #[test] + fn different_ciphertexts_for_same_input() { + let c1 = encrypt(b"data", "pw").unwrap(); + let c2 = encrypt(b"data", "pw").unwrap(); + assert_ne!(c1, c2); // different nonces + } +} diff --git a/src/auth_runner.rs b/src/auth_runner.rs new file mode 100644 index 00000000..97289412 --- /dev/null +++ b/src/auth_runner.rs @@ -0,0 +1,180 @@ +use crate::auth_db; +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::mpsc::channel; +use std::thread; +use std::time::Duration; + +const RATE_LIMIT_PATTERNS: &[&str] = &[ + "429", "rate limit", "too many requests", "quota exceeded", + "usage limit", "billing limit", "try again", +]; +const MAX_RETRIES: usize = 5; + +pub fn run(agent: &str, args: &[String], json: bool) { + let mut remaining = MAX_RETRIES; + loop { + let status = spawn_and_capture(agent, args); + match categorize_exit(status) { + ExitKind::Success => return, + ExitKind::RateLimited => { + if remaining == 0 { + eprintln!("error: all retries exhausted"); + std::process::exit(1); + } + remaining -= 1; + if !json { + eprintln!("rate limited — rotating profile..."); + } + let conn = auth_db::open_or_rebuild(); + // Cooldown the current profile + if let Some((profile, _)) = auth_db::get_rotation_last(&conn, agent) { + auth_db::set_cooldown(&conn, agent, &profile, 30, "rate limit"); + } + // Rotate to next profile + crate::auth::rotate(agent, "smart", json); + if !json { + eprintln!("retrying with new profile ({remaining} retries left)..."); + } + } + ExitKind::Failure(code) => { + std::process::exit(code); + } + } + } +} + +enum ExitKind { + Success, + RateLimited, + Failure(i32), +} + +fn categorize_exit(status: (i32, String)) -> ExitKind { + let (code, stderr) = status; + if code == 0 { + return ExitKind::Success; + } + let lower = stderr.to_lowercase(); + if RATE_LIMIT_PATTERNS.iter().any(|p| lower.contains(p)) { + return ExitKind::RateLimited; + } + ExitKind::Failure(code) +} + +fn spawn_and_capture(agent: &str, args: &[String]) -> (i32, String) { + let binary = find_binary(agent); + let mut child = Command::new(&binary) + .args(args) + .stdout(Stdio::inherit()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn agent"); + + let stderr_handle = child.stderr.take().unwrap(); + let (tx, rx) = channel(); + thread::spawn(move || { + let reader = BufReader::new(stderr_handle); + let mut buf = String::new(); + for line in reader.lines() { + if let Ok(l) = line { + eprintln!("{l}"); // passthrough to user + buf.push_str(&l); + buf.push('\n'); + } + } + tx.send(buf).ok(); + }); + + let status = child.wait().expect("wait for agent"); + let stderr = rx.recv_timeout(Duration::from_secs(5)).unwrap_or_default(); + let code = status.code().unwrap_or(1); + (code, stderr) +} + +fn find_binary(agent: &str) -> String { + crate::agent_detect::find_binary(&[agent]) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| agent.to_string()) +} + +pub fn daemon_running(agent: &str) -> bool { + let names = match agent { + "codex" => &["codex"][..], + "claude-code" => &["claude"][..], + _ => return false, + }; + if let Ok(output) = std::process::Command::new("pgrep") + .args(names) + .output() + { + return !output.stdout.is_empty(); + } + // Windows fallback + if let Ok(output) = std::process::Command::new("tasklist") + .arg("/FI") + .arg(format!("IMAGENAME eq {}.exe", names[0])) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + return stdout.contains(&format!("{}.exe", names[0])); + } + false +} + +pub fn reload_daemon(agent: &str) -> Result<(), String> { + if !daemon_running(agent) { + return Ok(()); + } + let names = match agent { + "codex" => &["codex"][..], + _ => &[], + }; + // SIGTERM on Unix, taskkill on Windows + #[cfg(windows)] + { + for name in names { + std::process::Command::new("taskkill") + .args(["/IM", &format!("{name}.exe")]) + .output() + .map_err(|e| format!("taskkill: {e}"))?; + } + } + #[cfg(not(windows))] + { + std::process::Command::new("pkill") + .args(names) + .output() + .map_err(|e| format!("pkill: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn categorize_rate_limit_detects_429() { + let result = categorize_exit((1, "HTTP 429 Too Many Requests".to_string())); + assert!(matches!(result, ExitKind::RateLimited)); + } + + #[test] + fn categorize_rate_limit_detects_quota() { + let result = categorize_exit((1, "quota exceeded for today".to_string())); + assert!(matches!(result, ExitKind::RateLimited)); + } + + #[test] + fn categorize_success_on_zero() { + let result = categorize_exit((0, String::new())); + assert!(matches!(result, ExitKind::Success)); + } + + #[test] + fn categorize_failure_on_unknown_error() { + let result = categorize_exit((1, "something went wrong".to_string())); + assert!(matches!(result, ExitKind::Failure(1))); + } +} diff --git a/src/main.rs b/src/main.rs index a79bd4e3..8c97d44c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,9 @@ mod agent_launch; mod agents; mod alias; mod auth; +mod auth_crypt; mod auth_db; +mod auth_runner; mod components; mod coaching; mod cost; @@ -284,6 +286,61 @@ enum AuthAction { #[command(subcommand)] action: ProjectAction, }, + /// Wrap CLI with auto-failover on rate limit. Rotates profiles automatically. + Run { + agent: String, + #[arg(long)] + json: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Manage isolated $HOME profiles for parallel sessions. + Isolate { + #[command(subcommand)] + action: IsolateAction, + }, + /// Run command with an isolated profile's $HOME. + Exec { + agent: String, + profile: String, + #[arg(long)] + json: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Login flow for an isolated profile. + Login { + agent: String, + profile: String, + #[arg(long)] + json: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +#[derive(Subcommand)] +enum IsolateAction { + /// Create isolated $HOME profile with symlinked host files. + Add { + agent: String, + profile: String, + #[arg(long)] + json: bool, + }, + /// List isolated profiles. + Ls { + agent: Option, + #[arg(long)] + json: bool, + }, + /// Delete isolated profile. + Delete { + agent: String, + profile: String, + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] @@ -402,6 +459,14 @@ fn main() { ProjectAction::Set { agent, profile, json } => auth::project_set(&agent, &profile, json), ProjectAction::Unset { agent, json } => auth::project_unset(&agent, json), }, + AuthAction::Run { agent, json, args } => auth_runner::run(&agent, &args, json), + AuthAction::Isolate { action } => match action { + IsolateAction::Add { agent, profile, json } => auth::isolate_add(&agent, &profile, json), + IsolateAction::Ls { agent, json } => auth::isolate_ls(agent.as_deref(), json), + IsolateAction::Delete { agent, profile, json } => auth::isolate_delete(&agent, &profile, json), + }, + AuthAction::Exec { agent, profile, json, args } => auth::auth_exec(&agent, &profile, &args, json), + AuthAction::Login { agent, profile, json, args } => auth::auth_login(&agent, &profile, &args, json), }, Commands::Alias { preferred, force, print, yes, shell, profile, json } => { alias::run(preferred, force, print, yes, shell, profile, json) From 5c257ef9af0400ec9d3c97cd7d07a52eb816bd91 Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Tue, 7 Jul 2026 13:05:44 +0530 Subject: [PATCH 2/3] chore: add winget manifests for v1.1.0 --- winget/getappz.agentflare.installer.yaml | 9 +++++++++ winget/getappz.agentflare.locale.en-US.yaml | 9 +++++++++ winget/getappz.agentflare.yaml | 5 +++++ 3 files changed, 23 insertions(+) create mode 100644 winget/getappz.agentflare.installer.yaml create mode 100644 winget/getappz.agentflare.locale.en-US.yaml create mode 100644 winget/getappz.agentflare.yaml diff --git a/winget/getappz.agentflare.installer.yaml b/winget/getappz.agentflare.installer.yaml new file mode 100644 index 00000000..03f62ff9 --- /dev/null +++ b/winget/getappz.agentflare.installer.yaml @@ -0,0 +1,9 @@ +PackageIdentifier: getappz.agentflare +PackageVersion: 1.1.0 +InstallerType: portable +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/getappz/agentflare/releases/download/v1.1.0/agentflare-x86_64-pc-windows-msvc.zip + InstallerSha256: 5b36a7447b604448b551c846eb6c2f365365925ece046bb3f9a6bf074f85e6fa +ManifestType: installer +ManifestVersion: 1.10.0 diff --git a/winget/getappz.agentflare.locale.en-US.yaml b/winget/getappz.agentflare.locale.en-US.yaml new file mode 100644 index 00000000..f3df3b27 --- /dev/null +++ b/winget/getappz.agentflare.locale.en-US.yaml @@ -0,0 +1,9 @@ +PackageIdentifier: getappz.agentflare +PackageVersion: 1.1.0 +PackageLocale: en-US +Publisher: getappz +PackageName: agentflare +License: MIT +ShortDescription: Optimize AI CLI agents for cost and performance +ManifestType: defaultLocale +ManifestVersion: 1.10.0 diff --git a/winget/getappz.agentflare.yaml b/winget/getappz.agentflare.yaml new file mode 100644 index 00000000..116a93ce --- /dev/null +++ b/winget/getappz.agentflare.yaml @@ -0,0 +1,5 @@ +PackageIdentifier: getappz.agentflare +PackageVersion: 1.1.0 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0 From ec9b9cd12d00e89131360b823c081d8c8c0558ca Mon Sep 17 00:00:00 2001 From: Shivakumar Date: Tue, 7 Jul 2026 13:36:41 +0530 Subject: [PATCH 3/3] ci: add winget auto-update workflow using komac Triggers on release published. Uses komac to update winget manifests automatically and create PR against microsoft/winget-pkgs. Keeps last 5 versions via komac cleanup. --- .github/workflows/winget.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/winget.yml diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 00000000..01913736 --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,24 @@ +name: winget + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + publish: + runs-on: windows-latest + steps: + - name: Install komac + run: winget install --id russellbanks.Komac --accept-source-agreements --accept-package-agreements + + - name: Update winget manifest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KOMAC_FORK_OWNER: getappz + run: | + $id = "getappz.agentflare" + $urls = gh release view --json assets --jq '.assets[] | select(.name | test("\\.(exe|msi)$")) | .url' + if (-not $urls) { throw "No winget installer assets found" } + komac update $id --version "${{ github.event.release.tag_name }}" --urls $urls + komac cleanup --only-merged