bugfixes batch

This commit is contained in:
jaberjaber23
2026-03-03 20:28:46 +03:00
parent a4a83b1699
commit 7c85308cf6
18 changed files with 381 additions and 127 deletions
Generated
+14 -14
View File
@@ -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"
+1 -1
View File
@@ -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"
+36
View File
@@ -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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
+4
View File
@@ -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),
@@ -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 {
+57 -1
View File
@@ -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. <name>, <thinking>) 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. `<name>`, `<thinking>`) gets escaped to `&lt;...&gt;`.
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("&lt;");
result.push_str(&text[i + 1..i + end]);
result.push_str("&gt;");
}
i += end + 1;
} else {
// No closing > — escape the lone <
result.push_str("&lt;");
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::*;
+10 -2
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf>) {
+8 -4
View File
@@ -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 {
+96 -51
View File
@@ -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<String> {
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<PathBuf>) {
/// Read the api_key from ~/.openfang/config.toml (if any).
fn read_api_key() -> Option<String> {
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<PathBuf>, agent: Option<String>) {
// ---------------------------------------------------------------------------
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());
}
}
+10 -3
View File
@@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
}
}
// 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);
}
@@ -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);
+18 -12
View File
@@ -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);
+58 -14
View File
@@ -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<String, HandDefinition>,
definitions: DashMap<String, HandDefinition>,
/// Active hand instances, keyed by instance UUID.
instances: DashMap<Uuid, HandInstance>,
}
@@ -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<HandDefinition> {
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<HandDefinition> {
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<HandDefinition> {
let mut defs: Vec<HandDefinition> = 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<HandDefinition> {
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();
+9 -5
View File
@@ -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")
+21 -3
View File
@@ -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<serde_json::Value, String> {
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,
@@ -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<serde_json::Value, String> {
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,
+2 -2
View File
@@ -627,7 +627,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"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<ToolDefinition> {
"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"]
}),
+11 -4
View File
@@ -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.