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:
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Added Skills management to settings
|
||||||
|
|
||||||
|
|
||||||
## [1.3.8] - 2025-12-29
|
## [1.3.8] - 2025-12-29
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"@heroui/system": "^2.4.23",
|
"@heroui/system": "^2.4.23",
|
||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.185",
|
"@opencode-ai/sdk": "^1.0.209",
|
||||||
"@pierre/diffs": "^1.0.0",
|
"@pierre/diffs": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.0.185",
|
"@opencode-ai/sdk": "^1.0.209",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
},
|
},
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@
|
|||||||
"@heroui/system": "^2.4.23",
|
"@heroui/system": "^2.4.23",
|
||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
Generated
+8
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.3.7"
|
version = "1.3.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2882,6 +2882,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"window-vibrancy 0.7.1",
|
"window-vibrancy 0.7.1",
|
||||||
]
|
]
|
||||||
@@ -5390,6 +5391,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ tauri-plugin-notification = "2.3.3"
|
|||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
urlencoding = "2.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.3", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|||||||
@@ -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(
|
async fn handle_command_route(
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
method: Method,
|
method: Method,
|
||||||
@@ -1514,6 +1797,39 @@ async fn handle_config_routes(
|
|||||||
return handle_command_route(&state, method, req, trimmed.to_string()).await;
|
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 path == "/api/config/reload" && method == Method::POST {
|
||||||
if let Err(resp) =
|
if let Err(resp) =
|
||||||
refresh_opencode_after_config_change(&state, "manual configuration reload").await
|
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/")
|
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/commands/")
|
||||||
|
|| origin_path.starts_with("/api/config/skills")
|
||||||
|| origin_path == "/api/config/reload"
|
|| origin_path == "/api/config/reload"
|
||||||
|| is_provider_auth_delete;
|
|| is_provider_auth_delete;
|
||||||
|
|
||||||
|
|||||||
@@ -1370,3 +1370,563 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path>
|
|||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@opencode-ai/sdk": "^1.0.185",
|
"@opencode-ai/sdk": "^1.0.209",
|
||||||
"@pierre/diffs": "^1.0.0",
|
"@pierre/diffs": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
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 { cn } from '@/lib/utils';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
||||||
@@ -82,6 +82,9 @@ export const getToolIcon = (toolName: string) => {
|
|||||||
if (tool === 'todowrite' || tool === 'todoread') {
|
if (tool === 'todowrite' || tool === 'todoread') {
|
||||||
return <RiListCheck3 className={iconClass} />;
|
return <RiListCheck3 className={iconClass} />;
|
||||||
}
|
}
|
||||||
|
if (tool === 'skill') {
|
||||||
|
return <RiBookLine className={iconClass} />;
|
||||||
|
}
|
||||||
if (tool.startsWith('git')) {
|
if (tool.startsWith('git')) {
|
||||||
return <RiGitBranchLine className={iconClass} />;
|
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);
|
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) || '';
|
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
|
||||||
return typeof desc === 'string' ? desc : '';
|
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) {
|
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
|
<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 { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
||||||
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
||||||
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
|
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 { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
|
||||||
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
||||||
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
|
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
|
||||||
@@ -209,6 +211,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
return <AgentsSidebar onItemSelect={handleMobileSidebarClick} />;
|
return <AgentsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||||
case 'commands':
|
case 'commands':
|
||||||
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
|
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||||
|
case 'skills':
|
||||||
|
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||||
case 'providers':
|
case 'providers':
|
||||||
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
|
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||||
case 'git-identities':
|
case 'git-identities':
|
||||||
@@ -226,6 +230,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
return <AgentsPage />;
|
return <AgentsPage />;
|
||||||
case 'commands':
|
case 'commands':
|
||||||
return <CommandsPage />;
|
return <CommandsPage />;
|
||||||
|
case 'skills':
|
||||||
|
return <SkillsPage />;
|
||||||
case 'providers':
|
case 'providers':
|
||||||
return <ProvidersPage />;
|
return <ProvidersPage />;
|
||||||
case 'git-identities':
|
case 'git-identities':
|
||||||
|
|||||||
@@ -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';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type IconComponent = ComponentType<any>;
|
export type IconComponent = ComponentType<any>;
|
||||||
@@ -32,6 +32,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
|
|||||||
description: 'Create and maintain custom slash commands for OpenCode.',
|
description: 'Create and maintain custom slash commands for OpenCode.',
|
||||||
icon: RiCommandLine,
|
icon: RiCommandLine,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'skills',
|
||||||
|
label: 'Skills',
|
||||||
|
description: 'Create reusable instruction files for agents to load on-demand.',
|
||||||
|
icon: RiBookLine,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'providers',
|
id: 'providers',
|
||||||
label: 'Providers',
|
label: 'Providers',
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
codesearch: 'web code search',
|
codesearch: 'web code search',
|
||||||
todowrite: 'updating todos',
|
todowrite: 'updating todos',
|
||||||
todoread: 'reading todos',
|
todoread: 'reading todos',
|
||||||
|
skill: 'learning skill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORKING_PHRASES = [
|
const WORKING_PHRASES = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ConfigChangeScope = "agents" | "providers" | "commands" | "all";
|
export type ConfigChangeScope = "agents" | "providers" | "commands" | "skills" | "all";
|
||||||
|
|
||||||
export interface ConfigChangeEvent {
|
export interface ConfigChangeEvent {
|
||||||
scopes: ConfigChangeScope[];
|
scopes: ConfigChangeScope[];
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
|
|||||||
category: 'system',
|
category: 'system',
|
||||||
outputLanguage: 'json',
|
outputLanguage: 'json',
|
||||||
inputFields: []
|
inputFields: []
|
||||||
|
},
|
||||||
|
skill: {
|
||||||
|
displayName: 'Load Skill',
|
||||||
|
category: 'ai',
|
||||||
|
outputLanguage: 'markdown',
|
||||||
|
inputFields: [
|
||||||
|
{ key: 'name', label: 'Skill Name', type: 'text' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.0.185",
|
"@opencode-ai/sdk": "^1.0.209",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { OpenCodeManager } from './opencode';
|
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';
|
import { removeProviderAuth } from './opencodeAuth';
|
||||||
|
|
||||||
export interface BridgeRequest {
|
export interface BridgeRequest {
|
||||||
@@ -760,6 +760,130 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
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': {
|
case 'api:opencode/directory': {
|
||||||
const target = (payload as { path?: string })?.path;
|
const target = (payload as { path?: string })?.path;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 (pathname.startsWith('/api/config/settings')) {
|
||||||
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
||||||
const settings = await sendBridgeMessage('api:config/settings:get');
|
const settings = await sendBridgeMessage('api:config/settings:get');
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -1834,6 +1834,7 @@ async function main(options = {}) {
|
|||||||
req.path.startsWith('/api/config/agents') ||
|
req.path.startsWith('/api/config/agents') ||
|
||||||
req.path.startsWith('/api/config/commands') ||
|
req.path.startsWith('/api/config/commands') ||
|
||||||
req.path.startsWith('/api/config/settings') ||
|
req.path.startsWith('/api/config/settings') ||
|
||||||
|
req.path.startsWith('/api/config/skills') ||
|
||||||
req.path.startsWith('/api/fs') ||
|
req.path.startsWith('/api/fs') ||
|
||||||
req.path.startsWith('/api/git') ||
|
req.path.startsWith('/api/git') ||
|
||||||
req.path.startsWith('/api/prompts') ||
|
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) => {
|
app.post('/api/config/reload', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('[Server] Manual configuration reload requested');
|
console.log('[Server] Manual configuration reload requested');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import stripJsonComments from 'strip-json-comments';
|
|||||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||||
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
|
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
|
||||||
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
|
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 CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
||||||
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||||
@@ -24,6 +25,11 @@ const COMMAND_SCOPE = {
|
|||||||
PROJECT: 'project'
|
PROJECT: 'project'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SKILL_SCOPE = {
|
||||||
|
USER: 'user',
|
||||||
|
PROJECT: 'project'
|
||||||
|
};
|
||||||
|
|
||||||
function ensureDirs() {
|
function ensureDirs() {
|
||||||
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
|
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
|
||||||
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
|
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
|
||||||
@@ -34,6 +40,9 @@ function ensureDirs() {
|
|||||||
if (!fs.existsSync(COMMAND_DIR)) {
|
if (!fs.existsSync(COMMAND_DIR)) {
|
||||||
fs.mkdirSync(COMMAND_DIR, { recursive: true });
|
fs.mkdirSync(COMMAND_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
if (!fs.existsSync(SKILL_DIR)) {
|
||||||
|
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== AGENT SCOPE HELPERS ==============
|
// ============== 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) {
|
function isPromptFileReference(value) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return false;
|
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 {
|
export {
|
||||||
getAgentSources,
|
getAgentSources,
|
||||||
getAgentScope,
|
getAgentScope,
|
||||||
@@ -888,11 +1380,22 @@ export {
|
|||||||
createCommand,
|
createCommand,
|
||||||
updateCommand,
|
updateCommand,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
|
getSkillSources,
|
||||||
|
getSkillScope,
|
||||||
|
discoverSkills,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
deleteSkill,
|
||||||
|
readSkillSupportingFile,
|
||||||
|
writeSkillSupportingFile,
|
||||||
|
deleteSkillSupportingFile,
|
||||||
readConfig,
|
readConfig,
|
||||||
writeConfig,
|
writeConfig,
|
||||||
AGENT_DIR,
|
AGENT_DIR,
|
||||||
COMMAND_DIR,
|
COMMAND_DIR,
|
||||||
|
SKILL_DIR,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
AGENT_SCOPE,
|
AGENT_SCOPE,
|
||||||
COMMAND_SCOPE
|
COMMAND_SCOPE,
|
||||||
|
SKILL_SCOPE
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user