Files
openchamber/packages/desktop/src-tauri/src/opencode_config.rs
T
Bohdan Triapitsyn 18c5b4c7b5 feat: add multi-project support (#110)
* feat: Implement project management store with project path validation and synchronization

- Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths.
- Implemented persistence for projects and active project ID using safe storage.
- Introduced synchronization from desktop settings to keep project data consistent.
- Enhanced session store to manage sessions by directory and added new methods for session management.
- Updated todo store to fetch session todos based on the directory context.
- Refactored server code to validate and resolve project directories for various API endpoints.
- Added project entry validation and sanitization to ensure data integrity.

* feat(settings): migrate legacy project settings and update settings loading logic

* feat: enhance project management with directory-aware settings and improved agent/command source handling

* feat: enhance session and project management with directory-aware settings and improved configuration refresh logic

* feat: enhance project management with worktree manager integration and project directory resolution

* feat: enhance agent groups store with project directory resolution and loading logic

* feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers

* feat: refactor command and project handling in useCommandsStore

- Replaced useDirectoryStore with useProjectsStore to manage project paths.
- Introduced getRequestDirectory function to determine the active project directory.
- Updated command fetching to respect project-level scoping.
- Enhanced error handling and logging for command configuration fetching.
- Improved command configuration saving and updating to utilize project directory context.

feat: enhance project path normalization in useProjectsStore

- Added resolveTildePath function to expand paths starting with ~.
- Updated normalizeProjectPath to utilize home directory for path expansion.

fix: update permission handling in useSessionStore

- Changed Permission type to PermissionRequest for clarity.
- Updated respondToPermission method to use requestId instead of permissionId.

refactor: improve permission utilities

- Introduced types for PermissionAction and PermissionRule.
- Enhanced getAgentDefinition and resolveConfigStore functions for better type safety.
- Added resolvePermissionAction to streamline permission resolution logic.

feat: add agent configuration retrieval endpoint

- Implemented new API endpoint to fetch agent configuration based on project directory.
- Enhanced getAgentPermissionSource to prioritize project-level permissions.

chore: update SDK version in package.json files

- Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files.

refactor: streamline bridge message handling

- Updated handleBridgeMessage to accept directory parameter for agent and command requests.
- Improved local API request handling to extract directory from query parameters and headers.

feat: enhance project configuration management

- Added functions to retrieve and merge project configuration paths.
- Improved handling of existing project configuration files for agents and commands.

* feat: enhance VSCode integration and session management

- Added support for a sticky sidebar header background in light and dark themes.
- Introduced functions to read VSCode workspace directory and check if running in VSCode.
- Implemented detailed logging for session loading and creation processes.
- Enhanced session filtering based on directory structure and canonical paths.
- Added a new method to reorder projects and prevent modifications in VSCode workspace.
- Improved error handling and logging for app initialization and markdown file parsing.
- Updated API checks and health checks to ensure readiness before proceeding.
- Refactored code for better readability and maintainability across various modules.

* feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response

* feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options

* fix(ui): share IME guard and cover multi-run

* fix(session): reduce maximum visible sessions in group from 7 to 5
2026-01-06 21:31:04 +02:00

2096 lines
67 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::env;
use std::path::{Path, PathBuf};
use tokio::fs;
static PROMPT_FILE_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex"));
/// Agent scope types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentScope {
User,
Project,
}
/// Command scope types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommandScope {
User,
Project,
}
/// Generic scope enum for SourceInfo (agents and commands share same structure)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
Project,
}
impl From<AgentScope> for Scope {
fn from(scope: AgentScope) -> Self {
match scope {
AgentScope::User => Scope::User,
AgentScope::Project => Scope::Project,
}
}
}
impl From<CommandScope> for Scope {
fn from(scope: CommandScope) -> Self {
match scope {
CommandScope::User => Scope::User,
CommandScope::Project => Scope::Project,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceInfo {
pub exists: bool,
pub path: Option<String>,
pub fields: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<Scope>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MdLocationInfo {
pub exists: bool,
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigSources {
pub md: SourceInfo,
pub json: SourceInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_md: Option<MdLocationInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_md: Option<MdLocationInfo>,
}
/// 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 user-level 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")
}
/// Get all possible project config paths in priority order
/// Priority: root > .opencode/, json > jsonc
fn get_project_config_candidates(working_directory: &Path) -> Vec<PathBuf> {
vec![
working_directory.join("opencode.json"),
working_directory.join("opencode.jsonc"),
working_directory.join(".opencode").join("opencode.json"),
working_directory.join(".opencode").join("opencode.jsonc"),
]
}
/// Find existing project config file or return default path for new config
fn get_project_config_file(working_directory: &Path) -> PathBuf {
let candidates = get_project_config_candidates(working_directory);
// Return first existing config file
for candidate in &candidates {
if candidate.exists() {
return candidate.clone();
}
}
// Default to root opencode.json for new configs
candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json"))
}
/// Get custom config file path from OPENCODE_CONFIG env var
fn get_custom_config_file() -> Option<PathBuf> {
env::var("OPENCODE_CONFIG").ok().map(PathBuf::from)
}
struct ConfigPaths {
user: PathBuf,
project: Option<PathBuf>,
custom: Option<PathBuf>,
}
struct ConfigLayers {
user: Value,
project: Value,
custom: Value,
#[allow(dead_code)]
merged: Value,
paths: ConfigPaths,
}
fn get_config_paths(working_directory: Option<&Path>) -> ConfigPaths {
ConfigPaths {
user: get_config_file(),
project: working_directory.map(get_project_config_file),
custom: get_custom_config_file(),
}
}
fn merge_values(base: &Value, overlay: &Value) -> Value {
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
let mut merged = base_map.clone();
for (key, value) in overlay_map.iter() {
let base_value = merged.get(key).unwrap_or(&Value::Null).clone();
let merged_value = merge_values(&base_value, value);
merged.insert(key.clone(), merged_value);
}
Value::Object(merged)
}
_ => overlay.clone(),
}
}
async fn read_config_file(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(serde_json::Map::new()));
}
let content = fs::read_to_string(path).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)
.or_else(|_| json5::from_str::<serde_json::Value>(&normalized))
.map_err(|e| anyhow!("Failed to parse config: {}", e))
}
async fn read_config_layers(working_directory: Option<&Path>) -> Result<ConfigLayers> {
let paths = get_config_paths(working_directory);
let user = read_config_file(&paths.user).await?;
let project = if let Some(ref path) = paths.project {
read_config_file(path).await?
} else {
Value::Object(serde_json::Map::new())
};
let custom = if let Some(ref path) = paths.custom {
read_config_file(path).await?
} else {
Value::Object(serde_json::Map::new())
};
let merged = merge_values(&merge_values(&user, &project), &custom);
Ok(ConfigLayers {
user,
project,
custom,
merged,
paths,
})
}
struct JsonEntrySource {
exists: bool,
path: Option<PathBuf>,
section: Option<Value>,
}
fn get_json_entry_source(
layers: &ConfigLayers,
section_key: &str,
entry_name: &str,
) -> JsonEntrySource {
if let Some(ref custom_path) = layers.paths.custom {
if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) {
if let Some(value) = section.get(entry_name) {
return JsonEntrySource {
exists: true,
path: Some(custom_path.clone()),
section: Some(value.clone()),
};
}
}
}
if let Some(ref project_path) = layers.paths.project {
if let Some(section) = layers.project.get(section_key).and_then(|v| v.as_object()) {
if let Some(value) = section.get(entry_name) {
return JsonEntrySource {
exists: true,
path: Some(project_path.clone()),
section: Some(value.clone()),
};
}
}
}
if let Some(section) = layers.user.get(section_key).and_then(|v| v.as_object()) {
if let Some(value) = section.get(entry_name) {
return JsonEntrySource {
exists: true,
path: Some(layers.paths.user.clone()),
section: Some(value.clone()),
};
}
}
JsonEntrySource {
exists: false,
path: None,
section: None,
}
}
fn get_json_write_target(layers: &ConfigLayers, preferred_scope: Option<Scope>) -> PathBuf {
if let Some(ref custom_path) = layers.paths.custom {
return custom_path.clone();
}
if preferred_scope == Some(Scope::Project) {
if let Some(ref project_path) = layers.paths.project {
return project_path.clone();
}
}
if let Some(ref project_path) = layers.paths.project {
return project_path.clone();
}
layers.paths.user.clone()
}
fn get_default_json_path(layers: &ConfigLayers) -> PathBuf {
if let Some(ref custom_path) = layers.paths.custom {
return custom_path.clone();
}
if let Some(ref project_path) = layers.paths.project {
return project_path.clone();
}
layers.paths.user.clone()
}
fn get_config_for_path<'a>(layers: &'a mut ConfigLayers, target_path: &Path) -> &'a mut Value {
if let Some(ref custom_path) = layers.paths.custom {
if custom_path == target_path {
return &mut layers.custom;
}
}
if let Some(ref project_path) = layers.paths.project {
if project_path == target_path {
return &mut layers.project;
}
}
&mut layers.user
}
// ============== AGENT SCOPE HELPERS ==============
/// Get project-level agent directory path
fn get_project_agent_dir(working_directory: &Path) -> PathBuf {
working_directory.join(".opencode").join("agent")
}
/// Get project-level agent path
fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf {
get_project_agent_dir(working_directory).join(format!("{}.md", agent_name))
}
/// Get user-level agent path
fn get_user_agent_path(agent_name: &str) -> PathBuf {
get_agent_dir().join(format!("{}.md", agent_name))
}
/// Ensure project agent directory exists
async fn ensure_project_agent_dir(working_directory: &Path) -> Result<PathBuf> {
let project_agent_dir = get_project_agent_dir(working_directory);
fs::create_dir_all(&project_agent_dir).await?;
Ok(project_agent_dir)
}
/// Determine agent scope based on where the .md file exists
pub fn get_agent_scope(
agent_name: &str,
working_directory: Option<&Path>,
) -> (Option<AgentScope>, Option<PathBuf>) {
if let Some(wd) = working_directory {
let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() {
return (Some(AgentScope::Project), Some(project_path));
}
}
let user_path = get_user_agent_path(agent_name);
if user_path.exists() {
return (Some(AgentScope::User), Some(user_path));
}
(None, None)
}
/// Get the path where an agent should be written based on scope
fn get_agent_write_path(
agent_name: &str,
working_directory: Option<&Path>,
requested_scope: Option<AgentScope>,
) -> (AgentScope, PathBuf) {
// For updates: check existing location first (project takes precedence)
let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory);
if let Some(path) = existing_path {
return (existing_scope.unwrap(), path);
}
// For new agents or built-in overrides: use requested scope or default to user
let scope = requested_scope.unwrap_or(AgentScope::User);
if scope == AgentScope::Project {
if let Some(wd) = working_directory {
return (AgentScope::Project, get_project_agent_path(wd, agent_name));
}
}
(AgentScope::User, get_user_agent_path(agent_name))
}
// ============== COMMAND SCOPE HELPERS ==============
/// Get project-level command directory path
fn get_project_command_dir(working_directory: &Path) -> PathBuf {
working_directory.join(".opencode").join("command")
}
/// Get project-level command path
fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf {
get_project_command_dir(working_directory).join(format!("{}.md", command_name))
}
/// Get user-level command path
fn get_user_command_path(command_name: &str) -> PathBuf {
get_command_dir().join(format!("{}.md", command_name))
}
/// Ensure project command directory exists
async fn ensure_project_command_dir(working_directory: &Path) -> Result<PathBuf> {
let project_command_dir = get_project_command_dir(working_directory);
fs::create_dir_all(&project_command_dir).await?;
Ok(project_command_dir)
}
/// Determine command scope based on where the .md file exists
pub fn get_command_scope(
command_name: &str,
working_directory: Option<&Path>,
) -> (Option<CommandScope>, Option<PathBuf>) {
if let Some(wd) = working_directory {
let project_path = get_project_command_path(wd, command_name);
if project_path.exists() {
return (Some(CommandScope::Project), Some(project_path));
}
}
let user_path = get_user_command_path(command_name);
if user_path.exists() {
return (Some(CommandScope::User), Some(user_path));
}
(None, None)
}
/// Get the path where a command should be written based on scope
fn get_command_write_path(
command_name: &str,
working_directory: Option<&Path>,
requested_scope: Option<CommandScope>,
) -> (CommandScope, PathBuf) {
// For updates: check existing location first (project takes precedence)
let (existing_scope, existing_path) = get_command_scope(command_name, working_directory);
if let Some(path) = existing_path {
return (existing_scope.unwrap(), path);
}
// For new commands or built-in overrides: use requested scope or default to user
let scope = requested_scope.unwrap_or(CommandScope::User);
if scope == CommandScope::Project {
if let Some(wd) = working_directory {
return (
CommandScope::Project,
get_project_command_path(wd, command_name),
);
}
}
(CommandScope::User, get_user_command_path(command_name))
}
/// 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 merged opencode.json configuration files
#[allow(dead_code)]
pub async fn read_config(working_directory: Option<&Path>) -> Result<Value> {
Ok(read_config_layers(working_directory).await?.merged)
}
/// Write opencode.json configuration file with backup
pub async fn write_config_at(config: &Value, config_file: &Path) -> Result<()> {
// 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)?;
if let Some(parent) = config_file.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(config_file, json_string).await?;
info!("Successfully wrote config file: {}", config_file.display());
Ok(())
}
/// Write user-level opencode.json configuration file
#[allow(dead_code)]
pub async fn write_config(config: &Value) -> Result<()> {
let config_file = get_config_file();
write_config_at(config, &config_file).await
}
/// 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<()> {
// Filter out null values - OpenCode expects keys to be omitted rather than set to null
let cleaned_frontmatter: HashMap<String, Value> = frontmatter
.iter()
.filter(|(_, v)| !v.is_null())
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let yaml_str = serde_yaml::to_string(&cleaned_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,
working_directory: Option<&Path>,
) -> Result<ConfigSources> {
ensure_dirs().await?;
// Check project level first (takes precedence)
let project_path = working_directory.map(|wd| get_project_agent_path(wd, agent_name));
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
// Then check user level
let user_path = get_user_agent_path(agent_name);
let user_exists = user_path.exists();
// Determine which md file to use (project takes precedence)
let (md_path, md_exists, md_scope) = if project_exists {
(project_path.clone(), true, Some(Scope::Project))
} else if user_exists {
(Some(user_path.clone()), true, Some(Scope::User))
} else {
(None, false, None)
};
let mut md_fields = Vec::new();
if md_exists {
if let Some(ref path) = md_path {
let md_data = parse_md_file(path).await?;
md_fields.extend(md_data.frontmatter.keys().cloned());
if !md_data.body.trim().is_empty() {
md_fields.push("prompt".to_string());
}
}
}
let layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "agent", agent_name);
let json_section = json_source.section.as_ref();
let json_fields = json_section
.and_then(|value| value.as_object())
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let json_path_buf = json_source
.path
.unwrap_or_else(|| get_default_json_path(&layers));
let json_path = json_path_buf.display().to_string();
let json_scope = if layers.paths.project.as_ref() == Some(&json_path_buf) {
Some(Scope::Project)
} else {
Some(Scope::User)
};
let sources = ConfigSources {
md: SourceInfo {
exists: md_exists,
path: md_path.map(|p| p.display().to_string()),
fields: md_fields,
scope: md_scope,
},
json: SourceInfo {
exists: json_source.exists,
path: Some(json_path),
fields: json_fields,
scope: if json_source.exists { json_scope } else { None },
},
project_md: Some(MdLocationInfo {
exists: project_exists,
path: project_path.map(|p| p.display().to_string()),
}),
user_md: Some(MdLocationInfo {
exists: user_exists,
path: Some(user_path.display().to_string()),
}),
};
Ok(sources)
}
/// Create new agent as .md file
pub async fn create_agent(
agent_name: &str,
config: &HashMap<String, Value>,
working_directory: Option<&Path>,
scope: Option<AgentScope>,
) -> Result<()> {
ensure_dirs().await?;
// Check if agent already exists at either level
if let Some(wd) = working_directory {
let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() {
return Err(anyhow!(
"Agent {} already exists as project-level .md file",
agent_name
));
}
}
let user_path = get_user_agent_path(agent_name);
if user_path.exists() {
return Err(anyhow!(
"Agent {} already exists as user-level .md file",
agent_name
));
}
let layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "agent", agent_name);
if json_source.exists {
return Err(anyhow!(
"Agent {} already exists in opencode.json",
agent_name
));
}
// Determine target path based on requested scope
let (target_scope, target_path) = if scope == Some(AgentScope::Project) {
if let Some(wd) = working_directory {
ensure_project_agent_dir(wd).await?;
(AgentScope::Project, get_project_agent_path(wd, agent_name))
} else {
(AgentScope::User, user_path)
}
} else {
(AgentScope::User, user_path)
};
// Extract prompt and scope from config - scope is only used for path determination, not written to file
let mut frontmatter = config.clone();
let prompt = frontmatter
.remove("prompt")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
frontmatter.remove("scope"); // Remove scope - it's not a valid agent field
// Write .md file
write_md_file(&target_path, &frontmatter, &prompt).await?;
info!(
"Created new agent: {} (scope: {:?}, path: {})",
agent_name,
target_scope,
target_path.display()
);
Ok(())
}
/// Update existing agent using field-level logic
pub async fn update_agent(
agent_name: &str,
updates: &HashMap<String, Value>,
working_directory: Option<&Path>,
) -> Result<()> {
ensure_dirs().await?;
// Determine correct path: project level takes precedence
let (scope, md_path) = get_agent_write_path(agent_name, working_directory, None);
let md_exists = md_path.exists();
// Check if agent exists in opencode.json across all config layers
let mut layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "agent", agent_name);
let mut existing_agent = json_source
.section
.as_ref()
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_else(Map::new);
let had_json_fields = !existing_agent.is_empty();
let preferred_scope = if working_directory.is_some() {
Some(Scope::Project)
} else {
Some(Scope::User)
};
let json_target_path = if json_source.exists {
json_source
.path
.clone()
.unwrap_or_else(|| get_json_write_target(&layers, preferred_scope))
} else {
get_json_write_target(&layers, preferred_scope)
};
let config = get_config_for_path(&mut layers, &json_target_path);
// Determine if we should create a new md file:
// Only for built-in agents (no md file AND no json config)
let is_builtin_override = !md_exists && !had_json_fields;
let target_path = if !md_exists && is_builtin_override {
// Built-in agent override - create at user level
get_user_agent_path(agent_name)
} else {
md_path.clone()
};
let mut md_data = if md_exists {
Some(parse_md_file(&md_path).await?)
} else if is_builtin_override {
// Only create new md data for built-in overrides
Some(MdData {
frontmatter: HashMap::new(),
body: String::new(),
})
} else {
None
};
// Only create new md if it's a built-in override
let creating_new_md = is_builtin_override;
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 || creating_new_md {
if let Some(ref mut data) = md_data {
data.body = normalized_value.clone();
md_modified = true;
}
continue;
} 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;
}
}
// For JSON-only agents, store prompt inline in JSON
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);
// JSON takes precedence over md, so update JSON first if field exists there
if in_json {
// Update in opencode.json (takes precedence)
existing_agent.insert(field.clone(), value.clone());
json_modified = true;
} else if in_md || creating_new_md {
// Update in .md frontmatter
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else {
// New field - add to the appropriate location based on agent source
if (md_exists || creating_new_md) && md_data.is_some() {
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else {
// JSON-only agent or has JSON fields - add to 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(&target_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_at(config, &json_target_path).await?;
}
info!(
"Updated agent: {} (scope: {:?}, md: {}, json: {})",
agent_name, scope, md_modified, json_modified
);
Ok(())
}
/// Delete agent configuration
pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) -> Result<()> {
let mut deleted = false;
// 1. Check project level first (takes precedence)
if let Some(wd) = working_directory {
let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() {
fs::remove_file(&project_path).await?;
info!(
"Deleted project-level agent .md file: {}",
project_path.display()
);
deleted = true;
}
}
// 2. Check user level
let user_path = get_user_agent_path(agent_name);
if user_path.exists() {
fs::remove_file(&user_path).await?;
info!("Deleted user-level agent .md file: {}", user_path.display());
deleted = true;
}
// 3. Remove section from opencode.json if exists (highest precedence entry only)
let mut layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "agent", agent_name);
if json_source.exists {
if let Some(json_path) = json_source.path.clone() {
let config = get_config_for_path(&mut layers, &json_path);
if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) {
if agents.remove(agent_name).is_some() {
write_config_at(config, &json_path).await?;
info!("Removed agent from opencode.json: {}", agent_name);
deleted = true;
}
}
}
}
// 4. If nothing was deleted (built-in agent), disable it in highest-precedence config
if !deleted {
let preferred_scope = if working_directory.is_some() {
Some(Scope::Project)
} else {
Some(Scope::User)
};
let json_path = get_json_write_target(&layers, preferred_scope);
let config = get_config_for_path(&mut layers, &json_path);
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_at(config, &json_path).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,
working_directory: Option<&Path>,
) -> Result<ConfigSources> {
ensure_dirs().await?;
// Check project level first (takes precedence)
let project_path = working_directory.map(|wd| get_project_command_path(wd, command_name));
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
// Then check user level
let user_path = get_user_command_path(command_name);
let user_exists = user_path.exists();
// Determine which md file to use (project takes precedence)
let (md_path, md_exists, md_scope) = if project_exists {
(project_path.clone(), true, Some(Scope::Project))
} else if user_exists {
(Some(user_path.clone()), true, Some(Scope::User))
} else {
(None, false, None)
};
let mut md_fields = Vec::new();
if md_exists {
if let Some(ref path) = md_path {
let md_data = parse_md_file(path).await?;
md_fields.extend(md_data.frontmatter.keys().cloned());
if !md_data.body.trim().is_empty() {
md_fields.push("template".to_string());
}
}
}
let layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "command", command_name);
let json_section = json_source.section.as_ref();
let json_fields = json_section
.and_then(|value| value.as_object())
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let json_path_buf = json_source
.path
.unwrap_or_else(|| get_default_json_path(&layers));
let json_path = json_path_buf.display().to_string();
let json_scope = if layers.paths.project.as_ref() == Some(&json_path_buf) {
Some(Scope::Project)
} else {
Some(Scope::User)
};
let sources = ConfigSources {
md: SourceInfo {
exists: md_exists,
path: md_path.map(|p| p.display().to_string()),
fields: md_fields,
scope: md_scope,
},
json: SourceInfo {
exists: json_source.exists,
path: Some(json_path),
fields: json_fields,
scope: if json_source.exists { json_scope } else { None },
},
project_md: Some(MdLocationInfo {
exists: project_exists,
path: project_path.map(|p| p.display().to_string()),
}),
user_md: Some(MdLocationInfo {
exists: user_exists,
path: Some(user_path.display().to_string()),
}),
};
Ok(sources)
}
/// Create new command as .md file
pub async fn create_command(
command_name: &str,
config: &HashMap<String, Value>,
working_directory: Option<&Path>,
scope: Option<CommandScope>,
) -> Result<()> {
ensure_dirs().await?;
// Check if command already exists at either level
if let Some(wd) = working_directory {
let project_path = get_project_command_path(wd, command_name);
if project_path.exists() {
return Err(anyhow!(
"Command {} already exists as project-level .md file",
command_name
));
}
}
let user_path = get_user_command_path(command_name);
if user_path.exists() {
return Err(anyhow!(
"Command {} already exists as user-level .md file",
command_name
));
}
let layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "command", command_name);
if json_source.exists {
return Err(anyhow!(
"Command {} already exists in opencode.json",
command_name
));
}
// Determine target path based on requested scope
let (target_scope, target_path) = if scope == Some(CommandScope::Project) {
if let Some(wd) = working_directory {
ensure_project_command_dir(wd).await?;
(
CommandScope::Project,
get_project_command_path(wd, command_name),
)
} else {
(CommandScope::User, user_path)
}
} else {
(CommandScope::User, user_path)
};
// Extract template and scope from config - scope is only used for path determination, not written to file
let mut frontmatter = config.clone();
let template = frontmatter
.remove("template")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
frontmatter.remove("scope"); // Remove scope - it's not a valid command field
// Write .md file
write_md_file(&target_path, &frontmatter, &template).await?;
info!(
"Created new command: {} (scope: {:?}, path: {})",
command_name,
target_scope,
target_path.display()
);
Ok(())
}
/// Update existing command using field-level logic
pub async fn update_command(
command_name: &str,
updates: &HashMap<String, Value>,
working_directory: Option<&Path>,
) -> Result<()> {
ensure_dirs().await?;
// Determine correct path: project level takes precedence
let (scope, md_path) = get_command_write_path(command_name, working_directory, None);
let md_exists = md_path.exists();
let mut layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "command", command_name);
let mut existing_command = json_source
.section
.as_ref()
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_else(Map::new);
let had_json_fields = !existing_command.is_empty();
let preferred_scope = if working_directory.is_some() {
Some(Scope::Project)
} else {
Some(Scope::User)
};
let json_target_path = if json_source.exists {
json_source
.path
.clone()
.unwrap_or_else(|| get_json_write_target(&layers, preferred_scope))
} else {
get_json_write_target(&layers, preferred_scope)
};
let config = get_config_for_path(&mut layers, &json_target_path);
// Only create a new md file for built-in overrides (no md + no json)
let is_builtin_override = !md_exists && !had_json_fields;
let target_path = if !md_exists && is_builtin_override {
// Built-in command override - create at user level
get_user_command_path(command_name)
} else {
md_path.clone()
};
let mut md_data = if md_exists {
Some(parse_md_file(&md_path).await?)
} else if is_builtin_override {
Some(MdData {
frontmatter: HashMap::new(),
body: String::new(),
})
} else {
None
};
let creating_new_md = is_builtin_override;
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 || creating_new_md {
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;
}
}
// For JSON-only commands, store template inline in JSON
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);
// JSON takes precedence over md, so update JSON first if field exists there
if in_json {
// Update in opencode.json while preserving existing fields
existing_command.insert(field.clone(), value.clone());
json_modified = true;
} else if in_md || creating_new_md {
// Update in .md frontmatter
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else {
// New field - add to the appropriate location based on command source
if (md_exists || creating_new_md) && md_data.is_some() {
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else {
// JSON-only command or built-in - add to 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(&target_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_at(config, &json_target_path).await?;
}
info!(
"Updated command: {} (scope: {:?}, md: {}, json: {})",
command_name, scope, md_modified, json_modified
);
Ok(())
}
/// Delete command configuration
pub async fn delete_command(command_name: &str, working_directory: Option<&Path>) -> Result<()> {
let mut deleted = false;
// 1. Check project level first (takes precedence)
if let Some(wd) = working_directory {
let project_path = get_project_command_path(wd, command_name);
if project_path.exists() {
fs::remove_file(&project_path).await?;
info!(
"Deleted project-level command .md file: {}",
project_path.display()
);
deleted = true;
}
}
// 2. Check user level
let user_path = get_user_command_path(command_name);
if user_path.exists() {
fs::remove_file(&user_path).await?;
info!(
"Deleted user-level command .md file: {}",
user_path.display()
);
deleted = true;
}
// 3. Remove section from opencode.json if exists (highest precedence entry only)
let mut layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "command", command_name);
if json_source.exists {
if let Some(json_path) = json_source.path.clone() {
let config = get_config_for_path(&mut layers, &json_path);
if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) {
if commands.remove(command_name).is_some() {
write_config_at(config, &json_path).await?;
info!("Removed command from opencode.json: {}", command_name);
deleted = true;
}
}
}
}
// 4. If nothing was deleted, throw error
if !deleted {
return Err(anyhow!("Command \"{}\" not found", command_name));
}
Ok(())
}
// ============== SKILL SCOPE TYPES ==============
/// Skill scope types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillScope {
User,
Project,
}
impl From<SkillScope> for Scope {
fn from(scope: SkillScope) -> Self {
match scope {
SkillScope::User => Scope::User,
SkillScope::Project => Scope::Project,
}
}
}
/// Skill source type (opencode vs claude-compat)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillSource {
Opencode,
Claude,
}
/// Supporting file info
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SupportingFile {
pub name: String,
pub path: String,
pub full_path: String,
}
/// Skill-specific source info with supporting files
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSourceInfo {
pub exists: bool,
pub path: Option<String>,
pub dir: Option<String>,
pub fields: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<Scope>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<SkillSource>,
pub supporting_files: Vec<SupportingFile>,
// Actual content values
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Skill config sources
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillConfigSources {
pub md: SkillSourceInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_md: Option<MdLocationInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_md: Option<MdLocationInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_md: Option<MdLocationInfo>,
}
/// Discovered skill info
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredSkill {
pub name: String,
pub path: String,
pub scope: Scope,
pub source: SkillSource,
}
// ============== SKILL SCOPE HELPERS ==============
/// Get user-level skill directory path
fn get_skill_dir() -> PathBuf {
get_config_dir().join("skill")
}
/// Get user-level skill directory for a specific skill
fn get_user_skill_dir(skill_name: &str) -> PathBuf {
get_skill_dir().join(skill_name)
}
/// Get user-level skill SKILL.md path
fn get_user_skill_path(skill_name: &str) -> PathBuf {
get_user_skill_dir(skill_name).join("SKILL.md")
}
/// Get project-level skill directory (.opencode/skill/)
fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
working_directory
.join(".opencode")
.join("skill")
.join(skill_name)
}
/// Get project-level skill SKILL.md path
fn get_project_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf {
get_project_skill_dir(working_directory, skill_name).join("SKILL.md")
}
/// Get Claude-compatible skill directory (.claude/skills/)
fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
working_directory
.join(".claude")
.join("skills")
.join(skill_name)
}
/// Get Claude-compatible skill SKILL.md path
fn get_claude_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf {
get_claude_skill_dir(working_directory, skill_name).join("SKILL.md")
}
/// Ensure skill directories exist
async fn ensure_skill_dirs() -> Result<()> {
let skill_dir = get_skill_dir();
fs::create_dir_all(&skill_dir).await?;
Ok(())
}
/// Ensure project skill directory exists
async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) -> Result<PathBuf> {
let project_skill_dir = get_project_skill_dir(working_directory, skill_name);
fs::create_dir_all(&project_skill_dir).await?;
Ok(project_skill_dir)
}
/// Determine skill scope based on where the SKILL.md file exists
pub fn get_skill_scope(
skill_name: &str,
working_directory: Option<&Path>,
) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
if let Some(wd) = working_directory {
// Check .opencode/skill first
let project_path = get_project_skill_path(wd, skill_name);
if project_path.exists() {
return (
Some(SkillScope::Project),
Some(project_path),
Some(SkillSource::Opencode),
);
}
// Check .claude/skills (claude-compat)
let claude_path = get_claude_skill_path(wd, skill_name);
if claude_path.exists() {
return (
Some(SkillScope::Project),
Some(claude_path),
Some(SkillSource::Claude),
);
}
}
let user_path = get_user_skill_path(skill_name);
if user_path.exists() {
return (
Some(SkillScope::User),
Some(user_path),
Some(SkillSource::Opencode),
);
}
(None, None, None)
}
/// List supporting files in a skill directory (excluding SKILL.md)
fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
let mut files = Vec::new();
fn walk_dir(dir: &Path, relative_base: &Path, files: &mut Vec<SupportingFile>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
walk_dir(&path, relative_base, files);
} else if file_name != "SKILL.md" {
let relative_path = path
.strip_prefix(relative_base)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_name.clone());
files.push(SupportingFile {
name: file_name,
path: relative_path,
full_path: path.display().to_string(),
});
}
}
}
}
walk_dir(skill_dir, skill_dir, &mut files);
files
}
/// Discover all skills from all sources
pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> {
let mut skills: std::collections::HashMap<String, DiscoveredSkill> =
std::collections::HashMap::new();
// Helper to add skill if not already found
let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| {
if !skills.contains_key(&name) {
skills.insert(
name.clone(),
DiscoveredSkill {
name,
path: path.display().to_string(),
scope,
source,
},
);
}
};
// 1. Project level .opencode/skill/ (highest priority)
if let Some(wd) = working_directory {
let project_skill_dir = wd.join(".opencode").join("skill");
if project_skill_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&project_skill_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let skill_name = entry.file_name().to_string_lossy().to_string();
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
add_skill(skill_name, skill_md, Scope::Project, SkillSource::Opencode);
}
}
}
}
}
// 2. Claude-compatible .claude/skills/
let claude_skill_dir = wd.join(".claude").join("skills");
if claude_skill_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&claude_skill_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let skill_name = entry.file_name().to_string_lossy().to_string();
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
add_skill(skill_name, skill_md, Scope::Project, SkillSource::Claude);
}
}
}
}
}
}
// 3. User level ~/.config/opencode/skill/
let user_skill_dir = get_skill_dir();
if user_skill_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&user_skill_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let skill_name = entry.file_name().to_string_lossy().to_string();
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
add_skill(skill_name, skill_md, Scope::User, SkillSource::Opencode);
}
}
}
}
}
skills.into_values().collect()
}
/// Get information about where skill configuration is stored
pub async fn get_skill_sources(
skill_name: &str,
working_directory: Option<&Path>,
) -> Result<SkillConfigSources> {
ensure_skill_dirs().await?;
// Check all possible locations
let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name));
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
let project_dir = project_exists
.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name)))
.flatten();
let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name));
let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false);
let claude_dir = claude_exists
.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name)))
.flatten();
let user_path = get_user_skill_path(skill_name);
let user_exists = user_path.exists();
let user_dir = if user_exists {
Some(get_user_skill_dir(skill_name))
} else {
None
};
// Determine which md file to use (priority: project > claude > user)
let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists {
(
project_path.clone(),
true,
Some(Scope::Project),
Some(SkillSource::Opencode),
project_dir.clone(),
)
} else if claude_exists {
(
claude_path.clone(),
true,
Some(Scope::Project),
Some(SkillSource::Claude),
claude_dir.clone(),
)
} else if user_exists {
(
Some(user_path.clone()),
true,
Some(Scope::User),
Some(SkillSource::Opencode),
user_dir.clone(),
)
} else {
(None, false, None, None, None)
};
let mut md_fields = Vec::new();
let mut supporting_files = Vec::new();
let mut md_name: Option<String> = None;
let mut md_description: Option<String> = None;
let mut md_instructions: Option<String> = None;
if md_exists {
if let Some(ref path) = md_path {
let md_data = parse_md_file(path).await?;
md_fields.extend(md_data.frontmatter.keys().cloned());
// Extract actual content values
md_name = md_data
.frontmatter
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
md_description = md_data
.frontmatter
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if !md_data.body.trim().is_empty() {
md_fields.push("instructions".to_string());
md_instructions = Some(md_data.body.clone());
}
}
if let Some(ref dir) = md_dir {
supporting_files = list_supporting_files(dir);
}
}
Ok(SkillConfigSources {
md: SkillSourceInfo {
exists: md_exists,
path: md_path.map(|p| p.display().to_string()),
dir: md_dir.map(|d| d.display().to_string()),
fields: md_fields,
scope: md_scope,
source: md_source,
supporting_files,
name: md_name,
description: md_description,
instructions: md_instructions,
},
project_md: Some(MdLocationInfo {
exists: project_exists,
path: project_path.map(|p| p.display().to_string()),
}),
claude_md: Some(MdLocationInfo {
exists: claude_exists,
path: claude_path.map(|p| p.display().to_string()),
}),
user_md: Some(MdLocationInfo {
exists: user_exists,
path: Some(user_path.display().to_string()),
}),
})
}
/// Read a supporting file content
pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) -> Result<String> {
let full_path = skill_dir.join(relative_path);
if !full_path.exists() {
return Err(anyhow!("File not found: {}", relative_path));
}
let content = fs::read_to_string(&full_path).await?;
Ok(content)
}
/// Write a supporting file
pub async fn write_skill_supporting_file(
skill_dir: &Path,
relative_path: &str,
content: &str,
) -> Result<()> {
let full_path = skill_dir.join(relative_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&full_path, content).await?;
info!("Wrote supporting file: {}", full_path.display());
Ok(())
}
/// Delete a supporting file
pub async fn delete_skill_supporting_file(skill_dir: &Path, relative_path: &str) -> Result<()> {
let full_path = skill_dir.join(relative_path);
if full_path.exists() {
fs::remove_file(&full_path).await?;
info!("Deleted supporting file: {}", full_path.display());
// Clean up empty parent directories
let mut parent = full_path.parent();
while let Some(p) = parent {
if p == skill_dir {
break;
}
if let Ok(mut entries) = std::fs::read_dir(p) {
if entries.next().is_none() {
let _ = std::fs::remove_dir(p);
parent = p.parent();
} else {
break;
}
} else {
break;
}
}
}
Ok(())
}
/// Validate skill name (lowercase alphanumeric with hyphens, 1-64 chars)
fn validate_skill_name(skill_name: &str) -> Result<()> {
let re = Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$").expect("valid regex");
if !re.is_match(skill_name) || skill_name.len() > 64 {
return Err(anyhow!(
"Invalid skill name \"{}\". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.",
skill_name
));
}
Ok(())
}
/// Create new skill
pub async fn create_skill(
skill_name: &str,
config: &HashMap<String, Value>,
working_directory: Option<&Path>,
scope: Option<SkillScope>,
) -> Result<()> {
ensure_skill_dirs().await?;
validate_skill_name(skill_name)?;
// Check if skill already exists
let (_existing_scope, existing_path, _) = get_skill_scope(skill_name, working_directory);
if existing_path.is_some() {
return Err(anyhow!("Skill {} already exists", skill_name));
}
// Determine target directory
let (target_scope, target_dir) = if scope == Some(SkillScope::Project) {
if let Some(wd) = working_directory {
let dir = ensure_project_skill_dir(wd, skill_name).await?;
(SkillScope::Project, dir)
} else {
let dir = get_user_skill_dir(skill_name);
fs::create_dir_all(&dir).await?;
(SkillScope::User, dir)
}
} else {
let dir = get_user_skill_dir(skill_name);
fs::create_dir_all(&dir).await?;
(SkillScope::User, dir)
};
let target_path = target_dir.join("SKILL.md");
// Extract fields
let mut frontmatter = config.clone();
let instructions = frontmatter
.remove("instructions")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
frontmatter.remove("scope");
frontmatter.remove("supportingFiles");
// Ensure required fields
if !frontmatter.contains_key("name") {
frontmatter.insert("name".to_string(), Value::String(skill_name.to_string()));
}
if !frontmatter.contains_key("description") {
return Err(anyhow!("Skill description is required"));
}
write_md_file(&target_path, &frontmatter, &instructions).await?;
// Write supporting files if provided
if let Some(supporting_files) = config.get("supportingFiles").and_then(|v| v.as_array()) {
for file in supporting_files {
if let (Some(path), Some(content)) = (
file.get("path").and_then(|v| v.as_str()),
file.get("content").and_then(|v| v.as_str()),
) {
write_skill_supporting_file(&target_dir, path, content).await?;
}
}
}
info!(
"Created new skill: {} (scope: {:?}, path: {})",
skill_name,
target_scope,
target_path.display()
);
Ok(())
}
/// Update existing skill
pub async fn update_skill(
skill_name: &str,
updates: &HashMap<String, Value>,
working_directory: Option<&Path>,
) -> Result<()> {
let (_, existing_path, _) = get_skill_scope(skill_name, working_directory);
let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?;
let md_dir = md_path
.parent()
.ok_or_else(|| anyhow!("Invalid skill path"))?;
let mut md_data = parse_md_file(&md_path).await?;
let mut md_modified = false;
for (field, value) in updates.iter() {
if field == "scope" {
continue;
}
if field == "instructions" {
let normalized = value.as_str().unwrap_or("").to_string();
md_data.body = normalized;
md_modified = true;
continue;
}
if field == "supportingFiles" {
if let Some(files) = value.as_array() {
for file in files {
if let Some(true) = file.get("delete").and_then(|v| v.as_bool()) {
if let Some(path) = file.get("path").and_then(|v| v.as_str()) {
delete_skill_supporting_file(md_dir, path).await?;
}
} else if let (Some(path), Some(content)) = (
file.get("path").and_then(|v| v.as_str()),
file.get("content").and_then(|v| v.as_str()),
) {
write_skill_supporting_file(md_dir, path, content).await?;
}
}
}
continue;
}
md_data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
if md_modified {
write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?;
}
info!(
"Updated skill: {} (path: {})",
skill_name,
md_path.display()
);
Ok(())
}
/// Delete skill
pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> Result<()> {
let mut deleted = false;
// Check and delete from all locations
if let Some(wd) = working_directory {
// Project level .opencode/skill/
let project_dir = get_project_skill_dir(wd, skill_name);
if project_dir.exists() {
fs::remove_dir_all(&project_dir).await?;
info!(
"Deleted project-level skill directory: {}",
project_dir.display()
);
deleted = true;
}
// Claude-compat .claude/skills/
let claude_dir = get_claude_skill_dir(wd, skill_name);
if claude_dir.exists() {
fs::remove_dir_all(&claude_dir).await?;
info!(
"Deleted claude-compat skill directory: {}",
claude_dir.display()
);
deleted = true;
}
}
// User level
let user_dir = get_user_skill_dir(skill_name);
if user_dir.exists() {
fs::remove_dir_all(&user_dir).await?;
info!("Deleted user-level skill directory: {}", user_dir.display());
deleted = true;
}
if !deleted {
return Err(anyhow!("Skill \"{}\" not found", skill_name));
}
Ok(())
}