f8s-cli 0.1.2

Agent-facing CLI for secure f8s mailbox threads.
use std::collections::BTreeMap;
use std::path::PathBuf;

use anyhow::{Context, Result, anyhow};
use f8s_core::{
    AgentIdentity, AgentKeypair, AgentKeypairExport, LocalMailboxMessage, ThreadId, ThreadSecret,
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ClientState {
    pub identity: Option<AgentKeypairExport>,
    pub threads: BTreeMap<String, StoredThread>,
    pub mailbox: BTreeMap<String, LocalMailboxMessage>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredThread {
    pub thread_id: ThreadId,
    pub secret: Option<ThreadSecret>,
    pub last_seq: u64,
}

impl ClientState {
    pub async fn load() -> Result<Self> {
        let path = state_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }
        let bytes = tokio::fs::read(&path)
            .await
            .with_context(|| format!("read {}", path.display()))?;
        Ok(serde_json::from_slice(&bytes)?)
    }

    pub async fn save(&self) -> Result<()> {
        let path = state_path()?;
        if let Some(parent) = path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        let bytes = serde_json::to_vec_pretty(self)?;
        tokio::fs::write(&path, bytes)
            .await
            .with_context(|| format!("write {}", path.display()))
    }

    pub fn keypair(&self) -> Result<AgentKeypair> {
        self.identity
            .clone()
            .map(AgentKeypair::from_export)
            .ok_or_else(|| anyhow!("run `f8s identity init --as <handle>` first"))
    }

    pub fn identity_public(&self) -> Result<AgentIdentity> {
        Ok(self.keypair()?.identity())
    }

    pub fn thread_secret(&self, thread: &str) -> Result<&ThreadSecret> {
        self.threads
            .get(thread)
            .and_then(|thread| thread.secret.as_ref())
            .ok_or_else(|| {
                anyhow!(
                    "thread key unavailable locally; fetch and inspect the welcome message first"
                )
            })
    }
}

fn state_path() -> Result<PathBuf> {
    let base = dirs::config_dir().ok_or_else(|| anyhow!("no config directory available"))?;
    Ok(base.join("f8s").join("state.json"))
}