zeptoclaw 0.7.3

Ultra-lightweight personal AI assistant
Documentation
//! Hands-lite registry and manifest parsing.

use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;

use crate::error::{Result, ZeptoError};

#[derive(Debug, Clone)]
pub struct Hand {
    pub manifest: HandManifest,
    pub skill_md: String,
    pub source: HandSource,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandSource {
    BuiltIn,
    User,
}

#[derive(Debug, Clone, Deserialize)]
pub struct HandManifest {
    pub name: String,
    pub description: String,
    pub system_prompt: String,
    #[serde(default)]
    pub required_tools: Vec<String>,
    #[serde(default)]
    pub settings: HashMap<String, String>,
    #[serde(default)]
    pub guardrails: HandGuardrails,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct HandGuardrails {
    #[serde(default)]
    pub require_approval_for: Vec<String>,
}

pub fn parse_hand_toml(content: &str) -> Result<HandManifest> {
    toml::from_str(content).map_err(|e| ZeptoError::Config(format!("Invalid HAND.toml: {}", e)))
}

pub fn built_in_hands() -> Vec<Hand> {
    vec![built_in_researcher(), built_in_coder(), built_in_monitor()]
}

pub fn resolve_hand(name: &str, hands_dir: &Path) -> Result<Option<Hand>> {
    if let Some(hand) = built_in_hands()
        .into_iter()
        .find(|h| h.manifest.name.eq_ignore_ascii_case(name))
    {
        return Ok(Some(hand));
    }

    let user_hands = load_hands_from_dir(hands_dir)?;
    Ok(user_hands
        .into_iter()
        .find(|h| h.manifest.name.eq_ignore_ascii_case(name)))
}

pub fn load_hands_from_dir(dir: &Path) -> Result<Vec<Hand>> {
    if !dir.exists() {
        return Ok(Vec::new());
    }
    if !dir.is_dir() {
        return Err(ZeptoError::Config(format!(
            "Hands path is not a directory: {}",
            dir.display()
        )));
    }

    let mut hands = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        if let Some(hand) = load_hand_dir(&path)? {
            hands.push(hand);
        }
    }
    Ok(hands)
}

fn load_hand_dir(path: &Path) -> Result<Option<Hand>> {
    let manifest_path = path.join("HAND.toml");
    if !manifest_path.is_file() {
        return Ok(None);
    }
    let skill_path = path.join("SKILL.md");
    let manifest_raw = std::fs::read_to_string(&manifest_path)?;
    let manifest = parse_hand_toml(&manifest_raw)?;
    let skill_md = if skill_path.is_file() {
        std::fs::read_to_string(skill_path)?
    } else {
        String::new()
    };
    Ok(Some(Hand {
        manifest,
        skill_md,
        source: HandSource::User,
    }))
}

fn built_in_researcher() -> Hand {
    Hand {
        manifest: HandManifest {
            name: "researcher".to_string(),
            description: "Autonomous web researcher with citation-first output".to_string(),
            system_prompt: "You are Researcher Hand. Build evidence-backed answers: plan search steps, gather sources, cross-check claims, and produce concise cited summaries. Prefer primary sources. Call out uncertainty. End with sources used."
                .to_string(),
            required_tools: vec![
                "web_search".to_string(),
                "web_fetch".to_string(),
                "memory_search".to_string(),
                "memory_get".to_string(),
            ],
            settings: HashMap::new(),
            guardrails: HandGuardrails {
                require_approval_for: vec!["shell*".to_string(), "write_*".to_string()],
            },
        },
        skill_md: "# Researcher Skill\nPrioritize primary sources and include citations.".to_string(),
        source: HandSource::BuiltIn,
    }
}

fn built_in_coder() -> Hand {
    Hand {
        manifest: HandManifest {
            name: "coder".to_string(),
            description: "Code-focused hand limited to repo-safe tools".to_string(),
            system_prompt: "You are Coder Hand. Write safe, minimal diffs. Validate assumptions before editing. Prefer deterministic commands, include tests, and avoid unrelated changes."
                .to_string(),
            required_tools: vec![
                "read_file".to_string(),
                "write_file".to_string(),
                "edit_file".to_string(),
                "list_dir".to_string(),
                "git".to_string(),
            ],
            settings: HashMap::new(),
            guardrails: HandGuardrails {
                require_approval_for: vec!["shell*".to_string()],
            },
        },
        skill_md: "# Coder Skill\nKeep patches small, test-backed, and reversible.".to_string(),
        source: HandSource::BuiltIn,
    }
}

fn built_in_monitor() -> Hand {
    Hand {
        manifest: HandManifest {
            name: "monitor".to_string(),
            description: "URL/API watcher with proactive notifications".to_string(),
            system_prompt: "You are Monitor Hand. Check targets periodically, detect meaningful change, and notify with terse diffs and severity. Avoid noisy updates."
                .to_string(),
            required_tools: vec![
                "web_fetch".to_string(),
                "http_request".to_string(),
                "message".to_string(),
                "cron".to_string(),
            ],
            settings: HashMap::new(),
            guardrails: HandGuardrails {
                require_approval_for: vec!["shell*".to_string(), "write_*".to_string()],
            },
        },
        skill_md: "# Monitor Skill\nOnly notify on meaningful changes.".to_string(),
        source: HandSource::BuiltIn,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_hand_toml() {
        let raw = r#"
name = "qa"
description = "QA hand"
system_prompt = "test"
required_tools = ["read_file", "git"]

[guardrails]
require_approval_for = ["shell*"]
"#;
        let hand = parse_hand_toml(raw).unwrap();
        assert_eq!(hand.name, "qa");
        assert_eq!(hand.required_tools.len(), 2);
        assert_eq!(hand.guardrails.require_approval_for, vec!["shell*"]);
    }
}