add cli config and mcp

This commit is contained in:
Nicolò Boschi 2025-11-21 17:13:31 +01:00
parent 536da41775
commit 4ffef6c666
5 changed files with 242 additions and 11 deletions

View file

@ -38,6 +38,7 @@ thiserror = "1.0"
# Utilities
chrono = "0.4"
walkdir = "2.5"
dirs = "5.0"
[profile.release]
opt-level = "z"

View file

@ -1,29 +1,153 @@
use anyhow::{Context, Result};
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
const DEFAULT_API_URL: &str = "http://localhost:8080";
const CONFIG_FILE_NAME: &str = "config";
const CONFIG_DIR_NAME: &str = ".memora";
pub struct Config {
pub api_url: String,
pub source: ConfigSource,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigSource {
LocalFile,
Environment,
Default,
}
impl std::fmt::Display for ConfigSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigSource::LocalFile => write!(f, "config file"),
ConfigSource::Environment => write!(f, "environment variable"),
ConfigSource::Default => write!(f, "default"),
}
}
}
impl Config {
pub fn from_env() -> Result<Self> {
let api_url = env::var("MEMORA_API_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
/// Load configuration with the following priority:
/// 1. Environment variable (MEMORA_API_URL) - highest priority, for overrides
/// 2. Local config file (~/.memora/config.toml)
/// 3. Default (http://localhost:8080)
pub fn load() -> Result<Self> {
// 1. Environment variable takes highest priority (for overrides)
if let Ok(api_url) = env::var("MEMORA_API_URL") {
return Self::validate_and_create(api_url, ConfigSource::Environment);
}
// Validate URL format
// 2. Try local config file
if let Some(api_url) = Self::load_from_file()? {
return Self::validate_and_create(api_url, ConfigSource::LocalFile);
}
// 3. Fall back to default
Self::validate_and_create(DEFAULT_API_URL.to_string(), ConfigSource::Default)
}
/// Legacy method for backwards compatibility
pub fn from_env() -> Result<Self> {
Self::load()
}
fn validate_and_create(api_url: String, source: ConfigSource) -> Result<Self> {
if !api_url.starts_with("http://") && !api_url.starts_with("https://") {
anyhow::bail!(
"Invalid API URL: {}. Must start with http:// or https://",
api_url
);
}
Ok(Config { api_url, source })
}
Ok(Config { api_url })
fn config_dir() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(CONFIG_DIR_NAME))
}
fn config_file_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join(CONFIG_FILE_NAME))
}
fn load_from_file() -> Result<Option<String>> {
let config_path = match Self::config_file_path() {
Some(path) => path,
None => return Ok(None),
};
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
// Simple TOML parsing for api_url
for line in content.lines() {
let line = line.trim();
if line.starts_with("api_url") {
if let Some(value) = line.split('=').nth(1) {
let value = value.trim().trim_matches('"').trim_matches('\'');
if !value.is_empty() {
return Ok(Some(value.to_string()));
}
}
}
}
Ok(None)
}
pub fn save_api_url(api_url: &str) -> Result<PathBuf> {
let config_dir = Self::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
// Create config directory if it doesn't exist
if !config_dir.exists() {
fs::create_dir_all(&config_dir)
.with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?;
}
let config_path = config_dir.join(CONFIG_FILE_NAME);
let content = format!("api_url = \"{}\"\n", api_url);
fs::write(&config_path, content)
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
Ok(config_path)
}
pub fn api_url(&self) -> &str {
&self.api_url
}
pub fn config_file_path_display() -> String {
Self::config_file_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.memora/config.toml".to_string())
}
}
/// Prompt user for API URL interactively
pub fn prompt_api_url(current_url: Option<&str>) -> Result<String> {
let default = current_url.unwrap_or(DEFAULT_API_URL);
print!("Enter API URL [{}]: ", default);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
Ok(default.to_string())
} else {
Ok(input.to_string())
}
}
pub fn generate_doc_id() -> String {

View file

@ -172,9 +172,15 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
pub fn print_config_help() {
println!("\n{}", "Configuration:".bright_cyan().bold());
println!(" Set the API URL using an environment variable:");
println!(" {}", "export MEMORA_API_URL=http://localhost:8080".bright_white());
println!("\n Add to your shell profile to make it permanent:");
println!(" {}", "echo 'export MEMORA_API_URL=http://localhost:8080' >> ~/.zshrc".bright_black());
println!(" Run the configure command to set the API URL:");
println!(" {}", "memora configure".bright_white());
println!();
println!(" Or set it directly:");
println!(" {}", "memora configure --api-url http://your-api:8080".bright_white());
println!();
println!(" {}", "Configuration priority:".bright_yellow());
println!(" 1. Environment variable (MEMORA_API_URL) - highest priority");
println!(" 2. Config file (~/.memora/config)");
println!(" 3. Default (http://localhost:8080)");
println!();
}

View file

@ -34,6 +34,7 @@ impl From<Format> for OutputFormat {
#[command(name = "memora")]
#[command(about = "Memora CLI - Semantic memory system", long_about = None)]
#[command(version)]
#[command(after_help = get_after_help())]
struct Cli {
/// Output format (pretty, json, yaml)
#[arg(short = 'o', long, global = true, default_value = "pretty")]
@ -47,6 +48,18 @@ struct Cli {
command: Commands,
}
fn get_after_help() -> String {
let config = config::Config::load().ok();
let (api_url, source) = match &config {
Some(c) => (c.api_url.as_str(), c.source.to_string()),
None => ("http://localhost:8080", "default".to_string()),
};
format!(
"Current API URL: {} (from {})\n\nRun 'memora configure' to change the API URL.",
api_url, source
)
}
#[derive(Subcommand)]
enum Commands {
/// Manage agents (list, profile, stats)
@ -64,6 +77,14 @@ enum Commands {
/// Manage async operations (list, cancel)
#[command(subcommand)]
Operation(OperationCommands),
/// Configure the CLI (API URL, etc.)
#[command(after_help = "Configuration priority:\n 1. Environment variable (MEMORA_API_URL) - highest priority\n 2. Config file (~/.memora/config)\n 3. Default (http://localhost:8080)")]
Configure {
/// API URL to connect to (interactive prompt if not provided)
#[arg(long)]
api_url: Option<String>,
},
}
#[derive(Subcommand)]
@ -285,6 +306,11 @@ fn run() -> Result<()> {
let output_format: OutputFormat = cli.output.into();
let verbose = cli.verbose;
// Handle configure command before loading full config (it doesn't need API client)
if let Commands::Configure { api_url } = cli.command {
return handle_configure(api_url, output_format);
}
// Load configuration
let config = Config::from_env().unwrap_or_else(|e| {
ui::print_error(&format!("Configuration error: {}", e));
@ -301,6 +327,7 @@ fn run() -> Result<()> {
// Execute command and handle errors
let result: Result<()> = match cli.command {
Commands::Configure { .. } => unreachable!(), // Handled above
Commands::Agent(agent_cmd) => match agent_cmd {
AgentCommands::List => {
let spinner = if output_format == OutputFormat::Pretty {
@ -1075,3 +1102,58 @@ fn run() -> Result<()> {
Ok(())
}
fn handle_configure(api_url: Option<String>, output_format: OutputFormat) -> Result<()> {
// Load current config to show current state
let current_config = Config::load().ok();
if output_format == OutputFormat::Pretty {
ui::print_info("Memora CLI Configuration");
println!();
// Show current configuration
if let Some(ref config) = current_config {
println!(" Current API URL: {}", config.api_url);
println!(" Source: {}", config.source);
println!();
}
}
// Get the new API URL (from argument or prompt)
let new_api_url = match api_url {
Some(url) => url,
None => {
// Interactive prompt
let current = current_config.as_ref().map(|c| c.api_url.as_str());
config::prompt_api_url(current)?
}
};
// Validate the URL
if !new_api_url.starts_with("http://") && !new_api_url.starts_with("https://") {
ui::print_error(&format!(
"Invalid API URL: {}. Must start with http:// or https://",
new_api_url
));
return Ok(());
}
// Save to config file
let config_path = Config::save_api_url(&new_api_url)?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Configuration saved to {}", config_path.display()));
println!();
println!(" API URL: {}", new_api_url);
println!();
println!("Note: Environment variable MEMORA_API_URL will override this setting.");
} else {
let result = serde_json::json!({
"api_url": new_api_url,
"config_path": config_path.display().to_string(),
});
output::print_output(&result, output_format)?;
}
Ok(())
}

View file

@ -28,6 +28,13 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP:
"""
**CRITICAL: Store important user information to long-term memory.**
** PER-USER TOOL - REQUIRES USER IDENTIFICATION:**
- This tool is STRICTLY per-user. Each user MUST have a unique `agent_id`.
- ONLY use this tool if you have a valid user identifier (user ID, email, session ID, etc.) to map to `agent_id`.
- DO NOT use this tool if you cannot identify the specific user.
- DO NOT share memories between different users - each user's memories are isolated by their `agent_id`.
- If you don't have a user identifier, DO NOT use this tool at all.
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests (e.g., "I love hiking", "I'm a vegetarian")
- Important events or milestones (e.g., "I got promoted", "My birthday is June 15")
@ -44,7 +51,9 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP:
"career_goals", "project_details", etc. This helps organize and retrieve related memories later.
Args:
agent_id: The unique identifier for the agent/user storing the memory
agent_id: **REQUIRED** - The unique, persistent identifier for this specific user (e.g., user_id, email, session_id).
This MUST be consistent across all interactions with the same user.
Example: "user_12345", "alice@example.com", "session_abc123"
content: The fact/memory to store (be specific and include relevant details)
context: Categorize the memory (e.g., 'personal_preferences', 'work_history', 'hobbies', 'family')
explanation: Optional explanation for why this memory is being stored
@ -69,6 +78,13 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP:
"""
**CRITICAL: Search user's memory to provide personalized, context-aware responses.**
** PER-USER TOOL - REQUIRES USER IDENTIFICATION:**
- This tool is STRICTLY per-user. Each user MUST have a unique `agent_id`.
- ONLY use this tool if you have a valid user identifier (user ID, email, session ID, etc.) to map to `agent_id`.
- DO NOT use this tool if you cannot identify the specific user.
- DO NOT search across multiple users - each user's memories are isolated by their `agent_id`.
- If you don't have a user identifier, DO NOT use this tool at all.
Use this tool PROACTIVELY at the start of conversations or when making recommendations to:
- Check user's preferences before making suggestions (e.g., "what foods does the user like?")
- Recall user's history to provide continuity (e.g., "what projects has the user worked on?")
@ -87,7 +103,9 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP:
"user's work experience", "user's dietary restrictions", "what does the user know about X?"
Args:
agent_id: The unique identifier for the agent/user whose memories to search
agent_id: **REQUIRED** - The unique, persistent identifier for this specific user (e.g., user_id, email, session_id).
This MUST be consistent across all interactions with the same user.
Example: "user_12345", "alice@example.com", "session_abc123"
query: Natural language search query to find relevant memories
max_tokens: Maximum tokens for search context (default: 4096)
explanation: Optional explanation for why this search is being performed