Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/mofa-cli/src/commands/agent/stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ pub async fn run(
.await
.map_err(|e| CliError::StateError(format!("Failed to unregister agent: {}", e)))?;

if !removed && persisted_updated && let Some(previous) = previous_entry {
if !removed
&& persisted_updated
&& let Some(previous) = previous_entry
{
ctx.agent_store.save(agent_id, &previous).map_err(|e| {
CliError::StateError(format!(
"Agent '{}' remained registered and failed to restore persisted state: {}",
Expand Down
136 changes: 69 additions & 67 deletions crates/mofa-cli/src/commands/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,36 +201,32 @@ fn validate_config_file(path: &PathBuf) -> Result<(), CliError> {

// Try to parse based on file extension
let result = match path.extension().and_then(|e| e.to_str()) {
Some(ext) => {
match ext.to_lowercase().as_str() {
"yaml" | "yml" => serde_yaml::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("YAML parsing error: {}", e))),
"toml" => toml::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("TOML parsing error: {}", e))),
"json" => serde_json::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("JSON parsing error: {}", e))),
"json5" => {
json5::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("JSON5 parsing error: {}", e)))
}
"ini" => {
return Err(CliError::ConfigError(
Some(ext) => match ext.to_lowercase().as_str() {
"yaml" | "yml" => serde_yaml::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("YAML parsing error: {}", e))),
"toml" => toml::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("TOML parsing error: {}", e))),
"json" => serde_json::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("JSON parsing error: {}", e))),
"json5" => json5::from_str::<Value>(&substituted)
.map_err(|e| CliError::ConfigError(format!("JSON5 parsing error: {}", e))),
"ini" => {
return Err(CliError::ConfigError(
"INI format validation is not yet supported. Please use YAML, TOML, or JSON format for validated configuration.".into()
));
}
"ron" => {
return Err(CliError::ConfigError(
}
"ron" => {
return Err(CliError::ConfigError(
"RON format validation is not yet supported. Please use YAML, TOML, or JSON format for validated configuration.".into()
));
}
_ => {
return Err(CliError::ConfigError(format!(
"Unsupported config format: {}",
ext
)));
}
}
}
_ => {
return Err(CliError::ConfigError(format!(
"Unsupported config format: {}",
ext
)));
}
},
None => {
return Err(CliError::ConfigError("Cannot determine file format".into()));
}
Expand Down Expand Up @@ -267,22 +263,22 @@ fn validate_config_file(path: &PathBuf) -> Result<(), CliError> {

#[cfg(test)]
mod tests {
use super::validate_config_file;
use crate::CliError;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;

fn write_temp_json5(content: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("create temp dir");
let path = dir.path().join("agent.json5");
fs::write(&path, content).expect("write json5 file");
(dir, path)
}
use super::validate_config_file;
use crate::CliError;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;

fn write_temp_json5(content: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("create temp dir");
let path = dir.path().join("agent.json5");
fs::write(&path, content).expect("write json5 file");
(dir, path)
}

#[test]
fn accepts_json5_comments() {
let json5 = r#"
#[test]
fn accepts_json5_comments() {
let json5 = r#"
{
// comment
agent: {
Expand All @@ -292,15 +288,15 @@ mod tests {
}
"#;

let (_dir, path) = write_temp_json5(json5);
let result = validate_config_file(&path);
let (_dir, path) = write_temp_json5(json5);
let result = validate_config_file(&path);

assert!(result.is_ok(), "expected JSON5 with comments to be valid");
}
assert!(result.is_ok(), "expected JSON5 with comments to be valid");
}

#[test]
fn accepts_json5_trailing_commas() {
let json5 = r#"
#[test]
fn accepts_json5_trailing_commas() {
let json5 = r#"
{
agent: {
id: "agent-1",
Expand All @@ -309,15 +305,18 @@ mod tests {
}
"#;

let (_dir, path) = write_temp_json5(json5);
let result = validate_config_file(&path);
let (_dir, path) = write_temp_json5(json5);
let result = validate_config_file(&path);

assert!(result.is_ok(), "expected JSON5 with trailing commas to be valid");
}
assert!(
result.is_ok(),
"expected JSON5 with trailing commas to be valid"
);
}

#[test]
fn accepts_json5_unquoted_keys() {
let json5 = r#"
#[test]
fn accepts_json5_unquoted_keys() {
let json5 = r#"
{
agent: {
id: "agent-1",
Expand All @@ -326,15 +325,18 @@ mod tests {
}
"#;

let (_dir, path) = write_temp_json5(json5);
let result = validate_config_file(&path);
let (_dir, path) = write_temp_json5(json5);
let result = validate_config_file(&path);

assert!(result.is_ok(), "expected JSON5 with unquoted keys to be valid");
}
assert!(
result.is_ok(),
"expected JSON5 with unquoted keys to be valid"
);
}

#[test]
fn rejects_invalid_json5() {
let invalid = r#"
#[test]
fn rejects_invalid_json5() {
let invalid = r#"
{
agent: {
id: "agent-1",
Expand All @@ -343,12 +345,12 @@ mod tests {
}
"#;

let (_dir, path) = write_temp_json5(invalid);
let result = validate_config_file(&path);
let (_dir, path) = write_temp_json5(invalid);
let result = validate_config_file(&path);

match result {
Err(CliError::ConfigError(_)) => {}
other => panic!("expected ConfigError, got: {:?}", other),
}
match result {
Err(CliError::ConfigError(_)) => {}
other => panic!("expected ConfigError, got: {:?}", other),
}
}
}
95 changes: 70 additions & 25 deletions crates/mofa-cli/src/commands/plugin/new.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use crate::error::CliError;
use clap::ValueEnum;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use dialoguer::{Input, Select, theme::ColorfulTheme};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use tera::{Context, Tera};

#[derive(Clone, Copy, Debug, ValueEnum)]
Expand Down Expand Up @@ -40,7 +40,7 @@ pub async fn run(name: Option<&str>) -> Result<(), CliError> {

// Normalize hyphen/underscore for rust crate names
let crate_name = plugin_name.replace("-", "_");

// 2. Prompt for Description
let description: String = Input::with_theme(&theme)
.with_prompt("Short description")
Expand Down Expand Up @@ -86,17 +86,27 @@ pub async fn run(name: Option<&str>) -> Result<(), CliError> {
)));
}

generate_scaffold(&target_dir, &plugin_name, &crate_name, &description, &author, selected_template)?;
generate_scaffold(
&target_dir,
&plugin_name,
&crate_name,
&description,
&author,
selected_template,
)?;

println!(
"✅ Successfully created plugin in {}!",
target_dir.display().to_string().green()
);

// Attempt auto-adding to workspace
let added_to_workspace = add_to_workspace_if_present(&target_dir)?;
if added_to_workspace {
println!("ℹ️ Added `{}` to the adjacent workspace Cargo.toml", plugin_name.cyan());
println!(
"ℹ️ Added `{}` to the adjacent workspace Cargo.toml",
plugin_name.cyan()
);
}

println!("\nNext steps:");
Expand Down Expand Up @@ -127,36 +137,57 @@ fn generate_scaffold(
ctx.insert("template_type", &format!("{:?}", template));

// Define all templates
tera.add_raw_template("Cargo.toml", include_str!("../../templates/Cargo.toml.tera"))
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template(
"Cargo.toml",
include_str!("../../templates/Cargo.toml.tera"),
)
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template("lib.rs", include_str!("../../templates/lib.rs.tera"))
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template("config.rs", include_str!("../../templates/config.rs.tera"))
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template("handler.rs", include_str!("../../templates/handler.rs.tera"))
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template("integration.rs", include_str!("../../templates/integration.rs.tera"))
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template(
"handler.rs",
include_str!("../../templates/handler.rs.tera"),
)
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template(
"integration.rs",
include_str!("../../templates/integration.rs.tera"),
)
.map_err(|e| CliError::Other(e.to_string()))?;
tera.add_raw_template("README.md", include_str!("../../templates/README.md.tera"))
.map_err(|e| CliError::Other(e.to_string()))?;

// Render & write
let cargo_toml = tera.render("Cargo.toml", &ctx).map_err(|e| CliError::Other(e.to_string()))?;
let cargo_toml = tera
.render("Cargo.toml", &ctx)
.map_err(|e| CliError::Other(e.to_string()))?;
fs::write(target.join("Cargo.toml"), cargo_toml)?;

let lib_rs = tera.render("lib.rs", &ctx).map_err(|e| CliError::Other(e.to_string()))?;
let lib_rs = tera
.render("lib.rs", &ctx)
.map_err(|e| CliError::Other(e.to_string()))?;
fs::write(target.join("src/lib.rs"), lib_rs)?;

let config_rs = tera.render("config.rs", &ctx).map_err(|e| CliError::Other(e.to_string()))?;
let config_rs = tera
.render("config.rs", &ctx)
.map_err(|e| CliError::Other(e.to_string()))?;
fs::write(target.join("src/config.rs"), config_rs)?;

let handler_rs = tera.render("handler.rs", &ctx).map_err(|e| CliError::Other(e.to_string()))?;
let handler_rs = tera
.render("handler.rs", &ctx)
.map_err(|e| CliError::Other(e.to_string()))?;
fs::write(target.join("src/handler.rs"), handler_rs)?;

let integration_rs = tera.render("integration.rs", &ctx).map_err(|e| CliError::Other(e.to_string()))?;
let integration_rs = tera
.render("integration.rs", &ctx)
.map_err(|e| CliError::Other(e.to_string()))?;
fs::write(target.join("tests/integration.rs"), integration_rs)?;

let readme = tera.render("README.md", &ctx).map_err(|e| CliError::Other(e.to_string()))?;
let readme = tera
.render("README.md", &ctx)
.map_err(|e| CliError::Other(e.to_string()))?;
fs::write(target.join("README.md"), readme)?;

Ok(())
Expand All @@ -165,7 +196,7 @@ fn generate_scaffold(
fn add_to_workspace_if_present(target_dir: &Path) -> Result<bool, CliError> {
// Try to find a workspace Cargo.toml at the parent level
let mut current = target_dir.parent();

// Typical heuristics: We usually execute from root workspace or nested once
// For safety, just check exactly one parent.
if let Some(parent) = current {
Expand All @@ -176,26 +207,40 @@ fn add_to_workspace_if_present(target_dir: &Path) -> Result<bool, CliError> {
if content.contains("[workspace]") && content.contains("members = [") {
// If it isn't already there...
let plugin_name = target_dir.file_name().unwrap_or_default().to_string_lossy();
if !content.contains(&format!("\"{}\"", plugin_name)) && !content.contains(&format!("'{}'", plugin_name)) {
if !content.contains(&format!("\"{}\"", plugin_name))
&& !content.contains(&format!("'{}'", plugin_name))
{
// Try to inject it at the end of members
if let Some(members_start) = content.find("members = [") {
let members_end = content[members_start..].find("]").map(|m| m + members_start);
let members_end = content[members_start..]
.find("]")
.map(|m| m + members_start);
if let Some(end_idx) = members_end {
// Extract inner array, add ours, reform
let inner = &content[members_start + 11..end_idx];
let new_content = if inner.trim().is_empty() {
format!("{}members = [\n \"{}\"\n]{}", &content[..members_start], plugin_name, &content[end_idx+1..])
format!(
"{}members = [\n \"{}\"\n]{}",
&content[..members_start],
plugin_name,
&content[end_idx + 1..]
)
} else {
// Find last entry
let last_quote = inner.rfind('"').or_else(|| inner.rfind('\''));
if let Some(q) = last_quote {
let absolute_q = members_start + 11 + q;
format!("{}\",\n \"{}\"{}", &content[..absolute_q], plugin_name, &content[absolute_q+1..])
format!(
"{}\",\n \"{}\"{}",
&content[..absolute_q],
plugin_name,
&content[absolute_q + 1..]
)
} else {
content.clone() // fallback
}
};

// To perfectly preserve user formatting, let's do a naive replace
fs::write(parent_cargo, new_content)?;
return Ok(true);
Expand All @@ -205,6 +250,6 @@ fn add_to_workspace_if_present(target_dir: &Path) -> Result<bool, CliError> {
}
}
}

Ok(false)
}
5 changes: 4 additions & 1 deletion crates/mofa-cli/src/commands/plugin/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ pub async fn run(ctx: &CliContext, name: &str, force: bool) -> Result<(), CliErr
.unregister(name)
.map_err(|e| CliError::PluginError(format!("Failed to unregister plugin: {}", e)))?;

if !removed && persisted_updated && let Some(previous) = previous_spec {
if !removed
&& persisted_updated
&& let Some(previous) = previous_spec
{
ctx.plugin_store.save(name, &previous).map_err(|e| {
CliError::PluginError(format!(
"Plugin '{}' remained registered and failed to restore persisted state: {}",
Expand Down
Loading