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:
Generated
+8
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openchamber-desktop"
|
||||
version = "1.3.7"
|
||||
version = "1.3.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2882,6 +2882,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-http 0.5.2",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"window-vibrancy 0.7.1",
|
||||
]
|
||||
@@ -5390,6 +5391,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -50,6 +50,7 @@ tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
base64 = "0.22.1"
|
||||
urlencoding = "2.1"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
@@ -1334,6 +1334,289 @@ async fn handle_agent_route(
|
||||
}
|
||||
}
|
||||
|
||||
/// Response type for skill metadata
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillMetadataResponse {
|
||||
name: String,
|
||||
exists: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
scope: Option<opencode_config::Scope>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source: Option<opencode_config::SkillSource>,
|
||||
sources: opencode_config::SkillConfigSources,
|
||||
}
|
||||
|
||||
/// Response type for skill list
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillListItem {
|
||||
name: String,
|
||||
path: String,
|
||||
scope: opencode_config::Scope,
|
||||
source: opencode_config::SkillSource,
|
||||
sources: opencode_config::SkillConfigSources,
|
||||
}
|
||||
|
||||
/// Response type for skill file content
|
||||
#[derive(Serialize)]
|
||||
struct SkillFileResponse {
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn handle_skill_list_route(
|
||||
state: &ServerState,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let discovered = opencode_config::discover_skills(Some(&working_directory));
|
||||
|
||||
let mut skills = Vec::new();
|
||||
for skill in discovered {
|
||||
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
skills.push(SkillListItem {
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
scope: skill.scope,
|
||||
source: skill.source,
|
||||
sources,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[desktop:config] Failed to get skill sources for {}: {}", skill.name, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills })))
|
||||
}
|
||||
|
||||
async fn handle_skill_route(
|
||||
state: &ServerState,
|
||||
method: Method,
|
||||
req: Request<Body>,
|
||||
name: String,
|
||||
file_path: Option<String>,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
|
||||
// Handle file operations: /api/config/skills/:name/files/*
|
||||
if let Some(ref fp) = file_path {
|
||||
match method {
|
||||
Method::GET => {
|
||||
// Read supporting file
|
||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
if !sources.md.exists {
|
||||
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
|
||||
}
|
||||
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match opencode_config::read_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await {
|
||||
Ok(content) => Ok(json_response(StatusCode::OK, SkillFileResponse { path: fp.clone(), content })),
|
||||
Err(_) => Ok(config_error_response(StatusCode::NOT_FOUND, "File not found")),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[desktop:config] Failed to read skill sources: {}", err);
|
||||
Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::PUT => {
|
||||
// Write supporting file
|
||||
let payload = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
if !sources.md.exists {
|
||||
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
|
||||
}
|
||||
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match opencode_config::write_skill_supporting_file(std::path::Path::new(&skill_dir), fp, content).await {
|
||||
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse {
|
||||
success: true,
|
||||
requires_reload: false,
|
||||
message: format!("File {} saved successfully", fp),
|
||||
reload_delay_ms: 0,
|
||||
})),
|
||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
Method::DELETE => {
|
||||
// Delete supporting file
|
||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
if !sources.md.exists {
|
||||
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
|
||||
}
|
||||
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match opencode_config::delete_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await {
|
||||
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse {
|
||||
success: true,
|
||||
requires_reload: false,
|
||||
message: format!("File {} deleted successfully", fp),
|
||||
reload_delay_ms: 0,
|
||||
})),
|
||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
||||
}
|
||||
} else {
|
||||
// Handle skill CRUD: /api/config/skills/:name
|
||||
match method {
|
||||
Method::GET => {
|
||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
let scope = sources.md.scope.clone();
|
||||
let source = sources.md.source.clone();
|
||||
Ok(json_response(
|
||||
StatusCode::OK,
|
||||
SkillMetadataResponse {
|
||||
name,
|
||||
exists: sources.md.exists,
|
||||
scope,
|
||||
source,
|
||||
sources,
|
||||
},
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[desktop:config] Failed to read skill sources: {}", err);
|
||||
Ok(config_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to read skill configuration",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::POST => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
let scope = payload.get("scope")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| match s {
|
||||
"project" => Some(opencode_config::SkillScope::Project),
|
||||
"user" => Some(opencode_config::SkillScope::User),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
match opencode_config::create_skill(&name, &payload, Some(&working_directory), scope).await {
|
||||
Ok(()) => {
|
||||
if let Err(resp) =
|
||||
refresh_opencode_after_config_change(state, "skill creation").await
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
Ok(json_response(
|
||||
StatusCode::OK,
|
||||
ConfigActionResponse {
|
||||
success: true,
|
||||
requires_reload: true,
|
||||
message: format!(
|
||||
"Skill {} created successfully. Reloading interface...",
|
||||
name
|
||||
),
|
||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[desktop:config] Failed to create skill {}: {}", name, err);
|
||||
Ok(config_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::PATCH => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await {
|
||||
Ok(()) => {
|
||||
if let Err(resp) =
|
||||
refresh_opencode_after_config_change(state, "skill update").await
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
Ok(json_response(
|
||||
StatusCode::OK,
|
||||
ConfigActionResponse {
|
||||
success: true,
|
||||
requires_reload: true,
|
||||
message: format!(
|
||||
"Skill {} updated successfully. Reloading interface...",
|
||||
name
|
||||
),
|
||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[desktop:config] Failed to update skill {}: {}", name, err);
|
||||
Ok(config_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::DELETE => match opencode_config::delete_skill(&name, Some(&working_directory)).await {
|
||||
Ok(()) => {
|
||||
if let Err(resp) =
|
||||
refresh_opencode_after_config_change(state, "skill deletion").await
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
Ok(json_response(
|
||||
StatusCode::OK,
|
||||
ConfigActionResponse {
|
||||
success: true,
|
||||
requires_reload: true,
|
||||
message: format!(
|
||||
"Skill {} deleted successfully. Reloading interface...",
|
||||
name
|
||||
),
|
||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[desktop:config] Failed to delete skill {}: {}", name, err);
|
||||
let status = if err.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
Ok(config_error_response(status, err.to_string()))
|
||||
}
|
||||
},
|
||||
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_command_route(
|
||||
state: &ServerState,
|
||||
method: Method,
|
||||
@@ -1514,6 +1797,39 @@ async fn handle_config_routes(
|
||||
return handle_command_route(&state, method, req, trimmed.to_string()).await;
|
||||
}
|
||||
|
||||
// Handle skill routes: /api/config/skills and /api/config/skills/:name
|
||||
if path == "/api/config/skills" && method == Method::GET {
|
||||
return handle_skill_list_route(&state).await;
|
||||
}
|
||||
|
||||
if let Some(rest) = path.strip_prefix("/api/config/skills/") {
|
||||
// Check if it's a file operation: /api/config/skills/:name/files/*
|
||||
if let Some(files_start) = rest.find("/files/") {
|
||||
let name = &rest[..files_start];
|
||||
let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/"
|
||||
// Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md")
|
||||
let file_path = urlencoding::decode(file_path_encoded)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| file_path_encoded.to_string());
|
||||
if name.is_empty() {
|
||||
return Ok(config_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Skill name is required",
|
||||
));
|
||||
}
|
||||
return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)).await;
|
||||
}
|
||||
|
||||
let trimmed = rest.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(config_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Skill name is required",
|
||||
));
|
||||
}
|
||||
return handle_skill_route(&state, method, req, trimmed.to_string(), None).await;
|
||||
}
|
||||
|
||||
if path == "/api/config/reload" && method == Method::POST {
|
||||
if let Err(resp) =
|
||||
refresh_opencode_after_config_change(&state, "manual configuration reload").await
|
||||
@@ -1685,6 +2001,7 @@ async fn proxy_to_opencode(
|
||||
|
||||
let is_desktop_config_route = origin_path.starts_with("/api/config/agents/")
|
||||
|| origin_path.starts_with("/api/config/commands/")
|
||||
|| origin_path.starts_with("/api/config/skills")
|
||||
|| origin_path == "/api/config/reload"
|
||||
|| is_provider_auth_delete;
|
||||
|
||||
|
||||
@@ -1370,3 +1370,563 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path>
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============== SKILL SCOPE TYPES ==============
|
||||
|
||||
/// Skill scope types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SkillScope {
|
||||
User,
|
||||
Project,
|
||||
}
|
||||
|
||||
impl From<SkillScope> for Scope {
|
||||
fn from(scope: SkillScope) -> Self {
|
||||
match scope {
|
||||
SkillScope::User => Scope::User,
|
||||
SkillScope::Project => Scope::Project,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill source type (opencode vs claude-compat)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SkillSource {
|
||||
Opencode,
|
||||
Claude,
|
||||
}
|
||||
|
||||
/// Supporting file info
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SupportingFile {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub full_path: String,
|
||||
}
|
||||
|
||||
/// Skill-specific source info with supporting files
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillSourceInfo {
|
||||
pub exists: bool,
|
||||
pub path: Option<String>,
|
||||
pub dir: Option<String>,
|
||||
pub fields: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<Scope>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<SkillSource>,
|
||||
pub supporting_files: Vec<SupportingFile>,
|
||||
// Actual content values
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// Skill config sources
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillConfigSources {
|
||||
pub md: SkillSourceInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_md: Option<MdLocationInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub claude_md: Option<MdLocationInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_md: Option<MdLocationInfo>,
|
||||
}
|
||||
|
||||
/// Discovered skill info
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiscoveredSkill {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub scope: Scope,
|
||||
pub source: SkillSource,
|
||||
}
|
||||
|
||||
// ============== SKILL SCOPE HELPERS ==============
|
||||
|
||||
/// Get user-level skill directory path
|
||||
fn get_skill_dir() -> PathBuf {
|
||||
get_config_dir().join("skill")
|
||||
}
|
||||
|
||||
/// Get user-level skill directory for a specific skill
|
||||
fn get_user_skill_dir(skill_name: &str) -> PathBuf {
|
||||
get_skill_dir().join(skill_name)
|
||||
}
|
||||
|
||||
/// Get user-level skill SKILL.md path
|
||||
fn get_user_skill_path(skill_name: &str) -> PathBuf {
|
||||
get_user_skill_dir(skill_name).join("SKILL.md")
|
||||
}
|
||||
|
||||
/// Get project-level skill directory (.opencode/skill/)
|
||||
fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
|
||||
working_directory.join(".opencode").join("skill").join(skill_name)
|
||||
}
|
||||
|
||||
/// Get project-level skill SKILL.md path
|
||||
fn get_project_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf {
|
||||
get_project_skill_dir(working_directory, skill_name).join("SKILL.md")
|
||||
}
|
||||
|
||||
/// Get Claude-compatible skill directory (.claude/skills/)
|
||||
fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
|
||||
working_directory.join(".claude").join("skills").join(skill_name)
|
||||
}
|
||||
|
||||
/// Get Claude-compatible skill SKILL.md path
|
||||
fn get_claude_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf {
|
||||
get_claude_skill_dir(working_directory, skill_name).join("SKILL.md")
|
||||
}
|
||||
|
||||
/// Ensure skill directories exist
|
||||
async fn ensure_skill_dirs() -> Result<()> {
|
||||
let skill_dir = get_skill_dir();
|
||||
fs::create_dir_all(&skill_dir).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure project skill directory exists
|
||||
async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) -> Result<PathBuf> {
|
||||
let project_skill_dir = get_project_skill_dir(working_directory, skill_name);
|
||||
fs::create_dir_all(&project_skill_dir).await?;
|
||||
Ok(project_skill_dir)
|
||||
}
|
||||
|
||||
/// Determine skill scope based on where the SKILL.md file exists
|
||||
pub fn get_skill_scope(skill_name: &str, working_directory: Option<&Path>) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
|
||||
if let Some(wd) = working_directory {
|
||||
// Check .opencode/skill first
|
||||
let project_path = get_project_skill_path(wd, skill_name);
|
||||
if project_path.exists() {
|
||||
return (Some(SkillScope::Project), Some(project_path), Some(SkillSource::Opencode));
|
||||
}
|
||||
|
||||
// Check .claude/skills (claude-compat)
|
||||
let claude_path = get_claude_skill_path(wd, skill_name);
|
||||
if claude_path.exists() {
|
||||
return (Some(SkillScope::Project), Some(claude_path), Some(SkillSource::Claude));
|
||||
}
|
||||
}
|
||||
|
||||
let user_path = get_user_skill_path(skill_name);
|
||||
if user_path.exists() {
|
||||
return (Some(SkillScope::User), Some(user_path), Some(SkillSource::Opencode));
|
||||
}
|
||||
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
/// List supporting files in a skill directory (excluding SKILL.md)
|
||||
fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
fn walk_dir(dir: &Path, relative_base: &Path, files: &mut Vec<SupportingFile>) {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if path.is_dir() {
|
||||
walk_dir(&path, relative_base, files);
|
||||
} else if file_name != "SKILL.md" {
|
||||
let relative_path = path.strip_prefix(relative_base)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| file_name.clone());
|
||||
|
||||
files.push(SupportingFile {
|
||||
name: file_name,
|
||||
path: relative_path,
|
||||
full_path: path.display().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk_dir(skill_dir, skill_dir, &mut files);
|
||||
files
|
||||
}
|
||||
|
||||
/// Discover all skills from all sources
|
||||
pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> {
|
||||
let mut skills: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new();
|
||||
|
||||
// Helper to add skill if not already found
|
||||
let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| {
|
||||
if !skills.contains_key(&name) {
|
||||
skills.insert(name.clone(), DiscoveredSkill {
|
||||
name,
|
||||
path: path.display().to_string(),
|
||||
scope,
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Project level .opencode/skill/ (highest priority)
|
||||
if let Some(wd) = working_directory {
|
||||
let project_skill_dir = wd.join(".opencode").join("skill");
|
||||
if project_skill_dir.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&project_skill_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
let skill_name = entry.file_name().to_string_lossy().to_string();
|
||||
let skill_md = entry.path().join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
add_skill(skill_name, skill_md, Scope::Project, SkillSource::Opencode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Claude-compatible .claude/skills/
|
||||
let claude_skill_dir = wd.join(".claude").join("skills");
|
||||
if claude_skill_dir.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&claude_skill_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
let skill_name = entry.file_name().to_string_lossy().to_string();
|
||||
let skill_md = entry.path().join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
add_skill(skill_name, skill_md, Scope::Project, SkillSource::Claude);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User level ~/.config/opencode/skill/
|
||||
let user_skill_dir = get_skill_dir();
|
||||
if user_skill_dir.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&user_skill_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
let skill_name = entry.file_name().to_string_lossy().to_string();
|
||||
let skill_md = entry.path().join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
add_skill(skill_name, skill_md, Scope::User, SkillSource::Opencode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills.into_values().collect()
|
||||
}
|
||||
|
||||
/// Get information about where skill configuration is stored
|
||||
pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path>) -> Result<SkillConfigSources> {
|
||||
ensure_skill_dirs().await?;
|
||||
|
||||
// Check all possible locations
|
||||
let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name));
|
||||
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
|
||||
let project_dir = project_exists.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name))).flatten();
|
||||
|
||||
let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name));
|
||||
let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false);
|
||||
let claude_dir = claude_exists.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name))).flatten();
|
||||
|
||||
let user_path = get_user_skill_path(skill_name);
|
||||
let user_exists = user_path.exists();
|
||||
let user_dir = if user_exists { Some(get_user_skill_dir(skill_name)) } else { None };
|
||||
|
||||
// Determine which md file to use (priority: project > claude > user)
|
||||
let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists {
|
||||
(project_path.clone(), true, Some(Scope::Project), Some(SkillSource::Opencode), project_dir.clone())
|
||||
} else if claude_exists {
|
||||
(claude_path.clone(), true, Some(Scope::Project), Some(SkillSource::Claude), claude_dir.clone())
|
||||
} else if user_exists {
|
||||
(Some(user_path.clone()), true, Some(Scope::User), Some(SkillSource::Opencode), user_dir.clone())
|
||||
} else {
|
||||
(None, false, None, None, None)
|
||||
};
|
||||
|
||||
let mut md_fields = Vec::new();
|
||||
let mut supporting_files = Vec::new();
|
||||
let mut md_name: Option<String> = None;
|
||||
let mut md_description: Option<String> = None;
|
||||
let mut md_instructions: Option<String> = None;
|
||||
|
||||
if md_exists {
|
||||
if let Some(ref path) = md_path {
|
||||
let md_data = parse_md_file(path).await?;
|
||||
md_fields.extend(md_data.frontmatter.keys().cloned());
|
||||
|
||||
// Extract actual content values
|
||||
md_name = md_data.frontmatter.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
md_description = md_data.frontmatter.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
if !md_data.body.trim().is_empty() {
|
||||
md_fields.push("instructions".to_string());
|
||||
md_instructions = Some(md_data.body.clone());
|
||||
}
|
||||
}
|
||||
if let Some(ref dir) = md_dir {
|
||||
supporting_files = list_supporting_files(dir);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SkillConfigSources {
|
||||
md: SkillSourceInfo {
|
||||
exists: md_exists,
|
||||
path: md_path.map(|p| p.display().to_string()),
|
||||
dir: md_dir.map(|d| d.display().to_string()),
|
||||
fields: md_fields,
|
||||
scope: md_scope,
|
||||
source: md_source,
|
||||
supporting_files,
|
||||
name: md_name,
|
||||
description: md_description,
|
||||
instructions: md_instructions,
|
||||
},
|
||||
project_md: Some(MdLocationInfo {
|
||||
exists: project_exists,
|
||||
path: project_path.map(|p| p.display().to_string()),
|
||||
}),
|
||||
claude_md: Some(MdLocationInfo {
|
||||
exists: claude_exists,
|
||||
path: claude_path.map(|p| p.display().to_string()),
|
||||
}),
|
||||
user_md: Some(MdLocationInfo {
|
||||
exists: user_exists,
|
||||
path: Some(user_path.display().to_string()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a supporting file content
|
||||
pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) -> Result<String> {
|
||||
let full_path = skill_dir.join(relative_path);
|
||||
if !full_path.exists() {
|
||||
return Err(anyhow!("File not found: {}", relative_path));
|
||||
}
|
||||
let content = fs::read_to_string(&full_path).await?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Write a supporting file
|
||||
pub async fn write_skill_supporting_file(skill_dir: &Path, relative_path: &str, content: &str) -> Result<()> {
|
||||
let full_path = skill_dir.join(relative_path);
|
||||
if let Some(parent) = full_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(&full_path, content).await?;
|
||||
info!("Wrote supporting file: {}", full_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a supporting file
|
||||
pub async fn delete_skill_supporting_file(skill_dir: &Path, relative_path: &str) -> Result<()> {
|
||||
let full_path = skill_dir.join(relative_path);
|
||||
if full_path.exists() {
|
||||
fs::remove_file(&full_path).await?;
|
||||
info!("Deleted supporting file: {}", full_path.display());
|
||||
|
||||
// Clean up empty parent directories
|
||||
let mut parent = full_path.parent();
|
||||
while let Some(p) = parent {
|
||||
if p == skill_dir {
|
||||
break;
|
||||
}
|
||||
if let Ok(mut entries) = std::fs::read_dir(p) {
|
||||
if entries.next().is_none() {
|
||||
let _ = std::fs::remove_dir(p);
|
||||
parent = p.parent();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate skill name (lowercase alphanumeric with hyphens, 1-64 chars)
|
||||
fn validate_skill_name(skill_name: &str) -> Result<()> {
|
||||
let re = Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$").expect("valid regex");
|
||||
if !re.is_match(skill_name) || skill_name.len() > 64 {
|
||||
return Err(anyhow!(
|
||||
"Invalid skill name \"{}\". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.",
|
||||
skill_name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create new skill
|
||||
pub async fn create_skill(
|
||||
skill_name: &str,
|
||||
config: &HashMap<String, Value>,
|
||||
working_directory: Option<&Path>,
|
||||
scope: Option<SkillScope>,
|
||||
) -> Result<()> {
|
||||
ensure_skill_dirs().await?;
|
||||
validate_skill_name(skill_name)?;
|
||||
|
||||
// Check if skill already exists
|
||||
let (_existing_scope, existing_path, _) = get_skill_scope(skill_name, working_directory);
|
||||
if existing_path.is_some() {
|
||||
return Err(anyhow!("Skill {} already exists", skill_name));
|
||||
}
|
||||
|
||||
// Determine target directory
|
||||
let (target_scope, target_dir) = if scope == Some(SkillScope::Project) {
|
||||
if let Some(wd) = working_directory {
|
||||
let dir = ensure_project_skill_dir(wd, skill_name).await?;
|
||||
(SkillScope::Project, dir)
|
||||
} else {
|
||||
let dir = get_user_skill_dir(skill_name);
|
||||
fs::create_dir_all(&dir).await?;
|
||||
(SkillScope::User, dir)
|
||||
}
|
||||
} else {
|
||||
let dir = get_user_skill_dir(skill_name);
|
||||
fs::create_dir_all(&dir).await?;
|
||||
(SkillScope::User, dir)
|
||||
};
|
||||
|
||||
let target_path = target_dir.join("SKILL.md");
|
||||
|
||||
// Extract fields
|
||||
let mut frontmatter = config.clone();
|
||||
let instructions = frontmatter
|
||||
.remove("instructions")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
frontmatter.remove("scope");
|
||||
frontmatter.remove("supportingFiles");
|
||||
|
||||
// Ensure required fields
|
||||
if !frontmatter.contains_key("name") {
|
||||
frontmatter.insert("name".to_string(), Value::String(skill_name.to_string()));
|
||||
}
|
||||
if !frontmatter.contains_key("description") {
|
||||
return Err(anyhow!("Skill description is required"));
|
||||
}
|
||||
|
||||
write_md_file(&target_path, &frontmatter, &instructions).await?;
|
||||
|
||||
// Write supporting files if provided
|
||||
if let Some(supporting_files) = config.get("supportingFiles").and_then(|v| v.as_array()) {
|
||||
for file in supporting_files {
|
||||
if let (Some(path), Some(content)) = (
|
||||
file.get("path").and_then(|v| v.as_str()),
|
||||
file.get("content").and_then(|v| v.as_str()),
|
||||
) {
|
||||
write_skill_supporting_file(&target_dir, path, content).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Created new skill: {} (scope: {:?}, path: {})", skill_name, target_scope, target_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update existing skill
|
||||
pub async fn update_skill(
|
||||
skill_name: &str,
|
||||
updates: &HashMap<String, Value>,
|
||||
working_directory: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let (_, existing_path, _) = get_skill_scope(skill_name, working_directory);
|
||||
let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?;
|
||||
let md_dir = md_path.parent().ok_or_else(|| anyhow!("Invalid skill path"))?;
|
||||
|
||||
let mut md_data = parse_md_file(&md_path).await?;
|
||||
let mut md_modified = false;
|
||||
|
||||
for (field, value) in updates.iter() {
|
||||
if field == "scope" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if field == "instructions" {
|
||||
let normalized = value.as_str().unwrap_or("").to_string();
|
||||
md_data.body = normalized;
|
||||
md_modified = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if field == "supportingFiles" {
|
||||
if let Some(files) = value.as_array() {
|
||||
for file in files {
|
||||
if let Some(true) = file.get("delete").and_then(|v| v.as_bool()) {
|
||||
if let Some(path) = file.get("path").and_then(|v| v.as_str()) {
|
||||
delete_skill_supporting_file(md_dir, path).await?;
|
||||
}
|
||||
} else if let (Some(path), Some(content)) = (
|
||||
file.get("path").and_then(|v| v.as_str()),
|
||||
file.get("content").and_then(|v| v.as_str()),
|
||||
) {
|
||||
write_skill_supporting_file(md_dir, path, content).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
md_data.frontmatter.insert(field.clone(), value.clone());
|
||||
md_modified = true;
|
||||
}
|
||||
|
||||
if md_modified {
|
||||
write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?;
|
||||
}
|
||||
|
||||
info!("Updated skill: {} (path: {})", skill_name, md_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete skill
|
||||
pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> Result<()> {
|
||||
let mut deleted = false;
|
||||
|
||||
// Check and delete from all locations
|
||||
if let Some(wd) = working_directory {
|
||||
// Project level .opencode/skill/
|
||||
let project_dir = get_project_skill_dir(wd, skill_name);
|
||||
if project_dir.exists() {
|
||||
fs::remove_dir_all(&project_dir).await?;
|
||||
info!("Deleted project-level skill directory: {}", project_dir.display());
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
// Claude-compat .claude/skills/
|
||||
let claude_dir = get_claude_skill_dir(wd, skill_name);
|
||||
if claude_dir.exists() {
|
||||
fs::remove_dir_all(&claude_dir).await?;
|
||||
info!("Deleted claude-compat skill directory: {}", claude_dir.display());
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// User level
|
||||
let user_dir = get_user_skill_dir(skill_name);
|
||||
if user_dir.exists() {
|
||||
fs::remove_dir_all(&user_dir).await?;
|
||||
info!("Deleted user-level skill directory: {}", user_dir.display());
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if !deleted {
|
||||
return Err(anyhow!("Skill \"{}\" not found", skill_name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@opencode-ai/sdk": "^1.0.185",
|
||||
"@opencode-ai/sdk": "^1.0.209",
|
||||
"@pierre/diffs": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
||||
@@ -82,6 +82,9 @@ export const getToolIcon = (toolName: string) => {
|
||||
if (tool === 'todowrite' || tool === 'todoread') {
|
||||
return <RiListCheck3 className={iconClass} />;
|
||||
}
|
||||
if (tool === 'skill') {
|
||||
return <RiBookLine className={iconClass} />;
|
||||
}
|
||||
if (tool.startsWith('git')) {
|
||||
return <RiGitBranchLine className={iconClass} />;
|
||||
}
|
||||
@@ -171,6 +174,10 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
|
||||
return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80);
|
||||
}
|
||||
|
||||
if (part.tool === 'skill' && input?.name && typeof input.name === 'string') {
|
||||
return input.name;
|
||||
}
|
||||
|
||||
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
|
||||
return typeof desc === 'string' ? desc : '';
|
||||
};
|
||||
@@ -654,6 +661,14 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (part.tool === 'skill' && hasStringOutput) {
|
||||
return renderScrollableBlock(
|
||||
<div className="w-full min-w-0">
|
||||
<SimpleMarkdownRenderer content={outputString} variant="tool" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) {
|
||||
return renderScrollableBlock(
|
||||
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiSaveLine, RiUser3Line } from '@remixicon/react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
|
||||
export const SkillsPage: React.FC = () => {
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
getSkillDetail,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
skills,
|
||||
skillDraft,
|
||||
setSkillDraft,
|
||||
setSelectedSkill,
|
||||
} = useSkillsStore();
|
||||
|
||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
||||
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
||||
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [instructions, setInstructions] = React.useState('');
|
||||
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
|
||||
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]); // For new skills
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// Track original values to detect changes
|
||||
const [originalDescription, setOriginalDescription] = React.useState('');
|
||||
const [originalInstructions, setOriginalInstructions] = React.useState('');
|
||||
|
||||
// File dialog state
|
||||
const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false);
|
||||
const [newFileName, setNewFileName] = React.useState('');
|
||||
const [newFileContent, setNewFileContent] = React.useState('');
|
||||
const [editingFilePath, setEditingFilePath] = React.useState<string | null>(null); // null = adding, string = editing
|
||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||
const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection
|
||||
|
||||
// Detect if skill-level fields have changed
|
||||
const hasSkillChanges = isNewSkill
|
||||
? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0)
|
||||
: (description !== originalDescription || instructions !== originalInstructions);
|
||||
|
||||
// Detect if file content has changed
|
||||
const hasFileChanges = editingFilePath
|
||||
? newFileContent !== originalFileContent
|
||||
: newFileName.trim() !== ''; // For new files, just need a name
|
||||
|
||||
// Load skill details when selection changes
|
||||
React.useEffect(() => {
|
||||
const loadSkillDetails = async () => {
|
||||
if (isNewSkill && skillDraft) {
|
||||
// Prefill from draft (for new or duplicated skills)
|
||||
setDraftName(skillDraft.name || '');
|
||||
setDraftScope(skillDraft.scope || 'user');
|
||||
setDescription(skillDraft.description || '');
|
||||
setInstructions(skillDraft.instructions || '');
|
||||
setOriginalDescription('');
|
||||
setOriginalInstructions('');
|
||||
setSupportingFiles([]);
|
||||
setPendingFiles(skillDraft.pendingFiles || []);
|
||||
} else if (selectedSkillName && selectedSkill) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
// Get actual content from the API response
|
||||
const md = detail.sources.md;
|
||||
setDescription(md.description || '');
|
||||
setInstructions(md.instructions || '');
|
||||
setOriginalDescription(md.description || '');
|
||||
setOriginalInstructions(md.instructions || '');
|
||||
setSupportingFiles(md.supportingFiles || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load skill details:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSkillDetails();
|
||||
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||
|
||||
if (!skillName) {
|
||||
toast.error('Skill name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate skill name format
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
toast.error('Description is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate name when creating new skill
|
||||
if (isNewSkill && skills.some((s) => s.name === skillName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const config: SkillConfig = {
|
||||
name: skillName,
|
||||
description: description.trim(),
|
||||
instructions: instructions.trim() || undefined,
|
||||
scope: isNewSkill ? draftScope : undefined,
|
||||
// Include pending files when creating new skill
|
||||
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
};
|
||||
|
||||
let success: boolean;
|
||||
if (isNewSkill) {
|
||||
success = await createSkill(config);
|
||||
if (success) {
|
||||
setSkillDraft(null); // Clear draft after successful creation
|
||||
setPendingFiles([]); // Clear pending files
|
||||
setSelectedSkill(skillName); // Select the newly created skill
|
||||
}
|
||||
} else {
|
||||
success = await updateSkill(skillName, config);
|
||||
if (success) {
|
||||
// Update original values to reflect saved state
|
||||
setOriginalDescription(description.trim());
|
||||
setOriginalInstructions(instructions.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewSkill ? 'Skill created successfully' : 'Skill updated successfully');
|
||||
} else {
|
||||
toast.error(isNewSkill ? 'Failed to create skill' : 'Failed to update skill');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving skill:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFile = () => {
|
||||
setEditingFilePath(null);
|
||||
setNewFileName('');
|
||||
setNewFileContent('');
|
||||
setOriginalFileContent('');
|
||||
setIsFileDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditFile = async (filePath: string) => {
|
||||
setEditingFilePath(filePath);
|
||||
setNewFileName(filePath);
|
||||
|
||||
// For new skills, get content from pending files
|
||||
if (isNewSkill) {
|
||||
const pendingFile = pendingFiles.find(f => f.path === filePath);
|
||||
const content = pendingFile?.content || '';
|
||||
setNewFileContent(content);
|
||||
setOriginalFileContent(content);
|
||||
setIsFileDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For existing skills, load content from server
|
||||
if (!selectedSkillName) return;
|
||||
|
||||
setIsLoadingFile(true);
|
||||
setIsFileDialogOpen(true);
|
||||
|
||||
try {
|
||||
const { readSupportingFile } = useSkillsStore.getState();
|
||||
const content = await readSupportingFile(selectedSkillName, filePath);
|
||||
setNewFileContent(content || '');
|
||||
setOriginalFileContent(content || '');
|
||||
} catch {
|
||||
toast.error('Failed to load file content');
|
||||
setNewFileContent('');
|
||||
setOriginalFileContent('');
|
||||
} finally {
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
if (!newFileName.trim()) {
|
||||
toast.error('File name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = newFileName.trim();
|
||||
const isEditing = editingFilePath !== null;
|
||||
|
||||
// For new skills, add/update pending files
|
||||
if (isNewSkill) {
|
||||
if (isEditing) {
|
||||
// Update existing pending file
|
||||
setPendingFiles(prev => prev.map(f =>
|
||||
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
|
||||
));
|
||||
toast.success(`File "${filePath}" updated`);
|
||||
} else {
|
||||
// Check for duplicate
|
||||
if (pendingFiles.some(f => f.path === filePath)) {
|
||||
toast.error('A file with this name already exists');
|
||||
return;
|
||||
}
|
||||
setPendingFiles(prev => [...prev, { path: filePath, content: newFileContent }]);
|
||||
toast.success(`File "${filePath}" added`);
|
||||
}
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// For existing skills, write directly to disk
|
||||
if (!selectedSkillName) {
|
||||
toast.error('No skill selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const { writeSupportingFile } = useSkillsStore.getState();
|
||||
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
|
||||
|
||||
if (success) {
|
||||
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
// Refresh skill details to get updated file list
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
} else {
|
||||
toast.error(isEditing ? 'Failed to update file' : 'Failed to create file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (filePath: string) => {
|
||||
// For new skills, remove from pending files
|
||||
if (isNewSkill) {
|
||||
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
|
||||
toast.success(`File "${filePath}" removed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// For existing skills, delete from disk
|
||||
if (!selectedSkillName) return;
|
||||
|
||||
if (window.confirm(`Are you sure you want to delete "${filePath}"?`)) {
|
||||
const { deleteSupportingFile } = useSkillsStore.getState();
|
||||
const success = await deleteSupportingFile(selectedSkillName, filePath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File "${filePath}" deleted`);
|
||||
// Refresh skill details
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to delete file');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Show empty state only when nothing is selected AND no draft
|
||||
if (!selectedSkillName && !skillDraft) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a skill from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="typography-body">Loading skill details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">
|
||||
{isNewSkill ? 'New Skill' : selectedSkillName}
|
||||
</h1>
|
||||
{selectedSkill && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{selectedSkill.scope === 'project' ? 'Project' : 'User'} skill
|
||||
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Configure skill identity and description
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isNewSkill && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Skill Name & Scope
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
placeholder="skill-name"
|
||||
className="flex-1 text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as SkillScope)}>
|
||||
<SelectTrigger className="!h-9 w-auto gap-1.5">
|
||||
{draftScope === 'user' ? (
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
) : (
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
)}
|
||||
<span className="capitalize">{draftScope}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
<span>User</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
<span>Project</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Description <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does..."
|
||||
rows={2}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
The agent uses this to decide when to load the skill
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Instructions</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Detailed instructions for the agent when this skill is loaded
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
rows={12}
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supporting Files */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Supporting Files</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Reference documentation, scripts, or templates
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddFile}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add File
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// For new skills, show pending files
|
||||
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
|
||||
|
||||
if (filesToShow.length === 0) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground py-2">
|
||||
{isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filesToShow.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg border bg-muted/30 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => handleEditFile(file.path)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<RiFileLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="typography-ui-label truncate">{file.path}</span>
|
||||
{isNewSkill && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(file.path);
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end border-t border-border/40 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasSkillChanges}
|
||||
className="gap-2 h-6 px-2 text-xs w-fit"
|
||||
>
|
||||
<RiSaveLine className="h-3 w-3" />
|
||||
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit File Dialog */}
|
||||
<Dialog open={isFileDialogOpen} onOpenChange={(open) => {
|
||||
setIsFileDialogOpen(open);
|
||||
if (!open) setEditingFilePath(null);
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingFilePath ? 'Modify the file content' : 'Create a new file in the skill directory'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoadingFile ? (
|
||||
<div className="flex-1 flex items-center justify-center py-8">
|
||||
<span className="typography-meta text-muted-foreground">Loading file content...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
File Path
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="example.md or docs/reference.txt"
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
disabled={editingFilePath !== null}
|
||||
/>
|
||||
{!editingFilePath && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Relative path within the skill directory. Subdirectories will be created automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
Content
|
||||
</label>
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
className="font-mono typography-meta flex-1 min-h-[200px] max-h-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
}}
|
||||
className="text-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
|
||||
{editingFilePath ? 'Save Changes' : 'Create File'}
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,401 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
|
||||
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
|
||||
const {
|
||||
selectedSkillName,
|
||||
skills,
|
||||
setSelectedSkill,
|
||||
setSkillDraft,
|
||||
createSkill,
|
||||
deleteSkill,
|
||||
loadSkills,
|
||||
getSkillDetail,
|
||||
} = useSkillsStore();
|
||||
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return typeof window.opencodeDesktop !== 'undefined';
|
||||
});
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
const bgClass = isDesktopRuntime
|
||||
? 'bg-transparent'
|
||||
: isVSCode
|
||||
? 'bg-background'
|
||||
: 'bg-sidebar';
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// Generate unique name
|
||||
const baseName = 'new-skill';
|
||||
let newName = baseName;
|
||||
let counter = 1;
|
||||
while (skills.some((s) => s.name === newName)) {
|
||||
newName = `${baseName}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Set draft and open the page for editing
|
||||
setSkillDraft({ name: newName, scope: 'user', description: '' });
|
||||
setSelectedSkill(newName);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
|
||||
if (window.confirm(`Are you sure you want to delete skill "${skill.name}"?`)) {
|
||||
const success = await deleteSkill(skill.name);
|
||||
if (success) {
|
||||
toast.success(`Skill "${skill.name}" deleted successfully`);
|
||||
} else {
|
||||
toast.error('Failed to delete skill');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateSkill = async (skill: DiscoveredSkill) => {
|
||||
const baseName = skill.name;
|
||||
let copyNumber = 1;
|
||||
let newName = `${baseName}-copy`;
|
||||
|
||||
while (skills.some((s) => s.name === newName)) {
|
||||
copyNumber++;
|
||||
newName = `${baseName}-copy-${copyNumber}`;
|
||||
}
|
||||
|
||||
// Get full skill detail to copy
|
||||
const detail = await getSkillDetail(skill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details for duplication');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set draft with prefilled values from source skill
|
||||
setSkillDraft({
|
||||
name: newName,
|
||||
scope: skill.scope || 'user',
|
||||
description: detail.sources.md.fields.includes('description') ? '' : '', // Will be populated from page
|
||||
instructions: '',
|
||||
});
|
||||
setSelectedSkill(newName);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
|
||||
setRenameNewName(skill.name);
|
||||
setRenameDialogSkill(skill);
|
||||
};
|
||||
|
||||
const handleRenameSkill = async () => {
|
||||
if (!renameDialogSkill) return;
|
||||
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Skill name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sanitizedName === renameDialogSkill.name) {
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skills.some((s) => s.name === sanitizedName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get full detail to copy
|
||||
const detail = await getSkillDetail(renameDialogSkill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details');
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new skill with new name
|
||||
const success = await createSkill({
|
||||
name: sanitizedName,
|
||||
description: 'Renamed skill', // Will need proper description
|
||||
scope: renameDialogSkill.scope,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Delete old skill
|
||||
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
|
||||
if (deleteSuccess) {
|
||||
toast.success(`Skill renamed to "${sanitizedName}"`);
|
||||
setSelectedSkill(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old skill after rename');
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename skill');
|
||||
}
|
||||
|
||||
setRenameDialogSkill(null);
|
||||
};
|
||||
|
||||
// Separate project and user skills
|
||||
const projectSkills = skills.filter((s) => s.scope === 'project');
|
||||
const userSkills = skills.filter((s) => s.scope === 'user');
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No skills configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Skills
|
||||
</div>
|
||||
{projectSkills.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isSelected={selectedSkillName === skill.name}
|
||||
onSelect={() => {
|
||||
setSelectedSkill(skill.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onRename={() => handleOpenRenameDialog(skill)}
|
||||
onDelete={() => handleDeleteSkill(skill)}
|
||||
onDuplicate={() => handleDuplicateSkill(skill)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{userSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Skills
|
||||
</div>
|
||||
{userSkills.map((skill) => (
|
||||
<SkillListItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isSelected={selectedSkillName === skill.name}
|
||||
onSelect={() => {
|
||||
setSelectedSkill(skill.name);
|
||||
onItemSelect?.();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onRename={() => handleOpenRenameDialog(skill)}
|
||||
onDelete={() => handleDeleteSkill(skill)}
|
||||
onDuplicate={() => handleDuplicateSkill(skill)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog open={renameDialogSkill !== null} onOpenChange={(open) => !open && setRenameDialogSkill(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the skill "{renameDialogSkill?.name}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New skill name..."
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRenameSkill();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogSkill(null)}
|
||||
className="text-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleRenameSkill}>
|
||||
Rename
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SkillListItemProps {
|
||||
skill: DiscoveredSkill;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: () => void;
|
||||
onDuplicate: () => void;
|
||||
}
|
||||
|
||||
const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
skill,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onDuplicate,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
|
||||
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{skill.scope}
|
||||
</span>
|
||||
{skill.source === 'claude' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
claude
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SkillsSidebar } from './SkillsSidebar';
|
||||
export { SkillsPage } from './SkillsPage';
|
||||
@@ -8,6 +8,8 @@ import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
||||
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
||||
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
||||
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
|
||||
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
|
||||
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
|
||||
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
|
||||
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
||||
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
|
||||
@@ -209,6 +211,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <AgentsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'commands':
|
||||
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'skills':
|
||||
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'providers':
|
||||
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'git-identities':
|
||||
@@ -226,6 +230,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <AgentsPage />;
|
||||
case 'commands':
|
||||
return <CommandsPage />;
|
||||
case 'skills':
|
||||
return <SkillsPage />;
|
||||
case 'providers':
|
||||
return <ProvidersPage />;
|
||||
case 'git-identities':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine } from '@remixicon/react';
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine, RiBookLine } from '@remixicon/react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings';
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'providers' | 'git-identities' | 'settings';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type IconComponent = ComponentType<any>;
|
||||
@@ -32,6 +32,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
|
||||
description: 'Create and maintain custom slash commands for OpenCode.',
|
||||
icon: RiCommandLine,
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills',
|
||||
description: 'Create reusable instruction files for agents to load on-demand.',
|
||||
icon: RiBookLine,
|
||||
},
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Providers',
|
||||
|
||||
@@ -228,6 +228,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
||||
codesearch: 'web code search',
|
||||
todowrite: 'updating todos',
|
||||
todoread: 'reading todos',
|
||||
skill: 'learning skill',
|
||||
};
|
||||
|
||||
const WORKING_PHRASES = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ConfigChangeScope = "agents" | "providers" | "commands" | "all";
|
||||
export type ConfigChangeScope = "agents" | "providers" | "commands" | "skills" | "all";
|
||||
|
||||
export interface ConfigChangeEvent {
|
||||
scopes: ConfigChangeScope[];
|
||||
|
||||
@@ -148,6 +148,14 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
|
||||
category: 'system',
|
||||
outputLanguage: 'json',
|
||||
inputFields: []
|
||||
},
|
||||
skill: {
|
||||
displayName: 'Load Skill',
|
||||
category: 'ai',
|
||||
outputLanguage: 'markdown',
|
||||
inputFields: [
|
||||
{ key: 'name', label: 'Skill Name', type: 'text' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.0.185",
|
||||
"@opencode-ai/sdk": "^1.0.209",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE } from './opencodeConfig';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig';
|
||||
import { removeProviderAuth } from './opencodeAuth';
|
||||
|
||||
export interface BridgeRequest {
|
||||
@@ -760,6 +760,130 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/skills': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
|
||||
// LIST all skills (no name provided)
|
||||
if (!name && normalizedMethod === 'GET') {
|
||||
const skills = discoverSkills(workingDirectory);
|
||||
return { id, type, success: true, data: { skills } };
|
||||
}
|
||||
|
||||
const skillName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!skillName) {
|
||||
return { id, type, success: false, error: 'Skill name is required' };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source },
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||
createSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Skill ${skillName} created successfully`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Skill ${skillName} updated successfully`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteSkill(skillName, workingDirectory);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Skill ${skillName} deleted successfully`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/skills/files': {
|
||||
const { method, name, filePath, content } = (payload || {}) as {
|
||||
method?: string;
|
||||
name?: string;
|
||||
filePath?: string;
|
||||
content?: string;
|
||||
};
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const skillName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!skillName) {
|
||||
return { id, type, success: false, error: 'Skill name is required' };
|
||||
}
|
||||
|
||||
const relativePath = typeof filePath === 'string' ? filePath.trim() : '';
|
||||
if (!relativePath) {
|
||||
return { id, type, success: false, error: 'File path is required' };
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
if (!sources.md.dir) {
|
||||
return { id, type, success: false, error: `Skill "${skillName}" not found` };
|
||||
}
|
||||
|
||||
const skillDir = sources.md.dir;
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const fileContent = readSkillSupportingFile(skillDir, relativePath);
|
||||
if (fileContent === null) {
|
||||
return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` };
|
||||
}
|
||||
return { id, type, success: true, data: { content: fileContent } };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PUT') {
|
||||
writeSkillSupportingFile(skillDir, relativePath, content || '');
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteSkillSupportingFile(skillDir, relativePath);
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:opencode/directory': {
|
||||
const target = (payload as { path?: string })?.path;
|
||||
if (!target) {
|
||||
|
||||
@@ -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 ((init?.method || 'GET').toUpperCase() === 'GET') {
|
||||
const settings = await sendBridgeMessage('api:config/settings:get');
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@opencode-ai/sdk": "^1.0.185",
|
||||
"@opencode-ai/sdk": "^1.0.209",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -1834,6 +1834,7 @@ async function main(options = {}) {
|
||||
req.path.startsWith('/api/config/agents') ||
|
||||
req.path.startsWith('/api/config/commands') ||
|
||||
req.path.startsWith('/api/config/settings') ||
|
||||
req.path.startsWith('/api/config/skills') ||
|
||||
req.path.startsWith('/api/fs') ||
|
||||
req.path.startsWith('/api/git') ||
|
||||
req.path.startsWith('/api/prompts') ||
|
||||
@@ -2364,6 +2365,203 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// ============== SKILL ENDPOINTS ==============
|
||||
|
||||
const {
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE
|
||||
} = await import('./lib/opencode-config.js');
|
||||
|
||||
// List all discovered skills
|
||||
app.get('/api/config/skills', (req, res) => {
|
||||
try {
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
const skills = discoverSkills(workingDirectory);
|
||||
|
||||
// Enrich with full sources info
|
||||
const enrichedSkills = skills.map(skill => {
|
||||
const sources = getSkillSources(skill.name, workingDirectory);
|
||||
return {
|
||||
...skill,
|
||||
sources
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ skills: enrichedSkills });
|
||||
} catch (error) {
|
||||
console.error('Failed to list skills:', error);
|
||||
res.status(500).json({ error: 'Failed to list skills' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single skill sources
|
||||
app.get('/api/config/skills/:name', (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
|
||||
res.json({
|
||||
name: skillName,
|
||||
sources: sources,
|
||||
scope: sources.md.scope,
|
||||
source: sources.md.source,
|
||||
exists: sources.md.exists
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get skill sources:', error);
|
||||
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get skill supporting file content
|
||||
app.get('/api/config/skills/:name/files/*filePath', (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
const content = readSkillSupportingFile(sources.md.dir, filePath);
|
||||
if (content === null) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
res.json({ path: filePath, content });
|
||||
} catch (error) {
|
||||
console.error('Failed to read skill file:', error);
|
||||
res.status(500).json({ error: 'Failed to read skill file' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new skill
|
||||
app.post('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { scope, ...config } = req.body;
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
|
||||
console.log('[Server] Creating skill:', skillName);
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
|
||||
|
||||
createSkill(skillName, config, workingDirectory, scope);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Skill ${skillName} created successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create skill:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create skill' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update existing skill
|
||||
app.patch('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const updates = req.body;
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
|
||||
console.log(`[Server] Updating skill: ${skillName}`);
|
||||
console.log('[Server] Working directory:', workingDirectory);
|
||||
|
||||
updateSkill(skillName, updates, workingDirectory);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Skill ${skillName} updated successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to update skill:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to update skill' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update/create supporting file
|
||||
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
||||
const { content } = req.body;
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `File ${filePath} saved successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to write skill file:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to write skill file' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete supporting file
|
||||
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
deleteSkillSupportingFile(sources.md.dir, filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `File ${filePath} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete skill file:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete skill
|
||||
app.delete('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
|
||||
deleteSkill(skillName, workingDirectory);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Skill ${skillName} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete skill:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete skill' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/reload', async (req, res) => {
|
||||
try {
|
||||
console.log('[Server] Manual configuration reload requested');
|
||||
|
||||
@@ -7,6 +7,7 @@ import stripJsonComments from 'strip-json-comments';
|
||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
|
||||
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
|
||||
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
||||
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
||||
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
@@ -24,6 +25,11 @@ const COMMAND_SCOPE = {
|
||||
PROJECT: 'project'
|
||||
};
|
||||
|
||||
const SKILL_SCOPE = {
|
||||
USER: 'user',
|
||||
PROJECT: 'project'
|
||||
};
|
||||
|
||||
function ensureDirs() {
|
||||
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
|
||||
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
|
||||
@@ -34,6 +40,9 @@ function ensureDirs() {
|
||||
if (!fs.existsSync(COMMAND_DIR)) {
|
||||
fs.mkdirSync(COMMAND_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(SKILL_DIR)) {
|
||||
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ============== AGENT SCOPE HELPERS ==============
|
||||
@@ -180,6 +189,195 @@ function getCommandWritePath(commandName, workingDirectory, requestedScope) {
|
||||
};
|
||||
}
|
||||
|
||||
// ============== SKILL SCOPE HELPERS ==============
|
||||
|
||||
/**
|
||||
* Ensure project-level skill directory exists
|
||||
*/
|
||||
function ensureProjectSkillDir(workingDirectory) {
|
||||
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
||||
if (!fs.existsSync(projectSkillDir)) {
|
||||
fs.mkdirSync(projectSkillDir, { recursive: true });
|
||||
}
|
||||
return projectSkillDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project-level skill directory path (.opencode/skill/{name}/)
|
||||
*/
|
||||
function getProjectSkillDir(workingDirectory, skillName) {
|
||||
return path.join(workingDirectory, '.opencode', 'skill', skillName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project-level skill SKILL.md path
|
||||
*/
|
||||
function getProjectSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getProjectSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-level skill directory path
|
||||
*/
|
||||
function getUserSkillDir(skillName) {
|
||||
return path.join(SKILL_DIR, skillName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-level skill SKILL.md path
|
||||
*/
|
||||
function getUserSkillPath(skillName) {
|
||||
return path.join(getUserSkillDir(skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude-compatible skill directory path (.claude/skills/{name}/)
|
||||
*/
|
||||
function getClaudeSkillDir(workingDirectory, skillName) {
|
||||
return path.join(workingDirectory, '.claude', 'skills', skillName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude-compatible skill SKILL.md path
|
||||
*/
|
||||
function getClaudeSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine skill scope based on where the SKILL.md file exists
|
||||
* Priority: project level (.opencode) > user level > claude-compat (.claude/skills)
|
||||
*/
|
||||
function getSkillScope(skillName, workingDirectory) {
|
||||
if (workingDirectory) {
|
||||
// Check .opencode/skill first
|
||||
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
|
||||
}
|
||||
|
||||
// Check .claude/skills (claude-compat)
|
||||
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
|
||||
if (fs.existsSync(claudePath)) {
|
||||
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
|
||||
}
|
||||
}
|
||||
|
||||
const userPath = getUserSkillPath(skillName);
|
||||
if (fs.existsSync(userPath)) {
|
||||
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
|
||||
}
|
||||
|
||||
return { scope: null, path: null, source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path where a skill should be written based on scope
|
||||
* Note: We never write to .claude/skills, only read from there
|
||||
*/
|
||||
function getSkillWritePath(skillName, workingDirectory, requestedScope) {
|
||||
// For updates: check existing location first
|
||||
const existing = getSkillScope(skillName, workingDirectory);
|
||||
if (existing.path) {
|
||||
// If it's from .claude/skills, we still edit in place
|
||||
return existing;
|
||||
}
|
||||
|
||||
// For new skills: use requested scope or default to user
|
||||
const scope = requestedScope || SKILL_SCOPE.USER;
|
||||
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
return {
|
||||
scope: SKILL_SCOPE.PROJECT,
|
||||
path: getProjectSkillPath(workingDirectory, skillName),
|
||||
source: 'opencode'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scope: SKILL_SCOPE.USER,
|
||||
path: getUserSkillPath(skillName),
|
||||
source: 'opencode'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all supporting files in a skill directory (excluding SKILL.md)
|
||||
*/
|
||||
function listSkillSupportingFiles(skillDir) {
|
||||
if (!fs.existsSync(skillDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
|
||||
function walkDir(dir, relativePath = '') {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath, relPath);
|
||||
} else if (entry.name !== 'SKILL.md') {
|
||||
files.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
fullPath: fullPath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(skillDir);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a supporting file content
|
||||
*/
|
||||
function readSkillSupportingFile(skillDir, relativePath) {
|
||||
const fullPath = path.join(skillDir, relativePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(fullPath, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a supporting file
|
||||
*/
|
||||
function writeSkillSupportingFile(skillDir, relativePath, content) {
|
||||
const fullPath = path.join(skillDir, relativePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a supporting file
|
||||
*/
|
||||
function deleteSkillSupportingFile(skillDir, relativePath) {
|
||||
const fullPath = path.join(skillDir, relativePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
// Clean up empty parent directories
|
||||
let parentDir = path.dirname(fullPath);
|
||||
while (parentDir !== skillDir) {
|
||||
try {
|
||||
const entries = fs.readdirSync(parentDir);
|
||||
if (entries.length === 0) {
|
||||
fs.rmdirSync(parentDir);
|
||||
parentDir = path.dirname(parentDir);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPromptFileReference(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
@@ -877,6 +1075,300 @@ function deleteCommand(commandName, workingDirectory) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SKILL CRUD ==============
|
||||
|
||||
/**
|
||||
* Discover all skills from all sources
|
||||
*/
|
||||
function discoverSkills(workingDirectory) {
|
||||
const skills = new Map();
|
||||
|
||||
// Helper to add skill if not already found (first found wins by priority)
|
||||
const addSkill = (name, skillPath, scope, source) => {
|
||||
if (!skills.has(name)) {
|
||||
skills.set(name, { name, path: skillPath, scope, source });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Project level .opencode/skill/ (highest priority)
|
||||
if (workingDirectory) {
|
||||
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
||||
if (fs.existsSync(projectSkillDir)) {
|
||||
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Claude-compatible .claude/skills/
|
||||
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
|
||||
if (fs.existsSync(claudeSkillDir)) {
|
||||
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User level ~/.config/opencode/skill/
|
||||
if (fs.existsSync(SKILL_DIR)) {
|
||||
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skills.values());
|
||||
}
|
||||
|
||||
function getSkillSources(skillName, workingDirectory) {
|
||||
// Check all possible locations
|
||||
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||
const projectDir = projectExists ? path.dirname(projectPath) : null;
|
||||
|
||||
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
|
||||
const claudeExists = claudePath && fs.existsSync(claudePath);
|
||||
const claudeDir = claudeExists ? path.dirname(claudePath) : null;
|
||||
|
||||
const userPath = getUserSkillPath(skillName);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
const userDir = userExists ? path.dirname(userPath) : null;
|
||||
|
||||
// Determine which md file to use (priority: project > claude > user)
|
||||
let mdPath = null;
|
||||
let mdScope = null;
|
||||
let mdSource = null;
|
||||
let mdDir = null;
|
||||
|
||||
if (projectExists) {
|
||||
mdPath = projectPath;
|
||||
mdScope = SKILL_SCOPE.PROJECT;
|
||||
mdSource = 'opencode';
|
||||
mdDir = projectDir;
|
||||
} else if (claudeExists) {
|
||||
mdPath = claudePath;
|
||||
mdScope = SKILL_SCOPE.PROJECT;
|
||||
mdSource = 'claude';
|
||||
mdDir = claudeDir;
|
||||
} else if (userExists) {
|
||||
mdPath = userPath;
|
||||
mdScope = SKILL_SCOPE.USER;
|
||||
mdSource = 'opencode';
|
||||
mdDir = userDir;
|
||||
}
|
||||
|
||||
const mdExists = !!mdPath;
|
||||
|
||||
const sources = {
|
||||
md: {
|
||||
exists: mdExists,
|
||||
path: mdPath,
|
||||
dir: mdDir,
|
||||
scope: mdScope,
|
||||
source: mdSource,
|
||||
fields: [],
|
||||
supportingFiles: []
|
||||
},
|
||||
// Additional info about all locations
|
||||
projectMd: {
|
||||
exists: projectExists,
|
||||
path: projectPath,
|
||||
dir: projectDir
|
||||
},
|
||||
claudeMd: {
|
||||
exists: claudeExists,
|
||||
path: claudePath,
|
||||
dir: claudeDir
|
||||
},
|
||||
userMd: {
|
||||
exists: userExists,
|
||||
path: userPath,
|
||||
dir: userDir
|
||||
}
|
||||
};
|
||||
|
||||
if (mdExists && mdDir) {
|
||||
const { frontmatter, body } = parseMdFile(mdPath);
|
||||
sources.md.fields = Object.keys(frontmatter);
|
||||
// Include actual content values
|
||||
sources.md.description = frontmatter.description || '';
|
||||
sources.md.name = frontmatter.name || skillName;
|
||||
if (body) {
|
||||
sources.md.fields.push('instructions');
|
||||
sources.md.instructions = body;
|
||||
} else {
|
||||
sources.md.instructions = '';
|
||||
}
|
||||
sources.md.supportingFiles = listSkillSupportingFiles(mdDir);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function createSkill(skillName, config, workingDirectory, scope) {
|
||||
ensureDirs();
|
||||
|
||||
// Validate skill name (must be lowercase alphanumeric with hyphens, max 64 chars)
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
|
||||
}
|
||||
|
||||
// Check if skill already exists at any location
|
||||
const existing = getSkillScope(skillName, workingDirectory);
|
||||
if (existing.path) {
|
||||
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
|
||||
}
|
||||
|
||||
// Determine target path based on requested scope
|
||||
let targetDir;
|
||||
let targetPath;
|
||||
let targetScope;
|
||||
|
||||
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
ensureProjectSkillDir(workingDirectory);
|
||||
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
targetScope = SKILL_SCOPE.PROJECT;
|
||||
} else {
|
||||
targetDir = getUserSkillDir(skillName);
|
||||
targetPath = getUserSkillPath(skillName);
|
||||
targetScope = SKILL_SCOPE.USER;
|
||||
}
|
||||
|
||||
// Create skill directory
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Extract fields - scope is only for path determination
|
||||
const { instructions, scope: _scopeFromConfig, supportingFiles, ...frontmatter } = config;
|
||||
|
||||
// Ensure required fields
|
||||
if (!frontmatter.name) {
|
||||
frontmatter.name = skillName;
|
||||
}
|
||||
if (!frontmatter.description) {
|
||||
throw new Error('Skill description is required');
|
||||
}
|
||||
|
||||
writeMdFile(targetPath, frontmatter, instructions || '');
|
||||
|
||||
// Write supporting files if provided
|
||||
if (supportingFiles && Array.isArray(supportingFiles)) {
|
||||
for (const file of supportingFiles) {
|
||||
if (file.path && file.content !== undefined) {
|
||||
writeSkillSupportingFile(targetDir, file.path, file.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||
}
|
||||
|
||||
function updateSkill(skillName, updates, workingDirectory) {
|
||||
ensureDirs();
|
||||
|
||||
// Get existing skill location
|
||||
const existing = getSkillScope(skillName, workingDirectory);
|
||||
if (!existing.path) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
|
||||
const mdPath = existing.path;
|
||||
const mdDir = path.dirname(mdPath);
|
||||
const mdData = parseMdFile(mdPath);
|
||||
|
||||
let mdModified = false;
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
// Skip scope field - it's metadata only
|
||||
if (field === 'scope') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field === 'instructions') {
|
||||
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||
mdData.body = normalizedValue;
|
||||
mdModified = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field === 'supportingFiles') {
|
||||
// Handle supporting files updates
|
||||
if (Array.isArray(value)) {
|
||||
for (const file of value) {
|
||||
if (file.delete && file.path) {
|
||||
deleteSkillSupportingFile(mdDir, file.path);
|
||||
} else if (file.path && file.content !== undefined) {
|
||||
writeSkillSupportingFile(mdDir, file.path, file.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update frontmatter field
|
||||
mdData.frontmatter[field] = value;
|
||||
mdModified = true;
|
||||
}
|
||||
|
||||
if (mdModified) {
|
||||
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
|
||||
}
|
||||
|
||||
console.log(`Updated skill: ${skillName} (path: ${mdPath})`);
|
||||
}
|
||||
|
||||
function deleteSkill(skillName, workingDirectory) {
|
||||
let deleted = false;
|
||||
|
||||
// Check and delete from all locations
|
||||
|
||||
// Project level .opencode/skill/
|
||||
if (workingDirectory) {
|
||||
const projectDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
if (fs.existsSync(projectDir)) {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
console.log(`Deleted project-level skill directory: ${projectDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
// Claude-compat .claude/skills/ - we allow deletion here too
|
||||
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
|
||||
if (fs.existsSync(claudeDir)) {
|
||||
fs.rmSync(claudeDir, { recursive: true, force: true });
|
||||
console.log(`Deleted claude-compat skill directory: ${claudeDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// User level
|
||||
const userDir = getUserSkillDir(skillName);
|
||||
if (fs.existsSync(userDir)) {
|
||||
fs.rmSync(userDir, { recursive: true, force: true });
|
||||
console.log(`Deleted user-level skill directory: ${userDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getAgentSources,
|
||||
getAgentScope,
|
||||
@@ -888,11 +1380,22 @@ export {
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
getSkillSources,
|
||||
getSkillScope,
|
||||
discoverSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
AGENT_DIR,
|
||||
COMMAND_DIR,
|
||||
SKILL_DIR,
|
||||
CONFIG_FILE,
|
||||
AGENT_SCOPE,
|
||||
COMMAND_SCOPE
|
||||
COMMAND_SCOPE,
|
||||
SKILL_SCOPE
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user