fix: adopt plural config dirs for agents, commands, and skills (#198)

This commit is contained in:
Bohdan Triapitsyn
2026-01-23 00:27:01 +02:00
committed by GitHub
parent 7c36418e23
commit 95e41940f8
7 changed files with 356 additions and 59 deletions
+79 -17
View File
@@ -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());
}
@@ -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' } };
}
@@ -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' } };
}