From 0adcba854bed850427e1179cd1b9fc17c834b35a Mon Sep 17 00:00:00 2001
From: Bohdan Triapitsyn
Date: Mon, 29 Dec 2025 17:48:06 +0200
Subject: [PATCH] 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.
---
CHANGELOG.md | 2 +-
bun.lock | 16 +-
packages/desktop/src-tauri/src/main.rs | 42 ++-
.../desktop/src-tauri/src/opencode_config.rs | 251 ++++++++++++---
.../sections/commands/CommandsPage.tsx | 120 +++++--
.../sections/commands/CommandsSidebar.tsx | 303 +++++++++++++-----
.../sections/openchamber/AboutSettings.tsx | 12 +-
packages/ui/src/stores/useCommandsStore.ts | 71 +++-
packages/vscode/src/bridge.ts | 18 +-
packages/vscode/src/opencodeConfig.ts | 196 +++++++++--
packages/web/server/index.js | 22 +-
packages/web/server/lib/opencode-config.js | 223 ++++++++++---
12 files changed, 1008 insertions(+), 268 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81e0d40d..b906f134 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file.
- Added queued message mode with chips, batching, and idle auto‑send (including attachments).
- Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes.
- Fixed scroll position persistence for active conversation turns across session switches.
-
+- Refactored command management with ability to configure project/user scoped commands.
## [1.3.7] - 2025-12-28
diff --git a/bun.lock b/bun.lock
index 5ca48194..850cde97 100644
--- a/bun.lock
+++ b/bun.lock
@@ -74,7 +74,7 @@
},
"packages/desktop": {
"name": "@openchamber/desktop",
- "version": "1.3.5",
+ "version": "1.3.7",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@tauri-apps/plugin-notification": "^2.3.3",
@@ -97,7 +97,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
- "version": "1.3.5",
+ "version": "1.3.7",
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
@@ -165,7 +165,7 @@
},
"packages/vscode": {
"name": "openchamber",
- "version": "1.3.5",
+ "version": "1.3.7",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.0.185",
@@ -184,7 +184,7 @@
},
"packages/web": {
"name": "@openchamber/web",
- "version": "1.3.5",
+ "version": "1.3.7",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -517,7 +517,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
- "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.203", "", {}, "sha512-Chr/eWlb5jB+aluf70t9H0zv0tkb+otVnnkZQg3xrB7dtHJc6SxqsixAjTGnND6v3mURwchYb0UdcnGM7dOcng=="],
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.207", "", {}, "sha512-/t4C3+PXZ5NpN7+HwKZmVwJsJmQ1eyub0eQcV8GZh7fuPo2wCxKHCD/lzk82CobW+3jVp2TSBKeDBBTtVvEFeQ=="],
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
@@ -1627,7 +1627,7 @@
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
- "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
@@ -2369,7 +2369,7 @@
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
- "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
+ "ts-api-utils": ["ts-api-utils@2.3.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg=="],
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
@@ -2819,6 +2819,8 @@
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
+ "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
+
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs
index 5f0d26b1..da05b23a 100644
--- a/packages/desktop/src-tauri/src/main.rs
+++ b/packages/desktop/src-tauri/src/main.rs
@@ -236,6 +236,7 @@ struct ConfigErrorResponse {
struct ConfigMetadataResponse {
name: String,
sources: opencode_config::ConfigSources,
+ scope: Option,
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,
name: String,
) -> Result, 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
diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs
index a3b3208b..2ac7e811 100644
--- a/packages/desktop/src-tauri/src/opencode_config.rs
+++ b/packages/desktop/src-tauri/src/opencode_config.rs
@@ -11,12 +11,29 @@ use tokio::fs;
static PROMPT_FILE_PATTERN: Lazy =
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,
pub fields: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub scope: Option,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MdLocationInfo {
+ pub exists: bool,
+ pub path: Option,
}
#[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,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_md: Option,
}
/// 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 {
+ 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, Option) {
+ 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, 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,
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 = 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 {
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 {
+pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result {
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 {
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) -> Result<()> {
+pub async fn create_command(
+ command_name: &str,
+ config: &HashMap,
+ working_directory: Option<&Path>,
+ scope: Option
+) -> 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)
}
}
- // 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)
pub async fn update_command(
command_name: &str,
updates: &HashMap,
+ 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));
}
diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx
index d0d83ce7..3e73062d 100644
--- a/packages/ui/src/components/sections/commands/CommandsPage.tsx
+++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx
@@ -3,21 +3,28 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
-import { useCommandsStore, type CommandConfig } from '@/stores/useCommandsStore';
-import { RiCheckLine, RiInformationLine, RiSaveLine, RiTerminalBoxLine } from '@remixicon/react';
+import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
+import { RiCheckLine, RiInformationLine, RiSaveLine, RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from '@/components/ui/select';
export const CommandsPage: React.FC = () => {
- const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands } = useCommandsStore();
+ const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands, commandDraft, setCommandDraft } = useCommandsStore();
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
- const isNewCommand = selectedCommandName && !selectedCommand;
+ const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
- const [name, setName] = React.useState('');
+ const [draftName, setDraftName] = React.useState('');
+ const [draftScope, setDraftScope] = React.useState('user');
const [description, setDescription] = React.useState('');
const [agent, setAgent] = React.useState('');
const [model, setModel] = React.useState('');
@@ -26,27 +33,28 @@ export const CommandsPage: React.FC = () => {
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
- if (isNewCommand) {
-
- setName(selectedCommandName || '');
- setDescription('');
- setAgent('');
- setModel('');
- setTemplate('');
- setSubtask(false);
+ if (isNewCommand && commandDraft) {
+ // Prefill from draft (for new or duplicated commands)
+ setDraftName(commandDraft.name || '');
+ setDraftScope(commandDraft.scope || 'user');
+ setDescription(commandDraft.description || '');
+ setAgent(commandDraft.agent || '');
+ setModel(commandDraft.model || '');
+ setTemplate(commandDraft.template || '');
+ setSubtask(commandDraft.subtask || false);
} else if (selectedCommand) {
-
- setName(selectedCommand.name);
setDescription(selectedCommand.description || '');
setAgent(selectedCommand.agent || '');
setModel(selectedCommand.model || '');
setTemplate(selectedCommand.template || '');
setSubtask(selectedCommand.subtask || false);
}
- }, [selectedCommand, isNewCommand, selectedCommandName, commands]);
+ }, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]);
const handleSave = async () => {
- if (!name.trim()) {
+ const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim();
+
+ if (!commandName) {
toast.error('Command name is required');
return;
}
@@ -56,6 +64,12 @@ export const CommandsPage: React.FC = () => {
return;
}
+ // Check for duplicate name when creating new command
+ if (isNewCommand && commands.some((cmd) => cmd.name === commandName)) {
+ toast.error('A command with this name already exists');
+ return;
+ }
+
setIsSaving(true);
try {
@@ -63,19 +77,23 @@ export const CommandsPage: React.FC = () => {
const trimmedModel = model.trim();
const trimmedTemplate = template.trim();
const config: CommandConfig = {
- name: name.trim(),
+ name: commandName,
description: description.trim() || undefined,
agent: trimmedAgent === '' ? null : trimmedAgent,
model: trimmedModel === '' ? null : trimmedModel,
template: trimmedTemplate,
subtask,
+ scope: isNewCommand ? draftScope : undefined,
};
let success: boolean;
if (isNewCommand) {
success = await createCommand(config);
+ if (success) {
+ setCommandDraft(null); // Clear draft after successful creation
+ }
} else {
- success = await updateCommand(name, config);
+ success = await updateCommand(commandName, config);
}
if (success) {
@@ -108,7 +126,7 @@ export const CommandsPage: React.FC = () => {
{}
- {isNewCommand ? 'New Command' : name}
+ {isNewCommand ? 'New Command' : `/${selectedCommandName}`}
@@ -121,20 +139,54 @@ export const CommandsPage: React.FC = () => {
-
-
-
setName(e.target.value)}
- placeholder="my-command"
- disabled={!isNewCommand}
- />
-
- Used with slash (/) prefix in chat
-
-
+ {isNewCommand && (
+
+
+
+
+ /
+ setDraftName(e.target.value)}
+ placeholder="command-name"
+ className="flex-1 text-foreground placeholder:text-muted-foreground"
+ />
+
+
+
+
+ )}