diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index 241b3971..6c3a7f78 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -99,11 +99,19 @@ fn get_config_dir() -> PathBuf { /// Get agent directory path fn get_agent_dir() -> PathBuf { + get_config_dir().join("agents") +} + +fn get_legacy_agent_dir() -> PathBuf { get_config_dir().join("agent") } /// Get user-level command directory path fn get_command_dir() -> PathBuf { + get_config_dir().join("commands") +} + +fn get_legacy_command_dir() -> PathBuf { get_config_dir().join("command") } @@ -467,23 +475,38 @@ pub async fn remove_provider_config( /// Get project-level agent directory path fn get_project_agent_dir(working_directory: &Path) -> PathBuf { + working_directory.join(".opencode").join("agents") +} + +fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf { working_directory.join(".opencode").join("agent") } /// Get project-level agent path fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf { - get_project_agent_dir(working_directory).join(format!("{}.md", agent_name)) + let plural_path = get_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); + let legacy_path = get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// Get user-level agent path fn get_user_agent_path(agent_name: &str) -> PathBuf { - get_agent_dir().join(format!("{}.md", agent_name)) + let plural_path = get_agent_dir().join(format!("{}.md", agent_name)); + let legacy_path = get_legacy_agent_dir().join(format!("{}.md", agent_name)); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// Ensure project agent directory exists async fn ensure_project_agent_dir(working_directory: &Path) -> Result { let project_agent_dir = get_project_agent_dir(working_directory); fs::create_dir_all(&project_agent_dir).await?; + fs::create_dir_all(&get_legacy_project_agent_dir(working_directory)).await?; Ok(project_agent_dir) } @@ -534,23 +557,38 @@ fn get_agent_write_path( /// Get project-level command directory path fn get_project_command_dir(working_directory: &Path) -> PathBuf { + working_directory.join(".opencode").join("commands") +} + +fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf { working_directory.join(".opencode").join("command") } /// Get project-level command path fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf { - get_project_command_dir(working_directory).join(format!("{}.md", command_name)) + let plural_path = get_project_command_dir(working_directory).join(format!("{}.md", command_name)); + let legacy_path = get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name)); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// Get user-level command path fn get_user_command_path(command_name: &str) -> PathBuf { - get_command_dir().join(format!("{}.md", command_name)) + let plural_path = get_command_dir().join(format!("{}.md", command_name)); + let legacy_path = get_legacy_command_dir().join(format!("{}.md", command_name)); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// Ensure project command directory exists async fn ensure_project_command_dir(working_directory: &Path) -> Result { let project_command_dir = get_project_command_dir(working_directory); fs::create_dir_all(&project_command_dir).await?; + fs::create_dir_all(&get_legacy_project_command_dir(working_directory)).await?; Ok(project_command_dir) } @@ -608,7 +646,9 @@ async fn ensure_dirs() -> Result<()> { fs::create_dir_all(&config_dir).await?; fs::create_dir_all(&agent_dir).await?; + fs::create_dir_all(&get_legacy_agent_dir()).await?; fs::create_dir_all(&command_dir).await?; + fs::create_dir_all(&get_legacy_command_dir()).await?; Ok(()) } @@ -1688,30 +1728,65 @@ pub struct DiscoveredSkill { /// Get user-level skill directory path fn get_skill_dir() -> PathBuf { + get_config_dir().join("skills") +} + +fn get_legacy_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) + let plural_path = get_skill_dir().join(skill_name); + let legacy_path = get_legacy_skill_dir().join(skill_name); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// 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") + let plural_path = get_skill_dir().join(skill_name).join("SKILL.md"); + let legacy_path = get_legacy_skill_dir().join(skill_name).join("SKILL.md"); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } -/// Get project-level skill directory (.opencode/skill/) +/// Get project-level skill directory (.opencode/skills/) fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { - working_directory + let plural_path = working_directory + .join(".opencode") + .join("skills") + .join(skill_name); + let legacy_path = working_directory .join(".opencode") .join("skill") - .join(skill_name) + .join(skill_name); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// 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") + let plural_path = working_directory + .join(".opencode") + .join("skills") + .join(skill_name) + .join("SKILL.md"); + let legacy_path = working_directory + .join(".opencode") + .join("skill") + .join(skill_name) + .join("SKILL.md"); + if legacy_path.exists() && !plural_path.exists() { + return legacy_path; + } + plural_path } /// Get Claude-compatible skill directory (.claude/skills/) @@ -1731,6 +1806,7 @@ fn get_claude_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf async fn ensure_skill_dirs() -> Result<()> { let skill_dir = get_skill_dir(); fs::create_dir_all(&skill_dir).await?; + fs::create_dir_all(&get_legacy_skill_dir()).await?; Ok(()) } @@ -1738,6 +1814,11 @@ async fn ensure_skill_dirs() -> Result<()> { async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) -> Result { let project_skill_dir = get_project_skill_dir(working_directory, skill_name); fs::create_dir_all(&project_skill_dir).await?; + let legacy_project_skill_dir = working_directory + .join(".opencode") + .join("skill") + .join(skill_name); + fs::create_dir_all(&legacy_project_skill_dir).await?; Ok(project_skill_dir) } @@ -1747,7 +1828,7 @@ pub fn get_skill_scope( working_directory: Option<&Path>, ) -> (Option, Option, Option) { if let Some(wd) = working_directory { - // Check .opencode/skill first + // Check .opencode/skills first let project_path = get_project_skill_path(wd, skill_name); if project_path.exists() { return ( @@ -1832,9 +1913,9 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } }; - // 1. Project level .opencode/skill/ (highest priority) + // 1. Project level .opencode/skills/ (highest priority) if let Some(wd) = working_directory { - let project_skill_dir = wd.join(".opencode").join("skill"); + let project_skill_dir = wd.join(".opencode").join("skills"); if project_skill_dir.exists() { if let Ok(entries) = std::fs::read_dir(&project_skill_dir) { for entry in entries.flatten() { @@ -1849,6 +1930,21 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } } + let legacy_project_skill_dir = wd.join(".opencode").join("skill"); + if legacy_project_skill_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&legacy_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() { @@ -1866,7 +1962,7 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } } - // 3. User level ~/.config/opencode/skill/ + // 3. User level ~/.config/opencode/skills/ let user_skill_dir = get_skill_dir(); if user_skill_dir.exists() { if let Ok(entries) = std::fs::read_dir(&user_skill_dir) { @@ -1882,6 +1978,21 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } } + let legacy_user_skill_dir = get_legacy_skill_dir(); + if legacy_user_skill_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&legacy_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() } @@ -2240,6 +2351,16 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> deleted = true; } + let legacy_user_dir = get_legacy_skill_dir().join(skill_name); + if legacy_user_dir.exists() { + fs::remove_dir_all(&legacy_user_dir).await?; + info!( + "Deleted legacy user-level skill directory: {}", + legacy_user_dir.display() + ); + deleted = true; + } + if !deleted { return Err(anyhow!("Skill \"{}\" not found", skill_name)); } diff --git a/packages/desktop/src-tauri/src/skills_catalog.rs b/packages/desktop/src-tauri/src/skills_catalog.rs index d873b483..54750a1f 100644 --- a/packages/desktop/src-tauri/src/skills_catalog.rs +++ b/packages/desktop/src-tauri/src/skills_catalog.rs @@ -1312,6 +1312,14 @@ pub struct SkillsInstallRequest { } fn user_skill_dir() -> Result { + Ok(dirs::home_dir() + .ok_or_else(|| anyhow!("Could not find home directory"))? + .join(".config") + .join("opencode") + .join("skills")) +} + +fn legacy_user_skill_dir() -> Result { Ok(dirs::home_dir() .ok_or_else(|| anyhow!("Could not find home directory"))? .join(".config") @@ -1321,14 +1329,27 @@ fn user_skill_dir() -> Result { fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) -> Result { if scope == "user" { - return Ok(user_skill_dir()?.join(skill_name)); + let preferred = user_skill_dir()?.join(skill_name); + let legacy = legacy_user_skill_dir()?.join(skill_name); + if legacy.exists() && !preferred.exists() { + return Ok(legacy); + } + return Ok(preferred); } if scope == "project" { - return Ok(working_directory + let preferred = working_directory + .join(".opencode") + .join("skills") + .join(skill_name); + let legacy = working_directory .join(".opencode") .join("skill") - .join(skill_name)); + .join(skill_name); + if legacy.exists() && !preferred.exists() { + return Ok(legacy); + } + return Ok(preferred); } Err(anyhow!("Invalid scope")) diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 9f57ae8b..e7271a17 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -5,8 +5,8 @@ import yaml from 'yaml'; import { parse as parseJsonc } from 'jsonc-parser'; 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 AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents'); +const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands'); const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) @@ -43,19 +43,29 @@ const ensureDirs = () => { // ============== AGENT SCOPE HELPERS ============== const ensureProjectAgentDir = (workingDirectory: string): string => { - const projectAgentDir = path.join(workingDirectory, '.opencode', 'agent'); + const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents'); if (!fs.existsSync(projectAgentDir)) { fs.mkdirSync(projectAgentDir, { recursive: true }); } + const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent'); + if (!fs.existsSync(legacyProjectAgentDir)) { + fs.mkdirSync(legacyProjectAgentDir, { recursive: true }); + } return projectAgentDir; }; const getProjectAgentPath = (workingDirectory: string, agentName: string): string => { - return path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`); + const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`); + const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; const getUserAgentPath = (agentName: string): string => { - return path.join(AGENT_DIR, `${agentName}.md`); + const pluralPath = path.join(AGENT_DIR, `${agentName}.md`); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; export const getAgentScope = (agentName: string, workingDirectory?: string): { scope: AgentScope | null; path: string | null } => { @@ -97,19 +107,29 @@ const getAgentWritePath = (agentName: string, workingDirectory?: string, request // ============== COMMAND SCOPE HELPERS ============== const ensureProjectCommandDir = (workingDirectory: string): string => { - const projectCommandDir = path.join(workingDirectory, '.opencode', 'command'); + const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands'); if (!fs.existsSync(projectCommandDir)) { fs.mkdirSync(projectCommandDir, { recursive: true }); } + const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command'); + if (!fs.existsSync(legacyProjectCommandDir)) { + fs.mkdirSync(legacyProjectCommandDir, { recursive: true }); + } return projectCommandDir; }; const getProjectCommandPath = (workingDirectory: string, commandName: string): string => { - return path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`); + const pluralPath = path.join(workingDirectory, '.opencode', 'commands', `${commandName}.md`); + const legacyPath = path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; const getUserCommandPath = (commandName: string): string => { - return path.join(COMMAND_DIR, `${commandName}.md`); + const pluralPath = path.join(COMMAND_DIR, `${commandName}.md`); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'command', `${commandName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; export const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => { @@ -870,7 +890,7 @@ export const deleteCommand = (commandName: string, workingDirectory?: string) => // ============== SKILL SCOPE HELPERS ============== -const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skill'); +const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills'); export const SKILL_SCOPE = { USER: 'user', @@ -915,19 +935,31 @@ const ensureSkillDirs = () => { }; const getUserSkillDir = (skillName: string): string => { - return path.join(SKILL_DIR, skillName); + const pluralPath = path.join(SKILL_DIR, skillName); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; const getUserSkillPath = (skillName: string): string => { - return path.join(getUserSkillDir(skillName), 'SKILL.md'); + const pluralPath = path.join(SKILL_DIR, skillName, 'SKILL.md'); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName, 'SKILL.md'); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; const getProjectSkillDir = (workingDirectory: string, skillName: string): string => { - return path.join(workingDirectory, '.opencode', 'skill', skillName); + const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName); + const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; const getProjectSkillPath = (workingDirectory: string, skillName: string): string => { - return path.join(getProjectSkillDir(workingDirectory, skillName), 'SKILL.md'); + const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName, 'SKILL.md'); + const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName, 'SKILL.md'); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; }; const getClaudeSkillDir = (workingDirectory: string, skillName: string): string => { @@ -1001,9 +1033,9 @@ export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => } }; - // 1. Project level .opencode/skill/ (highest priority) + // 1. Project level .opencode/skills/ (highest priority) if (workingDirectory) { - const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill'); + const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills'); if (fs.existsSync(projectSkillDir)) { const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true }); for (const entry of entries) { @@ -1015,6 +1047,19 @@ export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => } } } + + const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill'); + if (fs.existsSync(legacyProjectSkillDir)) { + const entries = fs.readdirSync(legacyProjectSkillDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(legacyProjectSkillDir, 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'); @@ -1031,7 +1076,7 @@ export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => } } - // 3. User level ~/.config/opencode/skill/ + // 3. User level ~/.config/opencode/skills/ if (fs.existsSync(SKILL_DIR)) { const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true }); for (const entry of entries) { @@ -1043,6 +1088,19 @@ export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => } } } + + const legacyUserSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill'); + if (fs.existsSync(legacyUserSkillDir)) { + const entries = fs.readdirSync(legacyUserSkillDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(legacyUserSkillDir, entry.name, 'SKILL.md'); + if (fs.existsSync(skillMdPath)) { + addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode'); + } + } + } + } return Array.from(skills.values()); }; diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index 5a773585..71c7a9ce 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -246,7 +246,7 @@ export async function installSkillsFromClawdHub(options: { const targetDir = options.scope === 'user' ? path.join(userSkillDir, slug) - : path.join(options.workingDirectory as string, '.opencode', 'skill', slug); + : path.join(options.workingDirectory as string, '.opencode', 'skills', slug); if (fs.existsSync(targetDir)) { const decision = options.conflictDecisions?.[slug]; @@ -286,7 +286,7 @@ export async function installSkillsFromClawdHub(options: { const targetDir = options.scope === 'user' ? path.join(userSkillDir, slug) - : path.join(options.workingDirectory as string, '.opencode', 'skill', slug); + : path.join(options.workingDirectory as string, '.opencode', 'skills', slug); const exists = fs.existsSync(targetDir); let decision = options.conflictDecisions?.[slug] || null; @@ -653,7 +653,10 @@ async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) { } function getUserSkillBaseDir() { - return path.join(os.homedir(), '.config', 'opencode', 'skill'); + const pluralPath = path.join(os.homedir(), '.config', 'opencode', 'skills'); + const legacyPath = path.join(os.homedir(), '.config', 'opencode', 'skill'); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } function toFsPath(repoDir: string, repoRelPosixPath: string) { @@ -701,7 +704,7 @@ export async function installSkillsFromRepository(options: { if (!plan.installable) continue; const targetDir = options.scope === 'user' ? path.join(userSkillDir, plan.skillName) - : path.join(options.workingDirectory as string, '.opencode', 'skill', plan.skillName); + : path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName); if (fs.existsSync(targetDir)) { const decision = options.conflictDecisions?.[plan.skillName]; @@ -756,7 +759,7 @@ export async function installSkillsFromRepository(options: { const targetDir = options.scope === 'user' ? path.join(userSkillDir, plan.skillName) - : path.join(options.workingDirectory as string, '.opencode', 'skill', plan.skillName); + : path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName); const exists = fs.existsSync(targetDir); let decision = options.conflictDecisions?.[plan.skillName] || null; diff --git a/packages/web/server/lib/opencode-config.js b/packages/web/server/lib/opencode-config.js index cec7952c..1a2b9cbb 100644 --- a/packages/web/server/lib/opencode-config.js +++ b/packages/web/server/lib/opencode-config.js @@ -5,9 +5,9 @@ import yaml from 'yaml'; import { parse as parseJsonc } from 'jsonc-parser'; 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 AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents'); +const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands'); +const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills'); const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) @@ -51,10 +51,14 @@ function ensureDirs() { * Ensure project-level agent directory exists */ function ensureProjectAgentDir(workingDirectory) { - const projectAgentDir = path.join(workingDirectory, '.opencode', 'agent'); + const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents'); if (!fs.existsSync(projectAgentDir)) { fs.mkdirSync(projectAgentDir, { recursive: true }); } + const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent'); + if (!fs.existsSync(legacyProjectAgentDir)) { + fs.mkdirSync(legacyProjectAgentDir, { recursive: true }); + } return projectAgentDir; } @@ -62,14 +66,20 @@ function ensureProjectAgentDir(workingDirectory) { * Get project-level agent path */ function getProjectAgentPath(workingDirectory, agentName) { - return path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`); + const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`); + const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** * Get user-level agent path */ function getUserAgentPath(agentName) { - return path.join(AGENT_DIR, `${agentName}.md`); + const pluralPath = path.join(AGENT_DIR, `${agentName}.md`); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** @@ -173,10 +183,14 @@ function getAgentPermissionSource(agentName, workingDirectory) { * Ensure project-level command directory exists */ function ensureProjectCommandDir(workingDirectory) { - const projectCommandDir = path.join(workingDirectory, '.opencode', 'command'); + const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands'); if (!fs.existsSync(projectCommandDir)) { fs.mkdirSync(projectCommandDir, { recursive: true }); } + const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command'); + if (!fs.existsSync(legacyProjectCommandDir)) { + fs.mkdirSync(legacyProjectCommandDir, { recursive: true }); + } return projectCommandDir; } @@ -184,14 +198,20 @@ function ensureProjectCommandDir(workingDirectory) { * Get project-level command path */ function getProjectCommandPath(workingDirectory, commandName) { - return path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`); + const pluralPath = path.join(workingDirectory, '.opencode', 'commands', `${commandName}.md`); + const legacyPath = path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** * Get user-level command path */ function getUserCommandPath(commandName) { - return path.join(COMMAND_DIR, `${commandName}.md`); + const pluralPath = path.join(COMMAND_DIR, `${commandName}.md`); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'command', `${commandName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** @@ -245,10 +265,14 @@ function getCommandWritePath(commandName, workingDirectory, requestedScope) { * Ensure project-level skill directory exists */ function ensureProjectSkillDir(workingDirectory) { - const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill'); + const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills'); if (!fs.existsSync(projectSkillDir)) { fs.mkdirSync(projectSkillDir, { recursive: true }); } + const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill'); + if (!fs.existsSync(legacyProjectSkillDir)) { + fs.mkdirSync(legacyProjectSkillDir, { recursive: true }); + } return projectSkillDir; } @@ -256,28 +280,40 @@ function ensureProjectSkillDir(workingDirectory) { * Get project-level skill directory path (.opencode/skill/{name}/) */ function getProjectSkillDir(workingDirectory, skillName) { - return path.join(workingDirectory, '.opencode', 'skill', skillName); + const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName); + const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** * Get project-level skill SKILL.md path */ function getProjectSkillPath(workingDirectory, skillName) { - return path.join(getProjectSkillDir(workingDirectory, skillName), 'SKILL.md'); + const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName, 'SKILL.md'); + const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName, 'SKILL.md'); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** * Get user-level skill directory path */ function getUserSkillDir(skillName) { - return path.join(SKILL_DIR, skillName); + const pluralPath = path.join(SKILL_DIR, skillName); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** * Get user-level skill SKILL.md path */ function getUserSkillPath(skillName) { - return path.join(getUserSkillDir(skillName), 'SKILL.md'); + const pluralPath = path.join(SKILL_DIR, skillName, 'SKILL.md'); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName, 'SKILL.md'); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; } /** @@ -1463,9 +1499,9 @@ function discoverSkills(workingDirectory) { } }; - // 1. Project level .opencode/skill/ (highest priority) + // 1. Project level .opencode/skills/ (highest priority) if (workingDirectory) { - const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill'); + const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills'); if (fs.existsSync(projectSkillDir)) { const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true }); for (const entry of entries) { @@ -1477,6 +1513,19 @@ function discoverSkills(workingDirectory) { } } } + + const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill'); + if (fs.existsSync(legacyProjectSkillDir)) { + const entries = fs.readdirSync(legacyProjectSkillDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(legacyProjectSkillDir, 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'); @@ -1493,7 +1542,7 @@ function discoverSkills(workingDirectory) { } } - // 3. User level ~/.config/opencode/skill/ + // 3. User level ~/.config/opencode/skills/ if (fs.existsSync(SKILL_DIR)) { const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true }); for (const entry of entries) { @@ -1505,6 +1554,19 @@ function discoverSkills(workingDirectory) { } } } + + const legacyUserSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill'); + if (fs.existsSync(legacyUserSkillDir)) { + const entries = fs.readdirSync(legacyUserSkillDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(legacyUserSkillDir, entry.name, 'SKILL.md'); + if (fs.existsSync(skillMdPath)) { + addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode'); + } + } + } + } return Array.from(skills.values()); } diff --git a/packages/web/server/lib/skills-catalog/clawdhub/install.js b/packages/web/server/lib/skills-catalog/clawdhub/install.js index 704c8b53..34eed46e 100644 --- a/packages/web/server/lib/skills-catalog/clawdhub/install.js +++ b/packages/web/server/lib/skills-catalog/clawdhub/install.js @@ -14,6 +14,17 @@ import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js'; const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; +function normalizeUserSkillDir(userSkillDir) { + if (!userSkillDir) return null; + const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill'); + const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills'); + if (userSkillDir === legacySkillDir) { + if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir; + return pluralSkillDir; + } + return userSkillDir; +} + function validateSkillName(skillName) { if (typeof skillName !== 'string') return false; if (skillName.length < 1 || skillName.length > 64) return false; @@ -41,7 +52,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) throw new Error('workingDirectory is required for project installs'); } - return path.join(workingDirectory, '.opencode', 'skill', skillName); + return path.join(workingDirectory, '.opencode', 'skills', skillName); } /** @@ -71,6 +82,11 @@ export async function installSkillsFromClawdHub({ return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } }; } + const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir); + if (normalizedUserSkillDir) { + userSkillDir = normalizedUserSkillDir; + } + if (scope === 'project' && !workingDirectory) { return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; } diff --git a/packages/web/server/lib/skills-catalog/install.js b/packages/web/server/lib/skills-catalog/install.js index 8fee7f0b..50308803 100644 --- a/packages/web/server/lib/skills-catalog/install.js +++ b/packages/web/server/lib/skills-catalog/install.js @@ -7,6 +7,17 @@ import { parseSkillRepoSource } from './source.js'; const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; +function normalizeUserSkillDir(userSkillDir) { + if (!userSkillDir) return null; + const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill'); + const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills'); + if (userSkillDir === legacySkillDir) { + if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir; + return pluralSkillDir; + } + return userSkillDir; +} + function validateSkillName(skillName) { if (typeof skillName !== 'string') return false; if (skillName.length < 1 || skillName.length > 64) return false; @@ -103,7 +114,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) throw new Error('workingDirectory is required for project installs'); } - return path.join(workingDirectory, '.opencode', 'skill', skillName); + return path.join(workingDirectory, '.opencode', 'skills', skillName); } export async function installSkillsFromRepository({ @@ -123,14 +134,19 @@ export async function installSkillsFromRepository({ return { ok: false, error: gitCheck.error }; } - if (scope !== 'user' && scope !== 'project') { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } }; + const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir); + if (normalizedUserSkillDir) { + userSkillDir = normalizedUserSkillDir; } if (!userSkillDir) { return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } }; } + if (scope !== 'user' && scope !== 'project') { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } }; + } + if (scope === 'project' && !workingDirectory) { return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; }