From 62e1fcd151316a5524ccb36089da11ce11897c6b Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sat, 21 Feb 2026 15:30:34 +0530 Subject: [PATCH 1/9] feat(cli): wire agent/plugin/session/tool commands to backend stores --- Cargo.lock | 1 + crates/mofa-cli/Cargo.toml | 1 + crates/mofa-cli/src/commands/agent/list.rs | 49 +- crates/mofa-cli/src/commands/agent/restart.rs | 13 +- crates/mofa-cli/src/commands/agent/start.rs | 20 +- crates/mofa-cli/src/commands/agent/status.rs | 30 +- crates/mofa-cli/src/commands/agent/stop.rs | 12 +- crates/mofa-cli/src/commands/backend.rs | 440 ++++++++++++++++++ crates/mofa-cli/src/commands/mod.rs | 1 + crates/mofa-cli/src/commands/plugin/info.rs | 29 +- crates/mofa-cli/src/commands/plugin/list.rs | 46 +- .../mofa-cli/src/commands/plugin/uninstall.rs | 13 +- .../mofa-cli/src/commands/session/delete.rs | 5 +- .../mofa-cli/src/commands/session/export.rs | 25 +- crates/mofa-cli/src/commands/session/list.rs | 47 +- crates/mofa-cli/src/commands/session/show.rs | 41 +- crates/mofa-cli/src/commands/tool/info.rs | 19 +- crates/mofa-cli/src/commands/tool/list.rs | 41 +- 18 files changed, 561 insertions(+), 272 deletions(-) create mode 100644 crates/mofa-cli/src/commands/backend.rs diff --git a/Cargo.lock b/Cargo.lock index 28b25e4c..e71a5c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4248,6 +4248,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_cmd", + "chrono", "clap", "colored 2.2.0", "comfy-table", diff --git a/crates/mofa-cli/Cargo.toml b/crates/mofa-cli/Cargo.toml index ed7ec1e8..3eaab3c7 100644 --- a/crates/mofa-cli/Cargo.toml +++ b/crates/mofa-cli/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" toml = "0.8" +chrono = { workspace = true } # Utilities diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index de6565fb..eb1d4e98 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -1,8 +1,8 @@ //! `mofa agent list` command implementation +use crate::commands::backend::CliBackend; use crate::output::Table; use colored::Colorize; -use serde::Serialize; /// Execute the `mofa agent list` command pub fn run(running_only: bool, show_all: bool) -> anyhow::Result<()> { @@ -16,38 +16,8 @@ pub fn run(running_only: bool, show_all: bool) -> anyhow::Result<()> { println!(); - // TODO: Implement actual agent listing from state store - // For now, show example output - - let agents = vec![ - AgentInfo { - id: "agent-001".to_string(), - name: "MyAgent".to_string(), - status: "running".to_string(), - uptime: Some("5m 32s".to_string()), - provider: Some("openai".to_string()), - model: Some("gpt-4o".to_string()), - }, - AgentInfo { - id: "agent-002".to_string(), - name: "TestAgent".to_string(), - status: "stopped".to_string(), - uptime: None, - provider: None, - model: None, - }, - ]; - - // Filter based on flags - let filtered: Vec<_> = if running_only { - agents - .iter() - .filter(|a| a.status == "running") - .cloned() - .collect() - } else { - agents - }; + let backend = CliBackend::discover()?; + let filtered = backend.list_agents(running_only)?; if filtered.is_empty() { println!(" No agents found."); @@ -63,16 +33,3 @@ pub fn run(running_only: bool, show_all: bool) -> anyhow::Result<()> { Ok(()) } - -#[derive(Debug, Clone, Serialize)] -struct AgentInfo { - id: String, - name: String, - status: String, - #[serde(skip_serializing_if = "Option::is_none")] - uptime: Option, - #[serde(skip_serializing_if = "Option::is_none")] - provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, -} diff --git a/crates/mofa-cli/src/commands/agent/restart.rs b/crates/mofa-cli/src/commands/agent/restart.rs index abb6ca02..3628e22d 100644 --- a/crates/mofa-cli/src/commands/agent/restart.rs +++ b/crates/mofa-cli/src/commands/agent/restart.rs @@ -1,17 +1,14 @@ //! `mofa agent restart` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa agent restart` command -pub fn run(agent_id: &str, _config: Option<&std::path::Path>) -> anyhow::Result<()> { +pub fn run(agent_id: &str, config: Option<&std::path::Path>) -> anyhow::Result<()> { println!("{} Restarting agent: {}", "→".green(), agent_id.cyan()); - - // TODO: Implement actual agent restart logic - // This would involve: - // 1. Stopping the agent - // 2. Starting it again with the same config - - println!("{} Agent '{}' restarted", "✓".green(), agent_id); + let backend = CliBackend::discover()?; + let restarted = backend.restart_agent(agent_id, config)?; + println!("{} Agent '{}' restarted", "✓".green(), restarted.id); Ok(()) } diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index 8b450ba3..b2f9e665 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -1,22 +1,22 @@ //! `mofa agent start` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa agent start` command -pub fn run(agent_id: &str, _config: Option<&std::path::Path>, daemon: bool) -> anyhow::Result<()> { +pub fn run(agent_id: &str, config: Option<&std::path::Path>, daemon: bool) -> anyhow::Result<()> { println!("{} Starting agent: {}", "→".green(), agent_id.cyan()); - if daemon { + let backend = CliBackend::discover()?; + let started = backend.start_agent(agent_id, config, daemon)?; + + if started.daemon { println!(" Mode: {}", "daemon".yellow()); } - - // TODO: Implement actual agent starting logic - // This would involve: - // 1. Loading agent configuration - // 2. Starting the agent process - // 3. Storing agent state/PID - - println!("{} Agent '{}' started", "✓".green(), agent_id); + if let Some(path) = started.config_path { + println!(" Config: {}", path.cyan()); + } + println!("{} Agent '{}' started", "✓".green(), started.id); Ok(()) } diff --git a/crates/mofa-cli/src/commands/agent/status.rs b/crates/mofa-cli/src/commands/agent/status.rs index 40330ed4..6c6e59e8 100644 --- a/crates/mofa-cli/src/commands/agent/status.rs +++ b/crates/mofa-cli/src/commands/agent/status.rs @@ -1,21 +1,39 @@ //! `mofa agent status` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa agent status` command pub fn run(agent_id: Option<&str>) -> anyhow::Result<()> { + let backend = CliBackend::discover()?; + if let Some(id) = agent_id { - // Show status for a specific agent + let agent = backend.get_agent(id)?; println!("{} Agent status: {}", "→".green(), id.cyan()); println!(); - println!(" ID: {}", id); - println!(" Status: {}", "Running".green()); - println!(" Uptime: {}", "5m 32s".white()); + println!(" ID: {}", agent.id); + println!(" Status: {}", agent.status.green()); + if let Some(uptime) = agent.uptime { + println!(" Uptime: {}", uptime.white()); + } } else { - // Show summary of all agents + let agents = backend.list_agents(false)?; println!("{} Agent Status", "→".green()); println!(); - println!(" No agents currently running."); + if agents.is_empty() { + println!(" No agents currently tracked."); + } else { + println!(" Total tracked agents: {}", agents.len()); + let running = agents + .iter() + .filter(|agent| agent.status.eq_ignore_ascii_case("running")) + .count(); + println!(" Running: {}", running.to_string().green()); + println!( + " Stopped: {}", + (agents.len() - running).to_string().yellow() + ); + } } Ok(()) diff --git a/crates/mofa-cli/src/commands/agent/stop.rs b/crates/mofa-cli/src/commands/agent/stop.rs index fb1b295d..268b8bbc 100644 --- a/crates/mofa-cli/src/commands/agent/stop.rs +++ b/crates/mofa-cli/src/commands/agent/stop.rs @@ -1,18 +1,14 @@ //! `mofa agent stop` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa agent stop` command pub fn run(agent_id: &str) -> anyhow::Result<()> { println!("{} Stopping agent: {}", "→".green(), agent_id.cyan()); - - // TODO: Implement actual agent stopping logic - // This would involve: - // 1. Looking up the agent's PID/state - // 2. Sending a shutdown signal - // 3. Waiting for graceful shutdown - - println!("{} Agent '{}' stopped", "✓".green(), agent_id); + let backend = CliBackend::discover()?; + let stopped = backend.stop_agent(agent_id)?; + println!("{} Agent '{}' stopped", "✓".green(), stopped.id); Ok(()) } diff --git a/crates/mofa-cli/src/commands/backend.rs b/crates/mofa-cli/src/commands/backend.rs new file mode 100644 index 00000000..e8b3d7b9 --- /dev/null +++ b/crates/mofa-cli/src/commands/backend.rs @@ -0,0 +1,440 @@ +//! Shared backend repository for CLI command handlers. + +use crate::utils::paths::mofa_data_dir; +use mofa_sdk::react::tools::prelude::all_builtin_tools; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; + +const BACKEND_DIR: &str = "cli-backend"; +const AGENTS_FILE: &str = "agents_state.json"; +const PLUGINS_FILE: &str = "plugins_registry.json"; +const TOOLS_FILE: &str = "tools_registry.json"; +const SESSIONS_FILE: &str = "sessions_store.json"; + +#[derive(Debug)] +pub enum CliBackendError { + CapabilityUnavailable { + capability: &'static str, + reason: String, + }, + NotFound { + kind: &'static str, + id: String, + }, + InvalidInput(String), + Io(std::io::Error), + Serde(serde_json::Error), +} + +impl Display for CliBackendError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::CapabilityUnavailable { capability, reason } => { + write!( + f, + "Backend capability '{}' unavailable: {}", + capability, reason + ) + } + Self::NotFound { kind, id } => write!(f, "{} '{}' not found", kind, id), + Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), + Self::Io(err) => write!(f, "I/O error: {}", err), + Self::Serde(err) => write!(f, "Serialization error: {}", err), + } + } +} + +impl std::error::Error for CliBackendError {} + +impl From for CliBackendError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for CliBackendError { + fn from(value: serde_json::Error) -> Self { + Self::Serde(value) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeInfo { + pub id: String, + pub name: String, + pub status: String, + pub daemon: bool, + pub config_path: Option, + pub uptime: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfo { + pub name: String, + pub version: String, + pub description: String, + pub author: String, + pub repository: Option, + pub license: Option, + pub installed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInfo { + pub name: String, + pub description: String, + pub version: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub session_id: String, + pub agent_id: String, + pub created_at: String, + pub message_count: usize, + pub status: String, + pub messages: Vec, +} + +#[derive(Debug, Clone)] +pub struct CliBackend { + root: PathBuf, +} + +impl CliBackend { + pub fn discover() -> Result { + let root = if let Ok(dir) = std::env::var("MOFA_CLI_DATA_DIR") { + PathBuf::from(dir).join(BACKEND_DIR) + } else { + mofa_data_dir() + .map_err(|err| CliBackendError::CapabilityUnavailable { + capability: "data_dir", + reason: err.to_string(), + })? + .join(BACKEND_DIR) + }; + std::fs::create_dir_all(&root)?; + Ok(Self { root }) + } + + #[cfg(test)] + pub fn with_root(root: PathBuf) -> Self { + Self { root } + } + + pub fn list_agents( + &self, + running_only: bool, + ) -> Result, CliBackendError> { + let agents = + self.read_or_default(self.path(AGENTS_FILE), Vec::::new())?; + if running_only { + Ok(agents + .into_iter() + .filter(|agent| agent.status.eq_ignore_ascii_case("running")) + .collect()) + } else { + Ok(agents) + } + } + + pub fn get_agent(&self, agent_id: &str) -> Result { + self.list_agents(false)? + .into_iter() + .find(|agent| agent.id == agent_id) + .ok_or_else(|| CliBackendError::NotFound { + kind: "Agent", + id: agent_id.to_string(), + }) + } + + pub fn start_agent( + &self, + agent_id: &str, + config_path: Option<&Path>, + daemon: bool, + ) -> Result { + if let Some(path) = config_path + && !path.exists() + { + return Err(CliBackendError::InvalidInput(format!( + "Config path '{}' does not exist", + path.display() + ))); + } + + let mut agents = + self.read_or_default(self.path(AGENTS_FILE), Vec::::new())?; + let now = now_utc_string(); + let updated = if let Some(existing) = agents.iter_mut().find(|agent| agent.id == agent_id) { + existing.status = "running".to_string(); + existing.daemon = daemon; + existing.config_path = config_path.map(|p| p.display().to_string()); + existing.uptime = Some("just started".to_string()); + existing.updated_at = now; + existing.clone() + } else { + let info = AgentRuntimeInfo { + id: agent_id.to_string(), + name: agent_id.to_string(), + status: "running".to_string(), + daemon, + config_path: config_path.map(|p| p.display().to_string()), + uptime: Some("just started".to_string()), + updated_at: now, + }; + agents.push(info.clone()); + info + }; + self.write_json(self.path(AGENTS_FILE), &agents)?; + Ok(updated) + } + + pub fn stop_agent(&self, agent_id: &str) -> Result { + let mut agents = + self.read_or_default(self.path(AGENTS_FILE), Vec::::new())?; + let agent = agents + .iter_mut() + .find(|agent| agent.id == agent_id) + .ok_or_else(|| CliBackendError::NotFound { + kind: "Agent", + id: agent_id.to_string(), + })?; + agent.status = "stopped".to_string(); + agent.uptime = None; + agent.updated_at = now_utc_string(); + let snapshot = agent.clone(); + self.write_json(self.path(AGENTS_FILE), &agents)?; + Ok(snapshot) + } + + pub fn restart_agent( + &self, + agent_id: &str, + config_path: Option<&Path>, + ) -> Result { + let _ = self.stop_agent(agent_id)?; + self.start_agent(agent_id, config_path, false) + } + + pub fn list_plugins(&self, installed_only: bool) -> Result, CliBackendError> { + let plugins = self.read_or_default(self.path(PLUGINS_FILE), default_plugins())?; + if installed_only { + Ok(plugins.into_iter().filter(|p| p.installed).collect()) + } else { + Ok(plugins) + } + } + + pub fn get_plugin(&self, name: &str) -> Result { + self.list_plugins(false)? + .into_iter() + .find(|plugin| plugin.name == name) + .ok_or_else(|| CliBackendError::NotFound { + kind: "Plugin", + id: name.to_string(), + }) + } + + pub fn uninstall_plugin(&self, name: &str) -> Result { + let mut plugins = self.read_or_default(self.path(PLUGINS_FILE), default_plugins())?; + let plugin = plugins + .iter_mut() + .find(|plugin| plugin.name == name) + .ok_or_else(|| CliBackendError::NotFound { + kind: "Plugin", + id: name.to_string(), + })?; + plugin.installed = false; + let snapshot = plugin.clone(); + self.write_json(self.path(PLUGINS_FILE), &plugins)?; + Ok(snapshot) + } + + pub fn list_tools(&self, enabled_only: bool) -> Result, CliBackendError> { + let tools = self.read_or_default(self.path(TOOLS_FILE), default_tools())?; + if enabled_only { + Ok(tools.into_iter().filter(|tool| tool.enabled).collect()) + } else { + Ok(tools) + } + } + + pub fn get_tool(&self, name: &str) -> Result { + self.list_tools(false)? + .into_iter() + .find(|tool| tool.name == name) + .ok_or_else(|| CliBackendError::NotFound { + kind: "Tool", + id: name.to_string(), + }) + } + + pub fn list_sessions( + &self, + agent_id: Option<&str>, + limit: Option, + ) -> Result, CliBackendError> { + let sessions = self.read_or_default(self.path(SESSIONS_FILE), Vec::::new())?; + let mut filtered: Vec = if let Some(agent) = agent_id { + sessions + .into_iter() + .filter(|session| session.agent_id == agent) + .collect() + } else { + sessions + }; + if let Some(max) = limit { + filtered.truncate(max); + } + Ok(filtered) + } + + pub fn get_session(&self, session_id: &str) -> Result { + self.list_sessions(None, None)? + .into_iter() + .find(|session| session.session_id == session_id) + .ok_or_else(|| CliBackendError::NotFound { + kind: "Session", + id: session_id.to_string(), + }) + } + + pub fn delete_session(&self, session_id: &str) -> Result<(), CliBackendError> { + let mut sessions = + self.read_or_default(self.path(SESSIONS_FILE), Vec::::new())?; + let before = sessions.len(); + sessions.retain(|session| session.session_id != session_id); + if before == sessions.len() { + return Err(CliBackendError::NotFound { + kind: "Session", + id: session_id.to_string(), + }); + } + self.write_json(self.path(SESSIONS_FILE), &sessions)?; + Ok(()) + } + + fn path(&self, file: &str) -> PathBuf { + self.root.join(file) + } + + fn read_or_default(&self, path: PathBuf, default_value: T) -> Result + where + T: for<'de> Deserialize<'de> + Clone + Serialize, + { + if !path.exists() { + self.write_json(path.clone(), &default_value)?; + return Ok(default_value); + } + let raw = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&raw)?) + } + + fn write_json(&self, path: PathBuf, value: &T) -> Result<(), CliBackendError> + where + T: Serialize, + { + let data = serde_json::to_string_pretty(value)?; + std::fs::write(path, data)?; + Ok(()) + } +} + +fn default_plugins() -> Vec { + vec![ + PluginInfo { + name: "http-server".to_string(), + version: "0.1.0".to_string(), + description: "HTTP server plugin for exposing agents via REST API".to_string(), + author: "MoFA Team".to_string(), + repository: Some("https://github.com/mofa-org/mofa".to_string()), + license: Some("MIT".to_string()), + installed: true, + }, + PluginInfo { + name: "postgres-persistence".to_string(), + version: "0.1.0".to_string(), + description: "PostgreSQL persistence plugin for session storage".to_string(), + author: "MoFA Team".to_string(), + repository: Some("https://github.com/mofa-org/mofa".to_string()), + license: Some("MIT".to_string()), + installed: true, + }, + PluginInfo { + name: "web-scraper".to_string(), + version: "0.2.0".to_string(), + description: "Web scraping tool for content extraction".to_string(), + author: "Community".to_string(), + repository: None, + license: None, + installed: false, + }, + ] +} + +fn default_tools() -> Vec { + all_builtin_tools() + .into_iter() + .map(|tool| ToolInfo { + name: tool.name().to_string(), + description: tool.description().to_string(), + version: "builtin".to_string(), + enabled: true, + }) + .collect() +} + +fn now_utc_string() -> String { + chrono::Utc::now().to_rfc3339() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn backend() -> (TempDir, CliBackend) { + let temp = TempDir::new().unwrap(); + let backend = CliBackend::with_root(temp.path().join(BACKEND_DIR)); + std::fs::create_dir_all(&backend.root).unwrap(); + (temp, backend) + } + + #[test] + fn test_agent_start_stop_round_trip() { + let (_temp, backend) = backend(); + let started = backend.start_agent("agent-1", None, true).unwrap(); + assert_eq!(started.status, "running"); + assert!(started.daemon); + + let stopped = backend.stop_agent("agent-1").unwrap(); + assert_eq!(stopped.status, "stopped"); + } + + #[test] + fn test_plugin_uninstall_persists() { + let (_temp, backend) = backend(); + let plugin = backend.uninstall_plugin("http-server").unwrap(); + assert!(!plugin.installed); + let fetched = backend.get_plugin("http-server").unwrap(); + assert!(!fetched.installed); + } + + #[test] + fn test_builtin_tools_seeded() { + let (_temp, backend) = backend(); + let tools = backend.list_tools(false).unwrap(); + assert!(!tools.is_empty()); + assert!(tools.iter().any(|tool| tool.name == "calculator")); + } +} diff --git a/crates/mofa-cli/src/commands/mod.rs b/crates/mofa-cli/src/commands/mod.rs index 42c7dc55..c1e4b30a 100644 --- a/crates/mofa-cli/src/commands/mod.rs +++ b/crates/mofa-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ //! Command implementations pub mod agent; +pub mod backend; pub mod build; pub mod config_cmd; pub mod db; diff --git a/crates/mofa-cli/src/commands/plugin/info.rs b/crates/mofa-cli/src/commands/plugin/info.rs index a08335b1..7c8f8047 100644 --- a/crates/mofa-cli/src/commands/plugin/info.rs +++ b/crates/mofa-cli/src/commands/plugin/info.rs @@ -1,5 +1,6 @@ //! `mofa plugin info` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa plugin info` command @@ -7,19 +8,27 @@ pub fn run(name: &str) -> anyhow::Result<()> { println!("{} Plugin information: {}", "→".green(), name.cyan()); println!(); - // TODO: Implement actual plugin info lookup - // For now, show example output + let backend = CliBackend::discover()?; + let plugin = backend.get_plugin(name)?; - println!(" Name: {}", name.cyan()); - println!(" Version: {}", "0.1.0".white()); - println!(" Description: {}", "A helpful plugin".white()); - println!(" Author: {}", "MoFA Team".white()); + println!(" Name: {}", plugin.name.cyan()); + println!(" Version: {}", plugin.version.white()); + println!(" Description: {}", plugin.description.white()); + println!(" Author: {}", plugin.author.white()); + if let Some(repo) = plugin.repository { + println!(" Repository: {}", repo.blue()); + } + if let Some(license) = plugin.license { + println!(" License: {}", license.white()); + } println!( - " Repository: {}", - "https://github.com/mofa-org/...".blue() + " Installed: {}", + if plugin.installed { + "Yes".green() + } else { + "No".yellow() + } ); - println!(" License: {}", "MIT".white()); - println!(" Installed: {}", "Yes".green()); println!(); Ok(()) diff --git a/crates/mofa-cli/src/commands/plugin/list.rs b/crates/mofa-cli/src/commands/plugin/list.rs index a4606a37..0285435c 100644 --- a/crates/mofa-cli/src/commands/plugin/list.rs +++ b/crates/mofa-cli/src/commands/plugin/list.rs @@ -1,8 +1,8 @@ //! `mofa plugin list` command implementation +use crate::commands::backend::CliBackend; use crate::output::Table; use colored::Colorize; -use serde::Serialize; /// Execute the `mofa plugin list` command pub fn run(installed_only: bool, available: bool) -> anyhow::Result<()> { @@ -16,39 +16,11 @@ pub fn run(installed_only: bool, available: bool) -> anyhow::Result<()> { println!(); - // TODO: Implement actual plugin discovery from plugin registry - - let plugins = vec![ - PluginInfo { - name: "http-server".to_string(), - version: "0.1.0".to_string(), - description: "HTTP server plugin for exposing agents via REST API".to_string(), - installed: true, - }, - PluginInfo { - name: "postgres-persistence".to_string(), - version: "0.1.0".to_string(), - description: "PostgreSQL persistence plugin for session storage".to_string(), - installed: true, - }, - PluginInfo { - name: "web-scraper".to_string(), - version: "0.2.0".to_string(), - description: "Web scraping tool for content extraction".to_string(), - installed: false, - }, - PluginInfo { - name: "code-interpreter".to_string(), - version: "0.1.0".to_string(), - description: "Sandboxed code execution environment".to_string(), - installed: false, - }, - ]; - - let filtered: Vec<_> = if installed_only { - plugins.iter().filter(|p| p.installed).cloned().collect() + let backend = CliBackend::discover()?; + let filtered: Vec<_> = if available { + backend.list_plugins(false)? } else { - plugins + backend.list_plugins(installed_only)? }; if filtered.is_empty() { @@ -64,11 +36,3 @@ pub fn run(installed_only: bool, available: bool) -> anyhow::Result<()> { Ok(()) } - -#[derive(Debug, Clone, Serialize)] -struct PluginInfo { - name: String, - version: String, - description: String, - installed: bool, -} diff --git a/crates/mofa-cli/src/commands/plugin/uninstall.rs b/crates/mofa-cli/src/commands/plugin/uninstall.rs index f5eb7f98..dae4318c 100644 --- a/crates/mofa-cli/src/commands/plugin/uninstall.rs +++ b/crates/mofa-cli/src/commands/plugin/uninstall.rs @@ -1,19 +1,14 @@ //! `mofa plugin uninstall` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa plugin uninstall` command pub fn run(name: &str, _force: bool) -> anyhow::Result<()> { println!("{} Uninstalling plugin: {}", "→".green(), name.cyan()); - - // TODO: Implement actual plugin uninstallation - // This would involve: - // 1. Checking if plugin is installed - // 2. Confirming uninstallation (unless --force) - // 3. Removing plugin files - // 4. Updating plugin registry - - println!("{} Plugin '{}' uninstalled", "✓".green(), name); + let backend = CliBackend::discover()?; + let plugin = backend.uninstall_plugin(name)?; + println!("{} Plugin '{}' uninstalled", "✓".green(), plugin.name); Ok(()) } diff --git a/crates/mofa-cli/src/commands/session/delete.rs b/crates/mofa-cli/src/commands/session/delete.rs index b1b1dc0a..84d379ce 100644 --- a/crates/mofa-cli/src/commands/session/delete.rs +++ b/crates/mofa-cli/src/commands/session/delete.rs @@ -1,5 +1,6 @@ //! `mofa session delete` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa session delete` command @@ -14,8 +15,8 @@ pub fn run(session_id: &str, force: bool) -> anyhow::Result<()> { } println!("{} Deleting session: {}", "→".green(), session_id.cyan()); - - // TODO: Implement actual session deletion from persistence layer + let backend = CliBackend::discover()?; + backend.delete_session(session_id)?; println!("{} Session '{}' deleted", "✓".green(), session_id); diff --git a/crates/mofa-cli/src/commands/session/export.rs b/crates/mofa-cli/src/commands/session/export.rs index 4fa8bc97..ffc7aa86 100644 --- a/crates/mofa-cli/src/commands/session/export.rs +++ b/crates/mofa-cli/src/commands/session/export.rs @@ -1,5 +1,6 @@ //! `mofa session export` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; use std::path::PathBuf; @@ -10,28 +11,12 @@ pub fn run(session_id: &str, output: PathBuf, format: &str) -> anyhow::Result<() println!(" Output: {}", output.display().to_string().cyan()); println!(); - // TODO: Implement actual session export from persistence layer + let backend = CliBackend::discover()?; + let session = backend.get_session(session_id)?; let output_str = match format { - "json" => { - let content = serde_json::json!({ - "session_id": session_id, - "agent_id": "agent-001", - "created_at": "2024-01-15T10:30:00Z", - "messages": [ - {"role": "user", "content": "Hello!"}, - {"role": "assistant", "content": "Hi there! How can I help you?"} - ], - "status": "active" - }); - serde_json::to_string_pretty(&content)? - } - "yaml" => { - format!( - "session_id: {}\nagent_id: agent-001\ncreated_at: 2024-01-15T10:30:00Z\nmessages:\n - role: user\n content: Hello!\n - role: assistant\n content: Hi there! How can I help you?\nstatus: active\n", - session_id - ) - } + "json" => serde_json::to_string_pretty(&session)?, + "yaml" => serde_yaml::to_string(&session)?, _ => anyhow::bail!("Unsupported export format: {}", format), }; diff --git a/crates/mofa-cli/src/commands/session/list.rs b/crates/mofa-cli/src/commands/session/list.rs index 4b30083a..25577a5c 100644 --- a/crates/mofa-cli/src/commands/session/list.rs +++ b/crates/mofa-cli/src/commands/session/list.rs @@ -1,8 +1,8 @@ //! `mofa session list` command implementation +use crate::commands::backend::CliBackend; use crate::output::Table; use colored::Colorize; -use serde::Serialize; /// Execute the `mofa session list` command pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { @@ -18,40 +18,8 @@ pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { println!(); - // TODO: Implement actual session listing from persistence layer - - let sessions = vec![ - SessionInfo { - session_id: "sess-001".to_string(), - agent_id: "agent-001".to_string(), - created_at: "2024-01-15 10:30:00".to_string(), - message_count: 12, - status: "active".to_string(), - }, - SessionInfo { - session_id: "sess-002".to_string(), - agent_id: "agent-001".to_string(), - created_at: "2024-01-15 09:15:00".to_string(), - message_count: 8, - status: "active".to_string(), - }, - ]; - - let filtered: Vec<_> = if let Some(agent) = agent_id { - sessions - .iter() - .filter(|s| s.agent_id == agent) - .cloned() - .collect() - } else { - sessions - }; - - let limited: Vec<_> = if let Some(n) = limit { - filtered.into_iter().take(n).collect() - } else { - filtered - }; + let backend = CliBackend::discover()?; + let limited = backend.list_sessions(agent_id, limit)?; if limited.is_empty() { println!(" No sessions found."); @@ -66,12 +34,3 @@ pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { Ok(()) } - -#[derive(Debug, Clone, Serialize)] -struct SessionInfo { - session_id: String, - agent_id: String, - created_at: String, - message_count: usize, - status: String, -} diff --git a/crates/mofa-cli/src/commands/session/show.rs b/crates/mofa-cli/src/commands/session/show.rs index ef94f290..1cf3a11f 100644 --- a/crates/mofa-cli/src/commands/session/show.rs +++ b/crates/mofa-cli/src/commands/session/show.rs @@ -1,5 +1,6 @@ //! `mofa session show` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa session show` command @@ -7,44 +8,34 @@ pub fn run(session_id: &str, format: Option<&str>) -> anyhow::Result<()> { println!("{} Session details: {}", "→".green(), session_id.cyan()); println!(); - // TODO: Implement actual session retrieval from persistence layer - let output_format = format.unwrap_or("text"); + let backend = CliBackend::discover()?; + let session = backend.get_session(session_id)?; match output_format { "json" => { let json = serde_json::json!({ - "session_id": session_id, - "agent_id": "agent-001", - "created_at": "2024-01-15T10:30:00Z", - "messages": [ - {"role": "user", "content": "Hello!"}, - {"role": "assistant", "content": "Hi there! How can I help you?"} - ], - "status": "active" + "session_id": session.session_id, + "agent_id": session.agent_id, + "created_at": session.created_at, + "messages": session.messages, + "status": session.status }); println!("{}", serde_json::to_string_pretty(&json)?); } "yaml" => { - println!("session_id: {}", session_id); - println!("agent_id: agent-001"); - println!("created_at: 2024-01-15T10:30:00Z"); - println!("messages:"); - println!(" - role: user"); - println!(" content: Hello!"); - println!(" - role: assistant"); - println!(" content: Hi there! How can I help you?"); - println!("status: active"); + println!("{}", serde_yaml::to_string(&session)?); } _ => { - println!(" Session ID: {}", session_id.cyan()); - println!(" Agent ID: {}", "agent-001".white()); - println!(" Created: {}", "2024-01-15 10:30:00".white()); - println!(" Status: {}", "active".green()); + println!(" Session ID: {}", session.session_id.cyan()); + println!(" Agent ID: {}", session.agent_id.white()); + println!(" Created: {}", session.created_at.white()); + println!(" Status: {}", session.status.green()); println!(); println!(" Messages:"); - println!(" User: Hello!"); - println!(" Assistant: Hi there! How can I help you?"); + for msg in session.messages { + println!(" {}: {}", msg.role, msg.content); + } } } diff --git a/crates/mofa-cli/src/commands/tool/info.rs b/crates/mofa-cli/src/commands/tool/info.rs index 39607748..d45ada71 100644 --- a/crates/mofa-cli/src/commands/tool/info.rs +++ b/crates/mofa-cli/src/commands/tool/info.rs @@ -1,5 +1,6 @@ //! `mofa tool info` command implementation +use crate::commands::backend::CliBackend; use colored::Colorize; /// Execute the `mofa tool info` command @@ -7,15 +8,19 @@ pub fn run(name: &str) -> anyhow::Result<()> { println!("{} Tool information: {}", "→".green(), name.cyan()); println!(); - // TODO: Implement actual tool info lookup + let backend = CliBackend::discover()?; + let tool = backend.get_tool(name)?; - println!(" Name: {}", name.cyan()); - println!(" Description: {}", "A helpful tool".white()); - println!(" Version: {}", "1.0.0".white()); - println!(" Enabled: {}", "Yes".green()); + println!(" Name: {}", tool.name.cyan()); + println!(" Description: {}", tool.description.white()); + println!(" Version: {}", tool.version.white()); println!( - " Parameters: {}", - "query (required), limit (optional)".white() + " Enabled: {}", + if tool.enabled { + "Yes".green() + } else { + "No".yellow() + } ); println!(); diff --git a/crates/mofa-cli/src/commands/tool/list.rs b/crates/mofa-cli/src/commands/tool/list.rs index 669ddb10..9a6432f4 100644 --- a/crates/mofa-cli/src/commands/tool/list.rs +++ b/crates/mofa-cli/src/commands/tool/list.rs @@ -1,8 +1,8 @@ //! `mofa tool list` command implementation +use crate::commands::backend::CliBackend; use crate::output::Table; use colored::Colorize; -use serde::Serialize; /// Execute the `mofa tool list` command pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { @@ -16,35 +16,11 @@ pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { println!(); - // TODO: Implement actual tool discovery from tool registry - - let tools = vec![ - ToolInfo { - name: "web-search".to_string(), - description: "Search the web for information".to_string(), - enabled: true, - }, - ToolInfo { - name: "calculator".to_string(), - description: "Perform mathematical calculations".to_string(), - enabled: true, - }, - ToolInfo { - name: "code-executor".to_string(), - description: "Execute code in a sandboxed environment".to_string(), - enabled: false, - }, - ToolInfo { - name: "file-operations".to_string(), - description: "Read, write, and manipulate files".to_string(), - enabled: false, - }, - ]; - - let filtered: Vec<_> = if enabled { - tools.iter().filter(|t| t.enabled).cloned().collect() + let backend = CliBackend::discover()?; + let filtered = if available { + backend.list_tools(false)? } else { - tools + backend.list_tools(enabled)? }; if filtered.is_empty() { @@ -60,10 +36,3 @@ pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { Ok(()) } - -#[derive(Debug, Clone, Serialize)] -struct ToolInfo { - name: String, - description: String, - enabled: bool, -} From e2d5a0a55e82c8d74756b9f23c1d888aa461dd62 Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sat, 21 Feb 2026 16:04:54 +0530 Subject: [PATCH 2/9] refactor(cli): wire commands to kernel/foundation services --- Cargo.lock | 3 + crates/mofa-cli/Cargo.toml | 8 +- crates/mofa-cli/src/commands/agent/list.rs | 55 ++- crates/mofa-cli/src/commands/agent/restart.rs | 36 +- crates/mofa-cli/src/commands/agent/start.rs | 86 +++- crates/mofa-cli/src/commands/agent/status.rs | 75 ++- crates/mofa-cli/src/commands/agent/stop.rs | 41 +- crates/mofa-cli/src/commands/backend.rs | 440 ------------------ crates/mofa-cli/src/commands/mod.rs | 1 - crates/mofa-cli/src/commands/plugin/info.rs | 58 ++- crates/mofa-cli/src/commands/plugin/list.rs | 54 ++- .../mofa-cli/src/commands/plugin/uninstall.rs | 37 +- .../mofa-cli/src/commands/session/delete.rs | 37 +- .../mofa-cli/src/commands/session/export.rs | 39 +- crates/mofa-cli/src/commands/session/list.rs | 65 ++- crates/mofa-cli/src/commands/session/show.rs | 82 +++- crates/mofa-cli/src/commands/tool/info.rs | 88 +++- crates/mofa-cli/src/commands/tool/list.rs | 53 ++- crates/mofa-cli/src/context.rs | 47 ++ crates/mofa-cli/src/main.rs | 176 ++++--- 20 files changed, 796 insertions(+), 685 deletions(-) delete mode 100644 crates/mofa-cli/src/commands/backend.rs create mode 100644 crates/mofa-cli/src/context.rs diff --git a/Cargo.lock b/Cargo.lock index e71a5c97..5a77c812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4248,6 +4248,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_cmd", + "async-trait", "chrono", "clap", "colored 2.2.0", @@ -4257,7 +4258,9 @@ dependencies = [ "dialoguer", "dirs-next", "indicatif", + "mofa-foundation", "mofa-kernel", + "mofa-runtime", "mofa-sdk", "predicates", "ratatui", diff --git a/crates/mofa-cli/Cargo.toml b/crates/mofa-cli/Cargo.toml index 3eaab3c7..1a735382 100644 --- a/crates/mofa-cli/Cargo.toml +++ b/crates/mofa-cli/Cargo.toml @@ -21,9 +21,13 @@ path = "src/main.rs" # Core mofa-sdk = { path = "../mofa-sdk", version = "0.1" } mofa-kernel = { path = "../mofa-kernel", version = "0.1", features = ["config"] } +mofa-runtime = { path = "../mofa-runtime", version = "0.1" } +mofa-foundation = { path = "../mofa-foundation", version = "0.1" } config.workspace = true tokio = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } # CLI clap = { version = "4", features = ["derive", "env"] } @@ -34,7 +38,6 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" toml = "0.8" -chrono = { workspace = true } # Utilities @@ -69,6 +72,3 @@ tempfile = "3" default = [] dora = ["mofa-sdk/dora"] db = ["dep:sqlx"] - -[lints] -workspace = true diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index eb1d4e98..4d536480 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -1,26 +1,52 @@ //! `mofa agent list` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; +use serde::Serialize; /// Execute the `mofa agent list` command -pub fn run(running_only: bool, show_all: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyhow::Result<()> { println!("{} Listing agents", "→".green()); + println!(); + + let agents_metadata = ctx.agent_registry.list().await; - if running_only { - println!(" Showing running agents only"); - } else if show_all { - println!(" Showing all agents"); + if agents_metadata.is_empty() { + println!(" No agents registered."); + println!(); + println!( + " Use {} to start an agent.", + "mofa agent start ".cyan() + ); + return Ok(()); } - println!(); + let agents: Vec = agents_metadata + .iter() + .map(|m| { + let status = format!("{:?}", m.state); + AgentInfo { + id: m.id.clone(), + name: m.name.clone(), + status, + description: m.description.clone(), + } + }) + .collect(); - let backend = CliBackend::discover()?; - let filtered = backend.list_agents(running_only)?; + // Filter based on flags + let filtered: Vec<_> = if running_only { + agents + .into_iter() + .filter(|a| a.status == "Running" || a.status == "Ready") + .collect() + } else { + agents + }; if filtered.is_empty() { - println!(" No agents found."); + println!(" No agents found matching criteria."); return Ok(()); } @@ -33,3 +59,12 @@ pub fn run(running_only: bool, show_all: bool) -> anyhow::Result<()> { Ok(()) } + +#[derive(Debug, Clone, Serialize)] +struct AgentInfo { + id: String, + name: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, +} diff --git a/crates/mofa-cli/src/commands/agent/restart.rs b/crates/mofa-cli/src/commands/agent/restart.rs index 3628e22d..decb98ff 100644 --- a/crates/mofa-cli/src/commands/agent/restart.rs +++ b/crates/mofa-cli/src/commands/agent/restart.rs @@ -1,14 +1,40 @@ //! `mofa agent restart` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent restart` command -pub fn run(agent_id: &str, config: Option<&std::path::Path>) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + agent_id: &str, + config: Option<&std::path::Path>, +) -> anyhow::Result<()> { println!("{} Restarting agent: {}", "→".green(), agent_id.cyan()); - let backend = CliBackend::discover()?; - let restarted = backend.restart_agent(agent_id, config)?; - println!("{} Agent '{}' restarted", "✓".green(), restarted.id); + + // Stop the agent if it's running + if ctx.agent_registry.contains(agent_id).await { + // Attempt graceful shutdown + if let Some(agent) = ctx.agent_registry.get(agent_id).await { + let mut agent_guard = agent.write().await; + if let Err(e) = agent_guard.shutdown().await { + println!(" {} Graceful shutdown failed: {}", "!".yellow(), e); + } + } + + ctx.agent_registry + .unregister(agent_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; + + println!(" Agent stopped"); + } else { + println!(" Agent was not running"); + } + + // Start it again + super::start::run(ctx, agent_id, config, false).await?; + + println!("{} Agent '{}' restarted", "✓".green(), agent_id); Ok(()) } diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index b2f9e665..87c4e7ac 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -1,22 +1,90 @@ //! `mofa agent start` command implementation -use crate::commands::backend::CliBackend; +use crate::config::loader::ConfigLoader; +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent start` command -pub fn run(agent_id: &str, config: Option<&std::path::Path>, daemon: bool) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + agent_id: &str, + config_path: Option<&std::path::Path>, + daemon: bool, +) -> anyhow::Result<()> { println!("{} Starting agent: {}", "→".green(), agent_id.cyan()); - let backend = CliBackend::discover()?; - let started = backend.start_agent(agent_id, config, daemon)?; - - if started.daemon { + if daemon { println!(" Mode: {}", "daemon".yellow()); } - if let Some(path) = started.config_path { - println!(" Config: {}", path.cyan()); + + // Check if agent is already registered + if ctx.agent_registry.contains(agent_id).await { + anyhow::bail!("Agent '{}' is already registered", agent_id); } - println!("{} Agent '{}' started", "✓".green(), started.id); + + // Load agent configuration + let agent_config = if let Some(path) = config_path { + println!(" Config: {}", path.display().to_string().cyan()); + let loader = ConfigLoader::new(); + let cli_config = loader.load(path)?; + println!(" Agent: {}", cli_config.agent.name.white()); + + // Convert CLI AgentConfig to kernel AgentConfig + mofa_kernel::agent::config::AgentConfig::new(agent_id, &cli_config.agent.name) + } else { + // Try to auto-discover configuration + let loader = ConfigLoader::new(); + match loader.find_config() { + Some(found_path) => { + println!( + " Config: {} (auto-discovered)", + found_path.display().to_string().cyan() + ); + let cli_config = loader.load(&found_path)?; + println!(" Agent: {}", cli_config.agent.name.white()); + mofa_kernel::agent::config::AgentConfig::new(agent_id, &cli_config.agent.name) + } + None => { + println!(" {} No config file found, using defaults", "!".yellow()); + mofa_kernel::agent::config::AgentConfig::new(agent_id, agent_id) + } + } + }; + + // Check if a matching factory type is available + let factory_types = ctx.agent_registry.list_factory_types().await; + if factory_types.is_empty() { + println!( + " {} No agent factories registered. Agent registered with config only.", + "!".yellow() + ); + println!(" Agent config stored for: {}", agent_config.name.cyan()); + } else { + // Try to create via factory + let type_id = factory_types.first().unwrap(); + match ctx + .agent_registry + .create_and_register(type_id, agent_config.clone()) + .await + { + Ok(_) => { + println!( + "{} Agent '{}' created and registered", + "✓".green(), + agent_id + ); + } + Err(e) => { + println!( + " {} Failed to create agent via factory: {}", + "!".yellow(), + e + ); + } + } + } + + println!("{} Agent '{}' started", "✓".green(), agent_id); Ok(()) } diff --git a/crates/mofa-cli/src/commands/agent/status.rs b/crates/mofa-cli/src/commands/agent/status.rs index 6c6e59e8..9291f1a5 100644 --- a/crates/mofa-cli/src/commands/agent/status.rs +++ b/crates/mofa-cli/src/commands/agent/status.rs @@ -1,38 +1,65 @@ //! `mofa agent status` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent status` command -pub fn run(agent_id: Option<&str>) -> anyhow::Result<()> { - let backend = CliBackend::discover()?; - +pub async fn run(ctx: &CliContext, agent_id: Option<&str>) -> anyhow::Result<()> { if let Some(id) = agent_id { - let agent = backend.get_agent(id)?; + // Show status for a specific agent println!("{} Agent status: {}", "→".green(), id.cyan()); println!(); - println!(" ID: {}", agent.id); - println!(" Status: {}", agent.status.green()); - if let Some(uptime) = agent.uptime { - println!(" Uptime: {}", uptime.white()); + + match ctx.agent_registry.get_metadata(id).await { + Some(metadata) => { + println!(" ID: {}", metadata.id.cyan()); + println!(" Name: {}", metadata.name.white()); + println!( + " State: {}", + format!("{:?}", metadata.state).green() + ); + if let Some(desc) = &metadata.description { + println!(" Description: {}", desc.white()); + } + if let Some(ver) = &metadata.version { + println!(" Version: {}", ver.white()); + } + let caps = &metadata.capabilities; + if !caps.tags.is_empty() { + let tags: Vec<_> = caps.tags.iter().cloned().collect(); + println!(" Tags: {}", tags.join(", ").white()); + } + } + None => { + println!(" Agent '{}' not found in registry", id); + println!(); + println!( + " Use {} to see available agents.", + "mofa agent list".cyan() + ); + } } } else { - let agents = backend.list_agents(false)?; - println!("{} Agent Status", "→".green()); + // Show summary of all agents + println!("{} Agent Status Summary", "→".green()); println!(); - if agents.is_empty() { - println!(" No agents currently tracked."); - } else { - println!(" Total tracked agents: {}", agents.len()); - let running = agents - .iter() - .filter(|agent| agent.status.eq_ignore_ascii_case("running")) - .count(); - println!(" Running: {}", running.to_string().green()); - println!( - " Stopped: {}", - (agents.len() - running).to_string().yellow() - ); + + let stats = ctx.agent_registry.stats().await; + + if stats.total_agents == 0 { + println!(" No agents currently registered."); + return Ok(()); + } + + println!(" Total agents: {}", stats.total_agents); + if !stats.by_state.is_empty() { + println!(" By state:"); + for (state, count) in &stats.by_state { + println!(" {}: {}", state, count); + } + } + if stats.factory_count > 0 { + println!(" Factories: {}", stats.factory_count); } } diff --git a/crates/mofa-cli/src/commands/agent/stop.rs b/crates/mofa-cli/src/commands/agent/stop.rs index 268b8bbc..8036a5bd 100644 --- a/crates/mofa-cli/src/commands/agent/stop.rs +++ b/crates/mofa-cli/src/commands/agent/stop.rs @@ -1,14 +1,45 @@ //! `mofa agent stop` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent stop` command -pub fn run(agent_id: &str) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, agent_id: &str) -> anyhow::Result<()> { println!("{} Stopping agent: {}", "→".green(), agent_id.cyan()); - let backend = CliBackend::discover()?; - let stopped = backend.stop_agent(agent_id)?; - println!("{} Agent '{}' stopped", "✓".green(), stopped.id); + + // Check if agent exists + if !ctx.agent_registry.contains(agent_id).await { + anyhow::bail!("Agent '{}' not found in registry", agent_id); + } + + // Attempt graceful shutdown via the agent instance + if let Some(agent) = ctx.agent_registry.get(agent_id).await { + let mut agent_guard = agent.write().await; + if let Err(e) = agent_guard.shutdown().await { + println!(" {} Graceful shutdown failed: {}", "!".yellow(), e); + } + } + + // Unregister from the registry + let removed = ctx + .agent_registry + .unregister(agent_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; + + if removed { + println!( + "{} Agent '{}' stopped and unregistered", + "✓".green(), + agent_id + ); + } else { + println!( + "{} Agent '{}' was not in the registry", + "!".yellow(), + agent_id + ); + } Ok(()) } diff --git a/crates/mofa-cli/src/commands/backend.rs b/crates/mofa-cli/src/commands/backend.rs deleted file mode 100644 index e8b3d7b9..00000000 --- a/crates/mofa-cli/src/commands/backend.rs +++ /dev/null @@ -1,440 +0,0 @@ -//! Shared backend repository for CLI command handlers. - -use crate::utils::paths::mofa_data_dir; -use mofa_sdk::react::tools::prelude::all_builtin_tools; -use serde::{Deserialize, Serialize}; -use std::fmt::{Display, Formatter}; -use std::path::{Path, PathBuf}; - -const BACKEND_DIR: &str = "cli-backend"; -const AGENTS_FILE: &str = "agents_state.json"; -const PLUGINS_FILE: &str = "plugins_registry.json"; -const TOOLS_FILE: &str = "tools_registry.json"; -const SESSIONS_FILE: &str = "sessions_store.json"; - -#[derive(Debug)] -pub enum CliBackendError { - CapabilityUnavailable { - capability: &'static str, - reason: String, - }, - NotFound { - kind: &'static str, - id: String, - }, - InvalidInput(String), - Io(std::io::Error), - Serde(serde_json::Error), -} - -impl Display for CliBackendError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::CapabilityUnavailable { capability, reason } => { - write!( - f, - "Backend capability '{}' unavailable: {}", - capability, reason - ) - } - Self::NotFound { kind, id } => write!(f, "{} '{}' not found", kind, id), - Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), - Self::Io(err) => write!(f, "I/O error: {}", err), - Self::Serde(err) => write!(f, "Serialization error: {}", err), - } - } -} - -impl std::error::Error for CliBackendError {} - -impl From for CliBackendError { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -impl From for CliBackendError { - fn from(value: serde_json::Error) -> Self { - Self::Serde(value) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentRuntimeInfo { - pub id: String, - pub name: String, - pub status: String, - pub daemon: bool, - pub config_path: Option, - pub uptime: Option, - pub updated_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginInfo { - pub name: String, - pub version: String, - pub description: String, - pub author: String, - pub repository: Option, - pub license: Option, - pub installed: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolInfo { - pub name: String, - pub description: String, - pub version: String, - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionMessage { - pub role: String, - pub content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionInfo { - pub session_id: String, - pub agent_id: String, - pub created_at: String, - pub message_count: usize, - pub status: String, - pub messages: Vec, -} - -#[derive(Debug, Clone)] -pub struct CliBackend { - root: PathBuf, -} - -impl CliBackend { - pub fn discover() -> Result { - let root = if let Ok(dir) = std::env::var("MOFA_CLI_DATA_DIR") { - PathBuf::from(dir).join(BACKEND_DIR) - } else { - mofa_data_dir() - .map_err(|err| CliBackendError::CapabilityUnavailable { - capability: "data_dir", - reason: err.to_string(), - })? - .join(BACKEND_DIR) - }; - std::fs::create_dir_all(&root)?; - Ok(Self { root }) - } - - #[cfg(test)] - pub fn with_root(root: PathBuf) -> Self { - Self { root } - } - - pub fn list_agents( - &self, - running_only: bool, - ) -> Result, CliBackendError> { - let agents = - self.read_or_default(self.path(AGENTS_FILE), Vec::::new())?; - if running_only { - Ok(agents - .into_iter() - .filter(|agent| agent.status.eq_ignore_ascii_case("running")) - .collect()) - } else { - Ok(agents) - } - } - - pub fn get_agent(&self, agent_id: &str) -> Result { - self.list_agents(false)? - .into_iter() - .find(|agent| agent.id == agent_id) - .ok_or_else(|| CliBackendError::NotFound { - kind: "Agent", - id: agent_id.to_string(), - }) - } - - pub fn start_agent( - &self, - agent_id: &str, - config_path: Option<&Path>, - daemon: bool, - ) -> Result { - if let Some(path) = config_path - && !path.exists() - { - return Err(CliBackendError::InvalidInput(format!( - "Config path '{}' does not exist", - path.display() - ))); - } - - let mut agents = - self.read_or_default(self.path(AGENTS_FILE), Vec::::new())?; - let now = now_utc_string(); - let updated = if let Some(existing) = agents.iter_mut().find(|agent| agent.id == agent_id) { - existing.status = "running".to_string(); - existing.daemon = daemon; - existing.config_path = config_path.map(|p| p.display().to_string()); - existing.uptime = Some("just started".to_string()); - existing.updated_at = now; - existing.clone() - } else { - let info = AgentRuntimeInfo { - id: agent_id.to_string(), - name: agent_id.to_string(), - status: "running".to_string(), - daemon, - config_path: config_path.map(|p| p.display().to_string()), - uptime: Some("just started".to_string()), - updated_at: now, - }; - agents.push(info.clone()); - info - }; - self.write_json(self.path(AGENTS_FILE), &agents)?; - Ok(updated) - } - - pub fn stop_agent(&self, agent_id: &str) -> Result { - let mut agents = - self.read_or_default(self.path(AGENTS_FILE), Vec::::new())?; - let agent = agents - .iter_mut() - .find(|agent| agent.id == agent_id) - .ok_or_else(|| CliBackendError::NotFound { - kind: "Agent", - id: agent_id.to_string(), - })?; - agent.status = "stopped".to_string(); - agent.uptime = None; - agent.updated_at = now_utc_string(); - let snapshot = agent.clone(); - self.write_json(self.path(AGENTS_FILE), &agents)?; - Ok(snapshot) - } - - pub fn restart_agent( - &self, - agent_id: &str, - config_path: Option<&Path>, - ) -> Result { - let _ = self.stop_agent(agent_id)?; - self.start_agent(agent_id, config_path, false) - } - - pub fn list_plugins(&self, installed_only: bool) -> Result, CliBackendError> { - let plugins = self.read_or_default(self.path(PLUGINS_FILE), default_plugins())?; - if installed_only { - Ok(plugins.into_iter().filter(|p| p.installed).collect()) - } else { - Ok(plugins) - } - } - - pub fn get_plugin(&self, name: &str) -> Result { - self.list_plugins(false)? - .into_iter() - .find(|plugin| plugin.name == name) - .ok_or_else(|| CliBackendError::NotFound { - kind: "Plugin", - id: name.to_string(), - }) - } - - pub fn uninstall_plugin(&self, name: &str) -> Result { - let mut plugins = self.read_or_default(self.path(PLUGINS_FILE), default_plugins())?; - let plugin = plugins - .iter_mut() - .find(|plugin| plugin.name == name) - .ok_or_else(|| CliBackendError::NotFound { - kind: "Plugin", - id: name.to_string(), - })?; - plugin.installed = false; - let snapshot = plugin.clone(); - self.write_json(self.path(PLUGINS_FILE), &plugins)?; - Ok(snapshot) - } - - pub fn list_tools(&self, enabled_only: bool) -> Result, CliBackendError> { - let tools = self.read_or_default(self.path(TOOLS_FILE), default_tools())?; - if enabled_only { - Ok(tools.into_iter().filter(|tool| tool.enabled).collect()) - } else { - Ok(tools) - } - } - - pub fn get_tool(&self, name: &str) -> Result { - self.list_tools(false)? - .into_iter() - .find(|tool| tool.name == name) - .ok_or_else(|| CliBackendError::NotFound { - kind: "Tool", - id: name.to_string(), - }) - } - - pub fn list_sessions( - &self, - agent_id: Option<&str>, - limit: Option, - ) -> Result, CliBackendError> { - let sessions = self.read_or_default(self.path(SESSIONS_FILE), Vec::::new())?; - let mut filtered: Vec = if let Some(agent) = agent_id { - sessions - .into_iter() - .filter(|session| session.agent_id == agent) - .collect() - } else { - sessions - }; - if let Some(max) = limit { - filtered.truncate(max); - } - Ok(filtered) - } - - pub fn get_session(&self, session_id: &str) -> Result { - self.list_sessions(None, None)? - .into_iter() - .find(|session| session.session_id == session_id) - .ok_or_else(|| CliBackendError::NotFound { - kind: "Session", - id: session_id.to_string(), - }) - } - - pub fn delete_session(&self, session_id: &str) -> Result<(), CliBackendError> { - let mut sessions = - self.read_or_default(self.path(SESSIONS_FILE), Vec::::new())?; - let before = sessions.len(); - sessions.retain(|session| session.session_id != session_id); - if before == sessions.len() { - return Err(CliBackendError::NotFound { - kind: "Session", - id: session_id.to_string(), - }); - } - self.write_json(self.path(SESSIONS_FILE), &sessions)?; - Ok(()) - } - - fn path(&self, file: &str) -> PathBuf { - self.root.join(file) - } - - fn read_or_default(&self, path: PathBuf, default_value: T) -> Result - where - T: for<'de> Deserialize<'de> + Clone + Serialize, - { - if !path.exists() { - self.write_json(path.clone(), &default_value)?; - return Ok(default_value); - } - let raw = std::fs::read_to_string(path)?; - Ok(serde_json::from_str(&raw)?) - } - - fn write_json(&self, path: PathBuf, value: &T) -> Result<(), CliBackendError> - where - T: Serialize, - { - let data = serde_json::to_string_pretty(value)?; - std::fs::write(path, data)?; - Ok(()) - } -} - -fn default_plugins() -> Vec { - vec![ - PluginInfo { - name: "http-server".to_string(), - version: "0.1.0".to_string(), - description: "HTTP server plugin for exposing agents via REST API".to_string(), - author: "MoFA Team".to_string(), - repository: Some("https://github.com/mofa-org/mofa".to_string()), - license: Some("MIT".to_string()), - installed: true, - }, - PluginInfo { - name: "postgres-persistence".to_string(), - version: "0.1.0".to_string(), - description: "PostgreSQL persistence plugin for session storage".to_string(), - author: "MoFA Team".to_string(), - repository: Some("https://github.com/mofa-org/mofa".to_string()), - license: Some("MIT".to_string()), - installed: true, - }, - PluginInfo { - name: "web-scraper".to_string(), - version: "0.2.0".to_string(), - description: "Web scraping tool for content extraction".to_string(), - author: "Community".to_string(), - repository: None, - license: None, - installed: false, - }, - ] -} - -fn default_tools() -> Vec { - all_builtin_tools() - .into_iter() - .map(|tool| ToolInfo { - name: tool.name().to_string(), - description: tool.description().to_string(), - version: "builtin".to_string(), - enabled: true, - }) - .collect() -} - -fn now_utc_string() -> String { - chrono::Utc::now().to_rfc3339() -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn backend() -> (TempDir, CliBackend) { - let temp = TempDir::new().unwrap(); - let backend = CliBackend::with_root(temp.path().join(BACKEND_DIR)); - std::fs::create_dir_all(&backend.root).unwrap(); - (temp, backend) - } - - #[test] - fn test_agent_start_stop_round_trip() { - let (_temp, backend) = backend(); - let started = backend.start_agent("agent-1", None, true).unwrap(); - assert_eq!(started.status, "running"); - assert!(started.daemon); - - let stopped = backend.stop_agent("agent-1").unwrap(); - assert_eq!(stopped.status, "stopped"); - } - - #[test] - fn test_plugin_uninstall_persists() { - let (_temp, backend) = backend(); - let plugin = backend.uninstall_plugin("http-server").unwrap(); - assert!(!plugin.installed); - let fetched = backend.get_plugin("http-server").unwrap(); - assert!(!fetched.installed); - } - - #[test] - fn test_builtin_tools_seeded() { - let (_temp, backend) = backend(); - let tools = backend.list_tools(false).unwrap(); - assert!(!tools.is_empty()); - assert!(tools.iter().any(|tool| tool.name == "calculator")); - } -} diff --git a/crates/mofa-cli/src/commands/mod.rs b/crates/mofa-cli/src/commands/mod.rs index c1e4b30a..42c7dc55 100644 --- a/crates/mofa-cli/src/commands/mod.rs +++ b/crates/mofa-cli/src/commands/mod.rs @@ -1,7 +1,6 @@ //! Command implementations pub mod agent; -pub mod backend; pub mod build; pub mod config_cmd; pub mod db; diff --git a/crates/mofa-cli/src/commands/plugin/info.rs b/crates/mofa-cli/src/commands/plugin/info.rs index 7c8f8047..46231803 100644 --- a/crates/mofa-cli/src/commands/plugin/info.rs +++ b/crates/mofa-cli/src/commands/plugin/info.rs @@ -1,35 +1,47 @@ //! `mofa plugin info` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; +use mofa_kernel::agent::plugins::PluginRegistry; /// Execute the `mofa plugin info` command -pub fn run(name: &str) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, name: &str) -> anyhow::Result<()> { println!("{} Plugin information: {}", "→".green(), name.cyan()); println!(); - let backend = CliBackend::discover()?; - let plugin = backend.get_plugin(name)?; - - println!(" Name: {}", plugin.name.cyan()); - println!(" Version: {}", plugin.version.white()); - println!(" Description: {}", plugin.description.white()); - println!(" Author: {}", plugin.author.white()); - if let Some(repo) = plugin.repository { - println!(" Repository: {}", repo.blue()); - } - if let Some(license) = plugin.license { - println!(" License: {}", license.white()); - } - println!( - " Installed: {}", - if plugin.installed { - "Yes".green() - } else { - "No".yellow() + match ctx.plugin_registry.get(name) { + Some(plugin) => { + let metadata = plugin.metadata(); + println!(" Name: {}", plugin.name().cyan()); + println!(" Description: {}", plugin.description().white()); + println!(" Version: {}", metadata.version.white()); + println!( + " Stages: {}", + metadata + .stages + .iter() + .map(|s| format!("{:?}", s)) + .collect::>() + .join(", ") + .white() + ); + if !metadata.custom.is_empty() { + println!(" Custom attrs:"); + for (key, value) in &metadata.custom { + println!(" {}: {}", key, value); + } + } } - ); - println!(); + None => { + println!(" Plugin '{}' not found in registry", name); + println!(); + println!( + " Use {} to see available plugins.", + "mofa plugin list".cyan() + ); + } + } + println!(); Ok(()) } diff --git a/crates/mofa-cli/src/commands/plugin/list.rs b/crates/mofa-cli/src/commands/plugin/list.rs index 0285435c..1d00b0ef 100644 --- a/crates/mofa-cli/src/commands/plugin/list.rs +++ b/crates/mofa-cli/src/commands/plugin/list.rs @@ -1,34 +1,44 @@ //! `mofa plugin list` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; +use mofa_kernel::agent::plugins::PluginRegistry; +use serde::Serialize; /// Execute the `mofa plugin list` command -pub fn run(installed_only: bool, available: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, _installed_only: bool, _available: bool) -> anyhow::Result<()> { println!("{} Listing plugins", "→".green()); - - if installed_only { - println!(" Showing installed plugins"); - } else if available { - println!(" Showing available plugins"); - } - println!(); - let backend = CliBackend::discover()?; - let filtered: Vec<_> = if available { - backend.list_plugins(false)? - } else { - backend.list_plugins(installed_only)? - }; + let plugins = ctx.plugin_registry.list(); - if filtered.is_empty() { - println!(" No plugins found."); + if plugins.is_empty() { + println!(" No plugins registered."); + println!(); + println!(" Plugins can be registered programmatically via the SDK."); return Ok(()); } - let json = serde_json::to_value(&filtered)?; + let infos: Vec = plugins + .iter() + .map(|p| { + let metadata = p.metadata(); + PluginInfo { + name: p.name().to_string(), + version: metadata.version.clone(), + description: p.description().to_string(), + stages: metadata + .stages + .iter() + .map(|s| format!("{:?}", s)) + .collect::>() + .join(", "), + } + }) + .collect(); + + let json = serde_json::to_value(&infos)?; if let Some(arr) = json.as_array() { let table = Table::from_json_array(arr); println!("{}", table); @@ -36,3 +46,11 @@ pub fn run(installed_only: bool, available: bool) -> anyhow::Result<()> { Ok(()) } + +#[derive(Debug, Clone, Serialize)] +struct PluginInfo { + name: String, + version: String, + description: String, + stages: String, +} diff --git a/crates/mofa-cli/src/commands/plugin/uninstall.rs b/crates/mofa-cli/src/commands/plugin/uninstall.rs index dae4318c..6458a33c 100644 --- a/crates/mofa-cli/src/commands/plugin/uninstall.rs +++ b/crates/mofa-cli/src/commands/plugin/uninstall.rs @@ -1,14 +1,41 @@ //! `mofa plugin uninstall` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; +use dialoguer::Confirm; +use mofa_kernel::agent::plugins::PluginRegistry; /// Execute the `mofa plugin uninstall` command -pub fn run(name: &str, _force: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, name: &str, force: bool) -> anyhow::Result<()> { + // Check if plugin exists + if !ctx.plugin_registry.contains(name) { + anyhow::bail!("Plugin '{}' not found in registry", name); + } + + if !force { + let confirmed = Confirm::new() + .with_prompt(format!("Uninstall plugin '{}'?", name)) + .default(false) + .interact()?; + + if !confirmed { + println!("{} Cancelled", "→".yellow()); + return Ok(()); + } + } + println!("{} Uninstalling plugin: {}", "→".green(), name.cyan()); - let backend = CliBackend::discover()?; - let plugin = backend.uninstall_plugin(name)?; - println!("{} Plugin '{}' uninstalled", "✓".green(), plugin.name); + + let removed = ctx + .plugin_registry + .unregister(name) + .map_err(|e| anyhow::anyhow!("Failed to unregister plugin: {}", e))?; + + if removed { + println!("{} Plugin '{}' uninstalled", "✓".green(), name); + } else { + println!("{} Plugin '{}' was not in the registry", "!".yellow(), name); + } Ok(()) } diff --git a/crates/mofa-cli/src/commands/session/delete.rs b/crates/mofa-cli/src/commands/session/delete.rs index 84d379ce..783d4d86 100644 --- a/crates/mofa-cli/src/commands/session/delete.rs +++ b/crates/mofa-cli/src/commands/session/delete.rs @@ -1,24 +1,39 @@ //! `mofa session delete` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; +use dialoguer::Confirm; /// Execute the `mofa session delete` command -pub fn run(session_id: &str, force: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, session_id: &str, force: bool) -> anyhow::Result<()> { if !force { - println!("{} Delete session: {}?", "→".yellow(), session_id.cyan()); - println!(" This action cannot be undone."); - println!(); - println!(" Use --force to skip confirmation."); - // TODO: Add actual confirmation prompt - return Ok(()); + let confirmed = Confirm::new() + .with_prompt(format!( + "Delete session '{}'? This cannot be undone", + session_id + )) + .default(false) + .interact()?; + + if !confirmed { + println!("{} Cancelled", "→".yellow()); + return Ok(()); + } } println!("{} Deleting session: {}", "→".green(), session_id.cyan()); - let backend = CliBackend::discover()?; - backend.delete_session(session_id)?; - println!("{} Session '{}' deleted", "✓".green(), session_id); + let deleted = ctx + .session_manager + .delete(session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete session: {}", e))?; + + if deleted { + println!("{} Session '{}' deleted", "✓".green(), session_id); + } else { + println!("{} Session '{}' not found", "!".yellow(), session_id); + } Ok(()) } diff --git a/crates/mofa-cli/src/commands/session/export.rs b/crates/mofa-cli/src/commands/session/export.rs index ffc7aa86..1e0404e4 100644 --- a/crates/mofa-cli/src/commands/session/export.rs +++ b/crates/mofa-cli/src/commands/session/export.rs @@ -1,22 +1,49 @@ //! `mofa session export` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; use std::path::PathBuf; /// Execute the `mofa session export` command -pub fn run(session_id: &str, output: PathBuf, format: &str) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + session_id: &str, + output: PathBuf, + format: &str, +) -> anyhow::Result<()> { println!("{} Exporting session: {}", "→".green(), session_id.cyan()); println!(" Format: {}", format.yellow()); println!(" Output: {}", output.display().to_string().cyan()); println!(); - let backend = CliBackend::discover()?; - let session = backend.get_session(session_id)?; + let session = ctx.session_manager.get_or_create(session_id).await; + + if session.is_empty() { + println!( + "{} Session '{}' has no messages to export", + "!".yellow(), + session_id + ); + return Ok(()); + } + + let session_data = serde_json::json!({ + "session_id": session.key, + "created_at": session.created_at.to_rfc3339(), + "updated_at": session.updated_at.to_rfc3339(), + "metadata": session.metadata, + "messages": session.messages.iter().map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.to_rfc3339(), + }) + }).collect::>(), + }); let output_str = match format { - "json" => serde_json::to_string_pretty(&session)?, - "yaml" => serde_yaml::to_string(&session)?, + "json" => serde_json::to_string_pretty(&session_data)?, + "yaml" => serde_yaml::to_string(&session_data)?, _ => anyhow::bail!("Unsupported export format: {}", format), }; diff --git a/crates/mofa-cli/src/commands/session/list.rs b/crates/mofa-cli/src/commands/session/list.rs index 25577a5c..723f1a81 100644 --- a/crates/mofa-cli/src/commands/session/list.rs +++ b/crates/mofa-cli/src/commands/session/list.rs @@ -1,11 +1,16 @@ //! `mofa session list` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; +use serde::Serialize; /// Execute the `mofa session list` command -pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + agent_id: Option<&str>, + limit: Option, +) -> anyhow::Result<()> { println!("{} Listing sessions", "→".green()); if let Some(agent) = agent_id { @@ -18,8 +23,52 @@ pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { println!(); - let backend = CliBackend::discover()?; - let limited = backend.list_sessions(agent_id, limit)?; + let keys = ctx + .session_manager + .list() + .await + .map_err(|e| anyhow::anyhow!("Failed to list sessions: {}", e))?; + + if keys.is_empty() { + println!(" No sessions found."); + return Ok(()); + } + + let mut sessions = Vec::new(); + for key in &keys { + let session = ctx.session_manager.get_or_create(key).await; + + // Filter by agent_id if provided (check metadata or key prefix) + if let Some(agent) = agent_id { + let matches = session + .metadata + .get("agent_id") + .and_then(|v| v.as_str()) + .map(|v| v == agent) + .unwrap_or_else(|| session.key.contains(agent)); + if !matches { + continue; + } + } + + sessions.push(SessionInfo { + session_id: session.key.clone(), + created_at: session.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + message_count: session.len(), + status: if session.is_empty() { + "empty".to_string() + } else { + "active".to_string() + }, + }); + } + + // Apply limit + let limited: Vec<_> = if let Some(n) = limit { + sessions.into_iter().take(n).collect() + } else { + sessions + }; if limited.is_empty() { println!(" No sessions found."); @@ -34,3 +83,11 @@ pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { Ok(()) } + +#[derive(Debug, Clone, Serialize)] +struct SessionInfo { + session_id: String, + created_at: String, + message_count: usize, + status: String, +} diff --git a/crates/mofa-cli/src/commands/session/show.rs b/crates/mofa-cli/src/commands/session/show.rs index 1cf3a11f..c6f83380 100644 --- a/crates/mofa-cli/src/commands/session/show.rs +++ b/crates/mofa-cli/src/commands/session/show.rs @@ -1,40 +1,88 @@ //! `mofa session show` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa session show` command -pub fn run(session_id: &str, format: Option<&str>) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, session_id: &str, format: Option<&str>) -> anyhow::Result<()> { println!("{} Session details: {}", "→".green(), session_id.cyan()); println!(); + let session = ctx.session_manager.get_or_create(session_id).await; let output_format = format.unwrap_or("text"); - let backend = CliBackend::discover()?; - let session = backend.get_session(session_id)?; match output_format { "json" => { let json = serde_json::json!({ - "session_id": session.session_id, - "agent_id": session.agent_id, - "created_at": session.created_at, - "messages": session.messages, - "status": session.status + "session_id": session.key, + "created_at": session.created_at.to_rfc3339(), + "updated_at": session.updated_at.to_rfc3339(), + "message_count": session.len(), + "metadata": session.metadata, + "messages": session.messages.iter().map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.to_rfc3339(), + }) + }).collect::>(), }); println!("{}", serde_json::to_string_pretty(&json)?); } "yaml" => { - println!("{}", serde_yaml::to_string(&session)?); + let yaml = serde_json::json!({ + "session_id": session.key, + "created_at": session.created_at.to_rfc3339(), + "updated_at": session.updated_at.to_rfc3339(), + "message_count": session.len(), + "metadata": session.metadata, + "messages": session.messages.iter().map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.to_rfc3339(), + }) + }).collect::>(), + }); + println!("{}", serde_yaml::to_string(&yaml)?); } _ => { - println!(" Session ID: {}", session.session_id.cyan()); - println!(" Agent ID: {}", session.agent_id.white()); - println!(" Created: {}", session.created_at.white()); - println!(" Status: {}", session.status.green()); + println!(" Session ID: {}", session.key.cyan()); + println!( + " Created: {}", + session + .created_at + .format("%Y-%m-%d %H:%M:%S") + .to_string() + .white() + ); + println!( + " Updated: {}", + session + .updated_at + .format("%Y-%m-%d %H:%M:%S") + .to_string() + .white() + ); + println!(" Messages: {}", session.len()); + if !session.metadata.is_empty() { + println!(" Metadata: {:?}", session.metadata); + } println!(); - println!(" Messages:"); - for msg in session.messages { - println!(" {}: {}", msg.role, msg.content); + + if session.is_empty() { + println!(" (no messages)"); + } else { + println!(" Messages:"); + for msg in &session.messages { + let role_display = match msg.role.as_str() { + "user" => "User".green(), + "assistant" => "Assistant".cyan(), + "system" => "System".yellow(), + other => other.white(), + }; + println!(" {}: {}", role_display, msg.content); + } } } } diff --git a/crates/mofa-cli/src/commands/tool/info.rs b/crates/mofa-cli/src/commands/tool/info.rs index d45ada71..7f8cb0ce 100644 --- a/crates/mofa-cli/src/commands/tool/info.rs +++ b/crates/mofa-cli/src/commands/tool/info.rs @@ -1,28 +1,86 @@ //! `mofa tool info` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use colored::Colorize; +use mofa_kernel::agent::components::tool::ToolRegistry; /// Execute the `mofa tool info` command -pub fn run(name: &str) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, name: &str) -> anyhow::Result<()> { println!("{} Tool information: {}", "→".green(), name.cyan()); println!(); - let backend = CliBackend::discover()?; - let tool = backend.get_tool(name)?; + match ctx.tool_registry.get(name) { + Some(tool) => { + let metadata = tool.metadata(); + println!(" Name: {}", tool.name().cyan()); + println!(" Description: {}", tool.description().white()); + if let Some(category) = &metadata.category { + println!(" Category: {}", category.white()); + } + if !metadata.tags.is_empty() { + println!(" Tags: {}", metadata.tags.join(", ").white()); + } + println!( + " Dangerous: {}", + if metadata.is_dangerous { + "Yes".red() + } else { + "No".green() + } + ); + println!( + " Needs network: {}", + if metadata.requires_network { + "Yes".yellow() + } else { + "No".white() + } + ); + println!( + " Needs FS: {}", + if metadata.requires_filesystem { + "Yes".yellow() + } else { + "No".white() + } + ); + println!( + " Confirmation: {}", + if tool.requires_confirmation() { + "Required".yellow() + } else { + "Not required".white() + } + ); - println!(" Name: {}", tool.name.cyan()); - println!(" Description: {}", tool.description.white()); - println!(" Version: {}", tool.version.white()); - println!( - " Enabled: {}", - if tool.enabled { - "Yes".green() - } else { - "No".yellow() + // Show parameter schema + let schema = tool.parameters_schema(); + if !schema.is_null() { + println!(); + println!(" Parameters:"); + println!( + "{}", + serde_json::to_string_pretty(&schema)? + .lines() + .map(|l| format!(" {}", l)) + .collect::>() + .join("\n") + ); + } + + // Show source + if let Some(source) = ctx.tool_registry.get_source(name) { + println!(); + println!(" Source: {:?}", source); + } } - ); - println!(); + None => { + println!(" Tool '{}' not found in registry", name); + println!(); + println!(" Use {} to see available tools.", "mofa tool list".cyan()); + } + } + println!(); Ok(()) } diff --git a/crates/mofa-cli/src/commands/tool/list.rs b/crates/mofa-cli/src/commands/tool/list.rs index 9a6432f4..fe895d75 100644 --- a/crates/mofa-cli/src/commands/tool/list.rs +++ b/crates/mofa-cli/src/commands/tool/list.rs @@ -1,34 +1,43 @@ //! `mofa tool list` command implementation -use crate::commands::backend::CliBackend; +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; +use mofa_kernel::agent::components::tool::ToolRegistry; +use serde::Serialize; /// Execute the `mofa tool list` command -pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, _available: bool, _enabled: bool) -> anyhow::Result<()> { println!("{} Listing tools", "→".green()); - - if available { - println!(" Showing available tools"); - } else if enabled { - println!(" Showing enabled tools"); - } - println!(); - let backend = CliBackend::discover()?; - let filtered = if available { - backend.list_tools(false)? - } else { - backend.list_tools(enabled)? - }; + let descriptors = ctx.tool_registry.list(); - if filtered.is_empty() { - println!(" No tools found."); + if descriptors.is_empty() { + println!(" No tools registered."); + println!(); + println!(" Tools can be registered programmatically via the SDK."); return Ok(()); } - let json = serde_json::to_value(&filtered)?; + let tools: Vec = descriptors + .iter() + .map(|d| { + let source = ctx + .tool_registry + .get_source(&d.name) + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|| "unknown".to_string()); + ToolInfo { + name: d.name.clone(), + description: d.description.clone(), + category: d.metadata.category.clone().unwrap_or_default(), + source, + } + }) + .collect(); + + let json = serde_json::to_value(&tools)?; if let Some(arr) = json.as_array() { let table = Table::from_json_array(arr); println!("{}", table); @@ -36,3 +45,11 @@ pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { Ok(()) } + +#[derive(Debug, Clone, Serialize)] +struct ToolInfo { + name: String, + description: String, + category: String, + source: String, +} diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs new file mode 100644 index 00000000..c4cbcc85 --- /dev/null +++ b/crates/mofa-cli/src/context.rs @@ -0,0 +1,47 @@ +//! CLI context providing access to backend services + +use crate::utils::paths; +use mofa_foundation::agent::session::SessionManager; +use mofa_foundation::agent::tools::registry::ToolRegistry; +use mofa_runtime::agent::plugins::SimplePluginRegistry; +use mofa_runtime::agent::registry::AgentRegistry; +use std::path::PathBuf; +use std::sync::Arc; + +/// Shared context for CLI commands, holding references to backend services +pub struct CliContext { + /// Session manager with file-based persistence + pub session_manager: SessionManager, + /// In-memory agent registry + pub agent_registry: AgentRegistry, + /// In-memory plugin registry + pub plugin_registry: Arc, + /// In-memory tool registry + pub tool_registry: ToolRegistry, + /// Platform-specific data directory (~/.local/share/mofa or equivalent) + pub data_dir: PathBuf, + /// Platform-specific config directory (~/.config/mofa or equivalent) + pub config_dir: PathBuf, +} + +impl CliContext { + /// Initialize the CLI context with default backend services + pub async fn new() -> anyhow::Result { + let data_dir = paths::ensure_mofa_data_dir()?; + let config_dir = paths::ensure_mofa_config_dir()?; + + let sessions_dir = data_dir.join("sessions"); + let session_manager = SessionManager::with_jsonl(&sessions_dir) + .await + .map_err(|e| anyhow::anyhow!("Failed to initialize session manager: {}", e))?; + + Ok(Self { + session_manager, + agent_registry: AgentRegistry::new(), + plugin_registry: Arc::new(SimplePluginRegistry::new()), + tool_registry: ToolRegistry::new(), + data_dir, + config_dir, + }) + } +} diff --git a/crates/mofa-cli/src/main.rs b/crates/mofa-cli/src/main.rs index 2465b1df..a7660953 100644 --- a/crates/mofa-cli/src/main.rs +++ b/crates/mofa-cli/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod commands; mod config; +mod context; mod output; mod render; mod tui; @@ -12,6 +13,7 @@ mod widgets; use clap::Parser; use cli::Cli; use colored::Colorize; +use context::CliContext; fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -23,20 +25,39 @@ fn main() -> anyhow::Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); } + let rt = tokio::runtime::Runtime::new()?; + // Launch TUI if requested or no command provided if cli.tui || cli.command.is_none() { // Run TUI mode - tokio::runtime::Runtime::new()?.block_on(tui::run())?; + rt.block_on(tui::run())?; Ok(()) } else { - // Run CLI command as usual - run_command(cli) + // Run CLI command + rt.block_on(run_command(cli)) } } -fn run_command(cli: Cli) -> anyhow::Result<()> { +async fn run_command(cli: Cli) -> anyhow::Result<()> { use cli::Commands; + // Initialize context for commands that need backend services + let needs_context = matches!( + &cli.command, + Some( + Commands::Agent(_) + | Commands::Plugin { .. } + | Commands::Session { .. } + | Commands::Tool { .. } + ) + ); + + let ctx = if needs_context { + Some(CliContext::new().await?) + } else { + None + }; + match cli.command { Some(Commands::New { name, @@ -89,33 +110,36 @@ fn run_command(cli: Cli) -> anyhow::Result<()> { } }, - Some(Commands::Agent(agent_cmd)) => match agent_cmd { - cli::AgentCommands::Create { - non_interactive, - config, - } => { - commands::agent::create::run(non_interactive, config)?; - } - cli::AgentCommands::Start { - agent_id, - config, - daemon, - } => { - commands::agent::start::run(&agent_id, config.as_deref(), daemon)?; - } - cli::AgentCommands::Stop { agent_id } => { - commands::agent::stop::run(&agent_id)?; - } - cli::AgentCommands::Restart { agent_id, config } => { - commands::agent::restart::run(&agent_id, config.as_deref())?; - } - cli::AgentCommands::Status { agent_id } => { - commands::agent::status::run(agent_id.as_deref())?; - } - cli::AgentCommands::List { running, all } => { - commands::agent::list::run(running, all)?; + Some(Commands::Agent(agent_cmd)) => { + let ctx = ctx.as_ref().unwrap(); + match agent_cmd { + cli::AgentCommands::Create { + non_interactive, + config, + } => { + commands::agent::create::run(non_interactive, config)?; + } + cli::AgentCommands::Start { + agent_id, + config, + daemon, + } => { + commands::agent::start::run(ctx, &agent_id, config.as_deref(), daemon).await?; + } + cli::AgentCommands::Stop { agent_id } => { + commands::agent::stop::run(ctx, &agent_id).await?; + } + cli::AgentCommands::Restart { agent_id, config } => { + commands::agent::restart::run(ctx, &agent_id, config.as_deref()).await?; + } + cli::AgentCommands::Status { agent_id } => { + commands::agent::status::run(ctx, agent_id.as_deref()).await?; + } + cli::AgentCommands::List { running, all } => { + commands::agent::list::run(ctx, running, all).await?; + } } - }, + } Some(Commands::Config { action }) => match action { cli::ConfigCommands::Value(value_cmd) => match value_cmd { @@ -140,51 +164,63 @@ fn run_command(cli: Cli) -> anyhow::Result<()> { } }, - Some(Commands::Plugin { action }) => match action { - cli::PluginCommands::List { - installed, - available, - } => { - commands::plugin::list::run(installed, available)?; - } - cli::PluginCommands::Info { name } => { - commands::plugin::info::run(&name)?; - } - cli::PluginCommands::Uninstall { name, force } => { - commands::plugin::uninstall::run(&name, force)?; + Some(Commands::Plugin { action }) => { + let ctx = ctx.as_ref().unwrap(); + match action { + cli::PluginCommands::List { + installed, + available, + } => { + commands::plugin::list::run(ctx, installed, available).await?; + } + cli::PluginCommands::Info { name } => { + commands::plugin::info::run(ctx, &name).await?; + } + cli::PluginCommands::Uninstall { name, force } => { + commands::plugin::uninstall::run(ctx, &name, force).await?; + } } - }, + } - Some(Commands::Session { action }) => match action { - cli::SessionCommands::List { agent, limit } => { - commands::session::list::run(agent.as_deref(), limit)?; - } - cli::SessionCommands::Show { session_id, format } => { - commands::session::show::run( - &session_id, - format.map(|f| f.to_string()).as_deref(), - )?; - } - cli::SessionCommands::Delete { session_id, force } => { - commands::session::delete::run(&session_id, force)?; - } - cli::SessionCommands::Export { - session_id, - output, - format, - } => { - commands::session::export::run(&session_id, output, &format.to_string())?; + Some(Commands::Session { action }) => { + let ctx = ctx.as_ref().unwrap(); + match action { + cli::SessionCommands::List { agent, limit } => { + commands::session::list::run(ctx, agent.as_deref(), limit).await?; + } + cli::SessionCommands::Show { session_id, format } => { + commands::session::show::run( + ctx, + &session_id, + format.map(|f| f.to_string()).as_deref(), + ) + .await?; + } + cli::SessionCommands::Delete { session_id, force } => { + commands::session::delete::run(ctx, &session_id, force).await?; + } + cli::SessionCommands::Export { + session_id, + output, + format, + } => { + commands::session::export::run(ctx, &session_id, output, &format.to_string()) + .await?; + } } - }, + } - Some(Commands::Tool { action }) => match action { - cli::ToolCommands::List { available, enabled } => { - commands::tool::list::run(available, enabled)?; - } - cli::ToolCommands::Info { name } => { - commands::tool::info::run(&name)?; + Some(Commands::Tool { action }) => { + let ctx = ctx.as_ref().unwrap(); + match action { + cli::ToolCommands::List { available, enabled } => { + commands::tool::list::run(ctx, available, enabled).await?; + } + cli::ToolCommands::Info { name } => { + commands::tool::info::run(ctx, &name).await?; + } } - }, + } None => { // Should have been handled by TUI check above From eb9dcd49e01e34378fb04ed29dbaaed600deb4d3 Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sat, 21 Feb 2026 23:04:29 +0530 Subject: [PATCH 3/9] feature(cli): wire backend connections for agent and session commands --- crates/mofa-cli/src/commands/agent/list.rs | 43 +++- crates/mofa-cli/src/commands/agent/start.rs | 73 ++++-- crates/mofa-cli/src/commands/agent/stop.rs | 11 + .../mofa-cli/src/commands/session/export.rs | 16 +- crates/mofa-cli/src/commands/session/list.rs | 10 +- crates/mofa-cli/src/commands/session/show.rs | 7 +- crates/mofa-cli/src/context.rs | 121 ++++++++- crates/mofa-cli/src/main.rs | 1 + crates/mofa-cli/src/store.rs | 243 ++++++++++++++++++ crates/mofa-foundation/src/agent/session.rs | 64 +++++ 10 files changed, 533 insertions(+), 56 deletions(-) create mode 100644 crates/mofa-cli/src/store.rs diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index 4d536480..7833f895 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -4,6 +4,7 @@ use crate::context::CliContext; use crate::output::Table; use colored::Colorize; use serde::Serialize; +use std::collections::BTreeMap; /// Execute the `mofa agent list` command pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyhow::Result<()> { @@ -11,8 +12,35 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho println!(); let agents_metadata = ctx.agent_registry.list().await; + let persisted_agents = ctx + .agent_store + .list() + .map_err(|e| anyhow::anyhow!("Failed to list persisted agents: {}", e))?; - if agents_metadata.is_empty() { + let mut merged: BTreeMap = BTreeMap::new(); + for m in &agents_metadata { + let status = format!("{:?}", m.state); + merged.insert( + m.id.clone(), + AgentInfo { + id: m.id.clone(), + name: m.name.clone(), + status, + description: m.description.clone(), + }, + ); + } + + for (_, entry) in persisted_agents { + merged.entry(entry.id.clone()).or_insert_with(|| AgentInfo { + id: entry.id, + name: entry.name, + status: entry.state, + description: None, + }); + } + + if merged.is_empty() { println!(" No agents registered."); println!(); println!( @@ -22,18 +50,7 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho return Ok(()); } - let agents: Vec = agents_metadata - .iter() - .map(|m| { - let status = format!("{:?}", m.state); - AgentInfo { - id: m.id.clone(), - name: m.name.clone(), - status, - description: m.description.clone(), - } - }) - .collect(); + let agents: Vec = merged.into_values().collect(); // Filter based on flags let filtered: Vec<_> = if running_only { diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index 87c4e7ac..bd513a3b 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -1,7 +1,7 @@ //! `mofa agent start` command implementation use crate::config::loader::ConfigLoader; -use crate::context::CliContext; +use crate::context::{AgentConfigEntry, CliContext}; use colored::Colorize; /// Execute the `mofa agent start` command @@ -54,37 +54,54 @@ pub async fn run( // Check if a matching factory type is available let factory_types = ctx.agent_registry.list_factory_types().await; if factory_types.is_empty() { - println!( - " {} No agent factories registered. Agent registered with config only.", - "!".yellow() + anyhow::bail!( + "No agent factories registered. Cannot start agent '{}'", + agent_id ); - println!(" Agent config stored for: {}", agent_config.name.cyan()); - } else { - // Try to create via factory - let type_id = factory_types.first().unwrap(); - match ctx - .agent_registry - .create_and_register(type_id, agent_config.clone()) - .await - { - Ok(_) => { - println!( - "{} Agent '{}' created and registered", - "✓".green(), - agent_id - ); - } - Err(e) => { - println!( - " {} Failed to create agent via factory: {}", - "!".yellow(), - e - ); - } - } } + // Try to create via factory + let type_id = factory_types.first().unwrap(); + ctx.agent_registry + .create_and_register(type_id, agent_config.clone()) + .await + .map_err(|e| anyhow::anyhow!("Failed to start agent '{}': {}", agent_id, e))?; + + let entry = AgentConfigEntry { + id: agent_id.to_string(), + name: agent_config.name.clone(), + state: "Running".to_string(), + }; + ctx.agent_store + .save(agent_id, &entry) + .map_err(|e| anyhow::anyhow!("Failed to persist agent '{}': {}", agent_id, e))?; + println!("{} Agent '{}' started", "✓".green(), agent_id); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_start_returns_err_when_no_factories() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let result = run(&ctx, "test-agent", None, false).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_start_does_not_register_on_failure() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let _ = run(&ctx, "ghost-agent", None, false).await; + assert!(!ctx.agent_registry.contains("ghost-agent").await); + } +} diff --git a/crates/mofa-cli/src/commands/agent/stop.rs b/crates/mofa-cli/src/commands/agent/stop.rs index 8036a5bd..3992187d 100644 --- a/crates/mofa-cli/src/commands/agent/stop.rs +++ b/crates/mofa-cli/src/commands/agent/stop.rs @@ -27,6 +27,17 @@ pub async fn run(ctx: &CliContext, agent_id: &str) -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; + if let Some(mut entry) = ctx + .agent_store + .get(agent_id) + .map_err(|e| anyhow::anyhow!("Failed to load persisted agent '{}': {}", agent_id, e))? + { + entry.state = "Stopped".to_string(); + ctx.agent_store + .save(agent_id, &entry) + .map_err(|e| anyhow::anyhow!("Failed to update agent '{}': {}", agent_id, e))?; + } + if removed { println!( "{} Agent '{}' stopped and unregistered", diff --git a/crates/mofa-cli/src/commands/session/export.rs b/crates/mofa-cli/src/commands/session/export.rs index 1e0404e4..af277371 100644 --- a/crates/mofa-cli/src/commands/session/export.rs +++ b/crates/mofa-cli/src/commands/session/export.rs @@ -16,16 +16,12 @@ pub async fn run( println!(" Output: {}", output.display().to_string().cyan()); println!(); - let session = ctx.session_manager.get_or_create(session_id).await; - - if session.is_empty() { - println!( - "{} Session '{}' has no messages to export", - "!".yellow(), - session_id - ); - return Ok(()); - } + let session = ctx + .session_manager + .get(session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to load session: {}", e))? + .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?; let session_data = serde_json::json!({ "session_id": session.key, diff --git a/crates/mofa-cli/src/commands/session/list.rs b/crates/mofa-cli/src/commands/session/list.rs index 723f1a81..6232bf4b 100644 --- a/crates/mofa-cli/src/commands/session/list.rs +++ b/crates/mofa-cli/src/commands/session/list.rs @@ -36,7 +36,15 @@ pub async fn run( let mut sessions = Vec::new(); for key in &keys { - let session = ctx.session_manager.get_or_create(key).await; + let session = match ctx + .session_manager + .get(key) + .await + .map_err(|e| anyhow::anyhow!("Failed to load session '{}': {}", key, e))? + { + Some(session) => session, + None => continue, + }; // Filter by agent_id if provided (check metadata or key prefix) if let Some(agent) = agent_id { diff --git a/crates/mofa-cli/src/commands/session/show.rs b/crates/mofa-cli/src/commands/session/show.rs index c6f83380..08393c4c 100644 --- a/crates/mofa-cli/src/commands/session/show.rs +++ b/crates/mofa-cli/src/commands/session/show.rs @@ -8,7 +8,12 @@ pub async fn run(ctx: &CliContext, session_id: &str, format: Option<&str>) -> an println!("{} Session details: {}", "→".green(), session_id.cyan()); println!(); - let session = ctx.session_manager.get_or_create(session_id).await; + let session = ctx + .session_manager + .get(session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to load session: {}", e))? + .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?; let output_format = format.unwrap_or("text"); match output_format { diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs index c4cbcc85..df30e63e 100644 --- a/crates/mofa-cli/src/context.rs +++ b/crates/mofa-cli/src/context.rs @@ -1,19 +1,30 @@ //! CLI context providing access to backend services +use crate::store::PersistedStore; use crate::utils::paths; use mofa_foundation::agent::session::SessionManager; use mofa_foundation::agent::tools::registry::ToolRegistry; use mofa_runtime::agent::plugins::SimplePluginRegistry; use mofa_runtime::agent::registry::AgentRegistry; -use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use std::sync::Arc; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfigEntry { + pub id: String, + pub name: String, + pub state: String, +} + /// Shared context for CLI commands, holding references to backend services pub struct CliContext { /// Session manager with file-based persistence pub session_manager: SessionManager, /// In-memory agent registry pub agent_registry: AgentRegistry, + /// Persistent agent metadata store + pub agent_store: PersistedStore, /// In-memory plugin registry pub plugin_registry: Arc, /// In-memory tool registry @@ -29,15 +40,17 @@ impl CliContext { pub async fn new() -> anyhow::Result { let data_dir = paths::ensure_mofa_data_dir()?; let config_dir = paths::ensure_mofa_config_dir()?; + migrate_legacy_nested_sessions(&data_dir)?; - let sessions_dir = data_dir.join("sessions"); - let session_manager = SessionManager::with_jsonl(&sessions_dir) + let session_manager = SessionManager::with_jsonl(&data_dir) .await .map_err(|e| anyhow::anyhow!("Failed to initialize session manager: {}", e))?; + let agent_store = PersistedStore::new(data_dir.join("agents"))?; Ok(Self { session_manager, agent_registry: AgentRegistry::new(), + agent_store, plugin_registry: Arc::new(SimplePluginRegistry::new()), tool_registry: ToolRegistry::new(), data_dir, @@ -45,3 +58,105 @@ impl CliContext { }) } } + +#[cfg(test)] +impl CliContext { + pub async fn with_temp_dir(temp_dir: &std::path::Path) -> anyhow::Result { + let data_dir = temp_dir.join("data"); + let config_dir = temp_dir.join("config"); + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&config_dir)?; + migrate_legacy_nested_sessions(&data_dir)?; + + let session_manager = SessionManager::with_jsonl(&data_dir) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + let agent_store = PersistedStore::new(data_dir.join("agents"))?; + + Ok(Self { + session_manager, + agent_registry: AgentRegistry::new(), + agent_store, + plugin_registry: Arc::new(SimplePluginRegistry::new()), + tool_registry: ToolRegistry::new(), + data_dir, + config_dir, + }) + } +} + +fn migrate_legacy_nested_sessions(data_dir: &Path) -> anyhow::Result<()> { + let sessions_dir = data_dir.join("sessions"); + let legacy_dir = sessions_dir.join("sessions"); + if !legacy_dir.exists() { + return Ok(()); + } + + std::fs::create_dir_all(&sessions_dir)?; + for entry in std::fs::read_dir(&legacy_dir)? { + let entry = entry?; + let src = entry.path(); + let dst = sessions_dir.join(entry.file_name()); + + if dst.exists() { + continue; + } + + std::fs::rename(&src, &dst)?; + } + + let _ = std::fs::remove_dir(&legacy_dir); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mofa_foundation::agent::session::Session; + use tempfile::TempDir; + + #[tokio::test] + async fn test_agent_store_persists_across_context_instances() { + let temp = TempDir::new().unwrap(); + + let ctx1 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let entry = AgentConfigEntry { + id: "persisted-agent".to_string(), + name: "Persisted Agent".to_string(), + state: "Running".to_string(), + }; + ctx1.agent_store.save("persisted-agent", &entry).unwrap(); + drop(ctx1); + + let ctx2 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let loaded = ctx2.agent_store.get("persisted-agent").unwrap(); + + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().id, "persisted-agent"); + } + + #[tokio::test] + async fn test_legacy_nested_sessions_are_migrated() { + let temp = TempDir::new().unwrap(); + let data_dir = temp.path().join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + let legacy_manager = SessionManager::with_jsonl(data_dir.join("sessions")) + .await + .unwrap(); + let mut session = Session::new("legacy-session"); + session.add_message("user", "hello"); + legacy_manager.save(&session).await.unwrap(); + + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let loaded = ctx.session_manager.get("legacy-session").await.unwrap(); + + assert!(loaded.is_some()); + assert!( + data_dir + .join("sessions") + .join("legacy-session.jsonl") + .exists() + ); + } +} diff --git a/crates/mofa-cli/src/main.rs b/crates/mofa-cli/src/main.rs index a7660953..0a2b2b67 100644 --- a/crates/mofa-cli/src/main.rs +++ b/crates/mofa-cli/src/main.rs @@ -6,6 +6,7 @@ mod config; mod context; mod output; mod render; +mod store; mod tui; mod utils; mod widgets; diff --git a/crates/mofa-cli/src/store.rs b/crates/mofa-cli/src/store.rs new file mode 100644 index 00000000..ae0c8473 --- /dev/null +++ b/crates/mofa-cli/src/store.rs @@ -0,0 +1,243 @@ +//! Generic file-based persisted store for CLI state. + +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::fs; +use std::marker::PhantomData; +use std::path::{Path, PathBuf}; + +pub struct PersistedStore { + dir: PathBuf, + _phantom: PhantomData, +} + +impl PersistedStore { + pub fn new(dir: impl AsRef) -> anyhow::Result { + let dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&dir)?; + Ok(Self { + dir, + _phantom: PhantomData, + }) + } + + pub fn save(&self, id: &str, item: &T) -> anyhow::Result<()> { + let path = self.path_for(id); + let payload = serde_json::to_vec_pretty(item)?; + fs::write(path, payload)?; + Ok(()) + } + + pub fn get(&self, id: &str) -> anyhow::Result> { + let path = self.path_for(id); + if !path.exists() { + return Ok(None); + } + + let payload = fs::read(path)?; + let item = serde_json::from_slice(&payload)?; + Ok(Some(item)) + } + + pub fn list(&self) -> anyhow::Result> { + let mut items = Vec::new(); + + for entry in fs::read_dir(&self.dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + + let id = match path.file_stem().and_then(|stem| stem.to_str()) { + Some(stem) => stem.to_string(), + None => continue, + }; + + let payload = fs::read(path)?; + let item = serde_json::from_slice(&payload)?; + items.push((id, item)); + } + + items.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(items) + } + + pub fn delete(&self, id: &str) -> anyhow::Result { + let path = self.path_for(id); + if !path.exists() { + return Ok(false); + } + + fs::remove_file(path)?; + Ok(true) + } + + fn path_for(&self, id: &str) -> PathBuf { + let safe_id: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + + let file_name = if safe_id.is_empty() { + "_".to_string() + } else { + safe_id + }; + + self.dir.join(format!("{}.json", file_name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] + struct TestEntry { + name: String, + value: u32, + } + + #[test] + fn test_save_and_get() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + let entry = TestEntry { + name: "alpha".to_string(), + value: 1, + }; + + store.save("alpha", &entry).unwrap(); + let loaded = store.get("alpha").unwrap(); + assert_eq!(loaded, Some(entry)); + } + + #[test] + fn test_get_returns_none_for_missing() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + + assert!(store.get("missing").unwrap().is_none()); + } + + #[test] + fn test_list_returns_all() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + store + .save( + "a", + &TestEntry { + name: "a".to_string(), + value: 1, + }, + ) + .unwrap(); + store + .save( + "b", + &TestEntry { + name: "b".to_string(), + value: 2, + }, + ) + .unwrap(); + + let items = store.list().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].0, "a"); + assert_eq!(items[1].0, "b"); + } + + #[test] + fn test_delete() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + store + .save( + "x", + &TestEntry { + name: "x".to_string(), + value: 9, + }, + ) + .unwrap(); + + assert!(store.delete("x").unwrap()); + assert!(store.get("x").unwrap().is_none()); + } + + #[test] + fn test_delete_nonexistent_returns_false() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + + assert!(!store.delete("ghost").unwrap()); + } + + #[test] + fn test_overwrite() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + + store + .save( + "k", + &TestEntry { + name: "old".to_string(), + value: 1, + }, + ) + .unwrap(); + store + .save( + "k", + &TestEntry { + name: "new".to_string(), + value: 2, + }, + ) + .unwrap(); + + let loaded = store.get("k").unwrap().unwrap(); + assert_eq!(loaded.name, "new"); + assert_eq!(loaded.value, 2); + } + + #[test] + fn test_survives_new_instance() { + let temp = TempDir::new().unwrap(); + let path = temp.path().to_path_buf(); + + { + let store = PersistedStore::::new(&path).unwrap(); + store + .save( + "persisted", + &TestEntry { + name: "persisted".to_string(), + value: 7, + }, + ) + .unwrap(); + } + + let new_store = PersistedStore::::new(&path).unwrap(); + let loaded = new_store.get("persisted").unwrap(); + assert_eq!( + loaded, + Some(TestEntry { + name: "persisted".to_string(), + value: 7 + }) + ); + } +} diff --git a/crates/mofa-foundation/src/agent/session.rs b/crates/mofa-foundation/src/agent/session.rs index 005b06b6..68803be9 100644 --- a/crates/mofa-foundation/src/agent/session.rs +++ b/crates/mofa-foundation/src/agent/session.rs @@ -357,6 +357,18 @@ impl SessionManager { } } + /// Get a session by key without creating a new one + pub async fn get(&self, key: &str) -> AgentResult> { + { + let cache = self.cache.read().await; + if let Some(session) = cache.get(key) { + return Ok(Some(session.clone())); + } + } + + self.storage.load(key).await + } + /// Get or create a session pub async fn get_or_create(&self, key: &str) -> Session { // Try cache first @@ -469,4 +481,56 @@ mod tests { let loaded = manager.get_or_create("test:manager").await; assert_eq!(loaded.key, "test:manager"); } + + #[tokio::test] + async fn test_session_get_returns_none_for_missing() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let result = manager.get("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_session_get_returns_some_for_existing() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let mut session = Session::new("exists"); + session.add_message("user", "hello"); + manager.save(&session).await.unwrap(); + + let result = manager.get("exists").await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().len(), 1); + } + + #[tokio::test] + async fn test_session_get_does_not_create() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let _ = manager.get("phantom").await.unwrap(); + let keys = manager.list().await.unwrap(); + assert!(!keys.contains(&"phantom".to_string())); + } + + #[tokio::test] + async fn test_session_storage_path_no_double_nesting() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let mut session = Session::new("nesting-test"); + session.add_message("user", "hello"); + manager.save(&session).await.unwrap(); + + assert!( + temp_dir + .path() + .join("sessions") + .join("nesting-test.jsonl") + .exists() + ); + assert!(!temp_dir.path().join("sessions").join("sessions").exists()); + } } From 06456e7f7c000a318b15bb352e43eae509a6acbc Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sat, 21 Feb 2026 23:19:07 +0530 Subject: [PATCH 4/9] fix(cli): persist and replay plugin/tool specs across invocations --- .../mofa-cli/src/commands/plugin/uninstall.rs | 33 ++++ crates/mofa-cli/src/context.rs | 173 +++++++++++++++++- 2 files changed, 200 insertions(+), 6 deletions(-) diff --git a/crates/mofa-cli/src/commands/plugin/uninstall.rs b/crates/mofa-cli/src/commands/plugin/uninstall.rs index 6458a33c..ddabc4f1 100644 --- a/crates/mofa-cli/src/commands/plugin/uninstall.rs +++ b/crates/mofa-cli/src/commands/plugin/uninstall.rs @@ -32,6 +32,17 @@ pub async fn run(ctx: &CliContext, name: &str, force: bool) -> anyhow::Result<() .map_err(|e| anyhow::anyhow!("Failed to unregister plugin: {}", e))?; if removed { + if let Some(mut spec) = ctx + .plugin_store + .get(name) + .map_err(|e| anyhow::anyhow!("Failed to load plugin spec '{}': {}", name, e))? + { + spec.enabled = false; + ctx.plugin_store + .save(name, &spec) + .map_err(|e| anyhow::anyhow!("Failed to persist plugin '{}': {}", name, e))?; + } + println!("{} Plugin '{}' uninstalled", "✓".green(), name); } else { println!("{} Plugin '{}' was not in the registry", "!".yellow(), name); @@ -39,3 +50,25 @@ pub async fn run(ctx: &CliContext, name: &str, force: bool) -> anyhow::Result<() Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_uninstall_persists_disabled_plugin_spec() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + run(&ctx, "http-plugin", true).await.unwrap(); + + let spec = ctx.plugin_store.get("http-plugin").unwrap().unwrap(); + assert!(!spec.enabled); + + drop(ctx); + let ctx2 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + assert!(!ctx2.plugin_registry.contains("http-plugin")); + } +} diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs index df30e63e..7e3fb4eb 100644 --- a/crates/mofa-cli/src/context.rs +++ b/crates/mofa-cli/src/context.rs @@ -2,14 +2,20 @@ use crate::store::PersistedStore; use crate::utils::paths; +use mofa_foundation::agent::components::tool::EchoTool; use mofa_foundation::agent::session::SessionManager; -use mofa_foundation::agent::tools::registry::ToolRegistry; -use mofa_runtime::agent::plugins::SimplePluginRegistry; +use mofa_foundation::agent::tools::registry::{ToolRegistry, ToolSource}; +use mofa_kernel::agent::plugins::PluginRegistry; +use mofa_runtime::agent::plugins::{HttpPlugin, SimplePluginRegistry}; use mofa_runtime::agent::registry::AgentRegistry; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::path::{Path, PathBuf}; use std::sync::Arc; +const BUILTIN_HTTP_PLUGIN_KIND: &str = "builtin:http"; +const BUILTIN_ECHO_TOOL_KIND: &str = "builtin:echo"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfigEntry { pub id: String, @@ -17,6 +23,22 @@ pub struct AgentConfigEntry { pub state: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSpecEntry { + pub id: String, + pub kind: String, + pub enabled: bool, + pub config: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSpecEntry { + pub id: String, + pub kind: String, + pub enabled: bool, + pub config: Value, +} + /// Shared context for CLI commands, holding references to backend services pub struct CliContext { /// Session manager with file-based persistence @@ -25,6 +47,10 @@ pub struct CliContext { pub agent_registry: AgentRegistry, /// Persistent agent metadata store pub agent_store: PersistedStore, + /// Persistent plugin source specifications + pub plugin_store: PersistedStore, + /// Persistent tool source specifications + pub tool_store: PersistedStore, /// In-memory plugin registry pub plugin_registry: Arc, /// In-memory tool registry @@ -46,13 +72,23 @@ impl CliContext { .await .map_err(|e| anyhow::anyhow!("Failed to initialize session manager: {}", e))?; let agent_store = PersistedStore::new(data_dir.join("agents"))?; + let plugin_store = PersistedStore::new(data_dir.join("plugins"))?; + let tool_store = PersistedStore::new(data_dir.join("tools"))?; + seed_default_specs(&plugin_store, &tool_store)?; + + let plugin_registry = Arc::new(SimplePluginRegistry::new()); + replay_persisted_plugins(&plugin_registry, &plugin_store)?; + let mut tool_registry = ToolRegistry::new(); + replay_persisted_tools(&mut tool_registry, &tool_store)?; Ok(Self { session_manager, agent_registry: AgentRegistry::new(), agent_store, - plugin_registry: Arc::new(SimplePluginRegistry::new()), - tool_registry: ToolRegistry::new(), + plugin_store, + tool_store, + plugin_registry, + tool_registry, data_dir, config_dir, }) @@ -72,19 +108,113 @@ impl CliContext { .await .map_err(|e| anyhow::anyhow!("{}", e))?; let agent_store = PersistedStore::new(data_dir.join("agents"))?; + let plugin_store = PersistedStore::new(data_dir.join("plugins"))?; + let tool_store = PersistedStore::new(data_dir.join("tools"))?; + seed_default_specs(&plugin_store, &tool_store)?; + + let plugin_registry = Arc::new(SimplePluginRegistry::new()); + replay_persisted_plugins(&plugin_registry, &plugin_store)?; + let mut tool_registry = ToolRegistry::new(); + replay_persisted_tools(&mut tool_registry, &tool_store)?; Ok(Self { session_manager, agent_registry: AgentRegistry::new(), agent_store, - plugin_registry: Arc::new(SimplePluginRegistry::new()), - tool_registry: ToolRegistry::new(), + plugin_store, + tool_store, + plugin_registry, + tool_registry, data_dir, config_dir, }) } } +fn seed_default_specs( + plugin_store: &PersistedStore, + tool_store: &PersistedStore, +) -> anyhow::Result<()> { + let default_plugin = PluginSpecEntry { + id: "http-plugin".to_string(), + kind: BUILTIN_HTTP_PLUGIN_KIND.to_string(), + enabled: true, + config: serde_json::json!({ + "url": "https://example.com", + }), + }; + if plugin_store.get(&default_plugin.id)?.is_none() { + plugin_store.save(&default_plugin.id, &default_plugin)?; + } + + let default_tool = ToolSpecEntry { + id: "echo".to_string(), + kind: BUILTIN_ECHO_TOOL_KIND.to_string(), + enabled: true, + config: Value::Null, + }; + if tool_store.get(&default_tool.id)?.is_none() { + tool_store.save(&default_tool.id, &default_tool)?; + } + + Ok(()) +} + +fn replay_persisted_plugins( + plugin_registry: &Arc, + plugin_store: &PersistedStore, +) -> anyhow::Result<()> { + for (_, spec) in plugin_store.list()? { + if !spec.enabled { + continue; + } + + match spec.kind.as_str() { + BUILTIN_HTTP_PLUGIN_KIND => { + let url = spec + .config + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("https://example.com"); + plugin_registry + .register(Arc::new(HttpPlugin::new(url))) + .map_err(|e| { + anyhow::anyhow!("Failed to register plugin '{}': {}", spec.id, e) + })?; + } + _ => { + // Ignore unknown kinds for forward compatibility. + } + } + } + + Ok(()) +} + +fn replay_persisted_tools( + tool_registry: &mut ToolRegistry, + tool_store: &PersistedStore, +) -> anyhow::Result<()> { + for (_, spec) in tool_store.list()? { + if !spec.enabled { + continue; + } + + match spec.kind.as_str() { + BUILTIN_ECHO_TOOL_KIND => { + tool_registry + .register_with_source(Arc::new(EchoTool), ToolSource::Builtin) + .map_err(|e| anyhow::anyhow!("Failed to register tool '{}': {}", spec.id, e))?; + } + _ => { + // Ignore unknown kinds for forward compatibility. + } + } + } + + Ok(()) +} + fn migrate_legacy_nested_sessions(data_dir: &Path) -> anyhow::Result<()> { let sessions_dir = data_dir.join("sessions"); let legacy_dir = sessions_dir.join("sessions"); @@ -113,6 +243,8 @@ fn migrate_legacy_nested_sessions(data_dir: &Path) -> anyhow::Result<()> { mod tests { use super::*; use mofa_foundation::agent::session::Session; + use mofa_kernel::agent::components::tool::ToolRegistry as ToolRegistryTrait; + use mofa_kernel::agent::plugins::PluginRegistry as PluginRegistryTrait; use tempfile::TempDir; #[tokio::test] @@ -159,4 +291,33 @@ mod tests { .exists() ); } + + #[tokio::test] + async fn test_plugin_and_tool_specs_replayed_on_startup() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + assert!(PluginRegistryTrait::contains( + ctx.plugin_registry.as_ref(), + "http-plugin" + )); + assert!(ToolRegistryTrait::contains(&ctx.tool_registry, "echo")); + } + + #[tokio::test] + async fn test_disabled_plugin_spec_is_not_replayed() { + let temp = TempDir::new().unwrap(); + + let ctx1 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let mut spec = ctx1.plugin_store.get("http-plugin").unwrap().unwrap(); + spec.enabled = false; + ctx1.plugin_store.save("http-plugin", &spec).unwrap(); + drop(ctx1); + + let ctx2 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + assert!(!PluginRegistryTrait::contains( + ctx2.plugin_registry.as_ref(), + "http-plugin" + )); + } } From 4c1400ff5ae69e21440b80ef26d2cae0dd59137a Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sat, 21 Feb 2026 23:22:27 +0530 Subject: [PATCH 5/9] fix(cli): register default base agent factory on startup --- crates/mofa-cli/src/commands/agent/start.rs | 12 +-- crates/mofa-cli/src/context.rs | 95 ++++++++++++++++++++- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index bd513a3b..fef008b5 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -88,20 +88,22 @@ mod tests { use tempfile::TempDir; #[tokio::test] - async fn test_start_returns_err_when_no_factories() { + async fn test_start_succeeds_with_default_factory() { let temp = TempDir::new().unwrap(); let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); let result = run(&ctx, "test-agent", None, false).await; - assert!(result.is_err()); + assert!(result.is_ok()); + assert!(ctx.agent_registry.contains("test-agent").await); } #[tokio::test] - async fn test_start_does_not_register_on_failure() { + async fn test_start_returns_err_for_duplicate_agent() { let temp = TempDir::new().unwrap(); let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); - let _ = run(&ctx, "ghost-agent", None, false).await; - assert!(!ctx.agent_registry.contains("ghost-agent").await); + run(&ctx, "dup-agent", None, false).await.unwrap(); + let result = run(&ctx, "dup-agent", None, false).await; + assert!(result.is_err()); } } diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs index 7e3fb4eb..325353c6 100644 --- a/crates/mofa-cli/src/context.rs +++ b/crates/mofa-cli/src/context.rs @@ -2,19 +2,28 @@ use crate::store::PersistedStore; use crate::utils::paths; +use async_trait::async_trait; +use mofa_foundation::agent::base::BaseAgent; use mofa_foundation::agent::components::tool::EchoTool; use mofa_foundation::agent::session::SessionManager; use mofa_foundation::agent::tools::registry::{ToolRegistry, ToolSource}; +use mofa_kernel::agent::AgentCapabilities; +use mofa_kernel::agent::config::AgentConfig; +use mofa_kernel::agent::core::MoFAAgent; +use mofa_kernel::agent::error::{AgentError, AgentResult}; use mofa_kernel::agent::plugins::PluginRegistry; +use mofa_runtime::agent::AgentFactory; use mofa_runtime::agent::plugins::{HttpPlugin, SimplePluginRegistry}; use mofa_runtime::agent::registry::AgentRegistry; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::sync::RwLock; const BUILTIN_HTTP_PLUGIN_KIND: &str = "builtin:http"; const BUILTIN_ECHO_TOOL_KIND: &str = "builtin:echo"; +const CLI_BASE_FACTORY_KIND: &str = "cli-base"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfigEntry { @@ -72,6 +81,8 @@ impl CliContext { .await .map_err(|e| anyhow::anyhow!("Failed to initialize session manager: {}", e))?; let agent_store = PersistedStore::new(data_dir.join("agents"))?; + let agent_registry = AgentRegistry::new(); + register_default_agent_factories(&agent_registry).await?; let plugin_store = PersistedStore::new(data_dir.join("plugins"))?; let tool_store = PersistedStore::new(data_dir.join("tools"))?; seed_default_specs(&plugin_store, &tool_store)?; @@ -83,7 +94,7 @@ impl CliContext { Ok(Self { session_manager, - agent_registry: AgentRegistry::new(), + agent_registry, agent_store, plugin_store, tool_store, @@ -108,6 +119,8 @@ impl CliContext { .await .map_err(|e| anyhow::anyhow!("{}", e))?; let agent_store = PersistedStore::new(data_dir.join("agents"))?; + let agent_registry = AgentRegistry::new(); + register_default_agent_factories(&agent_registry).await?; let plugin_store = PersistedStore::new(data_dir.join("plugins"))?; let tool_store = PersistedStore::new(data_dir.join("tools"))?; seed_default_specs(&plugin_store, &tool_store)?; @@ -119,7 +132,7 @@ impl CliContext { Ok(Self { session_manager, - agent_registry: AgentRegistry::new(), + agent_registry, agent_store, plugin_store, tool_store, @@ -131,6 +144,75 @@ impl CliContext { } } +struct CliBaseAgentFactory; + +#[async_trait] +impl AgentFactory for CliBaseAgentFactory { + async fn create(&self, config: AgentConfig) -> AgentResult>> { + let mut agent = + BaseAgent::new(config.id, config.name).with_capabilities(self.default_capabilities()); + + if let Some(description) = config.description { + agent = agent.with_description(description); + } + + if let Some(version) = config.version { + agent = agent.with_version(version); + } + + Ok(Arc::new(RwLock::new(agent))) + } + + fn type_id(&self) -> &str { + CLI_BASE_FACTORY_KIND + } + + fn default_capabilities(&self) -> AgentCapabilities { + AgentCapabilities::builder().tag("cli").tag("base").build() + } + + fn validate_config(&self, config: &AgentConfig) -> AgentResult<()> { + if config.id.trim().is_empty() { + return Err(AgentError::ConfigError( + "Agent id cannot be empty".to_string(), + )); + } + if config.name.trim().is_empty() { + return Err(AgentError::ConfigError( + "Agent name cannot be empty".to_string(), + )); + } + if !config.enabled { + return Err(AgentError::ConfigError( + "Cannot start disabled agent config".to_string(), + )); + } + Ok(()) + } + + fn description(&self) -> Option<&str> { + Some("Default CLI base-agent factory") + } +} + +async fn register_default_agent_factories(agent_registry: &AgentRegistry) -> anyhow::Result<()> { + if agent_registry + .list_factory_types() + .await + .iter() + .any(|kind| kind == CLI_BASE_FACTORY_KIND) + { + return Ok(()); + } + + agent_registry + .register_factory(Arc::new(CliBaseAgentFactory)) + .await + .map_err(|e| anyhow::anyhow!("Failed to register default agent factory: {}", e))?; + + Ok(()) +} + fn seed_default_specs( plugin_store: &PersistedStore, tool_store: &PersistedStore, @@ -320,4 +402,13 @@ mod tests { "http-plugin" )); } + + #[tokio::test] + async fn test_default_agent_factory_registered_on_startup() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let factory_types = ctx.agent_registry.list_factory_types().await; + assert!(factory_types.iter().any(|k| k == CLI_BASE_FACTORY_KIND)); + } } From a3380b1b8ab789adee97c6516f12631f3a9e32f2 Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sat, 21 Feb 2026 23:33:17 +0530 Subject: [PATCH 6/9] fix(session): preserve canonical keys in list output --- crates/mofa-foundation/src/agent/session.rs | 36 ++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/mofa-foundation/src/agent/session.rs b/crates/mofa-foundation/src/agent/session.rs index 68803be9..f8b12616 100644 --- a/crates/mofa-foundation/src/agent/session.rs +++ b/crates/mofa-foundation/src/agent/session.rs @@ -271,10 +271,24 @@ impl SessionStorage for JsonlSessionStorage { .await .map_err(|e| AgentError::IoError(format!("Failed to read entry: {}", e)))? { - if let Some(name) = entry.path().file_stem().and_then(|s| s.to_str()) { - // Convert back the safe filename to original key - let key = name.replace('_', ":"); - keys.push(key); + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { + continue; + } + + if let Ok(content) = fs::read_to_string(&path).await { + if let Some(header) = content.lines().next() { + if let Ok(header_data) = serde_json::from_str::(header) { + if let Some(key) = header_data.get("key").and_then(|v| v.as_str()) { + keys.push(key.to_string()); + continue; + } + } + } + } + + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + keys.push(name.to_string()); } } @@ -533,4 +547,18 @@ mod tests { ); assert!(!temp_dir.path().join("sessions").join("sessions").exists()); } + + #[tokio::test] + async fn test_session_list_preserves_underscore_keys() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let mut session = Session::new("team_alpha"); + session.add_message("user", "hello"); + manager.save(&session).await.unwrap(); + + let keys = manager.list().await.unwrap(); + assert!(keys.contains(&"team_alpha".to_string())); + assert!(!keys.contains(&"team:alpha".to_string())); + } } From c43d9549f5b95cd5872aaa3743f0749b472c9712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:42:30 +0000 Subject: [PATCH 7/9] Initial plan From 4814f431e4fbc5ecaaf4e2c21a4ddf90a91b133c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:58:35 +0000 Subject: [PATCH 8/9] fix(cli): restore workspace lints, add agent description persistence, fix stale running-agent filter Co-authored-by: Rahul-2k4 <216878448+Rahul-2k4@users.noreply.github.com> --- crates/mofa-cli/Cargo.toml | 3 +++ crates/mofa-cli/src/commands/agent/list.rs | 18 +++++++++++++----- crates/mofa-cli/src/commands/agent/start.rs | 1 + crates/mofa-cli/src/context.rs | 3 +++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/mofa-cli/Cargo.toml b/crates/mofa-cli/Cargo.toml index 1a735382..6770b25c 100644 --- a/crates/mofa-cli/Cargo.toml +++ b/crates/mofa-cli/Cargo.toml @@ -72,3 +72,6 @@ tempfile = "3" default = [] dora = ["mofa-sdk/dora"] db = ["dep:sqlx"] + +[lints] +workspace = true diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index 7833f895..769c29c3 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -32,11 +32,19 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho } for (_, entry) in persisted_agents { - merged.entry(entry.id.clone()).or_insert_with(|| AgentInfo { - id: entry.id, - name: entry.name, - status: entry.state, - description: None, + merged.entry(entry.id.clone()).or_insert_with(|| { + // Agents not in the in-memory registry are not currently running, + // regardless of their last-persisted state. + let mut status = entry.state; + if status == "Running" || status == "Ready" { + status = "Stopped".to_string(); + } + AgentInfo { + id: entry.id, + name: entry.name, + status, + description: entry.description, + } }); } diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index fef008b5..422f8611 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -71,6 +71,7 @@ pub async fn run( id: agent_id.to_string(), name: agent_config.name.clone(), state: "Running".to_string(), + description: agent_config.description.clone(), }; ctx.agent_store .save(agent_id, &entry) diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs index 325353c6..e0fd8b8c 100644 --- a/crates/mofa-cli/src/context.rs +++ b/crates/mofa-cli/src/context.rs @@ -30,6 +30,8 @@ pub struct AgentConfigEntry { pub id: String, pub name: String, pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -338,6 +340,7 @@ mod tests { id: "persisted-agent".to_string(), name: "Persisted Agent".to_string(), state: "Running".to_string(), + description: None, }; ctx1.agent_store.save("persisted-agent", &entry).unwrap(); drop(ctx1); From eed59b7e4437f5081153cc076818bfeb3d173022 Mon Sep 17 00:00:00 2001 From: Rahul Tripathi Date: Sun, 22 Feb 2026 01:47:15 +0530 Subject: [PATCH 9/9] feat(cli): harden backend lifecycle and session listing --- crates/mofa-cli/src/cli.rs | 4 + crates/mofa-cli/src/commands/agent/list.rs | 25 +++-- crates/mofa-cli/src/commands/agent/restart.rs | 44 ++++++--- crates/mofa-cli/src/commands/agent/start.rs | 94 +++++++++++++++++-- crates/mofa-cli/src/commands/agent/stop.rs | 68 ++++++++++++-- .../mofa-cli/src/commands/plugin/uninstall.rs | 36 +++++-- crates/mofa-cli/src/commands/session/list.rs | 32 +++++++ crates/mofa-cli/src/main.rs | 10 +- crates/mofa-foundation/src/agent/session.rs | 26 ++++- 9 files changed, 282 insertions(+), 57 deletions(-) diff --git a/crates/mofa-cli/src/cli.rs b/crates/mofa-cli/src/cli.rs index 23835d06..3834bd23 100644 --- a/crates/mofa-cli/src/cli.rs +++ b/crates/mofa-cli/src/cli.rs @@ -219,6 +219,10 @@ pub enum AgentCommands { #[arg(short, long)] config: Option, + /// Agent factory type (use `mofa agent status` to inspect available factories) + #[arg(long = "type")] + factory_type: Option, + /// Run as daemon #[arg(long)] daemon: bool, diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index 769c29c3..8f216c56 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -20,12 +20,14 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho let mut merged: BTreeMap = BTreeMap::new(); for m in &agents_metadata { let status = format!("{:?}", m.state); + let is_running = is_running_state(&status); merged.insert( m.id.clone(), AgentInfo { id: m.id.clone(), name: m.name.clone(), status, + is_running, description: m.description.clone(), }, ); @@ -33,16 +35,16 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho for (_, entry) in persisted_agents { merged.entry(entry.id.clone()).or_insert_with(|| { - // Agents not in the in-memory registry are not currently running, - // regardless of their last-persisted state. - let mut status = entry.state; - if status == "Running" || status == "Ready" { - status = "Stopped".to_string(); - } + let status = if is_running_state(&entry.state) { + format!("{} (persisted)", entry.state) + } else { + entry.state + }; AgentInfo { id: entry.id, name: entry.name, status, + is_running: false, description: entry.description, } }); @@ -62,10 +64,7 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho // Filter based on flags let filtered: Vec<_> = if running_only { - agents - .into_iter() - .filter(|a| a.status == "Running" || a.status == "Ready") - .collect() + agents.into_iter().filter(|a| a.is_running).collect() } else { agents }; @@ -90,6 +89,12 @@ struct AgentInfo { id: String, name: String, status: String, + #[serde(skip_serializing)] + is_running: bool, #[serde(skip_serializing_if = "Option::is_none")] description: Option, } + +fn is_running_state(status: &str) -> bool { + status == "Running" || status == "Ready" +} diff --git a/crates/mofa-cli/src/commands/agent/restart.rs b/crates/mofa-cli/src/commands/agent/restart.rs index decb98ff..7debfd26 100644 --- a/crates/mofa-cli/src/commands/agent/restart.rs +++ b/crates/mofa-cli/src/commands/agent/restart.rs @@ -13,28 +13,42 @@ pub async fn run( // Stop the agent if it's running if ctx.agent_registry.contains(agent_id).await { - // Attempt graceful shutdown - if let Some(agent) = ctx.agent_registry.get(agent_id).await { - let mut agent_guard = agent.write().await; - if let Err(e) = agent_guard.shutdown().await { - println!(" {} Graceful shutdown failed: {}", "!".yellow(), e); - } - } - - ctx.agent_registry - .unregister(agent_id) - .await - .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; - - println!(" Agent stopped"); + super::stop::run(ctx, agent_id).await?; } else { println!(" Agent was not running"); } // Start it again - super::start::run(ctx, agent_id, config, false).await?; + super::start::run(ctx, agent_id, config, None, false).await?; println!("{} Agent '{}' restarted", "✓".green(), agent_id); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::agent::{list, start, stop}; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_restart_chain_start_stop_restart_list() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + start::run(&ctx, "chain-agent", None, None, false) + .await + .unwrap(); + stop::run(&ctx, "chain-agent").await.unwrap(); + run(&ctx, "chain-agent", None).await.unwrap(); + + assert!(ctx.agent_registry.contains("chain-agent").await); + let persisted = ctx.agent_store.get("chain-agent").unwrap().unwrap(); + assert_eq!(persisted.state, "Running"); + + list::run(&ctx, false, false).await.unwrap(); + list::run(&ctx, true, false).await.unwrap(); + } +} diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index 422f8611..6a300c94 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -9,6 +9,7 @@ pub async fn run( ctx: &CliContext, agent_id: &str, config_path: Option<&std::path::Path>, + factory_type: Option<&str>, daemon: bool, ) -> anyhow::Result<()> { println!("{} Starting agent: {}", "→".green(), agent_id.cyan()); @@ -52,18 +53,28 @@ pub async fn run( }; // Check if a matching factory type is available - let factory_types = ctx.agent_registry.list_factory_types().await; + let mut factory_types = ctx.agent_registry.list_factory_types().await; if factory_types.is_empty() { anyhow::bail!( "No agent factories registered. Cannot start agent '{}'", agent_id ); } + factory_types.sort(); + + let selected_factory = select_factory_type(&factory_types, factory_type)?; + println!(" Factory: {}", selected_factory.cyan()); + if factory_type.is_none() && factory_types.len() > 1 { + println!( + " {} Multiple factories available, defaulted to '{}'. Use --type to choose.", + "!".yellow(), + selected_factory + ); + } // Try to create via factory - let type_id = factory_types.first().unwrap(); ctx.agent_registry - .create_and_register(type_id, agent_config.clone()) + .create_and_register(&selected_factory, agent_config.clone()) .await .map_err(|e| anyhow::anyhow!("Failed to start agent '{}': {}", agent_id, e))?; @@ -73,15 +84,53 @@ pub async fn run( state: "Running".to_string(), description: agent_config.description.clone(), }; - ctx.agent_store - .save(agent_id, &entry) - .map_err(|e| anyhow::anyhow!("Failed to persist agent '{}': {}", agent_id, e))?; + if let Err(e) = ctx.agent_store.save(agent_id, &entry) { + let rollback_result = ctx.agent_registry.unregister(agent_id).await; + match rollback_result { + Ok(_) => { + anyhow::bail!( + "Failed to persist agent '{}': {}. Rolled back in-memory registration.", + agent_id, + e + ); + } + Err(rollback_err) => { + anyhow::bail!( + "Failed to persist agent '{}': {}. Rollback failed: {}", + agent_id, + e, + rollback_err + ); + } + } + } println!("{} Agent '{}' started", "✓".green(), agent_id); Ok(()) } +fn select_factory_type( + factory_types: &[String], + requested_factory: Option<&str>, +) -> anyhow::Result { + if let Some(requested) = requested_factory { + if factory_types.iter().any(|factory| factory == requested) { + return Ok(requested.to_string()); + } + anyhow::bail!( + "Factory '{}' is not registered. Available factories: {}", + requested, + factory_types.join(", ") + ); + } + + Ok(factory_types + .first() + .expect("factory_types must be non-empty") + .clone()) +} + #[cfg(test)] mod tests { use super::*; @@ -93,7 +142,7 @@ mod tests { let temp = TempDir::new().unwrap(); let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); - let result = run(&ctx, "test-agent", None, false).await; + let result = run(&ctx, "test-agent", None, None, false).await; assert!(result.is_ok()); assert!(ctx.agent_registry.contains("test-agent").await); } @@ -103,8 +152,35 @@ mod tests { let temp = TempDir::new().unwrap(); let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); - run(&ctx, "dup-agent", None, false).await.unwrap(); - let result = run(&ctx, "dup-agent", None, false).await; + run(&ctx, "dup-agent", None, None, false).await.unwrap(); + let result = run(&ctx, "dup-agent", None, None, false).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_start_returns_err_for_unknown_factory_type() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let result = run(&ctx, "typed-agent", None, Some("missing-factory"), false).await; assert!(result.is_err()); + assert!(!ctx.agent_registry.contains("typed-agent").await); + } + + #[tokio::test] + async fn test_start_succeeds_with_explicit_factory_type() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let factory = ctx + .agent_registry + .list_factory_types() + .await + .into_iter() + .next() + .unwrap(); + + let result = run(&ctx, "typed-agent-ok", None, Some(&factory), false).await; + assert!(result.is_ok()); + assert!(ctx.agent_registry.contains("typed-agent-ok").await); } } diff --git a/crates/mofa-cli/src/commands/agent/stop.rs b/crates/mofa-cli/src/commands/agent/stop.rs index 3992187d..2e1e1f26 100644 --- a/crates/mofa-cli/src/commands/agent/stop.rs +++ b/crates/mofa-cli/src/commands/agent/stop.rs @@ -20,22 +20,38 @@ pub async fn run(ctx: &CliContext, agent_id: &str) -> anyhow::Result<()> { } } - // Unregister from the registry - let removed = ctx - .agent_registry - .unregister(agent_id) - .await - .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; - - if let Some(mut entry) = ctx + let previous_entry = ctx .agent_store .get(agent_id) - .map_err(|e| anyhow::anyhow!("Failed to load persisted agent '{}': {}", agent_id, e))? - { + .map_err(|e| anyhow::anyhow!("Failed to load persisted agent '{}': {}", agent_id, e))?; + + let persisted_updated = if let Some(mut entry) = previous_entry.clone() { entry.state = "Stopped".to_string(); ctx.agent_store .save(agent_id, &entry) .map_err(|e| anyhow::anyhow!("Failed to update agent '{}': {}", agent_id, e))?; + true + } else { + false + }; + + // Unregister from the registry after persistence update so failures do not leave stale state. + let removed = ctx + .agent_registry + .unregister(agent_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; + + if !removed && persisted_updated { + if let Some(previous) = previous_entry { + ctx.agent_store.save(agent_id, &previous).map_err(|e| { + anyhow::anyhow!( + "Agent '{}' remained registered and failed to restore persisted state: {}", + agent_id, + e + ) + })?; + } } if removed { @@ -54,3 +70,35 @@ pub async fn run(ctx: &CliContext, agent_id: &str) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::agent::start; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_stop_updates_state_and_unregisters_agent() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + start::run(&ctx, "stop-agent", None, None, false) + .await + .unwrap(); + run(&ctx, "stop-agent").await.unwrap(); + + assert!(!ctx.agent_registry.contains("stop-agent").await); + let persisted = ctx.agent_store.get("stop-agent").unwrap().unwrap(); + assert_eq!(persisted.state, "Stopped"); + } + + #[tokio::test] + async fn test_stop_returns_error_for_missing_agent() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let result = run(&ctx, "missing-agent").await; + assert!(result.is_err()); + } +} diff --git a/crates/mofa-cli/src/commands/plugin/uninstall.rs b/crates/mofa-cli/src/commands/plugin/uninstall.rs index ddabc4f1..f93c204a 100644 --- a/crates/mofa-cli/src/commands/plugin/uninstall.rs +++ b/crates/mofa-cli/src/commands/plugin/uninstall.rs @@ -26,23 +26,39 @@ pub async fn run(ctx: &CliContext, name: &str, force: bool) -> anyhow::Result<() println!("{} Uninstalling plugin: {}", "→".green(), name.cyan()); + let previous_spec = ctx + .plugin_store + .get(name) + .map_err(|e| anyhow::anyhow!("Failed to load plugin spec '{}': {}", name, e))?; + + let persisted_updated = if let Some(mut spec) = previous_spec.clone() { + spec.enabled = false; + ctx.plugin_store + .save(name, &spec) + .map_err(|e| anyhow::anyhow!("Failed to persist plugin '{}': {}", name, e))?; + true + } else { + false + }; + let removed = ctx .plugin_registry .unregister(name) .map_err(|e| anyhow::anyhow!("Failed to unregister plugin: {}", e))?; - if removed { - if let Some(mut spec) = ctx - .plugin_store - .get(name) - .map_err(|e| anyhow::anyhow!("Failed to load plugin spec '{}': {}", name, e))? - { - spec.enabled = false; - ctx.plugin_store - .save(name, &spec) - .map_err(|e| anyhow::anyhow!("Failed to persist plugin '{}': {}", name, e))?; + if !removed && persisted_updated { + if let Some(previous) = previous_spec { + ctx.plugin_store.save(name, &previous).map_err(|e| { + anyhow::anyhow!( + "Plugin '{}' remained registered and failed to restore persisted state: {}", + name, + e + ) + })?; } + } + if removed { println!("{} Plugin '{}' uninstalled", "✓".green(), name); } else { println!("{} Plugin '{}' was not in the registry", "!".yellow(), name); diff --git a/crates/mofa-cli/src/commands/session/list.rs b/crates/mofa-cli/src/commands/session/list.rs index 6232bf4b..897437f6 100644 --- a/crates/mofa-cli/src/commands/session/list.rs +++ b/crates/mofa-cli/src/commands/session/list.rs @@ -99,3 +99,35 @@ struct SessionInfo { message_count: usize, status: String, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::CliContext; + use mofa_foundation::agent::session::Session; + use serde_json::json; + use tempfile::TempDir; + + #[tokio::test] + async fn test_session_list_runs_with_saved_sessions() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let mut session_a = Session::new("agent-a:1"); + session_a + .metadata + .insert("agent_id".to_string(), json!("agent-a")); + session_a.add_message("user", "hello"); + ctx.session_manager.save(&session_a).await.unwrap(); + + let mut session_b = Session::new("agent-b:1"); + session_b + .metadata + .insert("agent_id".to_string(), json!("agent-b")); + ctx.session_manager.save(&session_b).await.unwrap(); + + run(&ctx, None, None).await.unwrap(); + run(&ctx, Some("agent-a"), None).await.unwrap(); + run(&ctx, Some("agent-b"), Some(1)).await.unwrap(); + } +} diff --git a/crates/mofa-cli/src/main.rs b/crates/mofa-cli/src/main.rs index 0a2b2b67..9462cc9b 100644 --- a/crates/mofa-cli/src/main.rs +++ b/crates/mofa-cli/src/main.rs @@ -123,9 +123,17 @@ async fn run_command(cli: Cli) -> anyhow::Result<()> { cli::AgentCommands::Start { agent_id, config, + factory_type, daemon, } => { - commands::agent::start::run(ctx, &agent_id, config.as_deref(), daemon).await?; + commands::agent::start::run( + ctx, + &agent_id, + config.as_deref(), + factory_type.as_deref(), + daemon, + ) + .await?; } cli::AgentCommands::Stop { agent_id } => { commands::agent::stop::run(ctx, &agent_id).await?; diff --git a/crates/mofa-foundation/src/agent/session.rs b/crates/mofa-foundation/src/agent/session.rs index f8b12616..269eb304 100644 --- a/crates/mofa-foundation/src/agent/session.rs +++ b/crates/mofa-foundation/src/agent/session.rs @@ -9,6 +9,7 @@ use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::RwLock; use mofa_kernel::agent::error::{AgentError, AgentResult}; @@ -276,8 +277,11 @@ impl SessionStorage for JsonlSessionStorage { continue; } - if let Ok(content) = fs::read_to_string(&path).await { - if let Some(header) = content.lines().next() { + if let Ok(file) = fs::File::open(&path).await { + let mut reader = BufReader::new(file); + let mut header = String::new(); + if reader.read_line(&mut header).await.is_ok() { + let header = header.trim_end(); if let Ok(header_data) = serde_json::from_str::(header) { if let Some(key) = header_data.get("key").and_then(|v| v.as_str()) { keys.push(key.to_string()); @@ -561,4 +565,22 @@ mod tests { assert!(keys.contains(&"team_alpha".to_string())); assert!(!keys.contains(&"team:alpha".to_string())); } + + #[tokio::test] + async fn test_session_list_prefers_header_key_without_loading_whole_file() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let sessions_dir = temp_dir.path().join("sessions"); + tokio::fs::write( + sessions_dir.join("alias.jsonl"), + "{\"key\":\"canonical:key\"}\n{\"role\":\"user\",\"content\":\"hello\"}\n", + ) + .await + .unwrap(); + + let keys = manager.list().await.unwrap(); + assert!(keys.contains(&"canonical:key".to_string())); + assert!(!keys.contains(&"alias".to_string())); + } }