feat: Implement skill management functionality

- Added skill scope helpers and CRUD operations for skills in opencodeConfig.ts.
- Introduced API endpoints for skill management in main.tsx and index.js.
- Enhanced server-side logic to support skill discovery, creation, updating, and deletion.
- Implemented supporting file operations for skills, including reading, writing, and deleting files.
- Updated package.json to use the latest version of @opencode-ai/sdk.
This commit is contained in:
Bohdan Triapitsyn
2025-12-30 17:36:52 +02:00
parent f3a00bc8f4
commit 2c833cef40
25 changed files with 3587 additions and 15 deletions
+8 -1
View File
@@ -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"
+1
View File
@@ -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 = [] }
+317
View File
@@ -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<opencode_config::Scope>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<opencode_config::SkillSource>,
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<Response<Body>, 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<Body>,
name: String,
file_path: Option<String>,
) -> Result<Response<Body>, 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;
@@ -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<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(())
}
+1 -1
View File
@@ -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",
@@ -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 <RiListCheck3 className={iconClass} />;
}
if (tool === 'skill') {
return <RiBookLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
@@ -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<ToolExpandedContentProps> = ({
);
}
if (part.tool === 'skill' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
</div>
);
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) {
return renderScrollableBlock(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
@@ -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<SkillScope>('user');
const [description, setDescription] = React.useState('');
const [instructions, setInstructions] = React.useState('');
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]); // 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<string | null>(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 (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiBookOpenLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select a skill from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="typography-body">Loading skill details...</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewSkill ? 'New Skill' : selectedSkillName}
</h1>
{selectedSkill && (
<p className="typography-meta text-muted-foreground">
{selectedSkill.scope === 'project' ? 'Project' : 'User'} skill
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
</p>
)}
</div>
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
<p className="typography-meta text-muted-foreground/80">
Configure skill identity and description
</p>
</div>
{isNewSkill && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Skill Name & Scope
</label>
<div className="flex items-center gap-2">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder="skill-name"
className="flex-1 text-foreground placeholder:text-muted-foreground"
/>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as SkillScope)}>
<SelectTrigger className="!h-9 w-auto gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" />
<span>User</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div>
</SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" />
<span>Project</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="typography-meta text-muted-foreground">
Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
</p>
</div>
)}
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..."
rows={2}
/>
<p className="typography-meta text-muted-foreground">
The agent uses this to decide when to load the skill
</p>
</div>
</div>
{/* Instructions */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Instructions</h2>
<p className="typography-meta text-muted-foreground/80">
Detailed instructions for the agent when this skill is loaded
</p>
</div>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Step-by-step instructions, guidelines, or reference content..."
rows={12}
className="font-mono typography-meta"
/>
</div>
{/* Supporting Files */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Supporting Files</h2>
<p className="typography-meta text-muted-foreground/80">
Reference documentation, scripts, or templates
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddFile}
className="gap-1.5"
>
<RiAddLine className="h-3.5 w-3.5" />
Add File
</Button>
</div>
{(() => {
// For new skills, show pending files
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
if (filesToShow.length === 0) {
return (
<p className="typography-meta text-muted-foreground py-2">
{isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'}
</p>
);
}
return (
<div className="space-y-2">
{filesToShow.map((file) => (
<div
key={file.path}
className="flex items-center justify-between px-3 py-2 rounded-lg border bg-muted/30 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => handleEditFile(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
<RiFileLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label truncate">{file.path}</span>
{isNewSkill && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
pending
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(file.path);
}}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
);
})()}
</div>
{/* Save Button */}
<div className="flex justify-end border-t border-border/40 pt-4">
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
className="gap-2 h-6 px-2 text-xs w-fit"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</Button>
</div>
{/* Add/Edit File Dialog */}
<Dialog open={isFileDialogOpen} onOpenChange={(open) => {
setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null);
}}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription>
{editingFilePath ? 'Modify the file content' : 'Create a new file in the skill directory'}
</DialogDescription>
</DialogHeader>
{isLoadingFile ? (
<div className="flex-1 flex items-center justify-center py-8">
<span className="typography-meta text-muted-foreground">Loading file content...</span>
</div>
) : (
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
<div className="space-y-2 flex-shrink-0">
<label className="typography-ui-label font-medium text-foreground">
File Path
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="example.md or docs/reference.txt"
className="text-foreground placeholder:text-muted-foreground"
disabled={editingFilePath !== null}
/>
{!editingFilePath && (
<p className="typography-micro text-muted-foreground">
Relative path within the skill directory. Subdirectories will be created automatically.
</p>
)}
</div>
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
Content
</label>
<Textarea
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..."
className="font-mono typography-meta flex-1 min-h-[200px] max-h-full resize-none"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setIsFileDialogOpen(false);
setEditingFilePath(null);
}}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
{editingFilePath ? 'Save Changes' : 'Create File'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollableOverlay>
);
};
@@ -0,0 +1,401 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface SkillsSidebarProps {
onItemSelect?: () => void;
}
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
const [renameNewName, setRenameNewName] = React.useState('');
const {
selectedSkillName,
skills,
setSelectedSkill,
setSkillDraft,
createSkill,
deleteSkill,
loadSkills,
getSkillDetail,
} = useSkillsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
loadSkills();
}, [loadSkills]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const handleCreateNew = () => {
// Generate unique name
const baseName = 'new-skill';
let newName = baseName;
let counter = 1;
while (skills.some((s) => s.name === newName)) {
newName = `${baseName}-${counter}`;
counter++;
}
// Set draft and open the page for editing
setSkillDraft({ name: newName, scope: 'user', description: '' });
setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
if (window.confirm(`Are you sure you want to delete skill "${skill.name}"?`)) {
const success = await deleteSkill(skill.name);
if (success) {
toast.success(`Skill "${skill.name}" deleted successfully`);
} else {
toast.error('Failed to delete skill');
}
}
};
const handleDuplicateSkill = async (skill: DiscoveredSkill) => {
const baseName = skill.name;
let copyNumber = 1;
let newName = `${baseName}-copy`;
while (skills.some((s) => s.name === newName)) {
copyNumber++;
newName = `${baseName}-copy-${copyNumber}`;
}
// Get full skill detail to copy
const detail = await getSkillDetail(skill.name);
if (!detail) {
toast.error('Failed to load skill details for duplication');
return;
}
// Set draft with prefilled values from source skill
setSkillDraft({
name: newName,
scope: skill.scope || 'user',
description: detail.sources.md.fields.includes('description') ? '' : '', // Will be populated from page
instructions: '',
});
setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
setRenameNewName(skill.name);
setRenameDialogSkill(skill);
};
const handleRenameSkill = async () => {
if (!renameDialogSkill) return;
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
if (!sanitizedName) {
toast.error('Skill name is required');
return;
}
if (sanitizedName === renameDialogSkill.name) {
setRenameDialogSkill(null);
return;
}
if (skills.some((s) => s.name === sanitizedName)) {
toast.error('A skill with this name already exists');
return;
}
// Get full detail to copy
const detail = await getSkillDetail(renameDialogSkill.name);
if (!detail) {
toast.error('Failed to load skill details');
setRenameDialogSkill(null);
return;
}
// Create new skill with new name
const success = await createSkill({
name: sanitizedName,
description: 'Renamed skill', // Will need proper description
scope: renameDialogSkill.scope,
});
if (success) {
// Delete old skill
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
if (deleteSuccess) {
toast.success(`Skill renamed to "${sanitizedName}"`);
setSelectedSkill(sanitizedName);
} else {
toast.error('Failed to remove old skill after rename');
}
} else {
toast.error('Failed to rename skill');
}
setRenameDialogSkill(null);
};
// Separate project and user skills
const projectSkills = skills.filter((s) => s.scope === 'project');
const userSkills = skills.filter((s) => s.scope === 'user');
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{skills.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiBookOpenLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No skills configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{projectSkills.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Project Skills
</div>
{projectSkills.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
isSelected={selectedSkillName === skill.name}
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
/>
))}
</>
)}
{userSkills.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Skills
</div>
{userSkills.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
isSelected={selectedSkillName === skill.name}
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
{/* Rename Dialog */}
<Dialog open={renameDialogSkill !== null} onOpenChange={(open) => !open && setRenameDialogSkill(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Skill</DialogTitle>
<DialogDescription>
Enter a new name for the skill "{renameDialogSkill?.name}"
</DialogDescription>
</DialogHeader>
<Input
value={renameNewName}
onChange={(e) => setRenameNewName(e.target.value)}
placeholder="New skill name..."
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameSkill();
}
}}
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setRenameDialogSkill(null)}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleRenameSkill}>
Rename
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface SkillListItemProps {
skill: DiscoveredSkill;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onRename: () => void;
onDuplicate: () => void;
}
const SkillListItem: React.FC<SkillListItemProps> = ({
skill,
isSelected,
onSelect,
onDelete,
onRename,
onDuplicate,
}) => {
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{skill.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{skill.scope}
</span>
{skill.source === 'claude' && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
claude
</span>
)}
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<RiEditLine className="h-4 w-4 mr-px" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<RiFileCopyLine className="h-4 w-4 mr-px" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
@@ -0,0 +1,2 @@
export { SkillsSidebar } from './SkillsSidebar';
export { SkillsPage } from './SkillsPage';
@@ -8,6 +8,8 @@ import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
@@ -209,6 +211,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <AgentsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'commands':
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'skills':
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'providers':
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
case 'git-identities':
@@ -226,6 +230,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <AgentsPage />;
case 'commands':
return <CommandsPage />;
case 'skills':
return <SkillsPage />;
case 'providers':
return <ProvidersPage />;
case 'git-identities':
+8 -2
View File
@@ -1,7 +1,7 @@
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine } from '@remixicon/react';
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine, RiBookLine } from '@remixicon/react';
import type { ComponentType } from 'react';
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings';
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'providers' | 'git-identities' | 'settings';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type IconComponent = ComponentType<any>;
@@ -32,6 +32,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
description: 'Create and maintain custom slash commands for OpenCode.',
icon: RiCommandLine,
},
{
id: 'skills',
label: 'Skills',
description: 'Create reusable instruction files for agents to load on-demand.',
icon: RiBookLine,
},
{
id: 'providers',
label: 'Providers',
@@ -228,6 +228,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
codesearch: 'web code search',
todowrite: 'updating todos',
todoread: 'reading todos',
skill: 'learning skill',
};
const WORKING_PHRASES = [
+1 -1
View File
@@ -1,4 +1,4 @@
export type ConfigChangeScope = "agents" | "providers" | "commands" | "all";
export type ConfigChangeScope = "agents" | "providers" | "commands" | "skills" | "all";
export interface ConfigChangeEvent {
scopes: ConfigChangeScope[];
+8
View File
@@ -148,6 +148,14 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
category: 'system',
outputLanguage: 'json',
inputFields: []
},
skill: {
displayName: 'Load Skill',
category: 'ai',
outputLanguage: 'markdown',
inputFields: [
{ key: 'name', label: 'Skill Name', type: 'text' }
]
}
};
+388
View File
@@ -0,0 +1,388 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
// Access directory store without circular dependency
const getCurrentDirectory = (): string | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
}
} catch {
// ignore
}
return null;
};
export type SkillScope = 'user' | 'project';
export type SkillSource = 'opencode' | 'claude';
export interface SupportingFile {
name: string;
path: string;
fullPath: string;
}
export interface SkillSources {
md: {
exists: boolean;
path: string | null;
dir: string | null;
fields: string[];
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
// Actual content values
name?: string;
description?: string;
instructions?: string;
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
}
export interface DiscoveredSkill {
name: string;
path: string;
scope: SkillScope;
source: SkillSource;
}
export interface SkillConfig {
name: string;
description: string;
instructions?: string;
scope?: SkillScope;
supportingFiles?: Array<{ path: string; content: string }>;
}
export interface PendingFile {
path: string;
content: string;
}
export interface SkillDraft {
name: string;
scope: SkillScope;
description: string;
instructions?: string;
pendingFiles?: PendingFile[];
}
export interface SkillDetail {
name: string;
sources: SkillSources;
scope?: SkillScope | null;
source?: SkillSource | null;
}
interface SkillsStore {
selectedSkillName: string | null;
skills: DiscoveredSkill[];
isLoading: boolean;
skillDraft: SkillDraft | null;
setSelectedSkill: (name: string | null) => void;
setSkillDraft: (draft: SkillDraft | null) => void;
loadSkills: () => Promise<boolean>;
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
// Supporting files
readSupportingFile: (skillName: string, filePath: string) => Promise<string | null>;
writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise<boolean>;
deleteSupportingFile: (skillName: string, filePath: string) => Promise<boolean>;
}
declare global {
interface Window {
__zustand_skills_store__?: UseBoundStore<StoreApi<SkillsStore>>;
}
}
const CONFIG_EVENT_SOURCE = "useSkillsStore";
export const useSkillsStore = create<SkillsStore>()(
devtools(
persist(
(set, get) => ({
selectedSkillName: null,
skills: [],
isLoading: false,
skillDraft: null,
setSelectedSkill: (name: string | null) => {
set({ selectedSkillName: name });
},
setSkillDraft: (draft: SkillDraft | null) => {
set({ skillDraft: draft });
},
loadSkills: async () => {
set({ isLoading: true });
const previousSkills = get().skills;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills${queryParams}`);
if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`);
}
const data = await response.json();
const skills = (data.skills || []) as DiscoveredSkill[];
set({ skills, isLoading: false });
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load skills:", lastError);
set({ skills: previousSkills, isLoading: false });
return false;
},
getSkillDetail: async (name: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`);
if (!response.ok) {
return null;
}
return await response.json() as SkillDetail;
} catch {
return null;
}
},
createSkill: async (config: SkillConfig) => {
startConfigUpdate("Creating skill...");
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
description: config.description,
};
if (config.instructions) skillConfig.instructions = config.instructions;
if (config.scope) skillConfig.scope = config.scope;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to create skill';
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
finishConfigUpdate();
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
startConfigUpdate("Updating skill...");
try {
const skillConfig: Record<string, unknown> = {};
if (config.description !== undefined) skillConfig.description = config.description;
if (config.instructions !== undefined) skillConfig.instructions = config.instructions;
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(skillConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to update skill';
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
finishConfigUpdate();
}
},
deleteSkill: async (name: string) => {
startConfigUpdate("Deleting skill...");
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to delete skill';
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedSkillName === name) {
set({ selectedSkillName: null });
}
return loaded;
} catch {
return false;
} finally {
finishConfigUpdate();
}
},
getSkillByName: (name: string) => {
const { skills } = get();
return skills.find((s) => s.name === name);
},
readSupportingFile: async (skillName: string, filePath: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `&directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`
);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.content ?? null;
} catch {
return null;
}
},
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
}
);
return response.ok;
} catch {
return false;
}
},
deleteSupportingFile: async (skillName: string, filePath: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{ method: 'DELETE' }
);
return response.ok;
} catch {
return false;
}
},
}),
{
name: "skills-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
selectedSkillName: state.selectedSkillName,
}),
},
),
{
name: "skills-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_skills_store__ = useSkillsStore;
}
// Subscribe to config changes from other stores
let unsubscribeSkillsConfigChanges: (() => void) | null = null;
if (!unsubscribeSkillsConfigChanges) {
unsubscribeSkillsConfigChanges = subscribeToConfigChanges((event) => {
if (event.source === CONFIG_EVENT_SOURCE) {
return;
}
if (scopeMatches(event, "skills")) {
const { loadSkills } = useSkillsStore.getState();
void loadSkills();
}
});
}
+1 -1
View File
@@ -134,7 +134,7 @@
},
"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"
}
+125 -1
View File
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import type { OpenCodeManager } from './opencode';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE } from './opencodeConfig';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig';
import { removeProviderAuth } from './opencodeAuth';
export interface BridgeRequest {
@@ -760,6 +760,130 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/skills': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
// LIST all skills (no name provided)
if (!name && normalizedMethod === 'GET') {
const skills = discoverSkills(workingDirectory);
return { id, type, success: true, data: { skills } };
}
const skillName = typeof name === 'string' ? name.trim() : '';
if (!skillName) {
return { id, type, success: false, error: 'Skill name is required' };
}
if (normalizedMethod === 'GET') {
const sources = getSkillSources(skillName, workingDirectory);
return {
id,
type,
success: true,
data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source },
};
}
if (normalizedMethod === 'POST') {
const scopeValue = body?.scope as string | undefined;
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
createSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
// Skills are just files - OpenCode loads them on-demand, no restart needed
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: false,
message: `Skill ${skillName} created successfully`,
},
};
}
if (normalizedMethod === 'PATCH') {
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
// Skills are just files - OpenCode loads them on-demand, no restart needed
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: false,
message: `Skill ${skillName} updated successfully`,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteSkill(skillName, workingDirectory);
// Skills are just files - OpenCode loads them on-demand, no restart needed
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: false,
message: `Skill ${skillName} deleted successfully`,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/skills/files': {
const { method, name, filePath, content } = (payload || {}) as {
method?: string;
name?: string;
filePath?: string;
content?: string;
};
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const skillName = typeof name === 'string' ? name.trim() : '';
if (!skillName) {
return { id, type, success: false, error: 'Skill name is required' };
}
const relativePath = typeof filePath === 'string' ? filePath.trim() : '';
if (!relativePath) {
return { id, type, success: false, error: 'File path is required' };
}
const sources = getSkillSources(skillName, workingDirectory);
if (!sources.md.dir) {
return { id, type, success: false, error: `Skill "${skillName}" not found` };
}
const skillDir = sources.md.dir;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const fileContent = readSkillSupportingFile(skillDir, relativePath);
if (fileContent === null) {
return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` };
}
return { id, type, success: true, data: { content: fileContent } };
}
if (normalizedMethod === 'PUT') {
writeSkillSupportingFile(skillDir, relativePath, content || '');
return { id, type, success: true, data: { success: true } };
}
if (normalizedMethod === 'DELETE') {
deleteSkillSupportingFile(skillDir, relativePath);
return { id, type, success: true, data: { success: true } };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:opencode/directory': {
const target = (payload as { path?: string })?.path;
if (!target) {
+410
View File
@@ -732,3 +732,413 @@ export const deleteCommand = (commandName: string, workingDirectory?: string) =>
}
};
// ============== SKILL SCOPE HELPERS ==============
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skill');
export const SKILL_SCOPE = {
USER: 'user',
PROJECT: 'project'
} as const;
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
export type SkillSource = 'opencode' | 'claude';
export type SupportingFile = {
name: string;
path: string;
fullPath: string;
};
export type SkillConfigSources = {
md: {
exists: boolean;
path: string | null;
dir: string | null;
fields: string[];
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
};
export type DiscoveredSkill = {
name: string;
path: string;
scope: SkillScope;
source: SkillSource;
};
const ensureSkillDirs = () => {
if (!fs.existsSync(SKILL_DIR)) {
fs.mkdirSync(SKILL_DIR, { recursive: true });
}
};
const getUserSkillDir = (skillName: string): string => {
return path.join(SKILL_DIR, skillName);
};
const getUserSkillPath = (skillName: string): string => {
return path.join(getUserSkillDir(skillName), 'SKILL.md');
};
const getProjectSkillDir = (workingDirectory: string, skillName: string): string => {
return path.join(workingDirectory, '.opencode', 'skill', skillName);
};
const getProjectSkillPath = (workingDirectory: string, skillName: string): string => {
return path.join(getProjectSkillDir(workingDirectory, skillName), 'SKILL.md');
};
const getClaudeSkillDir = (workingDirectory: string, skillName: string): string => {
return path.join(workingDirectory, '.claude', 'skills', skillName);
};
const getClaudeSkillPath = (workingDirectory: string, skillName: string): string => {
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
};
export const getSkillScope = (skillName: string, workingDirectory?: string): {
scope: SkillScope | null;
path: string | null;
source: SkillSource | null;
} => {
if (workingDirectory) {
// Check .opencode/skill first
const projectPath = getProjectSkillPath(workingDirectory, skillName);
if (fs.existsSync(projectPath)) {
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
}
// Check .claude/skills (claude-compat)
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
if (fs.existsSync(claudePath)) {
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
}
}
const userPath = getUserSkillPath(skillName);
if (fs.existsSync(userPath)) {
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
}
return { scope: null, path: null, source: null };
};
const listSupportingFiles = (skillDir: string): SupportingFile[] => {
if (!fs.existsSync(skillDir)) return [];
const files: SupportingFile[] = [];
const walkDir = (dir: string, relativePath: string = '') => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
walkDir(fullPath, relPath);
} else if (entry.name !== 'SKILL.md') {
files.push({
name: entry.name,
path: relPath,
fullPath
});
}
}
};
walkDir(skillDir);
return files;
};
export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => {
const skills = new Map<string, DiscoveredSkill>();
const addSkill = (name: string, skillPath: string, scope: SkillScope, source: SkillSource) => {
if (!skills.has(name)) {
skills.set(name, { name, path: skillPath, scope, source });
}
};
// 1. Project level .opencode/skill/ (highest priority)
if (workingDirectory) {
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
if (fs.existsSync(projectSkillDir)) {
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
}
}
}
}
// 2. Claude-compatible .claude/skills/
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
if (fs.existsSync(claudeSkillDir)) {
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
}
}
}
}
}
// 3. User level ~/.config/opencode/skill/
if (fs.existsSync(SKILL_DIR)) {
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
}
}
}
}
return Array.from(skills.values());
};
export const getSkillSources = (skillName: string, workingDirectory?: string): SkillConfigSources => {
ensureSkillDirs();
// Check all possible locations
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
const projectDir = projectExists && workingDirectory ? getProjectSkillDir(workingDirectory, skillName) : null;
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
const claudeExists = claudePath ? fs.existsSync(claudePath) : false;
const claudeDir = claudeExists && workingDirectory ? getClaudeSkillDir(workingDirectory, skillName) : null;
const userPath = getUserSkillPath(skillName);
const userExists = fs.existsSync(userPath);
const userDir = userExists ? getUserSkillDir(skillName) : null;
// Determine which md file to use (priority: project > claude > user)
let mdPath: string | null = null;
let mdScope: SkillScope | null = null;
let mdSource: SkillSource | null = null;
let mdDir: string | null = null;
if (projectExists) {
mdPath = projectPath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'opencode';
mdDir = projectDir;
} else if (claudeExists) {
mdPath = claudePath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'claude';
mdDir = claudeDir;
} else if (userExists) {
mdPath = userPath;
mdScope = SKILL_SCOPE.USER;
mdSource = 'opencode';
mdDir = userDir;
}
const mdExists = !!mdPath;
let mdFields: string[] = [];
let supportingFiles: SupportingFile[] = [];
if (mdExists && mdPath) {
const { frontmatter, body } = parseMdFile(mdPath);
mdFields = Object.keys(frontmatter);
if (body) mdFields.push('instructions');
if (mdDir) {
supportingFiles = listSupportingFiles(mdDir);
}
}
return {
md: {
exists: mdExists,
path: mdPath,
dir: mdDir,
fields: mdFields,
scope: mdScope,
source: mdSource,
supportingFiles
},
projectMd: { exists: projectExists, path: projectPath },
claudeMd: { exists: claudeExists, path: claudePath },
userMd: { exists: userExists, path: userPath }
};
};
export const readSkillSupportingFile = (skillDir: string, relativePath: string): string | null => {
const fullPath = path.join(skillDir, relativePath);
if (!fs.existsSync(fullPath)) return null;
return fs.readFileSync(fullPath, 'utf8');
};
export const writeSkillSupportingFile = (skillDir: string, relativePath: string, content: string): void => {
const fullPath = path.join(skillDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
};
export const deleteSkillSupportingFile = (skillDir: string, relativePath: string): void => {
const fullPath = path.join(skillDir, relativePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
// Clean up empty parent directories
let parentDir = path.dirname(fullPath);
while (parentDir !== skillDir) {
try {
const entries = fs.readdirSync(parentDir);
if (entries.length === 0) {
fs.rmdirSync(parentDir);
parentDir = path.dirname(parentDir);
} else {
break;
}
} catch {
break;
}
}
}
};
const validateSkillName = (skillName: string): void => {
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
}
};
export const createSkill = (skillName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: SkillScope): void => {
ensureSkillDirs();
validateSkillName(skillName);
// Check if skill already exists
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
}
// Determine target directory
let targetDir: string;
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
targetDir = getProjectSkillDir(workingDirectory, skillName);
} else {
targetDir = getUserSkillDir(skillName);
}
fs.mkdirSync(targetDir, { recursive: true });
const targetPath = path.join(targetDir, 'SKILL.md');
// Extract fields
const { instructions, scope: _ignored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
instructions?: unknown;
scope?: unknown;
supportingFiles?: Array<{ path: string; content: string }>;
};
void _ignored;
// Ensure required fields
if (!frontmatter.name) {
frontmatter.name = skillName;
}
if (!frontmatter.description) {
throw new Error('Skill description is required');
}
writeMdFile(targetPath, frontmatter, typeof instructions === 'string' ? instructions : '');
// Write supporting files if provided
if (supportingFilesData && Array.isArray(supportingFilesData)) {
for (const file of supportingFilesData) {
if (file.path && file.content !== undefined) {
writeSkillSupportingFile(targetDir, file.path, file.content);
}
}
}
};
export const updateSkill = (skillName: string, updates: Record<string, unknown>, workingDirectory?: string): void => {
const existing = getSkillScope(skillName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${skillName}" not found`);
}
const mdPath = existing.path;
const mdDir = path.dirname(mdPath);
const mdData = parseMdFile(mdPath);
let mdModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'scope') continue;
if (field === 'instructions') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
mdData.body = normalizedValue;
mdModified = true;
continue;
}
if (field === 'supportingFiles' && Array.isArray(value)) {
for (const file of value as Array<{ delete?: boolean; path?: string; content?: string }>) {
if (file.delete && file.path) {
deleteSkillSupportingFile(mdDir, file.path);
} else if (file.path && file.content !== undefined) {
writeSkillSupportingFile(mdDir, file.path, file.content);
}
}
continue;
}
mdData.frontmatter[field] = value;
mdModified = true;
}
if (mdModified) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
};
export const deleteSkill = (skillName: string, workingDirectory?: string): void => {
let deleted = false;
// Check and delete from all locations
if (workingDirectory) {
// Project level .opencode/skill/
const projectDir = getProjectSkillDir(workingDirectory, skillName);
if (fs.existsSync(projectDir)) {
fs.rmSync(projectDir, { recursive: true, force: true });
deleted = true;
}
// Claude-compat .claude/skills/
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
if (fs.existsSync(claudeDir)) {
fs.rmSync(claudeDir, { recursive: true, force: true });
deleted = true;
}
}
// User level
const userDir = getUserSkillDir(skillName);
if (fs.existsSync(userDir)) {
fs.rmSync(userDir, { recursive: true, force: true });
deleted = true;
}
if (!deleted) {
throw new Error(`Skill "${skillName}" not found`);
}
};
+46
View File
@@ -387,6 +387,52 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
}
// Skills file operations: /api/config/skills/:name/files/:filePath
const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/);
if (skillsFilesMatch) {
const name = decodeURIComponent(skillsFilesMatch[1]);
const filePath = decodeURIComponent(skillsFilesMatch[2]);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/skills/files', {
method: verb,
name,
filePath,
content: body.content
});
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Skills CRUD: /api/config/skills/:name or /api/config/skills
if (pathname === '/api/config/skills') {
try {
const data = await sendBridgeMessage('api:config/skills', { method: 'GET' });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/skills/')) {
const encodedName = pathname.slice('/api/config/skills/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/settings')) {
if ((init?.method || 'GET').toUpperCase() === 'GET') {
const settings = await sendBridgeMessage('api:config/settings:get');
+1 -1
View File
@@ -25,7 +25,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",
+198
View File
@@ -1834,6 +1834,7 @@ async function main(options = {}) {
req.path.startsWith('/api/config/agents') ||
req.path.startsWith('/api/config/commands') ||
req.path.startsWith('/api/config/settings') ||
req.path.startsWith('/api/config/skills') ||
req.path.startsWith('/api/fs') ||
req.path.startsWith('/api/git') ||
req.path.startsWith('/api/prompts') ||
@@ -2364,6 +2365,203 @@ async function main(options = {}) {
}
});
// ============== SKILL ENDPOINTS ==============
const {
getSkillSources,
discoverSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE
} = await import('./lib/opencode-config.js');
// List all discovered skills
app.get('/api/config/skills', (req, res) => {
try {
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
const skills = discoverSkills(workingDirectory);
// Enrich with full sources info
const enrichedSkills = skills.map(skill => {
const sources = getSkillSources(skill.name, workingDirectory);
return {
...skill,
sources
};
});
res.json({ skills: enrichedSkills });
} catch (error) {
console.error('Failed to list skills:', error);
res.status(500).json({ error: 'Failed to list skills' });
}
});
// Get single skill sources
app.get('/api/config/skills/:name', (req, res) => {
try {
const skillName = req.params.name;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
const sources = getSkillSources(skillName, workingDirectory);
res.json({
name: skillName,
sources: sources,
scope: sources.md.scope,
source: sources.md.source,
exists: sources.md.exists
});
} catch (error) {
console.error('Failed to get skill sources:', error);
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
}
});
// Get skill supporting file content
app.get('/api/config/skills/:name/files/*filePath', (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
const sources = getSkillSources(skillName, workingDirectory);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
const content = readSkillSupportingFile(sources.md.dir, filePath);
if (content === null) {
return res.status(404).json({ error: 'File not found' });
}
res.json({ path: filePath, content });
} catch (error) {
console.error('Failed to read skill file:', error);
res.status(500).json({ error: 'Failed to read skill file' });
}
});
// Create new skill
app.post('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { scope, ...config } = req.body;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
console.log('[Server] Creating skill:', skillName);
console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
createSkill(skillName, config, workingDirectory, scope);
// Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({
success: true,
requiresReload: false,
message: `Skill ${skillName} created successfully`,
});
} catch (error) {
console.error('Failed to create skill:', error);
res.status(500).json({ error: error.message || 'Failed to create skill' });
}
});
// Update existing skill
app.patch('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const updates = req.body;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', workingDirectory);
updateSkill(skillName, updates, workingDirectory);
// Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({
success: true,
requiresReload: false,
message: `Skill ${skillName} updated successfully`,
});
} catch (error) {
console.error('[Server] Failed to update skill:', error);
res.status(500).json({ error: error.message || 'Failed to update skill' });
}
});
// Update/create supporting file
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const { content } = req.body;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
const sources = getSkillSources(skillName, workingDirectory);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
res.json({
success: true,
message: `File ${filePath} saved successfully`,
});
} catch (error) {
console.error('Failed to write skill file:', error);
res.status(500).json({ error: error.message || 'Failed to write skill file' });
}
});
// Delete supporting file
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
const sources = getSkillSources(skillName, workingDirectory);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
deleteSkillSupportingFile(sources.md.dir, filePath);
res.json({
success: true,
message: `File ${filePath} deleted successfully`,
});
} catch (error) {
console.error('Failed to delete skill file:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
}
});
// Delete skill
app.delete('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
deleteSkill(skillName, workingDirectory);
// Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({
success: true,
requiresReload: false,
message: `Skill ${skillName} deleted successfully`,
});
} catch (error) {
console.error('Failed to delete skill:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill' });
}
});
app.post('/api/config/reload', async (req, res) => {
try {
console.log('[Server] Manual configuration reload requested');
+504 -1
View File
@@ -7,6 +7,7 @@ import stripJsonComments from 'strip-json-comments';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skill');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
@@ -24,6 +25,11 @@ const COMMAND_SCOPE = {
PROJECT: 'project'
};
const SKILL_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
function ensureDirs() {
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
@@ -34,6 +40,9 @@ function ensureDirs() {
if (!fs.existsSync(COMMAND_DIR)) {
fs.mkdirSync(COMMAND_DIR, { recursive: true });
}
if (!fs.existsSync(SKILL_DIR)) {
fs.mkdirSync(SKILL_DIR, { recursive: true });
}
}
// ============== AGENT SCOPE HELPERS ==============
@@ -180,6 +189,195 @@ function getCommandWritePath(commandName, workingDirectory, requestedScope) {
};
}
// ============== SKILL SCOPE HELPERS ==============
/**
* Ensure project-level skill directory exists
*/
function ensureProjectSkillDir(workingDirectory) {
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
if (!fs.existsSync(projectSkillDir)) {
fs.mkdirSync(projectSkillDir, { recursive: true });
}
return projectSkillDir;
}
/**
* Get project-level skill directory path (.opencode/skill/{name}/)
*/
function getProjectSkillDir(workingDirectory, skillName) {
return path.join(workingDirectory, '.opencode', 'skill', skillName);
}
/**
* Get project-level skill SKILL.md path
*/
function getProjectSkillPath(workingDirectory, skillName) {
return path.join(getProjectSkillDir(workingDirectory, skillName), 'SKILL.md');
}
/**
* Get user-level skill directory path
*/
function getUserSkillDir(skillName) {
return path.join(SKILL_DIR, skillName);
}
/**
* Get user-level skill SKILL.md path
*/
function getUserSkillPath(skillName) {
return path.join(getUserSkillDir(skillName), 'SKILL.md');
}
/**
* Get Claude-compatible skill directory path (.claude/skills/{name}/)
*/
function getClaudeSkillDir(workingDirectory, skillName) {
return path.join(workingDirectory, '.claude', 'skills', skillName);
}
/**
* Get Claude-compatible skill SKILL.md path
*/
function getClaudeSkillPath(workingDirectory, skillName) {
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
}
/**
* Determine skill scope based on where the SKILL.md file exists
* Priority: project level (.opencode) > user level > claude-compat (.claude/skills)
*/
function getSkillScope(skillName, workingDirectory) {
if (workingDirectory) {
// Check .opencode/skill first
const projectPath = getProjectSkillPath(workingDirectory, skillName);
if (fs.existsSync(projectPath)) {
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
}
// Check .claude/skills (claude-compat)
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
if (fs.existsSync(claudePath)) {
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
}
}
const userPath = getUserSkillPath(skillName);
if (fs.existsSync(userPath)) {
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
}
return { scope: null, path: null, source: null };
}
/**
* Get the path where a skill should be written based on scope
* Note: We never write to .claude/skills, only read from there
*/
function getSkillWritePath(skillName, workingDirectory, requestedScope) {
// For updates: check existing location first
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
// If it's from .claude/skills, we still edit in place
return existing;
}
// For new skills: use requested scope or default to user
const scope = requestedScope || SKILL_SCOPE.USER;
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
return {
scope: SKILL_SCOPE.PROJECT,
path: getProjectSkillPath(workingDirectory, skillName),
source: 'opencode'
};
}
return {
scope: SKILL_SCOPE.USER,
path: getUserSkillPath(skillName),
source: 'opencode'
};
}
/**
* List all supporting files in a skill directory (excluding SKILL.md)
*/
function listSkillSupportingFiles(skillDir) {
if (!fs.existsSync(skillDir)) {
return [];
}
const files = [];
function walkDir(dir, relativePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
walkDir(fullPath, relPath);
} else if (entry.name !== 'SKILL.md') {
files.push({
name: entry.name,
path: relPath,
fullPath: fullPath
});
}
}
}
walkDir(skillDir);
return files;
}
/**
* Read a supporting file content
*/
function readSkillSupportingFile(skillDir, relativePath) {
const fullPath = path.join(skillDir, relativePath);
if (!fs.existsSync(fullPath)) {
return null;
}
return fs.readFileSync(fullPath, 'utf8');
}
/**
* Write a supporting file
*/
function writeSkillSupportingFile(skillDir, relativePath, content) {
const fullPath = path.join(skillDir, relativePath);
const dir = path.dirname(fullPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
/**
* Delete a supporting file
*/
function deleteSkillSupportingFile(skillDir, relativePath) {
const fullPath = path.join(skillDir, relativePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
// Clean up empty parent directories
let parentDir = path.dirname(fullPath);
while (parentDir !== skillDir) {
try {
const entries = fs.readdirSync(parentDir);
if (entries.length === 0) {
fs.rmdirSync(parentDir);
parentDir = path.dirname(parentDir);
} else {
break;
}
} catch {
break;
}
}
}
}
function isPromptFileReference(value) {
if (typeof value !== 'string') {
return false;
@@ -877,6 +1075,300 @@ function deleteCommand(commandName, workingDirectory) {
}
}
// ============== SKILL CRUD ==============
/**
* Discover all skills from all sources
*/
function discoverSkills(workingDirectory) {
const skills = new Map();
// Helper to add skill if not already found (first found wins by priority)
const addSkill = (name, skillPath, scope, source) => {
if (!skills.has(name)) {
skills.set(name, { name, path: skillPath, scope, source });
}
};
// 1. Project level .opencode/skill/ (highest priority)
if (workingDirectory) {
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
if (fs.existsSync(projectSkillDir)) {
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
}
}
}
}
// 2. Claude-compatible .claude/skills/
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
if (fs.existsSync(claudeSkillDir)) {
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
}
}
}
}
}
// 3. User level ~/.config/opencode/skill/
if (fs.existsSync(SKILL_DIR)) {
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
if (fs.existsSync(skillMdPath)) {
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
}
}
}
}
return Array.from(skills.values());
}
function getSkillSources(skillName, workingDirectory) {
// Check all possible locations
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
const projectDir = projectExists ? path.dirname(projectPath) : null;
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
const claudeExists = claudePath && fs.existsSync(claudePath);
const claudeDir = claudeExists ? path.dirname(claudePath) : null;
const userPath = getUserSkillPath(skillName);
const userExists = fs.existsSync(userPath);
const userDir = userExists ? path.dirname(userPath) : null;
// Determine which md file to use (priority: project > claude > user)
let mdPath = null;
let mdScope = null;
let mdSource = null;
let mdDir = null;
if (projectExists) {
mdPath = projectPath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'opencode';
mdDir = projectDir;
} else if (claudeExists) {
mdPath = claudePath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'claude';
mdDir = claudeDir;
} else if (userExists) {
mdPath = userPath;
mdScope = SKILL_SCOPE.USER;
mdSource = 'opencode';
mdDir = userDir;
}
const mdExists = !!mdPath;
const sources = {
md: {
exists: mdExists,
path: mdPath,
dir: mdDir,
scope: mdScope,
source: mdSource,
fields: [],
supportingFiles: []
},
// Additional info about all locations
projectMd: {
exists: projectExists,
path: projectPath,
dir: projectDir
},
claudeMd: {
exists: claudeExists,
path: claudePath,
dir: claudeDir
},
userMd: {
exists: userExists,
path: userPath,
dir: userDir
}
};
if (mdExists && mdDir) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
// Include actual content values
sources.md.description = frontmatter.description || '';
sources.md.name = frontmatter.name || skillName;
if (body) {
sources.md.fields.push('instructions');
sources.md.instructions = body;
} else {
sources.md.instructions = '';
}
sources.md.supportingFiles = listSkillSupportingFiles(mdDir);
}
return sources;
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
// Validate skill name (must be lowercase alphanumeric with hyphens, max 64 chars)
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
}
// Check if skill already exists at any location
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
}
// Determine target path based on requested scope
let targetDir;
let targetPath;
let targetScope;
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
ensureProjectSkillDir(workingDirectory);
targetDir = getProjectSkillDir(workingDirectory, skillName);
targetPath = getProjectSkillPath(workingDirectory, skillName);
targetScope = SKILL_SCOPE.PROJECT;
} else {
targetDir = getUserSkillDir(skillName);
targetPath = getUserSkillPath(skillName);
targetScope = SKILL_SCOPE.USER;
}
// Create skill directory
fs.mkdirSync(targetDir, { recursive: true });
// Extract fields - scope is only for path determination
const { instructions, scope: _scopeFromConfig, supportingFiles, ...frontmatter } = config;
// Ensure required fields
if (!frontmatter.name) {
frontmatter.name = skillName;
}
if (!frontmatter.description) {
throw new Error('Skill description is required');
}
writeMdFile(targetPath, frontmatter, instructions || '');
// Write supporting files if provided
if (supportingFiles && Array.isArray(supportingFiles)) {
for (const file of supportingFiles) {
if (file.path && file.content !== undefined) {
writeSkillSupportingFile(targetDir, file.path, file.content);
}
}
}
console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`);
}
function updateSkill(skillName, updates, workingDirectory) {
ensureDirs();
// Get existing skill location
const existing = getSkillScope(skillName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${skillName}" not found`);
}
const mdPath = existing.path;
const mdDir = path.dirname(mdPath);
const mdData = parseMdFile(mdPath);
let mdModified = false;
for (const [field, value] of Object.entries(updates)) {
// Skip scope field - it's metadata only
if (field === 'scope') {
continue;
}
if (field === 'instructions') {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
mdData.body = normalizedValue;
mdModified = true;
continue;
}
if (field === 'supportingFiles') {
// Handle supporting files updates
if (Array.isArray(value)) {
for (const file of value) {
if (file.delete && file.path) {
deleteSkillSupportingFile(mdDir, file.path);
} else if (file.path && file.content !== undefined) {
writeSkillSupportingFile(mdDir, file.path, file.content);
}
}
}
continue;
}
// Update frontmatter field
mdData.frontmatter[field] = value;
mdModified = true;
}
if (mdModified) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
console.log(`Updated skill: ${skillName} (path: ${mdPath})`);
}
function deleteSkill(skillName, workingDirectory) {
let deleted = false;
// Check and delete from all locations
// Project level .opencode/skill/
if (workingDirectory) {
const projectDir = getProjectSkillDir(workingDirectory, skillName);
if (fs.existsSync(projectDir)) {
fs.rmSync(projectDir, { recursive: true, force: true });
console.log(`Deleted project-level skill directory: ${projectDir}`);
deleted = true;
}
// Claude-compat .claude/skills/ - we allow deletion here too
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
if (fs.existsSync(claudeDir)) {
fs.rmSync(claudeDir, { recursive: true, force: true });
console.log(`Deleted claude-compat skill directory: ${claudeDir}`);
deleted = true;
}
}
// User level
const userDir = getUserSkillDir(skillName);
if (fs.existsSync(userDir)) {
fs.rmSync(userDir, { recursive: true, force: true });
console.log(`Deleted user-level skill directory: ${userDir}`);
deleted = true;
}
if (!deleted) {
throw new Error(`Skill "${skillName}" not found`);
}
}
export {
getAgentSources,
getAgentScope,
@@ -888,11 +1380,22 @@ export {
createCommand,
updateCommand,
deleteCommand,
getSkillSources,
getSkillScope,
discoverSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
readConfig,
writeConfig,
AGENT_DIR,
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE
COMMAND_SCOPE,
SKILL_SCOPE
};