Files
openchamber/packages/desktop/src-tauri/src/opencode_config.rs
T

795 lines
25 KiB
Rust

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<Regex> =
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<String>,
pub fields: Vec<String>,
}
#[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<PathBuf> {
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<Value> {
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<String, Value>,
body: String,
}
/// Parse markdown file with YAML frontmatter
async fn parse_md_file(file_path: &Path) -> Result<MdData> {
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<String, Value> =
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<String, Value>,
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<ConfigSources> {
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::<Vec<_>>())
.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<String, Value>) -> 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<String, Value>) -> 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<ConfigSources> {
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::<Vec<_>>())
.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<String, Value>) -> 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<String, Value>,
) -> 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(())
}