From 7c85308cf65adf58a1a9eb4e171724547419dde2 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Tue, 3 Mar 2026 20:28:46 +0300 Subject: [PATCH] bugfixes batch --- Cargo.lock | 28 ++-- Cargo.toml | 2 +- crates/openfang-api/src/routes.rs | 36 +++++ crates/openfang-api/src/server.rs | 4 + crates/openfang-api/static/js/pages/wizard.js | 4 +- crates/openfang-channels/src/telegram.rs | 58 ++++++- crates/openfang-cli/src/dotenv.rs | 12 +- crates/openfang-cli/src/launcher.rs | 12 +- crates/openfang-cli/src/main.rs | 147 ++++++++++++------ crates/openfang-cli/src/templates.rs | 13 +- .../src/tui/screens/init_wizard.rs | 23 +-- crates/openfang-cli/src/tui/screens/wizard.rs | 30 ++-- crates/openfang-hands/src/registry.rs | 72 +++++++-- crates/openfang-kernel/src/config.rs | 14 +- crates/openfang-kernel/src/kernel.rs | 24 ++- crates/openfang-runtime/src/kernel_handle.rs | 10 ++ crates/openfang-runtime/src/tool_runner.rs | 4 +- crates/openfang-types/src/config.rs | 15 +- 18 files changed, 381 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76611e82..ca6d607e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.4" +version = "0.3.5" dependencies = [ "async-trait", "axum", @@ -3902,7 +3902,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.4" +version = "0.3.5" dependencies = [ "async-trait", "axum", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.4" +version = "0.3.5" dependencies = [ "clap", "clap_complete", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.4" +version = "0.3.5" dependencies = [ "axum", "open", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.4" +version = "0.3.5" dependencies = [ "aes-gcm", "argon2", @@ -4014,7 +4014,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.4" +version = "0.3.5" dependencies = [ "chrono", "dashmap", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.4" +version = "0.3.5" dependencies = [ "async-trait", "chrono", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.4" +version = "0.3.5" dependencies = [ "async-trait", "chrono", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.4" +version = "0.3.5" dependencies = [ "chrono", "dirs 6.0.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "async-trait", @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.4" +version = "0.3.5" dependencies = [ "chrono", "hex", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.4" +version = "0.3.5" dependencies = [ "async-trait", "chrono", @@ -4179,7 +4179,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.4" +version = "0.3.5" dependencies = [ "async-trait", "chrono", @@ -8791,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.4" +version = "0.3.5" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index bfc25855..1a9f0475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.3.5" +version = "0.3.6" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 70387bfc..1d92989c 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -3614,6 +3614,42 @@ pub async fn install_hand_deps( ) } +/// POST /api/hands/install — Install a hand from TOML content. +pub async fn install_hand( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let toml_content = body["toml_content"].as_str().unwrap_or(""); + let skill_content = body["skill_content"].as_str().unwrap_or(""); + + if toml_content.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing toml_content field"})), + ); + } + + match state + .kernel + .hand_registry + .install_from_content(toml_content, skill_content) + { + Ok(def) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": def.id, + "name": def.name, + "description": def.description, + "category": format!("{:?}", def.category), + })), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + /// POST /api/hands/{hand_id}/activate — Activate a hand (spawns agent). pub async fn activate_hand( State(state): State>, diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 8c9a590d..c9cf9120 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -331,6 +331,10 @@ pub async fn build_router( ) // Hands endpoints .route("/api/hands", axum::routing::get(routes::list_hands)) + .route( + "/api/hands/install", + axum::routing::post(routes::install_hand), + ) .route( "/api/hands/active", axum::routing::get(routes::list_active_hands), diff --git a/crates/openfang-api/static/js/pages/wizard.js b/crates/openfang-api/static/js/pages/wizard.js index 26c28cd7..2e3f835f 100644 --- a/crates/openfang-api/static/js/pages/wizard.js +++ b/crates/openfang-api/static/js/pages/wizard.js @@ -441,8 +441,8 @@ function wizardPage() { toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n'; toml += 'profile = "' + tpl.profile + '"\n\n'; toml += '[model]\nprovider = "' + provider + '"\n'; - toml += 'name = "' + model + '"\n\n'; - toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n'; + toml += 'model = "' + model + '"\n'; + toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n'; this.creatingAgent = true; try { diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index a1209a05..f22317fd 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -79,8 +79,13 @@ impl TelegramAdapter { self.token.as_str() ); + // Sanitize: strip unsupported HTML tags so Telegram doesn't reject with 400. + // Telegram only allows: b, i, u, s, tg-spoiler, a, code, pre, blockquote. + // Any other tag (e.g. , ) causes a 400 Bad Request. + let sanitized = sanitize_telegram_html(text); + // Telegram has a 4096 character limit per message — split if needed - let chunks = split_message(text, 4096); + let chunks = split_message(&sanitized, 4096); for chunk in chunks { let body = serde_json::json!({ "chat_id": chat_id, @@ -524,6 +529,57 @@ pub fn calculate_backoff(current: Duration) -> Duration { (current * 2).min(MAX_BACKOFF) } +/// Sanitize text for Telegram HTML parse mode. +/// +/// Escapes angle brackets that are NOT part of Telegram-allowed HTML tags. +/// Allowed tags: b, i, u, s, tg-spoiler, a, code, pre, blockquote. +/// Everything else (e.g. ``, ``) gets escaped to `<...>`. +fn sanitize_telegram_html(text: &str) -> String { + const ALLOWED: &[&str] = &[ + "b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler", + "tg-emoji", + ]; + + let mut result = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + if bytes[i] == b'<' { + // Try to parse an HTML tag + if let Some(end) = text[i..].find('>') { + let tag_content = &text[i + 1..i + end]; // content between < and > + let tag_name = tag_content + .trim_start_matches('/') + .split(|c: char| c.is_whitespace() || c == '/' || c == '>') + .next() + .unwrap_or("") + .to_lowercase(); + + if !tag_name.is_empty() && ALLOWED.contains(&tag_name.as_str()) { + // Allowed tag — keep as-is + result.push_str(&text[i..i + end + 1]); + } else { + // Unknown tag — escape both brackets + result.push_str("<"); + result.push_str(&text[i + 1..i + end]); + result.push_str(">"); + } + i += end + 1; + } else { + // No closing > — escape the lone < + result.push_str("<"); + i += 1; + } + } else { + result.push(text[i..].chars().next().unwrap()); + i += text[i..].chars().next().unwrap().len_utf8(); + } + } + + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/openfang-cli/src/dotenv.rs b/crates/openfang-cli/src/dotenv.rs index 23179e92..5bdfa149 100644 --- a/crates/openfang-cli/src/dotenv.rs +++ b/crates/openfang-cli/src/dotenv.rs @@ -6,9 +6,17 @@ use std::collections::BTreeMap; use std::path::PathBuf; +/// Get the OpenFang home directory, respecting OPENFANG_HOME env var. +fn dotenv_openfang_home() -> Option { + if let Ok(home) = std::env::var("OPENFANG_HOME") { + return Some(PathBuf::from(home)); + } + dirs::home_dir().map(|h| h.join(".openfang")) +} + /// Return the path to `~/.openfang/.env`. pub fn env_file_path() -> Option { - dirs::home_dir().map(|h| h.join(".openfang").join(".env")) + dotenv_openfang_home().map(|h| h.join(".env")) } /// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`. @@ -25,7 +33,7 @@ pub fn load_dotenv() { /// Return the path to `~/.openfang/secrets.env`. pub fn secrets_env_path() -> Option { - dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env")) + dotenv_openfang_home().map(|h| h.join("secrets.env")) } fn load_env_file(path: Option) { diff --git a/crates/openfang-cli/src/launcher.rs b/crates/openfang-cli/src/launcher.rs index e7882402..18a8f123 100644 --- a/crates/openfang-cli/src/launcher.rs +++ b/crates/openfang-cli/src/launcher.rs @@ -39,11 +39,15 @@ fn detect_provider() -> Option<(&'static str, &'static str)> { } fn is_first_run() -> bool { - let home = match dirs::home_dir() { - Some(h) => h, - None => return true, + let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") { + std::path::PathBuf::from(h) + } else { + match dirs::home_dir() { + Some(h) => h.join(".openfang"), + None => return true, + } }; - !home.join(".openfang").join("config.toml").exists() + !of_home.join("config.toml").exists() } fn has_openclaw() -> bool { diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 1218a53f..3ea3a001 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -390,6 +390,11 @@ enum HandCommands { List, /// Show currently active hand instances. Active, + /// Install a hand from a local directory containing HAND.toml. + Install { + /// Path to the hand directory (must contain HAND.toml). + path: String, + }, /// Activate a hand by ID. Activate { /// Hand ID (e.g. "clip", "lead", "researcher"). @@ -781,11 +786,19 @@ fn init_tracing_stderr() { .init(); } +/// Get the OpenFang home directory, respecting OPENFANG_HOME env var. +fn cli_openfang_home() -> std::path::PathBuf { + if let Ok(home) = std::env::var("OPENFANG_HOME") { + return std::path::PathBuf::from(home); + } + dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".openfang") +} + /// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI. fn init_tracing_file() { - let log_dir = dirs::home_dir() - .map(|h| h.join(".openfang")) - .unwrap_or_else(|| std::path::PathBuf::from(".")); + let log_dir = cli_openfang_home(); let _ = std::fs::create_dir_all(&log_dir); let log_path = log_dir.join("tui.log"); @@ -910,6 +923,7 @@ fn main() { Some(Commands::Hand(sub)) => match sub { HandCommands::List => cmd_hand_list(), HandCommands::Active => cmd_hand_active(), + HandCommands::Install { path } => cmd_hand_install(&path), HandCommands::Activate { id } => cmd_hand_activate(&id), HandCommands::Deactivate { id } => cmd_hand_deactivate(&id), HandCommands::Info { id } => cmd_hand_info(&id), @@ -1037,7 +1051,7 @@ pub(crate) fn restrict_dir_permissions(path: &std::path::Path) { pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {} pub(crate) fn find_daemon() -> Option { - let home_dir = dirs::home_dir()?.join(".openfang"); + let home_dir = cli_openfang_home(); let info = read_daemon_info(&home_dir)?; // Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid @@ -1119,7 +1133,7 @@ fn cmd_init(quick: bool) { } }; - let openfang_dir = home.join(".openfang"); + let openfang_dir = cli_openfang_home(); // --- Ensure directories exist --- if !openfang_dir.exists() { @@ -1421,7 +1435,7 @@ fn cmd_start(config: Option) { /// Read the api_key from ~/.openfang/config.toml (if any). fn read_api_key() -> Option { - let config_path = dirs::home_dir()?.join(".openfang").join("config.toml"); + let config_path = cli_openfang_home().join("config.toml"); let text = std::fs::read_to_string(config_path).ok()?; let table: toml::Value = text.parse().ok()?; let key = table.get("api_key")?.as_str()?; @@ -1451,8 +1465,8 @@ fn cmd_stop() { } } // Still alive — force kill via PID - if let Some(home) = dirs::home_dir() { - let of_dir = home.join(".openfang"); + { + let of_dir = cli_openfang_home(); if let Some(info) = read_daemon_info(&of_dir) { force_kill_pid(info.pid); let _ = std::fs::remove_file(of_dir.join("daemon.json")); @@ -1927,8 +1941,8 @@ fn cmd_doctor(json: bool, repair: bool) { } let home = dirs::home_dir(); - if let Some(h) = &home { - let openfang_dir = h.join(".openfang"); + if let Some(_h) = &home { + let openfang_dir = cli_openfang_home(); // --- Check 1: OpenFang directory --- if openfang_dir.exists() { @@ -2333,8 +2347,8 @@ decay_rate = 0.05 } // --- Check 11: .env keys vs config api_key_env consistency --- - if let Some(ref h) = home { - let openfang_dir = h.join(".openfang"); + { + let openfang_dir = cli_openfang_home(); let config_path = openfang_dir.join("config.toml"); if config_path.exists() { let config_str = std::fs::read_to_string(&config_path).unwrap_or_default(); @@ -2359,8 +2373,8 @@ decay_rate = 0.05 } // --- Check 12: Config deserialization into KernelConfig --- - if let Some(ref h) = home { - let openfang_dir = h.join(".openfang"); + { + let openfang_dir = cli_openfang_home(); let config_path = openfang_dir.join("config.toml"); if config_path.exists() { if !json { @@ -2464,10 +2478,7 @@ decay_rate = 0.05 if !json { println!("\n Skills:"); } - let skills_dir = home - .as_ref() - .map(|h| h.join(".openfang").join("skills")) - .unwrap_or_else(|| std::path::PathBuf::from("skills")); + let skills_dir = cli_openfang_home().join("skills"); let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone()); skill_reg.load_bundled(); let bundled_count = skill_reg.count(); @@ -2528,11 +2539,11 @@ decay_rate = 0.05 } // --- Check 14: Extension registry health --- - if let Some(ref h) = home { + { if !json { println!("\n Extensions:"); } - let openfang_dir = h.join(".openfang"); + let openfang_dir = cli_openfang_home(); let mut ext_registry = openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir); ext_registry.load_bundled(); @@ -3143,12 +3154,7 @@ fn cmd_migrate(args: MigrateArgs) { } }); - let target_dir = dirs::home_dir() - .unwrap_or_else(|| { - eprintln!("Error: Could not determine home directory"); - std::process::exit(1); - }) - .join(".openfang"); + let target_dir = cli_openfang_home(); println!("Migrating from {} ({})...", source, source_dir.display()); if args.dry_run { @@ -3839,6 +3845,52 @@ fn cmd_channel_toggle(channel: &str, enable: bool) { // Hand commands // --------------------------------------------------------------------------- +fn cmd_hand_install(path: &str) { + let base = require_daemon("hand install"); + let dir = std::path::Path::new(path); + let toml_path = dir.join("HAND.toml"); + let skill_path = dir.join("SKILL.md"); + + if !toml_path.exists() { + eprintln!( + "Error: No HAND.toml found in {}", + dir.canonicalize() + .unwrap_or_else(|_| dir.to_path_buf()) + .display() + ); + std::process::exit(1); + } + + let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| { + eprintln!("Error reading {}: {e}", toml_path.display()); + std::process::exit(1); + }); + let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default(); + + let client = daemon_client(); + let body = daemon_json( + client + .post(format!("{base}/api/hands/install")) + .json(&serde_json::json!({ + "toml_content": toml_content, + "skill_content": skill_content, + })) + .send(), + ); + + if let Some(err) = body.get("error").and_then(|v| v.as_str()) { + eprintln!("Error: {err}"); + std::process::exit(1); + } + + println!( + "Installed hand: {} ({})", + body["name"].as_str().unwrap_or("?"), + body["id"].as_str().unwrap_or("?"), + ); + println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?")); +} + fn cmd_hand_list() { let base = require_daemon("hand list"); let client = daemon_client(); @@ -4565,6 +4617,9 @@ fn cmd_quick_chat(config: Option, agent: Option) { // --------------------------------------------------------------------------- pub(crate) fn openfang_home() -> PathBuf { + if let Ok(home) = std::env::var("OPENFANG_HOME") { + return PathBuf::from(home); + } dirs::home_dir() .unwrap_or_else(|| { eprintln!("Error: Could not determine home directory"); @@ -5407,9 +5462,7 @@ fn cmd_sessions(agent: Option<&str>, json: bool) { } fn cmd_logs(lines: usize, follow: bool) { - let log_path = dirs::home_dir() - .map(|h| h.join(".openfang").join("tui.log")) - .unwrap_or_else(|| PathBuf::from("tui.log")); + let log_path = cli_openfang_home().join("tui.log"); if !log_path.exists() { ui::error_with_fix( @@ -5939,13 +5992,7 @@ fn cmd_system_version(json: bool) { } fn cmd_reset(confirm: bool) { - let openfang_dir = match dirs::home_dir() { - Some(h) => h.join(".openfang"), - None => { - ui::error("Could not determine home directory"); - std::process::exit(1); - } - }; + let openfang_dir = cli_openfang_home(); if !openfang_dir.exists() { println!( @@ -5980,14 +6027,7 @@ fn cmd_reset(confirm: bool) { // --------------------------------------------------------------------------- fn cmd_uninstall(confirm: bool, keep_config: bool) { - let home = match dirs::home_dir() { - Some(h) => h, - None => { - ui::error("Could not determine home directory"); - std::process::exit(1); - } - }; - let openfang_dir = home.join(".openfang"); + let openfang_dir = cli_openfang_home(); let exe_path = std::env::current_exe().ok(); // Step 1: Show what will be removed @@ -6013,11 +6053,15 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) { println!(" • Remove binary: {}", exe.display()); } // Check cargo bin path - let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) { - "openfang.exe" - } else { - "openfang" - }); + let cargo_bin = dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".cargo") + .join("bin") + .join(if cfg!(windows) { + "openfang.exe" + } else { + "openfang" + }); if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) { println!(" • Remove cargo binary: {}", cargo_bin.display()); } @@ -6051,12 +6095,13 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) { } // Step 4: Remove auto-start entries - remove_autostart_entries(&home); + let user_home = dirs::home_dir().unwrap_or_else(std::env::temp_dir); + remove_autostart_entries(&user_home); // Step 5: Clean PATH from shell configs if let Some(ref exe) = exe_path { if let Some(bin_dir) = exe.parent() { - clean_path_entries(&home, &bin_dir.to_string_lossy()); + clean_path_entries(&user_home, &bin_dir.to_string_lossy()); } } diff --git a/crates/openfang-cli/src/templates.rs b/crates/openfang-cli/src/templates.rs index 650bc126..df4daf4f 100644 --- a/crates/openfang-cli/src/templates.rs +++ b/crates/openfang-cli/src/templates.rs @@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec { } } - // Installed templates - if let Some(home) = dirs::home_dir() { - let agents = home.join(".openfang").join("agents"); + // Installed templates (respects OPENFANG_HOME) + let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") { + PathBuf::from(h) + } else if let Some(home) = dirs::home_dir() { + home.join(".openfang") + } else { + std::env::temp_dir().join(".openfang") + }; + { + let agents = of_home.join("agents"); if agents.is_dir() && !dirs.contains(&agents) { dirs.push(agents); } diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 3f15ca69..89e774f6 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -825,8 +825,11 @@ fn handle_migration_key( if yes { state.migration_phase = MigrationPhase::Running; let source_dir = state.openclaw_path.clone().unwrap_or_default(); - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - let target_dir = home.join(".openfang"); + let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") { + PathBuf::from(h) + } else { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang") + }; let tx = migrate_tx.clone(); std::thread::spawn(move || { let options = openfang_migrate::MigrateOptions { @@ -945,15 +948,17 @@ fn save_config(state: &mut State) { } }; - let home = match dirs::home_dir() { - Some(h) => h, - None => { - state.save_error = "Could not determine home directory".to_string(); - return; + let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") { + PathBuf::from(h) + } else { + match dirs::home_dir() { + Some(h) => h.join(".openfang"), + None => { + state.save_error = "Could not determine home directory".to_string(); + return; + } } }; - - let openfang_dir = home.join(".openfang"); let _ = std::fs::create_dir_all(openfang_dir.join("agents")); let _ = std::fs::create_dir_all(openfang_dir.join("data")); crate::restrict_dir_permissions(&openfang_dir); diff --git a/crates/openfang-cli/src/tui/screens/wizard.rs b/crates/openfang-cli/src/tui/screens/wizard.rs index d225fb4e..817749ba 100644 --- a/crates/openfang-cli/src/tui/screens/wizard.rs +++ b/crates/openfang-cli/src/tui/screens/wizard.rs @@ -89,11 +89,15 @@ const PROVIDERS: &[ProviderInfo] = &[ /// Check if first-run setup is needed. pub fn needs_setup() -> bool { - let home = match dirs::home_dir() { - Some(h) => h, - None => return true, + let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") { + std::path::PathBuf::from(h) + } else { + match dirs::home_dir() { + Some(h) => h.join(".openfang"), + None => return true, + } }; - !home.join(".openfang").join("config.toml").exists() + !of_home.join("config.toml").exists() } #[derive(Clone, Copy, PartialEq, Eq)] @@ -294,16 +298,18 @@ impl WizardState { } }; - let home = match dirs::home_dir() { - Some(h) => h, - None => { - self.status_msg = "Could not determine home directory".to_string(); - self.step = WizardStep::Done; - return; + let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") { + std::path::PathBuf::from(h) + } else { + match dirs::home_dir() { + Some(h) => h.join(".openfang"), + None => { + self.status_msg = "Could not determine home directory".to_string(); + self.step = WizardStep::Done; + return; + } } }; - - let openfang_dir = home.join(".openfang"); let _ = std::fs::create_dir_all(openfang_dir.join("agents")); let _ = std::fs::create_dir_all(openfang_dir.join("data")); crate::restrict_dir_permissions(&openfang_dir); diff --git a/crates/openfang-hands/src/registry.rs b/crates/openfang-hands/src/registry.rs index 93f16655..22ad9452 100644 --- a/crates/openfang-hands/src/registry.rs +++ b/crates/openfang-hands/src/registry.rs @@ -38,7 +38,7 @@ pub struct SettingStatus { /// The Hand registry — stores definitions and tracks active instances. pub struct HandRegistry { /// All known hand definitions, keyed by hand_id. - definitions: HashMap, + definitions: DashMap, /// Active hand instances, keyed by instance UUID. instances: DashMap, } @@ -47,13 +47,13 @@ impl HandRegistry { /// Create an empty registry. pub fn new() -> Self { Self { - definitions: HashMap::new(), + definitions: DashMap::new(), instances: DashMap::new(), } } /// Load all bundled hand definitions. Returns count of definitions loaded. - pub fn load_bundled(&mut self) -> usize { + pub fn load_bundled(&self) -> usize { let bundled = bundled::bundled_hands(); let mut count = 0; for (id, toml_content, skill_content) in bundled { @@ -71,16 +71,60 @@ impl HandRegistry { count } + /// Install a hand from a directory containing HAND.toml (and optional SKILL.md). + pub fn install_from_path(&self, path: &std::path::Path) -> HandResult { + let toml_path = path.join("HAND.toml"); + let skill_path = path.join("SKILL.md"); + + let toml_content = std::fs::read_to_string(&toml_path).map_err(|e| { + HandError::NotFound(format!("Cannot read {}: {e}", toml_path.display())) + })?; + let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default(); + + let def = bundled::parse_bundled("custom", &toml_content, &skill_content)?; + + if self.definitions.contains_key(&def.id) { + return Err(HandError::AlreadyActive(format!( + "Hand '{}' already registered", + def.id + ))); + } + + info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path"); + self.definitions.insert(def.id.clone(), def.clone()); + Ok(def) + } + + /// Install a hand from raw TOML + skill content (for API-based installs). + pub fn install_from_content( + &self, + toml_content: &str, + skill_content: &str, + ) -> HandResult { + let def = bundled::parse_bundled("custom", toml_content, skill_content)?; + + if self.definitions.contains_key(&def.id) { + return Err(HandError::AlreadyActive(format!( + "Hand '{}' already registered", + def.id + ))); + } + + info!(hand = %def.id, name = %def.name, "Installed hand from content"); + self.definitions.insert(def.id.clone(), def.clone()); + Ok(def) + } + /// List all known hand definitions. - pub fn list_definitions(&self) -> Vec<&HandDefinition> { - let mut defs: Vec<&HandDefinition> = self.definitions.values().collect(); - defs.sort_by_key(|d| &d.name); + pub fn list_definitions(&self) -> Vec { + let mut defs: Vec = self.definitions.iter().map(|r| r.value().clone()).collect(); + defs.sort_by(|a, b| a.name.cmp(&b.name)); defs } /// Get a specific hand definition by ID. - pub fn get_definition(&self, hand_id: &str) -> Option<&HandDefinition> { - self.definitions.get(hand_id) + pub fn get_definition(&self, hand_id: &str) -> Option { + self.definitions.get(hand_id).map(|r| r.value().clone()) } /// Activate a hand — creates an instance (agent spawning is done by kernel). @@ -344,7 +388,7 @@ mod tests { #[test] fn load_bundled_hands() { - let mut reg = HandRegistry::new(); + let reg = HandRegistry::new(); let count = reg.load_bundled(); assert_eq!(count, 7); assert!(!reg.list_definitions().is_empty()); @@ -368,7 +412,7 @@ mod tests { #[test] fn activate_and_deactivate() { - let mut reg = HandRegistry::new(); + let reg = HandRegistry::new(); reg.load_bundled(); let instance = reg.activate("clip", HashMap::new()).unwrap(); @@ -390,7 +434,7 @@ mod tests { #[test] fn pause_and_resume() { - let mut reg = HandRegistry::new(); + let reg = HandRegistry::new(); reg.load_bundled(); let instance = reg.activate("clip", HashMap::new()).unwrap(); @@ -409,7 +453,7 @@ mod tests { #[test] fn set_agent() { - let mut reg = HandRegistry::new(); + let reg = HandRegistry::new(); reg.load_bundled(); let instance = reg.activate("clip", HashMap::new()).unwrap(); @@ -427,7 +471,7 @@ mod tests { #[test] fn check_requirements() { - let mut reg = HandRegistry::new(); + let reg = HandRegistry::new(); reg.load_bundled(); let results = reg.check_requirements("clip").unwrap(); @@ -452,7 +496,7 @@ mod tests { #[test] fn set_error_status() { - let mut reg = HandRegistry::new(); + let reg = HandRegistry::new(); reg.load_bundled(); let instance = reg.activate("clip", HashMap::new()).unwrap(); diff --git a/crates/openfang-kernel/src/config.rs b/crates/openfang-kernel/src/config.rs index 810e433a..f77d6ac3 100644 --- a/crates/openfang-kernel/src/config.rs +++ b/crates/openfang-kernel/src/config.rs @@ -224,15 +224,19 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) { } /// Get the default config file path. +/// +/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`). pub fn default_config_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(std::env::temp_dir) - .join(".openfang") - .join("config.toml") + openfang_home().join("config.toml") } -/// Get the default OpenFang home directory. +/// Get the OpenFang home directory. +/// +/// Priority: `OPENFANG_HOME` env var > `~/.openfang`. pub fn openfang_home() -> PathBuf { + if let Ok(home) = std::env::var("OPENFANG_HOME") { + return PathBuf::from(home); + } dirs::home_dir() .unwrap_or_else(std::env::temp_dir) .join(".openfang") diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 2166fab9..6558d331 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -687,7 +687,7 @@ impl OpenFangKernel { } // Initialize hand registry (curated autonomous packages) - let mut hand_registry = openfang_hands::registry::HandRegistry::new(); + let hand_registry = openfang_hands::registry::HandRegistry::new(); let hand_count = hand_registry.load_bundled(); if hand_count > 0 { info!("Loaded {hand_count} bundled hand(s)"); @@ -5031,6 +5031,24 @@ impl KernelHandle for OpenFangKernel { Ok(result) } + async fn hand_install( + &self, + toml_content: &str, + skill_content: &str, + ) -> Result { + let def = self + .hand_registry + .install_from_content(toml_content, skill_content) + .map_err(|e| format!("{e}"))?; + + Ok(serde_json::json!({ + "id": def.id, + "name": def.name, + "description": def.description, + "category": format!("{:?}", def.category), + })) + } + async fn hand_activate( &self, hand_id: &str, @@ -5057,8 +5075,8 @@ impl KernelHandle for OpenFangKernel { .ok_or_else(|| format!("No active instance found for hand '{hand_id}'"))?; let def = self.hand_registry.get_definition(hand_id); - let def_name = def.map(|d| d.name.clone()).unwrap_or_default(); - let def_icon = def.map(|d| d.icon.clone()).unwrap_or_default(); + let def_name = def.as_ref().map(|d| d.name.clone()).unwrap_or_default(); + let def_icon = def.as_ref().map(|d| d.icon.clone()).unwrap_or_default(); Ok(serde_json::json!({ "hand_id": hand_id, diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index 74aad001..a479750b 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -139,6 +139,16 @@ pub trait KernelHandle: Send + Sync { Err("Hands system not available".to_string()) } + /// Install a Hand from TOML content. + async fn hand_install( + &self, + toml_content: &str, + skill_content: &str, + ) -> Result { + let _ = (toml_content, skill_content); + Err("Hands system not available".to_string()) + } + /// Activate a Hand — spawns a specialized autonomous agent. async fn hand_activate( &self, diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 84934fd1..cb6de523 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -627,7 +627,7 @@ pub fn builtin_tool_definitions() -> Vec { "type": "object", "properties": { "key": { "type": "string", "description": "The storage key" }, - "value": { "description": "The JSON value to store (any type)" } + "value": { "type": "string", "description": "The value to store (JSON-encode objects/arrays, or pass a plain string)" } }, "required": ["key", "value"] }), @@ -705,7 +705,7 @@ pub fn builtin_tool_definitions() -> Vec { "type": "object", "properties": { "event_type": { "type": "string", "description": "Type identifier for the event (e.g., 'code_review_requested')" }, - "payload": { "description": "JSON payload data for the event" } + "payload": { "type": "object", "description": "JSON payload data for the event" } }, "required": ["event_type"] }), diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 3e8b50e7..a5455d79 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1168,7 +1168,7 @@ fn default_language() -> String { impl Default for KernelConfig { fn default() -> Self { - let home_dir = dirs_next_home().join(".openfang"); + let home_dir = openfang_home_dir(); Self { data_dir: home_dir.join("data"), home_dir, @@ -1308,9 +1308,16 @@ impl std::fmt::Debug for KernelConfig { } } -/// Fallback home directory resolution. -fn dirs_next_home() -> PathBuf { - dirs::home_dir().unwrap_or_else(std::env::temp_dir) +/// Resolve the OpenFang home directory. +/// +/// Priority: `OPENFANG_HOME` env var > `~/.openfang`. +fn openfang_home_dir() -> PathBuf { + if let Ok(home) = std::env::var("OPENFANG_HOME") { + return PathBuf::from(home); + } + dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".openfang") } /// Default LLM model configuration.