use anyhow::{anyhow, Result}; use log::info; use once_cell::sync::Lazy; use regex::Regex; use serde::Serialize; use serde_json::{Map, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; static PROMPT_FILE_PATTERN: Lazy = Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex")); #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceInfo { pub exists: bool, pub path: Option, pub fields: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigSources { pub md: SourceInfo, pub json: SourceInfo, } /// Get OpenCode config directory path fn get_config_dir() -> PathBuf { dirs::home_dir() .expect("Cannot determine home directory") .join(".config") .join("opencode") } /// Get agent directory path fn get_agent_dir() -> PathBuf { get_config_dir().join("agent") } /// Get command directory path fn get_command_dir() -> PathBuf { get_config_dir().join("command") } /// Get config file path fn get_config_file() -> PathBuf { get_config_dir().join("opencode.json") } /// Ensure required directories exist async fn ensure_dirs() -> Result<()> { let config_dir = get_config_dir(); let agent_dir = get_agent_dir(); let command_dir = get_command_dir(); fs::create_dir_all(&config_dir).await?; fs::create_dir_all(&agent_dir).await?; fs::create_dir_all(&command_dir).await?; Ok(()) } /// Check if a value is a prompt file reference like {file:./prompts/agent.txt} fn is_prompt_file_reference(value: &str) -> bool { PROMPT_FILE_PATTERN.is_match(value.trim()) } /// Resolve a prompt file reference to an absolute path fn resolve_prompt_file_path(reference: &str) -> Option { let trimmed = reference.trim(); let captures = PROMPT_FILE_PATTERN.captures(trimmed)?; let target = captures.get(1)?.as_str().trim(); if target.is_empty() { return None; } let path = if target.starts_with("./") { get_config_dir().join(&target[2..]) } else if Path::new(target).is_absolute() { PathBuf::from(target) } else { get_config_dir().join(target) }; Some(path) } /// Write content to a prompt file async fn write_prompt_file(file_path: &Path, content: &str) -> Result<()> { if let Some(parent) = file_path.parent() { fs::create_dir_all(parent).await?; } fs::write(file_path, content).await?; info!("Updated prompt file: {}", file_path.display()); Ok(()) } /// Strip JSON comments from content fn strip_json_comments(content: &str) -> String { let mut result = String::new(); let mut in_string = false; let mut escape_next = false; let mut chars = content.chars().peekable(); while let Some(ch) = chars.next() { if escape_next { result.push(ch); escape_next = false; continue; } if ch == '\\' && in_string { result.push(ch); escape_next = true; continue; } if ch == '"' { in_string = !in_string; result.push(ch); continue; } if !in_string { if ch == '/' { if let Some(&next_ch) = chars.peek() { if next_ch == '/' { // Line comment - skip until end of line chars.next(); // consume the second '/' while let Some(c) = chars.next() { if c == '\n' { result.push('\n'); break; } } continue; } else if next_ch == '*' { // Block comment - skip until */ chars.next(); // consume the '*' let mut prev = ' '; while let Some(c) = chars.next() { if prev == '*' && c == '/' { break; } prev = c; } continue; } } } } result.push(ch); } result } /// Read opencode.json configuration file pub async fn read_config() -> Result { let config_file = get_config_file(); if !config_file.exists() { return Ok(Value::Object(serde_json::Map::new())); } let content = fs::read_to_string(&config_file).await?; let normalized = strip_json_comments(&content).trim().to_string(); if normalized.is_empty() { return Ok(Value::Object(serde_json::Map::new())); } serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e)) } /// Write opencode.json configuration file with backup pub async fn write_config(config: &Value) -> Result<()> { let config_file = get_config_file(); // Create/overwrite single backup before writing if config_file.exists() { let file_name = config_file .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| anyhow!("Invalid config file name"))?; let backup_path = config_file.with_file_name(format!("{file_name}.openchamber.backup")); fs::copy(&config_file, &backup_path).await?; info!("Created config backup: {}", backup_path.display()); } let json_string = serde_json::to_string_pretty(config)?; fs::write(&config_file, json_string).await?; info!("Successfully wrote config file"); Ok(()) } /// Markdown file data #[derive(Debug)] struct MdData { frontmatter: HashMap, body: String, } /// Parse markdown file with YAML frontmatter async fn parse_md_file(file_path: &Path) -> Result { let content = fs::read_to_string(file_path).await?; // Match YAML frontmatter: ---\n...\n---\n let re = Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$").expect("valid regex"); if let Some(captures) = re.captures(&content) { let yaml_str = captures.get(1).map(|m| m.as_str()).unwrap_or(""); let body = captures.get(2).map(|m| m.as_str()).unwrap_or("").trim(); let frontmatter: HashMap = serde_yaml::from_str(yaml_str).unwrap_or_default(); Ok(MdData { frontmatter, body: body.to_string(), }) } else { // No frontmatter, treat entire content as body Ok(MdData { frontmatter: HashMap::new(), body: content.trim().to_string(), }) } } /// Write markdown file with YAML frontmatter async fn write_md_file( file_path: &Path, frontmatter: &HashMap, body: &str, ) -> Result<()> { let yaml_str = serde_yaml::to_string(frontmatter)?; let content = format!("---\n{}---\n\n{}", yaml_str, body); fs::write(file_path, content).await?; info!("Successfully wrote markdown file: {}", file_path.display()); Ok(()) } /// Get information about where agent configuration is stored pub async fn get_agent_sources(agent_name: &str) -> Result { ensure_dirs().await?; let md_path = get_agent_dir().join(format!("{}.md", agent_name)); let md_exists = md_path.exists(); let mut md_fields = Vec::new(); if md_exists { let md_data = parse_md_file(&md_path).await?; md_fields.extend(md_data.frontmatter.keys().cloned()); if !md_data.body.trim().is_empty() { md_fields.push("prompt".to_string()); } } let config = read_config().await?; let json_section = config .get("agent") .and_then(|v| v.as_object()) .and_then(|obj| obj.get(agent_name)); let json_fields = json_section .and_then(|value| value.as_object()) .map(|obj| obj.keys().cloned().collect::>()) .unwrap_or_default(); let sources = ConfigSources { md: SourceInfo { exists: md_exists, path: md_exists.then(|| md_path.display().to_string()), fields: md_fields, }, json: SourceInfo { exists: json_section.is_some(), path: Some(get_config_file().display().to_string()), fields: json_fields, }, }; Ok(sources) } /// Create new agent as .md file pub async fn create_agent(agent_name: &str, config: &HashMap) -> Result<()> { ensure_dirs().await?; let md_path = get_agent_dir().join(format!("{}.md", agent_name)); // Check if agent already exists if md_path.exists() { return Err(anyhow!("Agent {} already exists as .md file", agent_name)); } let existing_config = read_config().await?; if let Some(agents) = existing_config.get("agent").and_then(|v| v.as_object()) { if agents.contains_key(agent_name) { return Err(anyhow!( "Agent {} already exists in opencode.json", agent_name )); } } // Extract prompt from config let mut frontmatter = config.clone(); let prompt = frontmatter .remove("prompt") .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(); // Write .md file write_md_file(&md_path, &frontmatter, &prompt).await?; info!("Created new agent: {}", agent_name); Ok(()) } /// Update existing agent using field-level logic pub async fn update_agent(agent_name: &str, updates: &HashMap) -> Result<()> { ensure_dirs().await?; let md_path = get_agent_dir().join(format!("{}.md", agent_name)); let md_exists = md_path.exists(); let mut md_data = if md_exists { Some(parse_md_file(&md_path).await?) } else { None }; let mut config = read_config().await?; let mut existing_agent = config .get("agent") .and_then(|v| v.as_object()) .and_then(|obj| obj.get(agent_name)) .and_then(|v| v.as_object()) .cloned() .unwrap_or_else(Map::new); let had_json_fields = !existing_agent.is_empty(); let mut md_modified = false; let mut json_modified = false; for (field, value) in updates.iter() { // Handle explicit removals (null payload) for scalar/frontmatter/JSON fields if value.is_null() { if md_exists { if let Some(ref mut data) = md_data { if data.frontmatter.remove(field).is_some() { md_modified = true; } } } if existing_agent.remove(field).is_some() { json_modified = true; } continue; } // Special handling for prompt field if field == "prompt" { let normalized_value = value.as_str().unwrap_or("").to_string(); if md_exists { if let Some(ref mut data) = md_data { data.body = normalized_value.clone(); md_modified = true; } } else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) { if is_prompt_file_reference(prompt_ref) { if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) { write_prompt_file(&prompt_file_path, &normalized_value).await?; } else { return Err(anyhow!( "Invalid prompt file reference for agent {}", agent_name )); } continue; } } // Write prompt directly to JSON entry (file ref or inline string) existing_agent.insert("prompt".to_string(), Value::String(normalized_value)); json_modified = true; continue; } // Check where field is currently defined let in_md = md_data .as_ref() .map(|data| data.frontmatter.contains_key(field)) .unwrap_or(false); let in_json = existing_agent.contains_key(field); if in_md { // Update in .md frontmatter if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } } else if in_json { // Update in opencode.json while preserving existing fields existing_agent.insert(field.clone(), value.clone()); json_modified = true; } else { // Field not defined - apply priority rules if md_exists && !existing_agent.is_empty() { // Both exist → add to opencode.json (higher priority) without dropping other keys existing_agent.insert(field.clone(), value.clone()); json_modified = true; } else if md_exists { // Only .md exists → add to frontmatter if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } } else { // Only JSON or built-in → add/create section in opencode.json existing_agent.insert(field.clone(), value.clone()); json_modified = true; } } } // Write changes if md_modified { if let Some(data) = md_data { write_md_file(&md_path, &data.frontmatter, &data.body).await?; } } if json_modified { // Avoid creating a new JSON section for agents that already live exclusively in .md if md_exists && !had_json_fields { json_modified = false; } } if json_modified { if !config.is_object() { config = Value::Object(Map::new()); } let config_obj = config.as_object_mut().unwrap(); let agents_entry = config_obj .entry("agent".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !agents_entry.is_object() { *agents_entry = Value::Object(Map::new()); } let agents_obj = agents_entry.as_object_mut().unwrap(); agents_obj.insert(agent_name.to_string(), Value::Object(existing_agent)); write_config(&config).await?; } info!( "Updated agent: {} (md: {}, json: {})", agent_name, md_modified, json_modified ); Ok(()) } /// Delete agent configuration pub async fn delete_agent(agent_name: &str) -> Result<()> { let md_path = get_agent_dir().join(format!("{}.md", agent_name)); let mut deleted = false; // 1. Delete .md file if exists if md_path.exists() { fs::remove_file(&md_path).await?; info!("Deleted agent .md file: {}", md_path.display()); deleted = true; } // 2. Remove section from opencode.json if exists let mut config = read_config().await?; if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) { if agents.remove(agent_name).is_some() { write_config(&config).await?; info!("Removed agent from opencode.json: {}", agent_name); deleted = true; } } // 3. If nothing was deleted (built-in agent), disable it if !deleted { if !config.is_object() { config = Value::Object(serde_json::Map::new()); } let config_obj = config.as_object_mut().unwrap(); if !config_obj.contains_key("agent") { config_obj.insert("agent".to_string(), Value::Object(serde_json::Map::new())); } let agents = config_obj.get_mut("agent").unwrap(); if !agents.is_object() { *agents = Value::Object(serde_json::Map::new()); } let mut disable_obj = serde_json::Map::new(); disable_obj.insert("disable".to_string(), Value::Bool(true)); agents .as_object_mut() .unwrap() .insert(agent_name.to_string(), Value::Object(disable_obj)); write_config(&config).await?; info!("Disabled built-in agent: {}", agent_name); } Ok(()) } /// Get information about where command configuration is stored pub async fn get_command_sources(command_name: &str) -> Result { ensure_dirs().await?; let md_path = get_command_dir().join(format!("{}.md", command_name)); let md_exists = md_path.exists(); let mut md_fields = Vec::new(); if md_exists { let md_data = parse_md_file(&md_path).await?; md_fields.extend(md_data.frontmatter.keys().cloned()); if !md_data.body.trim().is_empty() { md_fields.push("template".to_string()); } } let config = read_config().await?; let json_section = config .get("command") .and_then(|v| v.as_object()) .and_then(|obj| obj.get(command_name)); let json_fields = json_section .and_then(|value| value.as_object()) .map(|obj| obj.keys().cloned().collect::>()) .unwrap_or_default(); let sources = ConfigSources { md: SourceInfo { exists: md_exists, path: md_exists.then(|| md_path.display().to_string()), fields: md_fields, }, json: SourceInfo { exists: json_section.is_some(), path: Some(get_config_file().display().to_string()), fields: json_fields, }, }; Ok(sources) } /// Create new command as .md file pub async fn create_command(command_name: &str, config: &HashMap) -> Result<()> { ensure_dirs().await?; let md_path = get_command_dir().join(format!("{}.md", command_name)); // Check if command already exists if md_path.exists() { return Err(anyhow!( "Command {} already exists as .md file", command_name )); } let existing_config = read_config().await?; if let Some(commands) = existing_config.get("command").and_then(|v| v.as_object()) { if commands.contains_key(command_name) { return Err(anyhow!( "Command {} already exists in opencode.json", command_name )); } } // Extract template from config let mut frontmatter = config.clone(); let template = frontmatter .remove("template") .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(); // Write .md file write_md_file(&md_path, &frontmatter, &template).await?; info!("Created new command: {}", command_name); Ok(()) } /// Update existing command using field-level logic pub async fn update_command( command_name: &str, updates: &HashMap, ) -> Result<()> { ensure_dirs().await?; let md_path = get_command_dir().join(format!("{}.md", command_name)); let md_exists = md_path.exists(); let mut md_data = if md_exists { Some(parse_md_file(&md_path).await?) } else { None }; let mut config = read_config().await?; let mut existing_command = config .get("command") .and_then(|v| v.as_object()) .and_then(|obj| obj.get(command_name)) .and_then(|v| v.as_object()) .cloned() .unwrap_or_else(Map::new); let had_json_fields = !existing_command.is_empty(); let mut md_modified = false; let mut json_modified = false; for (field, value) in updates.iter() { // Handle explicit removals (null payload) for scalar/frontmatter/JSON fields if value.is_null() { if md_exists { if let Some(ref mut data) = md_data { if data.frontmatter.remove(field).is_some() { md_modified = true; } } } if existing_command.remove(field).is_some() { json_modified = true; } continue; } // Special handling for template field if field == "template" { let normalized_value = value.as_str().unwrap_or("").to_string(); if md_exists { if let Some(ref mut data) = md_data { data.body = normalized_value.clone(); md_modified = true; } continue; } else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) { if is_prompt_file_reference(template_ref) { if let Some(template_file_path) = resolve_prompt_file_path(template_ref) { write_prompt_file(&template_file_path, &normalized_value).await?; } else { return Err(anyhow!( "Invalid template file reference for command {}", command_name )); } continue; } } // Write template directly to JSON entry (file ref or inline string) existing_command.insert("template".to_string(), Value::String(normalized_value)); json_modified = true; continue; } // Check where field is currently defined let in_md = md_data .as_ref() .map(|data| data.frontmatter.contains_key(field)) .unwrap_or(false); let in_json = existing_command.contains_key(field); if in_md { // Update in .md frontmatter if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } } else if in_json { // Update in opencode.json while preserving existing fields existing_command.insert(field.clone(), value.clone()); json_modified = true; } else { // Field not defined - apply priority rules if md_exists && !existing_command.is_empty() { // Both exist → add to opencode.json (higher priority) existing_command.insert(field.clone(), value.clone()); json_modified = true; } else if md_exists { // Only .md exists → add to frontmatter if let Some(ref mut data) = md_data { data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } } else { // Only JSON or built-in → add/create section in opencode.json existing_command.insert(field.clone(), value.clone()); json_modified = true; } } } // Write changes if md_modified { if let Some(data) = md_data { write_md_file(&md_path, &data.frontmatter, &data.body).await?; } } if json_modified { // Avoid creating a new JSON section for commands that already live exclusively in .md if md_exists && !had_json_fields { json_modified = false; } } if json_modified { if !config.is_object() { config = Value::Object(Map::new()); } let config_obj = config.as_object_mut().unwrap(); let commands_entry = config_obj .entry("command".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !commands_entry.is_object() { *commands_entry = Value::Object(Map::new()); } let commands_obj = commands_entry.as_object_mut().unwrap(); commands_obj.insert(command_name.to_string(), Value::Object(existing_command)); write_config(&config).await?; } info!( "Updated command: {} (md: {}, json: {})", command_name, md_modified, json_modified ); Ok(()) } /// Delete command configuration pub async fn delete_command(command_name: &str) -> Result<()> { let md_path = get_command_dir().join(format!("{}.md", command_name)); let mut deleted = false; // 1. Delete .md file if exists if md_path.exists() { fs::remove_file(&md_path).await?; info!("Deleted command .md file: {}", md_path.display()); deleted = true; } // 2. Remove section from opencode.json if exists let mut config = read_config().await?; if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) { if commands.remove(command_name).is_some() { write_config(&config).await?; info!("Removed command from opencode.json: {}", command_name); deleted = true; } } // 3. If nothing was deleted, throw error if !deleted { return Err(anyhow!("Command \"{}\" not found", command_name)); } Ok(()) }