feat: add command scope support and reload functionality

- Introduced command scope types (user and project) to manage command configurations at different levels.
- Enhanced command management functions to check and create commands based on their scope.
- Updated the API to handle command creation, updates, and deletions with respect to their scope.
- Added a reload button in the AboutSettings component to refresh OpenCode configuration.
- Improved command source retrieval to prioritize project-level commands over user-level commands.
- Refactored related functions to ensure proper handling of command paths and configurations based on scope.
This commit is contained in:
Bohdan Triapitsyn
2025-12-29 17:48:06 +02:00
parent 66351bea6c
commit 0adcba854b
12 changed files with 1008 additions and 268 deletions
+30 -12
View File
@@ -236,6 +236,7 @@ struct ConfigErrorResponse {
struct ConfigMetadataResponse {
name: String,
sources: opencode_config::ConfigSources,
scope: Option<opencode_config::CommandScope>,
is_built_in: bool,
}
@@ -1197,6 +1198,7 @@ async fn handle_agent_route(
ConfigMetadataResponse {
name,
is_built_in: !sources.md.exists && !sources.json.exists,
scope: None,
sources,
},
)),
@@ -1320,17 +1322,24 @@ async fn handle_command_route(
req: Request<Body>,
name: String,
) -> Result<Response<Body>, StatusCode> {
// Get working directory for project-level command detection
let working_directory = state.opencode.get_working_directory();
match method {
Method::GET => {
match opencode_config::get_command_sources(&name).await {
Ok(sources) => Ok(json_response(
StatusCode::OK,
ConfigMetadataResponse {
name,
is_built_in: !sources.md.exists && !sources.json.exists,
sources,
},
)),
match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
Ok(sources) => {
let scope = sources.md.scope.clone();
Ok(json_response(
StatusCode::OK,
ConfigMetadataResponse {
name,
is_built_in: !sources.md.exists && !sources.json.exists,
scope,
sources,
},
))
}
Err(err) => {
error!("[desktop:config] Failed to read command sources: {}", err);
Ok(config_error_response(
@@ -1345,8 +1354,17 @@ async fn handle_command_route(
Ok(data) => data,
Err(resp) => return Ok(resp),
};
// Extract scope from payload if present
let scope = payload.get("scope")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"project" => Some(opencode_config::CommandScope::Project),
"user" => Some(opencode_config::CommandScope::User),
_ => None,
});
match opencode_config::create_command(&name, &payload).await {
match opencode_config::create_command(&name, &payload, Some(&working_directory), scope).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "command creation").await
@@ -1382,7 +1400,7 @@ async fn handle_command_route(
Err(resp) => return Ok(resp),
};
match opencode_config::update_command(&name, &payload).await {
match opencode_config::update_command(&name, &payload, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "command update").await
@@ -1412,7 +1430,7 @@ async fn handle_command_route(
}
}
}
Method::DELETE => match opencode_config::delete_command(&name).await {
Method::DELETE => match opencode_config::delete_command(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "command deletion").await
+205 -46
View File
@@ -11,12 +11,29 @@ use tokio::fs;
static PROMPT_FILE_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex"));
/// Command scope types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommandScope {
User,
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<CommandScope>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MdLocationInfo {
pub exists: bool,
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -24,6 +41,10 @@ pub struct SourceInfo {
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
@@ -39,7 +60,7 @@ fn get_agent_dir() -> PathBuf {
get_config_dir().join("agent")
}
/// Get command directory path
/// Get user-level command directory path
fn get_command_dir() -> PathBuf {
get_config_dir().join("command")
}
@@ -49,6 +70,64 @@ fn get_config_file() -> PathBuf {
get_config_dir().join("opencode.json")
}
/// 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();
@@ -240,7 +319,13 @@ async fn write_md_file(
frontmatter: &HashMap<String, Value>,
body: &str,
) -> Result<()> {
let yaml_str = serde_yaml::to_string(frontmatter)?;
// 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?;
@@ -281,12 +366,16 @@ pub async fn get_agent_sources(agent_name: &str) -> Result<ConfigSources> {
exists: md_exists,
path: md_exists.then(|| md_path.display().to_string()),
fields: md_fields,
scope: None, // Agents don't have project/user scope distinction yet
},
json: SourceInfo {
exists: json_section.is_some(),
path: Some(get_config_file().display().to_string()),
fields: json_fields,
scope: None,
},
project_md: None,
user_md: None,
};
Ok(sources)
@@ -528,18 +617,34 @@ pub async fn delete_agent(agent_name: &str) -> Result<()> {
}
/// Get information about where command configuration is stored
pub async fn get_command_sources(command_name: &str) -> Result<ConfigSources> {
pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> {
ensure_dirs().await?;
let md_path = get_command_dir().join(format!("{}.md", command_name));
let md_exists = md_path.exists();
// 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(CommandScope::Project))
} else if user_exists {
(Some(user_path.clone()), true, Some(CommandScope::User))
} else {
(None, false, None)
};
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());
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());
}
}
}
@@ -557,29 +662,53 @@ pub async fn get_command_sources(command_name: &str) -> Result<ConfigSources> {
let sources = ConfigSources {
md: SourceInfo {
exists: md_exists,
path: md_exists.then(|| md_path.display().to_string()),
path: md_path.map(|p| p.display().to_string()),
fields: md_fields,
scope: md_scope,
},
json: SourceInfo {
exists: json_section.is_some(),
path: Some(get_config_file().display().to_string()),
fields: json_fields,
scope: 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>) -> Result<()> {
pub async fn create_command(
command_name: &str,
config: &HashMap<String, Value>,
working_directory: Option<&Path>,
scope: Option<CommandScope>
) -> Result<()> {
ensure_dirs().await?;
let md_path = get_command_dir().join(format!("{}.md", command_name));
// Check if command already exists
if md_path.exists() {
// 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 .md file",
"Command {} already exists as user-level .md file",
command_name
));
}
@@ -594,16 +723,29 @@ pub async fn create_command(command_name: &str, config: &HashMap<String, Value>)
}
}
// Extract template from config
// 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(&md_path, &frontmatter, &template).await?;
info!("Created new command: {}", command_name);
write_md_file(&target_path, &frontmatter, &template).await?;
info!("Created new command: {} (scope: {:?}, path: {})", command_name, target_scope, target_path.display());
Ok(())
}
@@ -612,17 +754,29 @@ pub async fn create_command(command_name: &str, config: &HashMap<String, Value>)
pub async fn update_command(
command_name: &str,
updates: &HashMap<String, Value>,
working_directory: Option<&Path>,
) -> Result<()> {
ensure_dirs().await?;
let md_path = get_command_dir().join(format!("{}.md", command_name));
// 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();
// If no existing md file, we need to create one (for built-in command overrides)
let target_path = if !md_exists {
// No existing md file - this is a built-in 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 {
None
Some(MdData { frontmatter: HashMap::new(), body: String::new() })
};
let creating_new_md = !md_exists;
let mut config = read_config().await?;
let mut existing_command = config
@@ -657,7 +811,7 @@ pub async fn update_command(
if field == "template" {
let normalized_value = value.as_str().unwrap_or("").to_string();
if md_exists {
if md_exists || creating_new_md {
if let Some(ref mut data) = md_data {
data.body = normalized_value.clone();
md_modified = true;
@@ -677,10 +831,11 @@ pub async fn update_command(
}
}
// Write template directly to JSON entry (file ref or inline string)
existing_command.insert("template".to_string(), Value::String(normalized_value));
json_modified = true;
// Create new md file for the update
if let Some(ref mut data) = md_data {
data.body = normalized_value;
md_modified = true;
}
continue;
}
@@ -691,7 +846,7 @@ pub async fn update_command(
.unwrap_or(false);
let in_json = existing_command.contains_key(field);
if in_md {
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());
@@ -702,19 +857,13 @@ pub async fn update_command(
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
// New field - add to md if it exists or we're creating one
if md_exists || creating_new_md {
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;
}
@@ -724,7 +873,7 @@ pub async fn update_command(
// Write changes
if md_modified {
if let Some(data) = md_data {
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
write_md_file(&target_path, &data.frontmatter, &data.body).await?;
}
}
@@ -756,26 +905,36 @@ pub async fn update_command(
}
info!(
"Updated command: {} (md: {}, json: {})",
command_name, md_modified, json_modified
"Updated command: {} (scope: {:?}, md: {}, json: {})",
command_name, scope, 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));
pub async fn delete_command(command_name: &str, working_directory: Option<&Path>) -> Result<()> {
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());
// 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;
}
// 2. Remove section from opencode.json if exists
// 3. 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() {
@@ -785,7 +944,7 @@ pub async fn delete_command(command_name: &str) -> Result<()> {
}
}
// 3. If nothing was deleted, throw error
// 4. If nothing was deleted, throw error
if !deleted {
return Err(anyhow!("Command \"{}\" not found", command_name));
}