diff --git a/CHANGELOG.md b/CHANGELOG.md index 54883f5e..f8332a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Added Skills management to settings + ## [1.3.8] - 2025-12-29 diff --git a/bun.lock b/bun.lock index 73fbc131..e2dfce0f 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.185", + "@opencode-ai/sdk": "^1.0.209", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -102,7 +102,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.185", + "@opencode-ai/sdk": "^1.0.209", "@pierre/diffs": "^1.0.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -168,7 +168,7 @@ "version": "1.3.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.0.185", + "@opencode-ai/sdk": "^1.0.209", "react": "^19.1.1", "react-dom": "^19.1.1", }, @@ -192,7 +192,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.185", + "@opencode-ai/sdk": "^1.0.209", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/package.json b/package.json index 79292e48..4a214b6f 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.185", + "@opencode-ai/sdk": "^1.0.209", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index f817d308..12697619 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.3.7" +version = "1.3.8" dependencies = [ "anyhow", "axum", @@ -2882,6 +2882,7 @@ dependencies = [ "tokio", "tokio-util", "tower-http 0.5.2", + "urlencoding", "uuid", "window-vibrancy 0.7.1", ] @@ -5390,6 +5391,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 9741b751..5823fed5 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ tauri-plugin-notification = "2.3.3" tauri-plugin-updater = "2" tauri-plugin-process = "2" base64 = "0.22.1" +urlencoding = "2.1" [build-dependencies] tauri-build = { version = "2.5.3", features = [] } diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index d97734b9..a42f3543 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1334,6 +1334,289 @@ async fn handle_agent_route( } } +/// Response type for skill metadata +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SkillMetadataResponse { + name: String, + exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + sources: opencode_config::SkillConfigSources, +} + +/// Response type for skill list +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SkillListItem { + name: String, + path: String, + scope: opencode_config::Scope, + source: opencode_config::SkillSource, + sources: opencode_config::SkillConfigSources, +} + +/// Response type for skill file content +#[derive(Serialize)] +struct SkillFileResponse { + path: String, + content: String, +} + +async fn handle_skill_list_route( + state: &ServerState, +) -> Result, StatusCode> { + let working_directory = state.opencode.get_working_directory(); + let discovered = opencode_config::discover_skills(Some(&working_directory)); + + let mut skills = Vec::new(); + for skill in discovered { + match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { + Ok(sources) => { + skills.push(SkillListItem { + name: skill.name, + path: skill.path, + scope: skill.scope, + source: skill.source, + sources, + }); + } + Err(err) => { + error!("[desktop:config] Failed to get skill sources for {}: {}", skill.name, err); + } + } + } + + Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills }))) +} + +async fn handle_skill_route( + state: &ServerState, + method: Method, + req: Request, + name: String, + file_path: Option, +) -> Result, StatusCode> { + let working_directory = state.opencode.get_working_directory(); + + // Handle file operations: /api/config/skills/:name/files/* + if let Some(ref fp) = file_path { + match method { + Method::GET => { + // Read supporting file + match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { + Ok(sources) => { + if !sources.md.exists { + return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found")); + } + let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + match opencode_config::read_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await { + Ok(content) => Ok(json_response(StatusCode::OK, SkillFileResponse { path: fp.clone(), content })), + Err(_) => Ok(config_error_response(StatusCode::NOT_FOUND, "File not found")), + } + } + Err(err) => { + error!("[desktop:config] Failed to read skill sources: {}", err); + Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill")) + } + } + } + Method::PUT => { + // Write supporting file + let payload = match parse_request_payload(req).await { + Ok(data) => data, + Err(resp) => return Ok(resp), + }; + let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or(""); + + match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { + Ok(sources) => { + if !sources.md.exists { + return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found")); + } + let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + match opencode_config::write_skill_supporting_file(std::path::Path::new(&skill_dir), fp, content).await { + Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse { + success: true, + requires_reload: false, + message: format!("File {} saved successfully", fp), + reload_delay_ms: 0, + })), + Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + } + } + Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + } + } + Method::DELETE => { + // Delete supporting file + match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { + Ok(sources) => { + if !sources.md.exists { + return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found")); + } + let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + match opencode_config::delete_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await { + Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse { + success: true, + requires_reload: false, + message: format!("File {} deleted successfully", fp), + reload_delay_ms: 0, + })), + Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + } + } + Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + } + } + _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), + } + } else { + // Handle skill CRUD: /api/config/skills/:name + match method { + Method::GET => { + match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { + Ok(sources) => { + let scope = sources.md.scope.clone(); + let source = sources.md.source.clone(); + Ok(json_response( + StatusCode::OK, + SkillMetadataResponse { + name, + exists: sources.md.exists, + scope, + source, + sources, + }, + )) + } + Err(err) => { + error!("[desktop:config] Failed to read skill sources: {}", err); + Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to read skill configuration", + )) + } + } + } + Method::POST => { + let payload = match parse_request_payload(req).await { + Ok(data) => data, + Err(resp) => return Ok(resp), + }; + + let scope = payload.get("scope") + .and_then(|v| v.as_str()) + .and_then(|s| match s { + "project" => Some(opencode_config::SkillScope::Project), + "user" => Some(opencode_config::SkillScope::User), + _ => None, + }); + + match opencode_config::create_skill(&name, &payload, Some(&working_directory), scope).await { + Ok(()) => { + if let Err(resp) = + refresh_opencode_after_config_change(state, "skill creation").await + { + return Ok(resp); + } + + Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: format!( + "Skill {} created successfully. Reloading interface...", + name + ), + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )) + } + Err(err) => { + error!("[desktop:config] Failed to create skill {}: {}", name, err); + Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )) + } + } + } + Method::PATCH => { + let payload = match parse_request_payload(req).await { + Ok(data) => data, + Err(resp) => return Ok(resp), + }; + + match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await { + Ok(()) => { + if let Err(resp) = + refresh_opencode_after_config_change(state, "skill update").await + { + return Ok(resp); + } + + Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: format!( + "Skill {} updated successfully. Reloading interface...", + name + ), + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )) + } + Err(err) => { + error!("[desktop:config] Failed to update skill {}: {}", name, err); + Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )) + } + } + } + Method::DELETE => match opencode_config::delete_skill(&name, Some(&working_directory)).await { + Ok(()) => { + if let Err(resp) = + refresh_opencode_after_config_change(state, "skill deletion").await + { + return Ok(resp); + } + + Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: format!( + "Skill {} deleted successfully. Reloading interface...", + name + ), + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )) + } + Err(err) => { + error!("[desktop:config] Failed to delete skill {}: {}", name, err); + let status = if err.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + Ok(config_error_response(status, err.to_string())) + } + }, + _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), + } + } +} + async fn handle_command_route( state: &ServerState, method: Method, @@ -1514,6 +1797,39 @@ async fn handle_config_routes( return handle_command_route(&state, method, req, trimmed.to_string()).await; } + // Handle skill routes: /api/config/skills and /api/config/skills/:name + if path == "/api/config/skills" && method == Method::GET { + return handle_skill_list_route(&state).await; + } + + if let Some(rest) = path.strip_prefix("/api/config/skills/") { + // Check if it's a file operation: /api/config/skills/:name/files/* + if let Some(files_start) = rest.find("/files/") { + let name = &rest[..files_start]; + let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/" + // Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md") + let file_path = urlencoding::decode(file_path_encoded) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| file_path_encoded.to_string()); + if name.is_empty() { + return Ok(config_error_response( + StatusCode::BAD_REQUEST, + "Skill name is required", + )); + } + return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)).await; + } + + let trimmed = rest.trim(); + if trimmed.is_empty() { + return Ok(config_error_response( + StatusCode::BAD_REQUEST, + "Skill name is required", + )); + } + return handle_skill_route(&state, method, req, trimmed.to_string(), None).await; + } + if path == "/api/config/reload" && method == Method::POST { if let Err(resp) = refresh_opencode_after_config_change(&state, "manual configuration reload").await @@ -1685,6 +2001,7 @@ async fn proxy_to_opencode( let is_desktop_config_route = origin_path.starts_with("/api/config/agents/") || origin_path.starts_with("/api/config/commands/") + || origin_path.starts_with("/api/config/skills") || origin_path == "/api/config/reload" || is_provider_auth_delete; diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index cfef9174..0df3c7db 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -1370,3 +1370,563 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path> 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 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, + pub dir: Option, + pub fields: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + pub supporting_files: Vec, + // Actual content values + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub claude_md: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_md: Option, +} + +/// 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 { + 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, Option, Option) { + 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 { + let mut files = Vec::new(); + + fn walk_dir(dir: &Path, relative_base: &Path, files: &mut Vec) { + 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 { + let mut skills: std::collections::HashMap = 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 { + 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 = None; + let mut md_description: Option = None; + let mut md_instructions: Option = 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 { + 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, + working_directory: Option<&Path>, + scope: Option, +) -> 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, + 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(()) +} diff --git a/packages/ui/package.json b/packages/ui/package.json index b073d505..35164f65 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.185", + "@opencode-ai/sdk": "^1.0.209", "@pierre/diffs": "^1.0.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 5826badd..f01415cb 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; +import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers'; @@ -82,6 +82,9 @@ export const getToolIcon = (toolName: string) => { if (tool === 'todowrite' || tool === 'todoread') { return ; } + if (tool === 'skill') { + return ; + } if (tool.startsWith('git')) { return ; } @@ -171,6 +174,10 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80); } + if (part.tool === 'skill' && input?.name && typeof input.name === 'string') { + return input.name; + } + const desc = input?.description || metadata?.description || ('title' in state && state.title) || ''; return typeof desc === 'string' ? desc : ''; }; @@ -654,6 +661,14 @@ const ToolExpandedContent: React.FC = ({ ); } + if (part.tool === 'skill' && hasStringOutput) { + return renderScrollableBlock( +
+ +
+ ); + } + if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) { return renderScrollableBlock( , diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx new file mode 100644 index 00000000..ba43de63 --- /dev/null +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -0,0 +1,577 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore'; +import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiSaveLine, RiUser3Line } from '@remixicon/react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ButtonLarge } from '@/components/ui/button-large'; + +export const SkillsPage: React.FC = () => { + const { + selectedSkillName, + getSkillByName, + getSkillDetail, + createSkill, + updateSkill, + skills, + skillDraft, + setSkillDraft, + setSelectedSkill, + } = useSkillsStore(); + + const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null; + const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill); + + const [draftName, setDraftName] = React.useState(''); + const [draftScope, setDraftScope] = React.useState('user'); + const [description, setDescription] = React.useState(''); + const [instructions, setInstructions] = React.useState(''); + const [supportingFiles, setSupportingFiles] = React.useState([]); + const [pendingFiles, setPendingFiles] = React.useState([]); // For new skills + const [isSaving, setIsSaving] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + // Track original values to detect changes + const [originalDescription, setOriginalDescription] = React.useState(''); + const [originalInstructions, setOriginalInstructions] = React.useState(''); + + // File dialog state + const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false); + const [newFileName, setNewFileName] = React.useState(''); + const [newFileContent, setNewFileContent] = React.useState(''); + const [editingFilePath, setEditingFilePath] = React.useState(null); // null = adding, string = editing + const [isLoadingFile, setIsLoadingFile] = React.useState(false); + const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection + + // Detect if skill-level fields have changed + const hasSkillChanges = isNewSkill + ? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0) + : (description !== originalDescription || instructions !== originalInstructions); + + // Detect if file content has changed + const hasFileChanges = editingFilePath + ? newFileContent !== originalFileContent + : newFileName.trim() !== ''; // For new files, just need a name + + // Load skill details when selection changes + React.useEffect(() => { + const loadSkillDetails = async () => { + if (isNewSkill && skillDraft) { + // Prefill from draft (for new or duplicated skills) + setDraftName(skillDraft.name || ''); + setDraftScope(skillDraft.scope || 'user'); + setDescription(skillDraft.description || ''); + setInstructions(skillDraft.instructions || ''); + setOriginalDescription(''); + setOriginalInstructions(''); + setSupportingFiles([]); + setPendingFiles(skillDraft.pendingFiles || []); + } else if (selectedSkillName && selectedSkill) { + setIsLoading(true); + try { + const detail = await getSkillDetail(selectedSkillName); + if (detail) { + // Get actual content from the API response + const md = detail.sources.md; + setDescription(md.description || ''); + setInstructions(md.instructions || ''); + setOriginalDescription(md.description || ''); + setOriginalInstructions(md.instructions || ''); + setSupportingFiles(md.supportingFiles || []); + } + } catch (error) { + console.error('Failed to load skill details:', error); + } finally { + setIsLoading(false); + } + } + }; + + loadSkillDetails(); + }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]); + + const handleSave = async () => { + const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim(); + + if (!skillName) { + toast.error('Skill name is required'); + return; + } + + // Validate skill name format + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) { + toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen'); + return; + } + + if (!description.trim()) { + toast.error('Description is required'); + return; + } + + // Check for duplicate name when creating new skill + if (isNewSkill && skills.some((s) => s.name === skillName)) { + toast.error('A skill with this name already exists'); + return; + } + + setIsSaving(true); + + try { + const config: SkillConfig = { + name: skillName, + description: description.trim(), + instructions: instructions.trim() || undefined, + scope: isNewSkill ? draftScope : undefined, + // Include pending files when creating new skill + supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined, + }; + + let success: boolean; + if (isNewSkill) { + success = await createSkill(config); + if (success) { + setSkillDraft(null); // Clear draft after successful creation + setPendingFiles([]); // Clear pending files + setSelectedSkill(skillName); // Select the newly created skill + } + } else { + success = await updateSkill(skillName, config); + if (success) { + // Update original values to reflect saved state + setOriginalDescription(description.trim()); + setOriginalInstructions(instructions.trim()); + } + } + + if (success) { + toast.success(isNewSkill ? 'Skill created successfully' : 'Skill updated successfully'); + } else { + toast.error(isNewSkill ? 'Failed to create skill' : 'Failed to update skill'); + } + } catch (error) { + console.error('Error saving skill:', error); + toast.error('An error occurred while saving'); + } finally { + setIsSaving(false); + } + }; + + const handleAddFile = () => { + setEditingFilePath(null); + setNewFileName(''); + setNewFileContent(''); + setOriginalFileContent(''); + setIsFileDialogOpen(true); + }; + + const handleEditFile = async (filePath: string) => { + setEditingFilePath(filePath); + setNewFileName(filePath); + + // For new skills, get content from pending files + if (isNewSkill) { + const pendingFile = pendingFiles.find(f => f.path === filePath); + const content = pendingFile?.content || ''; + setNewFileContent(content); + setOriginalFileContent(content); + setIsFileDialogOpen(true); + return; + } + + // For existing skills, load content from server + if (!selectedSkillName) return; + + setIsLoadingFile(true); + setIsFileDialogOpen(true); + + try { + const { readSupportingFile } = useSkillsStore.getState(); + const content = await readSupportingFile(selectedSkillName, filePath); + setNewFileContent(content || ''); + setOriginalFileContent(content || ''); + } catch { + toast.error('Failed to load file content'); + setNewFileContent(''); + setOriginalFileContent(''); + } finally { + setIsLoadingFile(false); + } + }; + + const handleSaveFile = async () => { + if (!newFileName.trim()) { + toast.error('File name is required'); + return; + } + + const filePath = newFileName.trim(); + const isEditing = editingFilePath !== null; + + // For new skills, add/update pending files + if (isNewSkill) { + if (isEditing) { + // Update existing pending file + setPendingFiles(prev => prev.map(f => + f.path === editingFilePath ? { path: filePath, content: newFileContent } : f + )); + toast.success(`File "${filePath}" updated`); + } else { + // Check for duplicate + if (pendingFiles.some(f => f.path === filePath)) { + toast.error('A file with this name already exists'); + return; + } + setPendingFiles(prev => [...prev, { path: filePath, content: newFileContent }]); + toast.success(`File "${filePath}" added`); + } + setIsFileDialogOpen(false); + setEditingFilePath(null); + return; + } + + // For existing skills, write directly to disk + if (!selectedSkillName) { + toast.error('No skill selected'); + return; + } + + const { writeSupportingFile } = useSkillsStore.getState(); + const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent); + + if (success) { + toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`); + setIsFileDialogOpen(false); + setEditingFilePath(null); + // Refresh skill details to get updated file list + const detail = await getSkillDetail(selectedSkillName); + if (detail) { + setSupportingFiles(detail.sources.md.supportingFiles || []); + } + } else { + toast.error(isEditing ? 'Failed to update file' : 'Failed to create file'); + } + }; + + const handleDeleteFile = async (filePath: string) => { + // For new skills, remove from pending files + if (isNewSkill) { + setPendingFiles(prev => prev.filter(f => f.path !== filePath)); + toast.success(`File "${filePath}" removed`); + return; + } + + // For existing skills, delete from disk + if (!selectedSkillName) return; + + if (window.confirm(`Are you sure you want to delete "${filePath}"?`)) { + const { deleteSupportingFile } = useSkillsStore.getState(); + const success = await deleteSupportingFile(selectedSkillName, filePath); + + if (success) { + toast.success(`File "${filePath}" deleted`); + // Refresh skill details + const detail = await getSkillDetail(selectedSkillName); + if (detail) { + setSupportingFiles(detail.sources.md.supportingFiles || []); + } + } else { + toast.error('Failed to delete file'); + } + } + }; + + // Show empty state only when nothing is selected AND no draft + if (!selectedSkillName && !skillDraft) { + return ( +
+
+ +

Select a skill from the sidebar

+

or create a new one

+
+
+ ); + } + + if (isLoading) { + return ( +
+
+

Loading skill details...

+
+
+ ); + } + + return ( + + {/* Header */} +
+

+ {isNewSkill ? 'New Skill' : selectedSkillName} +

+ {selectedSkill && ( +

+ {selectedSkill.scope === 'project' ? 'Project' : 'User'} skill + {selectedSkill.source === 'claude' && ' (Claude-compatible)'} +

+ )} +
+ + {/* Basic Information */} +
+
+

Basic Information

+

+ Configure skill identity and description +

+
+ + {isNewSkill && ( +
+ +
+ setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))} + placeholder="skill-name" + className="flex-1 text-foreground placeholder:text-muted-foreground" + /> + +
+

+ Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen. +

+
+ )} + +
+ +