diff --git a/AGENTS.md b/AGENTS.md index c83b996c..a00b4514 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,10 @@ Git repository operations for the web server runtime. GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing. - Module docs: `packages/web/server/lib/github/DOCUMENTATION.md` +##### opencode +OpenCode server integration utilities including config management, provider authentication, and UI authentication. +- Module docs: `packages/web/server/lib/opencode/DOCUMENTATION.md` + ## Build / dev commands (verified) All scripts are in `package.json`. - Validate: `bun run type-check`, `bun run lint` diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 95e8ecbc..86c33694 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -8,7 +8,7 @@ import { WebSocketServer } from 'ws'; import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; -import { createUiAuth } from './lib/ui-auth.js'; +import { createUiAuth } from './lib/opencode/ui-auth.js'; import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js'; import { prepareNotificationLastMessage } from './lib/notification-message.js'; import { @@ -6642,7 +6642,7 @@ async function main(options = {}) { removeProviderConfig, AGENT_SCOPE, COMMAND_SCOPE - } = await import('./lib/opencode-config.js'); + } = await import('./lib/opencode/index.js'); app.get('/api/config/agents/:name', async (req, res) => { try { @@ -6891,7 +6891,7 @@ async function main(options = {}) { deleteSkillSupportingFile, SKILL_SCOPE, SKILL_DIR, - } = await import('./lib/opencode-config.js'); + } = await import('./lib/opencode/index.js'); const findWorktreeRootForSkills = (workingDirectory) => { if (!workingDirectory) return null; @@ -7588,7 +7588,7 @@ async function main(options = {}) { let authLibrary = null; const getAuthLibrary = async () => { if (!authLibrary) { - authLibrary = await import('./lib/opencode-auth.js'); + authLibrary = await import('./lib/opencode/auth.js'); } return authLibrary; }; diff --git a/packages/web/server/lib/opencode-config.js b/packages/web/server/lib/opencode-config.js deleted file mode 100644 index 567bd458..00000000 --- a/packages/web/server/lib/opencode-config.js +++ /dev/null @@ -1,2030 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -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, '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) - : null; -const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i; - -// Scope types (shared by agents and commands) -const AGENT_SCOPE = { - USER: 'user', - PROJECT: 'project' -}; - -const COMMAND_SCOPE = { - USER: 'user', - PROJECT: 'project' -}; - -const SKILL_SCOPE = { - USER: 'user', - PROJECT: 'project' -}; - -function ensureDirs() { - if (!fs.existsSync(OPENCODE_CONFIG_DIR)) { - fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }); - } - if (!fs.existsSync(AGENT_DIR)) { - fs.mkdirSync(AGENT_DIR, { recursive: true }); - } - 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 ============== - -/** - * Ensure project-level agent directory exists - */ -function ensureProjectAgentDir(workingDirectory) { - 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; -} - -/** - * Get project-level agent path - */ -function getProjectAgentPath(workingDirectory, agentName) { - 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) { - 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; -} - -/** - * Determine agent scope based on where the .md file exists - * Priority: project level > user level > null (built-in only) - */ -function getAgentScope(agentName, workingDirectory) { - if (workingDirectory) { - const projectPath = getProjectAgentPath(workingDirectory, agentName); - if (fs.existsSync(projectPath)) { - return { scope: AGENT_SCOPE.PROJECT, path: projectPath }; - } - } - - const userPath = getUserAgentPath(agentName); - if (fs.existsSync(userPath)) { - return { scope: AGENT_SCOPE.USER, path: userPath }; - } - - return { scope: null, path: null }; -} - -/** - * Get the path where an agent should be written based on scope - */ -function getAgentWritePath(agentName, workingDirectory, requestedScope) { - // For updates: check existing location first (project takes precedence) - const existing = getAgentScope(agentName, workingDirectory); - if (existing.path) { - return existing; - } - - // For new agents or built-in overrides: use requested scope or default to user - const scope = requestedScope || AGENT_SCOPE.USER; - if (scope === AGENT_SCOPE.PROJECT && workingDirectory) { - return { - scope: AGENT_SCOPE.PROJECT, - path: getProjectAgentPath(workingDirectory, agentName) - }; - } - - return { - scope: AGENT_SCOPE.USER, - path: getUserAgentPath(agentName) - }; -} - -/** - * Detect where an agent's permission field is currently defined - * Priority: project .md > user .md > project JSON > user JSON - * Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null } - */ -function getAgentPermissionSource(agentName, workingDirectory) { - // Check project-level .md first - if (workingDirectory) { - const projectMdPath = getProjectAgentPath(workingDirectory, agentName); - if (fs.existsSync(projectMdPath)) { - const { frontmatter } = parseMdFile(projectMdPath); - if (frontmatter.permission !== undefined) { - return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath }; - } - } - } - - // Check user-level .md - const userMdPath = getUserAgentPath(agentName); - if (fs.existsSync(userMdPath)) { - const { frontmatter } = parseMdFile(userMdPath); - if (frontmatter.permission !== undefined) { - return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath }; - } - } - - // Check JSON layers (project > user) - const layers = readConfigLayers(workingDirectory); - - // Project opencode.json - const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission; - if (projectJsonPermission !== undefined && layers.paths.projectPath) { - return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath }; - } - - // User opencode.json - const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission; - if (userJsonPermission !== undefined) { - return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath }; - } - - // Custom config (env var) - const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission; - if (customJsonPermission !== undefined && layers.paths.customPath) { - return { source: 'json', scope: 'custom', path: layers.paths.customPath }; - } - - return { source: null, scope: null, path: null }; -} - -// ============== COMMAND SCOPE HELPERS ============== - -/** - * Ensure project-level command directory exists - */ -function ensureProjectCommandDir(workingDirectory) { - 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; -} - -/** - * Get project-level command path - */ -function getProjectCommandPath(workingDirectory, commandName) { - 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) { - 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; -} - -/** - * Determine command scope based on where the .md file exists - * Priority: project level > user level > null (built-in only) - */ -function getCommandScope(commandName, workingDirectory) { - if (workingDirectory) { - const projectPath = getProjectCommandPath(workingDirectory, commandName); - if (fs.existsSync(projectPath)) { - return { scope: COMMAND_SCOPE.PROJECT, path: projectPath }; - } - } - - const userPath = getUserCommandPath(commandName); - if (fs.existsSync(userPath)) { - return { scope: COMMAND_SCOPE.USER, path: userPath }; - } - - return { scope: null, path: null }; -} - -/** - * Get the path where a command should be written based on scope - */ -function getCommandWritePath(commandName, workingDirectory, requestedScope) { - // For updates: check existing location first (project takes precedence) - const existing = getCommandScope(commandName, workingDirectory); - if (existing.path) { - return existing; - } - - // For new commands or built-in overrides: use requested scope or default to user - const scope = requestedScope || COMMAND_SCOPE.USER; - if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) { - return { - scope: COMMAND_SCOPE.PROJECT, - path: getProjectCommandPath(workingDirectory, commandName) - }; - } - - return { - scope: COMMAND_SCOPE.USER, - path: getUserCommandPath(commandName) - }; -} - -// ============== SKILL SCOPE HELPERS ============== - -/** - * Ensure project-level skill directory exists - */ -function ensureProjectSkillDir(workingDirectory) { - 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; -} - -/** - * Get project-level skill directory path (.opencode/skill/{name}/) - */ -function getProjectSkillDir(workingDirectory, 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) { - 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) { - 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) { - 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; -} - -/** - * 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'); -} - -function getUserAgentsSkillDir(skillName) { - return path.join(os.homedir(), '.agents', 'skills', skillName); -} - -function getUserAgentsSkillPath(skillName) { - return path.join(getUserAgentsSkillDir(skillName), 'SKILL.md'); -} - -function getProjectAgentsSkillDir(workingDirectory, skillName) { - return path.join(workingDirectory, '.agents', 'skills', skillName); -} - -function getProjectAgentsSkillPath(workingDirectory, skillName) { - return path.join(getProjectAgentsSkillDir(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) { - const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName); - if (discovered?.path) { - return { scope: discovered.scope || null, path: discovered.path, source: discovered.source || 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 }; -} - -/** - * 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; - } - return PROMPT_FILE_PATTERN.test(value.trim()); -} - -function resolvePromptFilePath(reference) { - const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null; - if (!match) { - return null; - } - let target = match[1].trim(); - if (!target) { - return null; - } - - if (target.startsWith('./')) { - target = target.slice(2); - target = path.join(OPENCODE_CONFIG_DIR, target); - } else if (!path.isAbsolute(target)) { - target = path.join(OPENCODE_CONFIG_DIR, target); - } - - return target; -} - -function writePromptFile(filePath, content) { - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(filePath, content ?? '', 'utf8'); - console.log(`Updated prompt file: ${filePath}`); -} - -/** - * Get all possible project config paths in priority order - * Priority: root > .opencode/, json > jsonc - */ -function getProjectConfigCandidates(workingDirectory) { - if (!workingDirectory) return []; - return [ - path.join(workingDirectory, 'opencode.json'), - path.join(workingDirectory, 'opencode.jsonc'), - path.join(workingDirectory, '.opencode', 'opencode.json'), - path.join(workingDirectory, '.opencode', 'opencode.jsonc'), - ]; -} - -/** - * Find existing project config file or return default path for new config - */ -function getProjectConfigPath(workingDirectory) { - if (!workingDirectory) return null; - - const candidates = getProjectConfigCandidates(workingDirectory); - - // Return first existing config file - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - - // Default to root opencode.json for new configs - return candidates[0]; -} - -/** - * Merge new permission config with existing non-wildcard patterns - * Non-wildcard patterns (patterns other than "*") are preserved from existing config - * @param {object|string|null} newPermission - New permission config from UI (wildcards only) - * @param {object} permissionSource - Result from getAgentPermissionSource - * @param {string} agentName - Agent name - * @param {string|null} workingDirectory - Working directory - * @returns {object|string|null} Merged permission config - */ -function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName, workingDirectory) { - // If no existing permission, return new permission as-is - if (!permissionSource.source || !permissionSource.path) { - return newPermission; - } - - // Get existing permission config - let existingPermission = null; - if (permissionSource.source === 'md') { - const { frontmatter } = parseMdFile(permissionSource.path); - existingPermission = frontmatter.permission; - } else if (permissionSource.source === 'json') { - const config = readConfigFile(permissionSource.path); - existingPermission = config?.agent?.[agentName]?.permission; - } - - // If no existing permission or it's a simple string, return new permission as-is - if (!existingPermission || typeof existingPermission === 'string') { - return newPermission; - } - - // If new permission is null/undefined, return null to clear it - if (newPermission == null) { - return null; - } - - // If new permission is a simple string (e.g., "allow"), return it as-is - if (typeof newPermission === 'string') { - return newPermission; - } - - // Extract non-wildcard patterns from existing permission - const nonWildcardPatterns = {}; - for (const [permKey, permValue] of Object.entries(existingPermission)) { - if (permKey === '*') continue; // Skip global default - - if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) { - // Permission has pattern-based config (e.g., { "npm *": "allow", "*": "ask" }) - const nonWildcards = {}; - for (const [pattern, action] of Object.entries(permValue)) { - if (pattern !== '*') { - nonWildcards[pattern] = action; - } - } - if (Object.keys(nonWildcards).length > 0) { - nonWildcardPatterns[permKey] = nonWildcards; - } - } - // Simple string values (e.g., "allow") don't have patterns, skip them - } - - // If no non-wildcard patterns to preserve, return new permission as-is - if (Object.keys(nonWildcardPatterns).length === 0) { - return newPermission; - } - - // Merge non-wildcards into new permission - const merged = { ...newPermission }; - for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) { - const newValue = merged[permKey]; - if (typeof newValue === 'string') { - // Convert string to object with wildcard + preserved patterns - merged[permKey] = { '*': newValue, ...patterns }; - } else if (typeof newValue === 'object' && newValue !== null) { - // Merge patterns, new wildcards take precedence - merged[permKey] = { ...patterns, ...newValue }; - } else { - // Permission not in new config - preserve existing patterns with their wildcard if it existed - const existingValue = existingPermission[permKey]; - if (typeof existingValue === 'object' && existingValue !== null) { - const wildcard = existingValue['*']; - merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns; - } - } - } - - return merged; -} - -function getConfigPaths(workingDirectory) { - return { - userPath: CONFIG_FILE, - projectPath: getProjectConfigPath(workingDirectory), - customPath: CUSTOM_CONFIG_FILE - }; -} - -function readConfigFile(filePath) { - if (!filePath || !fs.existsSync(filePath)) { - return {}; - } - try { - const content = fs.readFileSync(filePath, 'utf8'); - const normalized = content.trim(); - if (!normalized) { - return {}; - } - // jsonc-parser handles comments, trailing commas, unquoted keys - return parseJsonc(normalized, [], { allowTrailingComma: true }); - } catch (error) { - console.error(`Failed to read config file: ${filePath}`, error); - throw new Error('Failed to read OpenCode configuration'); - } -} - -function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); -} - -function mergeConfigs(base, override) { - if (!isPlainObject(base) || !isPlainObject(override)) { - return override; - } - const result = { ...base }; - for (const [key, value] of Object.entries(override)) { - if (key in result) { - const baseValue = result[key]; - if (isPlainObject(baseValue) && isPlainObject(value)) { - result[key] = mergeConfigs(baseValue, value); - } else { - result[key] = value; - } - } else { - result[key] = value; - } - } - return result; -} - -function readConfigLayers(workingDirectory) { - const { userPath, projectPath, customPath } = getConfigPaths(workingDirectory); - const userConfig = readConfigFile(userPath); - const projectConfig = readConfigFile(projectPath); - const customConfig = readConfigFile(customPath); - const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig); - - return { - userConfig, - projectConfig, - customConfig, - mergedConfig, - paths: { userPath, projectPath, customPath } - }; -} - -function readConfig(workingDirectory) { - return readConfigLayers(workingDirectory).mergedConfig; -} - -function getAncestors(startDir, stopDir) { - if (!startDir) return []; - const result = []; - let current = path.resolve(startDir); - const resolvedStop = stopDir ? path.resolve(stopDir) : null; - - while (true) { - result.push(current); - if (resolvedStop && current === resolvedStop) { - break; - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - return result; -} - -function findWorktreeRoot(startDir) { - if (!startDir) return null; - let current = path.resolve(startDir); - - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -function walkSkillMdFiles(rootDir) { - if (!rootDir || !fs.existsSync(rootDir)) return []; - - const results = []; - const walk = (dir) => { - let entries = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - continue; - } - if (entry.isFile() && entry.name === 'SKILL.md') { - results.push(fullPath); - } - } - }; - - walk(rootDir); - return results; -} - -function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) { - let parsed; - try { - parsed = parseMdFile(skillMdPath); - } catch { - return; - } - - const name = typeof parsed.frontmatter?.name === 'string' - ? parsed.frontmatter.name.trim() - : ''; - const description = typeof parsed.frontmatter?.description === 'string' - ? parsed.frontmatter.description - : ''; - - if (!name) { - return; - } - - skillsMap.set(name, { - name, - path: skillMdPath, - scope, - source, - description, - }); -} - -function resolveSkillSearchDirectories(workingDirectory) { - const directories = []; - const pushDir = (dir) => { - if (!dir) return; - const resolved = path.resolve(dir); - if (!directories.includes(resolved)) { - directories.push(resolved); - } - }; - - // Equivalent to Opencode Config.directories order. - pushDir(OPENCODE_CONFIG_DIR); - - if (workingDirectory) { - const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); - const projectDirs = getAncestors(workingDirectory, worktreeRoot) - .map((dir) => path.join(dir, '.opencode')); - projectDirs.forEach(pushDir); - } - - pushDir(path.join(os.homedir(), '.opencode')); - - const customConfigDir = process.env.OPENCODE_CONFIG_DIR - ? path.resolve(process.env.OPENCODE_CONFIG_DIR) - : null; - pushDir(customConfigDir); - - return directories; -} - -function getConfigForPath(layers, targetPath) { - if (!targetPath) { - return layers.userConfig; - } - if (layers.paths.customPath && targetPath === layers.paths.customPath) { - return layers.customConfig; - } - if (layers.paths.projectPath && targetPath === layers.paths.projectPath) { - return layers.projectConfig; - } - return layers.userConfig; -} - -function writeConfig(config, filePath = CONFIG_FILE) { - try { - if (fs.existsSync(filePath)) { - const backupFile = `${filePath}.openchamber.backup`; - fs.copyFileSync(filePath, backupFile); - console.log(`Created config backup: ${backupFile}`); - } - - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8'); - console.log(`Successfully wrote config file: ${filePath}`); - } catch (error) { - console.error(`Failed to write config file: ${filePath}`, error); - throw new Error('Failed to write OpenCode configuration'); - } -} - -function getJsonEntrySource(layers, sectionKey, entryName) { - const { userConfig, projectConfig, customConfig, paths } = layers; - const customSection = customConfig?.[sectionKey]?.[entryName]; - if (customSection !== undefined) { - return { section: customSection, config: customConfig, path: paths.customPath, exists: true }; - } - - const projectSection = projectConfig?.[sectionKey]?.[entryName]; - if (projectSection !== undefined) { - return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true }; - } - - const userSection = userConfig?.[sectionKey]?.[entryName]; - if (userSection !== undefined) { - return { section: userSection, config: userConfig, path: paths.userPath, exists: true }; - } - - return { section: null, config: null, path: null, exists: false }; -} - -function getJsonWriteTarget(layers, preferredScope) { - const { userConfig, projectConfig, customConfig, paths } = layers; - if (paths.customPath) { - return { config: customConfig, path: paths.customPath }; - } - if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) { - return { config: projectConfig, path: paths.projectPath }; - } - if (paths.projectPath) { - return { config: projectConfig, path: paths.projectPath }; - } - return { config: userConfig, path: paths.userPath }; -} - -function parseMdFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); - - if (!match) { - return { frontmatter: {}, body: content.trim() }; - } - - let frontmatter = {}; - try { - frontmatter = yaml.parse(match[1]) || {}; - } catch (error) { - console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error); - frontmatter = {}; - } - - const body = match[2].trim(); - return { frontmatter, body }; -} - -function writeMdFile(filePath, frontmatter, body) { - try { - // Filter out null/undefined values - OpenCode expects keys to be omitted rather than set to null - const cleanedFrontmatter = Object.fromEntries( - Object.entries(frontmatter).filter(([, value]) => value != null) - ); - const yamlStr = yaml.stringify(cleanedFrontmatter); - const content = `---\n${yamlStr}---\n\n${body}`; - fs.writeFileSync(filePath, content, 'utf8'); - console.log(`Successfully wrote markdown file: ${filePath}`); - } catch (error) { - console.error(`Failed to write markdown file ${filePath}:`, error); - throw new Error('Failed to write agent markdown file'); - } -} - -function getAgentSources(agentName, workingDirectory) { - // Check project level first (takes precedence) - const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; - const projectExists = projectPath && fs.existsSync(projectPath); - - // Then check user level - const userPath = getUserAgentPath(agentName); - const userExists = fs.existsSync(userPath); - - // Determine which md file to use (project takes precedence) - const mdPath = projectExists ? projectPath : (userExists ? userPath : null); - const mdExists = !!mdPath; - const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null); - - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'agent', agentName); - const jsonSection = jsonSource.section; - const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; - const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER; - - const sources = { - md: { - exists: mdExists, - path: mdPath, - scope: mdScope, - fields: [] - }, - json: { - exists: jsonSource.exists, - path: jsonPath, - scope: jsonSource.exists ? jsonScope : null, - fields: [] - }, - // Additional info about both levels - projectMd: { - exists: projectExists, - path: projectPath - }, - userMd: { - exists: userExists, - path: userPath - } - }; - - if (mdExists) { - const { frontmatter, body } = parseMdFile(mdPath); - sources.md.fields = Object.keys(frontmatter); - if (body) { - sources.md.fields.push('prompt'); - } - } - - if (jsonSection) { - sources.json.fields = Object.keys(jsonSection); - } - - return sources; -} - -function getAgentConfig(agentName, workingDirectory) { - // Prefer markdown agents (project > user) - const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; - const projectExists = projectPath && fs.existsSync(projectPath); - - const userPath = getUserAgentPath(agentName); - const userExists = fs.existsSync(userPath); - - if (projectExists || userExists) { - const mdPath = projectExists ? projectPath : userPath; - const { frontmatter, body } = parseMdFile(mdPath); - - return { - source: 'md', - scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER, - config: { - ...frontmatter, - ...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}), - }, - }; - } - - // Then fall back to opencode.json (highest-precedence entry) - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'agent', agentName); - - if (jsonSource.exists && jsonSource.section) { - const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER; - return { - source: 'json', - scope, - config: { ...jsonSource.section }, - }; - } - - return { - source: 'none', - scope: null, - config: {}, - }; -} - -function createAgent(agentName, config, workingDirectory, scope) { - ensureDirs(); - - // Check if agent already exists at either level - const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; - const userPath = getUserAgentPath(agentName); - - if (projectPath && fs.existsSync(projectPath)) { - throw new Error(`Agent ${agentName} already exists as project-level .md file`); - } - - if (fs.existsSync(userPath)) { - throw new Error(`Agent ${agentName} already exists as user-level .md file`); - } - - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'agent', agentName); - if (jsonSource.exists) { - throw new Error(`Agent ${agentName} already exists in opencode.json`); - } - - // Determine target path based on requested scope - let targetPath; - let targetScope; - - if (scope === AGENT_SCOPE.PROJECT && workingDirectory) { - ensureProjectAgentDir(workingDirectory); - targetPath = projectPath; - targetScope = AGENT_SCOPE.PROJECT; - } else { - targetPath = userPath; - targetScope = AGENT_SCOPE.USER; - } - - // Extract scope and prompt from config - scope is only used for path determination, not written to file - const { prompt, scope: _scopeFromConfig, ...frontmatter } = config; - - writeMdFile(targetPath, frontmatter, prompt || ''); - console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`); -} - -function updateAgent(agentName, updates, workingDirectory) { - ensureDirs(); - - // Determine correct path: project level takes precedence - const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory); - const mdExists = mdPath && fs.existsSync(mdPath); - - // Check if agent exists in opencode.json across all config layers - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'agent', agentName); - const jsonSection = jsonSource.section; - const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0; - const jsonTarget = jsonSource.exists - ? { config: jsonSource.config, path: jsonSource.path } - : getJsonWriteTarget(layers, AGENT_SCOPE.USER); - let config = jsonTarget.config || {}; - - // Determine if we should create a new md file: - // Only for built-in agents (no md file AND no json config) - const isBuiltinOverride = !mdExists && !hasJsonFields; - - let targetPath = mdPath; - let targetScope = scope; - - if (!mdExists && isBuiltinOverride) { - // Built-in agent override - create at user level - targetPath = getUserAgentPath(agentName); - targetScope = AGENT_SCOPE.USER; - } - - // Only create md data for existing md files or built-in overrides - let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null); - - let mdModified = false; - let jsonModified = false; - // Only create new md if it's a built-in override - let creatingNewMd = isBuiltinOverride; - - for (const [field, value] of Object.entries(updates)) { - - if (field === 'prompt') { - const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value)); - - if (mdExists || creatingNewMd) { - if (mdData) { - mdData.body = normalizedValue; - mdModified = true; - } - continue; - } else if (isPromptFileReference(jsonSection?.prompt)) { - const promptFilePath = resolvePromptFilePath(jsonSection.prompt); - if (!promptFilePath) { - throw new Error(`Invalid prompt file reference for agent ${agentName}`); - } - writePromptFile(promptFilePath, normalizedValue); - continue; - } else if (isPromptFileReference(normalizedValue)) { - if (!config.agent) config.agent = {}; - if (!config.agent[agentName]) config.agent[agentName] = {}; - config.agent[agentName].prompt = normalizedValue; - jsonModified = true; - continue; - } - - // For JSON-only agents, store prompt inline in JSON - if (!config.agent) config.agent = {}; - if (!config.agent[agentName]) config.agent[agentName] = {}; - config.agent[agentName].prompt = normalizedValue; - jsonModified = true; - continue; - } - - // Special handling for permission field - uses location detection and preserves non-wildcards - if (field === 'permission') { - const permissionSource = getAgentPermissionSource(agentName, workingDirectory); - const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName, workingDirectory); - - if (permissionSource.source === 'md') { - // Write to existing .md file - const existingMdData = parseMdFile(permissionSource.path); - existingMdData.frontmatter.permission = newPermission; - writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body); - console.log(`Updated permission in .md file: ${permissionSource.path}`); - } else if (permissionSource.source === 'json') { - // Write to existing JSON location - const existingConfig = readConfigFile(permissionSource.path); - if (!existingConfig.agent) existingConfig.agent = {}; - if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {}; - existingConfig.agent[agentName].permission = newPermission; - writeConfig(existingConfig, permissionSource.path); - console.log(`Updated permission in JSON: ${permissionSource.path}`); - } else { - // Permission not defined anywhere - use agent's source location - if ((mdExists || creatingNewMd) && mdData) { - mdData.frontmatter.permission = newPermission; - mdModified = true; - } else if (hasJsonFields) { - // Agent exists in JSON - add permission there - if (!config.agent) config.agent = {}; - if (!config.agent[agentName]) config.agent[agentName] = {}; - config.agent[agentName].permission = newPermission; - jsonModified = true; - } else { - // Built-in agent with no config - write to project JSON if available, else user JSON - const writeTarget = workingDirectory - ? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath } - : { config: layers.userConfig || {}, path: layers.paths.userPath }; - if (!writeTarget.config.agent) writeTarget.config.agent = {}; - if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {}; - writeTarget.config.agent[agentName].permission = newPermission; - writeConfig(writeTarget.config, writeTarget.path); - console.log(`Created permission in JSON: ${writeTarget.path}`); - } - } - continue; - } - - const inMd = mdData?.frontmatter?.[field] !== undefined; - const inJson = jsonSection?.[field] !== undefined; - - if (value === null) { - // Treat null as a request to remove the field. - if (mdData && inMd) { - delete mdData.frontmatter[field]; - mdModified = true; - } - - if (inJson) { - if (config.agent?.[agentName]) { - delete config.agent[agentName][field]; - - if (Object.keys(config.agent[agentName]).length === 0) { - delete config.agent[agentName]; - } - if (Object.keys(config.agent).length === 0) { - delete config.agent; - } - - jsonModified = true; - } - } - - continue; - } - - // JSON takes precedence over md, so update JSON first if field exists there - if (inJson) { - if (!config.agent) config.agent = {}; - if (!config.agent[agentName]) config.agent[agentName] = {}; - config.agent[agentName][field] = value; - jsonModified = true; - } else if (inMd || creatingNewMd) { - if (mdData) { - mdData.frontmatter[field] = value; - mdModified = true; - } - } else { - // New field - add to the appropriate location based on agent source - if ((mdExists || creatingNewMd) && mdData) { - mdData.frontmatter[field] = value; - mdModified = true; - } else { - // JSON-only agent or has JSON fields - add to JSON - if (!config.agent) config.agent = {}; - if (!config.agent[agentName]) config.agent[agentName] = {}; - config.agent[agentName][field] = value; - jsonModified = true; - } - } - } - - if (mdModified && mdData) { - writeMdFile(targetPath, mdData.frontmatter, mdData.body); - } - - if (jsonModified) { - writeConfig(config, jsonTarget.path || CONFIG_FILE); - } - - console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`); -} - -function deleteAgent(agentName, workingDirectory) { - let deleted = false; - - // Check project level first (takes precedence) - if (workingDirectory) { - const projectPath = getProjectAgentPath(workingDirectory, agentName); - if (fs.existsSync(projectPath)) { - fs.unlinkSync(projectPath); - console.log(`Deleted project-level agent .md file: ${projectPath}`); - deleted = true; - } - } - - // Then check user level - const userPath = getUserAgentPath(agentName); - if (fs.existsSync(userPath)) { - fs.unlinkSync(userPath); - console.log(`Deleted user-level agent .md file: ${userPath}`); - deleted = true; - } - - // Also check json config (highest precedence entry only) - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'agent', agentName); - if (jsonSource.exists && jsonSource.config && jsonSource.path) { - if (!jsonSource.config.agent) jsonSource.config.agent = {}; - delete jsonSource.config.agent[agentName]; - writeConfig(jsonSource.config, jsonSource.path); - console.log(`Removed agent from opencode.json: ${agentName}`); - deleted = true; - } - - // If nothing was deleted (built-in agent), disable it in highest-precedence config - if (!deleted) { - const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER); - const targetConfig = jsonTarget.config || {}; - if (!targetConfig.agent) targetConfig.agent = {}; - targetConfig.agent[agentName] = { disable: true }; - writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE); - console.log(`Disabled built-in agent: ${agentName}`); - } -} - -function getCommandSources(commandName, workingDirectory) { - // Check project level first (takes precedence) - const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null; - const projectExists = projectPath && fs.existsSync(projectPath); - - // Then check user level - const userPath = getUserCommandPath(commandName); - const userExists = fs.existsSync(userPath); - - // Determine which md file to use (project takes precedence) - const mdPath = projectExists ? projectPath : (userExists ? userPath : null); - const mdExists = !!mdPath; - const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null); - - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'command', commandName); - const jsonSection = jsonSource.section; - const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; - const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER; - - const sources = { - md: { - exists: mdExists, - path: mdPath, - scope: mdScope, - fields: [] - }, - json: { - exists: jsonSource.exists, - path: jsonPath, - scope: jsonSource.exists ? jsonScope : null, - fields: [] - }, - // Additional info about both levels - projectMd: { - exists: projectExists, - path: projectPath - }, - userMd: { - exists: userExists, - path: userPath - } - }; - - if (mdExists) { - const { frontmatter, body } = parseMdFile(mdPath); - sources.md.fields = Object.keys(frontmatter); - if (body) { - sources.md.fields.push('template'); - } - } - - if (jsonSection) { - sources.json.fields = Object.keys(jsonSection); - } - - return sources; -} - -function createCommand(commandName, config, workingDirectory, scope) { - ensureDirs(); - - // Check if command already exists at either level - const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null; - const userPath = getUserCommandPath(commandName); - - if (projectPath && fs.existsSync(projectPath)) { - throw new Error(`Command ${commandName} already exists as project-level .md file`); - } - - if (fs.existsSync(userPath)) { - throw new Error(`Command ${commandName} already exists as user-level .md file`); - } - - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'command', commandName); - if (jsonSource.exists) { - throw new Error(`Command ${commandName} already exists in opencode.json`); - } - - // Determine target path based on requested scope - let targetPath; - let targetScope; - - if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) { - ensureProjectCommandDir(workingDirectory); - targetPath = projectPath; - targetScope = COMMAND_SCOPE.PROJECT; - } else { - targetPath = userPath; - targetScope = COMMAND_SCOPE.USER; - } - - // Extract scope from config - it's only used for path determination, not written to file - const { template, scope: _scopeFromConfig, ...frontmatter } = config; - - writeMdFile(targetPath, frontmatter, template || ''); - console.log(`Created new command: ${commandName} (scope: ${targetScope}, path: ${targetPath})`); -} - -function updateCommand(commandName, updates, workingDirectory) { - ensureDirs(); - - // Determine correct path: project level takes precedence - const { scope, path: mdPath } = getCommandWritePath(commandName, workingDirectory); - const mdExists = mdPath && fs.existsSync(mdPath); - - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'command', commandName); - const jsonSection = jsonSource.section; - const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0; - const jsonTarget = jsonSource.exists - ? { config: jsonSource.config, path: jsonSource.path } - : getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER); - let config = jsonTarget.config || {}; - - // Only create a new md file for built-in overrides (no md + no json) - const isBuiltinOverride = !mdExists && !hasJsonFields; - - let targetPath = mdPath; - let targetScope = scope; - - if (!mdExists && isBuiltinOverride) { - // Built-in command override - create at user level - targetPath = getUserCommandPath(commandName); - targetScope = COMMAND_SCOPE.USER; - } - - const mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null); - - let mdModified = false; - let jsonModified = false; - let creatingNewMd = isBuiltinOverride; - - for (const [field, value] of Object.entries(updates)) { - - if (field === 'template') { - const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value)); - - if (mdExists || creatingNewMd) { - if (mdData) { - mdData.body = normalizedValue; - mdModified = true; - } - continue; - } else if (isPromptFileReference(jsonSection?.template)) { - const templateFilePath = resolvePromptFilePath(jsonSection.template); - if (!templateFilePath) { - throw new Error(`Invalid template file reference for command ${commandName}`); - } - writePromptFile(templateFilePath, normalizedValue); - continue; - } else if (isPromptFileReference(normalizedValue)) { - if (!config.command) config.command = {}; - if (!config.command[commandName]) config.command[commandName] = {}; - config.command[commandName].template = normalizedValue; - jsonModified = true; - continue; - } - - // For JSON-only commands, store template inline in JSON - if (!config.command) config.command = {}; - if (!config.command[commandName]) config.command[commandName] = {}; - config.command[commandName].template = normalizedValue; - jsonModified = true; - continue; - } - - const inMd = mdData?.frontmatter?.[field] !== undefined; - const inJson = jsonSection?.[field] !== undefined; - - // JSON takes precedence over md, so update JSON first if field exists there - if (inJson) { - if (!config.command) config.command = {}; - if (!config.command[commandName]) config.command[commandName] = {}; - config.command[commandName][field] = value; - jsonModified = true; - } else if (inMd || creatingNewMd) { - if (mdData) { - mdData.frontmatter[field] = value; - mdModified = true; - } - } else { - // New field - add to appropriate location based on command source - if ((mdExists || creatingNewMd) && mdData) { - mdData.frontmatter[field] = value; - mdModified = true; - } else { - if (!config.command) config.command = {}; - if (!config.command[commandName]) config.command[commandName] = {}; - config.command[commandName][field] = value; - jsonModified = true; - } - } - } - - if (mdModified && mdData) { - writeMdFile(targetPath, mdData.frontmatter, mdData.body); - } - - if (jsonModified) { - writeConfig(config, jsonTarget.path || CONFIG_FILE); - } - - console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`); -} - -function getProviderSources(providerId, workingDirectory) { - const layers = readConfigLayers(workingDirectory); - const { userConfig, projectConfig, customConfig, paths } = layers; - - const customProviders = isPlainObject(customConfig?.provider) ? customConfig.provider : {}; - const customProvidersAlias = isPlainObject(customConfig?.providers) ? customConfig.providers : {}; - const projectProviders = isPlainObject(projectConfig?.provider) ? projectConfig.provider : {}; - const projectProvidersAlias = isPlainObject(projectConfig?.providers) ? projectConfig.providers : {}; - const userProviders = isPlainObject(userConfig?.provider) ? userConfig.provider : {}; - const userProvidersAlias = isPlainObject(userConfig?.providers) ? userConfig.providers : {}; - - const customExists = - (customProviders && Object.prototype.hasOwnProperty.call(customProviders, providerId)) || - (customProvidersAlias && Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId)); - const projectExists = - (projectProviders && Object.prototype.hasOwnProperty.call(projectProviders, providerId)) || - (projectProvidersAlias && Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId)); - const userExists = - (userProviders && Object.prototype.hasOwnProperty.call(userProviders, providerId)) || - (userProvidersAlias && Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId)); - - return { - sources: { - auth: { exists: false }, - user: { exists: userExists, path: paths.userPath }, - project: { exists: projectExists, path: paths.projectPath || null }, - custom: { exists: customExists, path: paths.customPath } - } - }; -} - -function removeProviderConfig(providerId, workingDirectory, scope = 'user') { - if (!providerId || typeof providerId !== 'string') { - throw new Error('Provider ID is required'); - } - - const layers = readConfigLayers(workingDirectory); - let targetPath = layers.paths.userPath; - - if (scope === 'project') { - if (!workingDirectory) { - throw new Error('Working directory is required for project scope'); - } - targetPath = layers.paths.projectPath || targetPath; - } else if (scope === 'custom') { - if (!layers.paths.customPath) { - return false; - } - targetPath = layers.paths.customPath; - } - - const targetConfig = getConfigForPath(layers, targetPath); - const providerConfig = isPlainObject(targetConfig.provider) ? targetConfig.provider : {}; - const providersConfig = isPlainObject(targetConfig.providers) ? targetConfig.providers : {}; - const removedProvider = providerConfig && Object.prototype.hasOwnProperty.call(providerConfig, providerId); - const removedProviders = providersConfig && Object.prototype.hasOwnProperty.call(providersConfig, providerId); - - if (!removedProvider && !removedProviders) { - return false; - } - - if (removedProvider) { - delete providerConfig[providerId]; - if (Object.keys(providerConfig).length === 0) { - delete targetConfig.provider; - } else { - targetConfig.provider = providerConfig; - } - } - - if (removedProviders) { - delete providersConfig[providerId]; - if (Object.keys(providersConfig).length === 0) { - delete targetConfig.providers; - } else { - targetConfig.providers = providersConfig; - } - } - - writeConfig(targetConfig, targetPath || CONFIG_FILE); - console.log(`Removed provider ${providerId} from config: ${targetPath}`); - return true; -} - -function deleteCommand(commandName, workingDirectory) { - let deleted = false; - - // Check project level first (takes precedence) - if (workingDirectory) { - const projectPath = getProjectCommandPath(workingDirectory, commandName); - if (fs.existsSync(projectPath)) { - fs.unlinkSync(projectPath); - console.log(`Deleted project-level command .md file: ${projectPath}`); - deleted = true; - } - } - - // Then check user level - const userPath = getUserCommandPath(commandName); - if (fs.existsSync(userPath)) { - fs.unlinkSync(userPath); - console.log(`Deleted user-level command .md file: ${userPath}`); - deleted = true; - } - - // Also check json config (highest precedence entry only) - const layers = readConfigLayers(workingDirectory); - const jsonSource = getJsonEntrySource(layers, 'command', commandName); - if (jsonSource.exists && jsonSource.config && jsonSource.path) { - if (!jsonSource.config.command) jsonSource.config.command = {}; - delete jsonSource.config.command[commandName]; - writeConfig(jsonSource.config, jsonSource.path); - console.log(`Removed command from opencode.json: ${commandName}`); - deleted = true; - } - - if (!deleted) { - throw new Error(`Command "${commandName}" not found`); - } -} - -// ============== SKILL CRUD ============== - -/** - * Discover all skills from all sources - */ -function discoverSkills(workingDirectory) { - const skills = new Map(); - - // 1) External global (.claude, .agents) - for (const externalRootName of ['.claude', '.agents']) { - const homeRoot = path.join(os.homedir(), externalRootName, 'skills'); - const source = externalRootName === '.agents' ? 'agents' : 'claude'; - for (const skillMdPath of walkSkillMdFiles(homeRoot)) { - addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source); - } - } - - // 2) External project ancestors (.claude, .agents) - if (workingDirectory) { - const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); - const ancestors = getAncestors(workingDirectory, worktreeRoot); - for (const ancestor of ancestors) { - for (const externalRootName of ['.claude', '.agents']) { - const source = externalRootName === '.agents' ? 'agents' : 'claude'; - const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills'); - for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) { - addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source); - } - } - } - } - - // 3) Config directories: {skill,skills}/**/SKILL.md - const configDirectories = resolveSkillSearchDirectories(workingDirectory); - const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode')); - const customConfigDir = process.env.OPENCODE_CONFIG_DIR - ? path.resolve(process.env.OPENCODE_CONFIG_DIR) - : null; - for (const dir of configDirectories) { - for (const subDir of ['skill', 'skills']) { - const root = path.join(dir, subDir); - for (const skillMdPath of walkSkillMdFiles(root)) { - const isUserConfigDir = dir === OPENCODE_CONFIG_DIR - || dir === homeOpencodeDir - || (customConfigDir && dir === customConfigDir); - const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT; - addSkillFromMdFile(skills, skillMdPath, scope, 'opencode'); - } - } - } - - // 4) Additional config.skills.paths - let configuredPaths = []; - try { - const config = readConfig(workingDirectory); - configuredPaths = Array.isArray(config?.skills?.paths) ? config.skills.paths : []; - } catch { - configuredPaths = []; - } - for (const skillPath of configuredPaths) { - if (typeof skillPath !== 'string' || !skillPath.trim()) continue; - const expanded = skillPath.startsWith('~/') - ? path.join(os.homedir(), skillPath.slice(2)) - : skillPath; - const resolved = path.isAbsolute(expanded) - ? path.resolve(expanded) - : path.resolve(workingDirectory || process.cwd(), expanded); - for (const skillMdPath of walkSkillMdFiles(resolved)) { - addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode'); - } - } - - // 5) Cached skills from config.skills.urls pulls (best-effort, no network) - const cacheCandidates = []; - if (process.env.XDG_CACHE_HOME) { - cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills')); - } - cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills')); - cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills')); - - for (const cacheRoot of cacheCandidates) { - if (!fs.existsSync(cacheRoot)) continue; - const entries = fs.readdirSync(cacheRoot, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const skillRoot = path.join(cacheRoot, entry.name); - for (const skillMdPath of walkSkillMdFiles(skillRoot)) { - addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode'); - } - } - } - - return Array.from(skills.values()); -} - -function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { - // 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; - - const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName - ? discoveredSkill - : discoverSkills(workingDirectory).find((skill) => skill.name === skillName); - - // 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; - } else if (matchedDiscovered?.path) { - mdPath = matchedDiscovered.path; - mdScope = matchedDiscovered.scope || null; - mdSource = matchedDiscovered.source || null; - mdDir = path.dirname(matchedDiscovered.path); - } - - 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; - - const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER; - const requestedSource = config?.source === 'agents' ? 'agents' : 'opencode'; - - if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) { - ensureProjectSkillDir(workingDirectory); - if (requestedSource === 'agents') { - targetDir = getProjectAgentsSkillDir(workingDirectory, skillName); - targetPath = getProjectAgentsSkillPath(workingDirectory, skillName); - } else { - targetDir = getProjectSkillDir(workingDirectory, skillName); - targetPath = getProjectSkillPath(workingDirectory, skillName); - } - targetScope = SKILL_SCOPE.PROJECT; - } else { - if (requestedSource === 'agents') { - targetDir = getUserAgentsSkillDir(skillName); - targetPath = getUserAgentsSkillPath(skillName); - } 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, source: _sourceFromConfig, supportingFiles, ...frontmatter } = config; - void _scopeFromConfig; - void _sourceFromConfig; - - // 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; - } - - const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName); - if (fs.existsSync(projectAgentsDir)) { - fs.rmSync(projectAgentsDir, { recursive: true, force: true }); - console.log(`Deleted project-level agents skill directory: ${projectAgentsDir}`); - 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; - } - - const userAgentsDir = getUserAgentsSkillDir(skillName); - if (fs.existsSync(userAgentsDir)) { - fs.rmSync(userAgentsDir, { recursive: true, force: true }); - console.log(`Deleted user-level agents skill directory: ${userAgentsDir}`); - deleted = true; - } - - if (!deleted) { - throw new Error(`Skill "${skillName}" not found`); - } -} - -export { - getAgentSources, - getAgentScope, - getAgentPermissionSource, - getAgentConfig, - createAgent, - updateAgent, - deleteAgent, - getCommandSources, - getCommandScope, - createCommand, - updateCommand, - deleteCommand, - getSkillSources, - getSkillScope, - discoverSkills, - createSkill, - updateSkill, - deleteSkill, - readSkillSupportingFile, - writeSkillSupportingFile, - deleteSkillSupportingFile, - readConfig, - writeConfig, - getProviderSources, - removeProviderConfig, - AGENT_DIR, - COMMAND_DIR, - SKILL_DIR, - CONFIG_FILE, - AGENT_SCOPE, - COMMAND_SCOPE, - SKILL_SCOPE -}; diff --git a/packages/web/server/lib/opencode-config.js.d.ts b/packages/web/server/lib/opencode-config.js.d.ts deleted file mode 100644 index 92ca0ba6..00000000 --- a/packages/web/server/lib/opencode-config.js.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module "../server/lib/opencode-config.js" { - export function getAgentSources(agentName: string): { - md: { exists: boolean; path: string | null; fields: string[] }; - json: { exists: boolean; path: string | null; fields: string[] }; - }; - - export function createAgent(agentName: string, config: Record): void; - - export function updateAgent(agentName: string, updates: Record): void; - - export function deleteAgent(agentName: string): void; -} diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md new file mode 100644 index 00000000..ed8b9725 --- /dev/null +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -0,0 +1,58 @@ +# OpenCode Module Documentation + +## Purpose +This module provides OpenCode server integration utilities for the web server runtime, including configuration management, provider authentication, and UI authentication with rate limiting. + +## Entrypoints and structure +- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder). +- `packages/web/server/lib/opencode/auth.js`: provider authentication file operations. +- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers. +- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting. + +## Public exports (auth.js) +- `readAuthFile()`: Reads and parses `~/.local/share/opencode/auth.json`. +- `writeAuthFile(auth)`: Writes auth file with automatic backup. +- `removeProviderAuth(providerId)`: Removes a provider's auth entry. +- `getProviderAuth(providerId)`: Returns auth for a specific provider or null. +- `listProviderAuths()`: Returns list of provider IDs with configured auth. +- `AUTH_FILE`: Auth file path constant. +- `OPENCODE_DATA_DIR`: OpenCode data directory path constant. + +## Public exports (shared.js) +- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants. +- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values. +- `ensureDirs()`: Creates required OpenCode directories. +- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. +- `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). +- `writeConfig(config, filePath)`: Writes config with automatic backup. +- `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. +- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. +- `getAncestors(startDir, stopDir)`, `findWorktreeRoot(startDir)`: Git worktree helpers. +- `isPromptFileReference(value)`, `resolvePromptFilePath(reference)`, `writePromptFile(filePath, content)`: Prompt file reference handling. +- `walkSkillMdFiles(rootDir)`: Recursively finds all SKILL.md files. +- `addSkillFromMdFile(skillsMap, skillMdPath, scope, source)`: Parses and indexes a skill file. +- `resolveSkillSearchDirectories(workingDirectory)`: Returns skill search path order (config, project, home, custom). +- `listSkillSupportingFiles(skillDir)`, `readSkillSupportingFile(skillDir, relativePath)`, `writeSkillSupportingFile(skillDir, relativePath, content)`, `deleteSkillSupportingFile(skillDir, relativePath)`: Skill supporting file management. + +## Public exports (ui-auth.js) +- `createUiAuth({ password, cookieName, sessionTtlMs })`: Creates UI auth instance with methods: + - `enabled`: Boolean indicating if auth is configured. + - `requireAuth(req, res, next)`: Express middleware to enforce authentication. + - `handleSessionStatus(req, res)`: Returns authentication status. + - `handleSessionCreate(req, res)`: Handles login with rate limiting. + - `ensureSessionToken(req, res)`: Returns or creates session token. + - `dispose()`: Cleans up timers and state. + +## Storage and configuration +- Provider auth: `~/.local/share/opencode/auth.json`. +- User config: `~/.config/opencode/opencode.json`. +- Project config: `/.opencode/opencode.json` or `opencode.json`. +- Custom config: `OPENCODE_CONFIG` env var path. +- Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars. + +## Notes for contributors +- This module serves as foundation for OpenCode-related server utilities. +- Index.js is currently a baseline placeholder; direct imports use submodule paths. +- All file writes include automatic backup before modification. +- Config merging follows priority: custom > project > user. +- UI auth uses scrypt for password hashing with constant-time comparison. diff --git a/packages/web/server/lib/opencode/agents.js b/packages/web/server/lib/opencode/agents.js new file mode 100644 index 00000000..3ce16021 --- /dev/null +++ b/packages/web/server/lib/opencode/agents.js @@ -0,0 +1,563 @@ +import fs from 'fs'; +import path from 'path'; +import { + CONFIG_FILE, + AGENT_DIR, + AGENT_SCOPE, + ensureDirs, + parseMdFile, + writeMdFile, + readConfigLayers, + readConfigFile, + writeConfig, + getJsonEntrySource, + getJsonWriteTarget, + isPromptFileReference, + resolvePromptFilePath, + writePromptFile, +} from './shared.js'; + +// ============== AGENT SCOPE HELPERS ============== + +/** + * Ensure project-level agent directory exists + */ +function ensureProjectAgentDir(workingDirectory) { + 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; +} + +/** + * Get project-level agent path + */ +function getProjectAgentPath(workingDirectory, agentName) { + 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) { + const pluralPath = path.join(AGENT_DIR, `${agentName}.md`); + const legacyPath = path.join(AGENT_DIR, '..', 'agent', `${agentName}.md`); + if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; + return pluralPath; +} + +/** + * Determine agent scope based on where the .md file exists + * Priority: project level > user level > null (built-in only) + */ +function getAgentScope(agentName, workingDirectory) { + if (workingDirectory) { + const projectPath = getProjectAgentPath(workingDirectory, agentName); + if (fs.existsSync(projectPath)) { + return { scope: AGENT_SCOPE.PROJECT, path: projectPath }; + } + } + + const userPath = getUserAgentPath(agentName); + if (fs.existsSync(userPath)) { + return { scope: AGENT_SCOPE.USER, path: userPath }; + } + + return { scope: null, path: null }; +} + +/** + * Get the path where an agent should be written based on scope + */ +function getAgentWritePath(agentName, workingDirectory, requestedScope) { + // For updates: check existing location first (project takes precedence) + const existing = getAgentScope(agentName, workingDirectory); + if (existing.path) { + return existing; + } + + // For new agents or built-in overrides: use requested scope or default to user + const scope = requestedScope || AGENT_SCOPE.USER; + if (scope === AGENT_SCOPE.PROJECT && workingDirectory) { + return { + scope: AGENT_SCOPE.PROJECT, + path: getProjectAgentPath(workingDirectory, agentName) + }; + } + + return { + scope: AGENT_SCOPE.USER, + path: getUserAgentPath(agentName) + }; +} + +/** + * Detect where an agent's permission field is currently defined + * Priority: project .md > user .md > project JSON > user JSON + * Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null } + */ +function getAgentPermissionSource(agentName, workingDirectory) { + // Check project-level .md first + if (workingDirectory) { + const projectMdPath = getProjectAgentPath(workingDirectory, agentName); + if (fs.existsSync(projectMdPath)) { + const { frontmatter } = parseMdFile(projectMdPath); + if (frontmatter.permission !== undefined) { + return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath }; + } + } + } + + // Check user-level .md + const userMdPath = getUserAgentPath(agentName); + if (fs.existsSync(userMdPath)) { + const { frontmatter } = parseMdFile(userMdPath); + if (frontmatter.permission !== undefined) { + return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath }; + } + } + + // Check JSON layers (project > user) + const layers = readConfigLayers(workingDirectory); + + // Project opencode.json + const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission; + if (projectJsonPermission !== undefined && layers.paths.projectPath) { + return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath }; + } + + // User opencode.json + const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission; + if (userJsonPermission !== undefined) { + return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath }; + } + + // Custom config (env var) + const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission; + if (customJsonPermission !== undefined && layers.paths.customPath) { + return { source: 'json', scope: 'custom', path: layers.paths.customPath }; + } + + return { source: null, scope: null, path: null }; +} + +function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName) { + if (!permissionSource.source || !permissionSource.path) { + return newPermission; + } + + let existingPermission = null; + if (permissionSource.source === 'md') { + const { frontmatter } = parseMdFile(permissionSource.path); + existingPermission = frontmatter.permission; + } else if (permissionSource.source === 'json') { + const config = readConfigFile(permissionSource.path); + existingPermission = config?.agent?.[agentName]?.permission; + } + + if (!existingPermission || typeof existingPermission === 'string') { + return newPermission; + } + + if (newPermission == null) { + return null; + } + + if (typeof newPermission === 'string') { + return newPermission; + } + + const nonWildcardPatterns = {}; + for (const [permKey, permValue] of Object.entries(existingPermission)) { + if (permKey === '*') continue; + + if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) { + const nonWildcards = {}; + for (const [pattern, action] of Object.entries(permValue)) { + if (pattern !== '*') { + nonWildcards[pattern] = action; + } + } + if (Object.keys(nonWildcards).length > 0) { + nonWildcardPatterns[permKey] = nonWildcards; + } + } + } + + if (Object.keys(nonWildcardPatterns).length === 0) { + return newPermission; + } + + const merged = { ...newPermission }; + for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) { + const newValue = merged[permKey]; + if (typeof newValue === 'string') { + merged[permKey] = { '*': newValue, ...patterns }; + } else if (typeof newValue === 'object' && newValue !== null) { + merged[permKey] = { ...patterns, ...newValue }; + } else { + const existingValue = existingPermission[permKey]; + if (typeof existingValue === 'object' && existingValue !== null) { + const wildcard = existingValue['*']; + merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns; + } + } + } + + return merged; +} + +function getAgentSources(agentName, workingDirectory) { + const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; + const projectExists = projectPath && fs.existsSync(projectPath); + + const userPath = getUserAgentPath(agentName); + const userExists = fs.existsSync(userPath); + + const mdPath = projectExists ? projectPath : (userExists ? userPath : null); + const mdExists = !!mdPath; + const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null); + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'agent', agentName); + const jsonSection = jsonSource.section; + const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; + const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER; + + const sources = { + md: { + exists: mdExists, + path: mdPath, + scope: mdScope, + fields: [] + }, + json: { + exists: jsonSource.exists, + path: jsonPath, + scope: jsonSource.exists ? jsonScope : null, + fields: [] + }, + projectMd: { + exists: projectExists, + path: projectPath + }, + userMd: { + exists: userExists, + path: userPath + } + }; + + if (mdExists) { + const { frontmatter, body } = parseMdFile(mdPath); + sources.md.fields = Object.keys(frontmatter); + if (body) { + sources.md.fields.push('prompt'); + } + } + + if (jsonSection) { + sources.json.fields = Object.keys(jsonSection); + } + + return sources; +} + +function getAgentConfig(agentName, workingDirectory) { + const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; + const projectExists = projectPath && fs.existsSync(projectPath); + + const userPath = getUserAgentPath(agentName); + const userExists = fs.existsSync(userPath); + + if (projectExists || userExists) { + const mdPath = projectExists ? projectPath : userPath; + const { frontmatter, body } = parseMdFile(mdPath); + + return { + source: 'md', + scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER, + config: { + ...frontmatter, + ...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}), + }, + }; + } + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'agent', agentName); + + if (jsonSource.exists && jsonSource.section) { + const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER; + return { + source: 'json', + scope, + config: { ...jsonSource.section }, + }; + } + + return { + source: 'none', + scope: null, + config: {}, + }; +} + +function createAgent(agentName, config, workingDirectory, scope) { + ensureDirs(); + + const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null; + const userPath = getUserAgentPath(agentName); + + if (projectPath && fs.existsSync(projectPath)) { + throw new Error(`Agent ${agentName} already exists as project-level .md file`); + } + + if (fs.existsSync(userPath)) { + throw new Error(`Agent ${agentName} already exists as user-level .md file`); + } + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'agent', agentName); + if (jsonSource.exists) { + throw new Error(`Agent ${agentName} already exists in opencode.json`); + } + + let targetPath; + let targetScope; + + if (scope === AGENT_SCOPE.PROJECT && workingDirectory) { + ensureProjectAgentDir(workingDirectory); + targetPath = projectPath; + targetScope = AGENT_SCOPE.PROJECT; + } else { + targetPath = userPath; + targetScope = AGENT_SCOPE.USER; + } + + const { prompt, scope: _scopeFromConfig, ...frontmatter } = config; + + writeMdFile(targetPath, frontmatter, prompt || ''); + console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`); +} + +function updateAgent(agentName, updates, workingDirectory) { + ensureDirs(); + + const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory); + const mdExists = mdPath && fs.existsSync(mdPath); + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'agent', agentName); + const jsonSection = jsonSource.section; + const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0; + const jsonTarget = jsonSource.exists + ? { config: jsonSource.config, path: jsonSource.path } + : getJsonWriteTarget(layers, AGENT_SCOPE.USER); + let config = jsonTarget.config || {}; + + const isBuiltinOverride = !mdExists && !hasJsonFields; + + let targetPath = mdPath; + let targetScope = scope; + + if (!mdExists && isBuiltinOverride) { + targetPath = getUserAgentPath(agentName); + targetScope = AGENT_SCOPE.USER; + } + + let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null); + + let mdModified = false; + let jsonModified = false; + const creatingNewMd = isBuiltinOverride; + + for (const [field, value] of Object.entries(updates)) { + if (field === 'prompt') { + const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value)); + + if (mdExists || creatingNewMd) { + if (mdData) { + mdData.body = normalizedValue; + mdModified = true; + } + continue; + } else if (isPromptFileReference(jsonSection?.prompt)) { + const promptFilePath = resolvePromptFilePath(jsonSection.prompt); + if (!promptFilePath) { + throw new Error(`Invalid prompt file reference for agent ${agentName}`); + } + writePromptFile(promptFilePath, normalizedValue); + continue; + } else if (isPromptFileReference(normalizedValue)) { + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName].prompt = normalizedValue; + jsonModified = true; + continue; + } + + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName].prompt = normalizedValue; + jsonModified = true; + continue; + } + + if (field === 'permission') { + const permissionSource = getAgentPermissionSource(agentName, workingDirectory); + const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName); + + if (permissionSource.source === 'md') { + const existingMdData = parseMdFile(permissionSource.path); + existingMdData.frontmatter.permission = newPermission; + writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body); + console.log(`Updated permission in .md file: ${permissionSource.path}`); + } else if (permissionSource.source === 'json') { + const existingConfig = readConfigFile(permissionSource.path); + if (!existingConfig.agent) existingConfig.agent = {}; + if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {}; + existingConfig.agent[agentName].permission = newPermission; + writeConfig(existingConfig, permissionSource.path); + console.log(`Updated permission in JSON: ${permissionSource.path}`); + } else { + if ((mdExists || creatingNewMd) && mdData) { + mdData.frontmatter.permission = newPermission; + mdModified = true; + } else if (hasJsonFields) { + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName].permission = newPermission; + jsonModified = true; + } else { + const writeTarget = workingDirectory + ? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath } + : { config: layers.userConfig || {}, path: layers.paths.userPath }; + if (!writeTarget.config.agent) writeTarget.config.agent = {}; + if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {}; + writeTarget.config.agent[agentName].permission = newPermission; + writeConfig(writeTarget.config, writeTarget.path); + console.log(`Created permission in JSON: ${writeTarget.path}`); + } + } + continue; + } + + const inMd = mdData?.frontmatter?.[field] !== undefined; + const inJson = jsonSection?.[field] !== undefined; + + if (value === null) { + if (mdData && inMd) { + delete mdData.frontmatter[field]; + mdModified = true; + } + + if (inJson && config.agent?.[agentName]) { + delete config.agent[agentName][field]; + + if (Object.keys(config.agent[agentName]).length === 0) { + delete config.agent[agentName]; + } + if (Object.keys(config.agent).length === 0) { + delete config.agent; + } + + jsonModified = true; + } + + continue; + } + + if (inJson) { + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName][field] = value; + jsonModified = true; + } else if (inMd || creatingNewMd) { + if (mdData) { + mdData.frontmatter[field] = value; + mdModified = true; + } + } else { + if ((mdExists || creatingNewMd) && mdData) { + mdData.frontmatter[field] = value; + mdModified = true; + } else { + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName][field] = value; + jsonModified = true; + } + } + } + + if (mdModified && mdData) { + writeMdFile(targetPath, mdData.frontmatter, mdData.body); + } + + if (jsonModified) { + writeConfig(config, jsonTarget.path || CONFIG_FILE); + } + + console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`); +} + +function deleteAgent(agentName, workingDirectory) { + let deleted = false; + + if (workingDirectory) { + const projectPath = getProjectAgentPath(workingDirectory, agentName); + if (fs.existsSync(projectPath)) { + fs.unlinkSync(projectPath); + console.log(`Deleted project-level agent .md file: ${projectPath}`); + deleted = true; + } + } + + const userPath = getUserAgentPath(agentName); + if (fs.existsSync(userPath)) { + fs.unlinkSync(userPath); + console.log(`Deleted user-level agent .md file: ${userPath}`); + deleted = true; + } + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'agent', agentName); + if (jsonSource.exists && jsonSource.config && jsonSource.path) { + if (!jsonSource.config.agent) jsonSource.config.agent = {}; + delete jsonSource.config.agent[agentName]; + writeConfig(jsonSource.config, jsonSource.path); + console.log(`Removed agent from opencode.json: ${agentName}`); + deleted = true; + } + + if (!deleted) { + const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER); + const targetConfig = jsonTarget.config || {}; + if (!targetConfig.agent) targetConfig.agent = {}; + targetConfig.agent[agentName] = { disable: true }; + writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE); + console.log(`Disabled built-in agent: ${agentName}`); + } +} + +export { + ensureProjectAgentDir, + getProjectAgentPath, + getUserAgentPath, + getAgentScope, + getAgentWritePath, + getAgentPermissionSource, + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, +}; diff --git a/packages/web/server/lib/opencode-auth.js b/packages/web/server/lib/opencode/auth.js similarity index 100% rename from packages/web/server/lib/opencode-auth.js rename to packages/web/server/lib/opencode/auth.js diff --git a/packages/web/server/lib/opencode/commands.js b/packages/web/server/lib/opencode/commands.js new file mode 100644 index 00000000..6c963aa7 --- /dev/null +++ b/packages/web/server/lib/opencode/commands.js @@ -0,0 +1,339 @@ +import fs from 'fs'; +import path from 'path'; +import { + CONFIG_FILE, + OPENCODE_CONFIG_DIR, + COMMAND_DIR, + COMMAND_SCOPE, + ensureDirs, + parseMdFile, + writeMdFile, + readConfigLayers, + writeConfig, + getJsonEntrySource, + getJsonWriteTarget, + isPromptFileReference, + resolvePromptFilePath, + writePromptFile, +} from './shared.js'; + +// ============== COMMAND SCOPE HELPERS ============== + +/** + * Ensure project-level command directory exists + */ +function ensureProjectCommandDir(workingDirectory) { + 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; +} + +/** + * Get project-level command path + */ +function getProjectCommandPath(workingDirectory, commandName) { + 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) { + 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; +} + +/** + * Determine command scope based on where the .md file exists + * Priority: project level > user level > null (built-in only) + */ +function getCommandScope(commandName, workingDirectory) { + if (workingDirectory) { + const projectPath = getProjectCommandPath(workingDirectory, commandName); + if (fs.existsSync(projectPath)) { + return { scope: COMMAND_SCOPE.PROJECT, path: projectPath }; + } + } + + const userPath = getUserCommandPath(commandName); + if (fs.existsSync(userPath)) { + return { scope: COMMAND_SCOPE.USER, path: userPath }; + } + + return { scope: null, path: null }; +} + +/** + * Get the path where a command should be written based on scope + */ +function getCommandWritePath(commandName, workingDirectory, requestedScope) { + // For updates: check existing location first (project takes precedence) + const existing = getCommandScope(commandName, workingDirectory); + if (existing.path) { + return existing; + } + + // For new commands or built-in overrides: use requested scope or default to user + const scope = requestedScope || COMMAND_SCOPE.USER; + if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) { + return { + scope: COMMAND_SCOPE.PROJECT, + path: getProjectCommandPath(workingDirectory, commandName) + }; + } + + return { + scope: COMMAND_SCOPE.USER, + path: getUserCommandPath(commandName) + }; +} + +function getCommandSources(commandName, workingDirectory) { + const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null; + const projectExists = projectPath && fs.existsSync(projectPath); + + const userPath = getUserCommandPath(commandName); + const userExists = fs.existsSync(userPath); + + const mdPath = projectExists ? projectPath : (userExists ? userPath : null); + const mdExists = !!mdPath; + const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null); + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'command', commandName); + const jsonSection = jsonSource.section; + const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath; + const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER; + + const sources = { + md: { + exists: mdExists, + path: mdPath, + scope: mdScope, + fields: [] + }, + json: { + exists: jsonSource.exists, + path: jsonPath, + scope: jsonSource.exists ? jsonScope : null, + fields: [] + }, + projectMd: { + exists: projectExists, + path: projectPath + }, + userMd: { + exists: userExists, + path: userPath + } + }; + + if (mdExists) { + const { frontmatter, body } = parseMdFile(mdPath); + sources.md.fields = Object.keys(frontmatter); + if (body) { + sources.md.fields.push('template'); + } + } + + if (jsonSection) { + sources.json.fields = Object.keys(jsonSection); + } + + return sources; +} + +function createCommand(commandName, config, workingDirectory, scope) { + ensureDirs(); + + const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null; + const userPath = getUserCommandPath(commandName); + + if (projectPath && fs.existsSync(projectPath)) { + throw new Error(`Command ${commandName} already exists as project-level .md file`); + } + + if (fs.existsSync(userPath)) { + throw new Error(`Command ${commandName} already exists as user-level .md file`); + } + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'command', commandName); + if (jsonSource.exists) { + throw new Error(`Command ${commandName} already exists in opencode.json`); + } + + let targetPath; + let targetScope; + + if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) { + ensureProjectCommandDir(workingDirectory); + targetPath = projectPath; + targetScope = COMMAND_SCOPE.PROJECT; + } else { + targetPath = userPath; + targetScope = COMMAND_SCOPE.USER; + } + + const { template, scope: _scopeFromConfig, ...frontmatter } = config; + + writeMdFile(targetPath, frontmatter, template || ''); + console.log(`Created new command: ${commandName} (scope: ${targetScope}, path: ${targetPath})`); +} + +function updateCommand(commandName, updates, workingDirectory) { + ensureDirs(); + + const { scope, path: mdPath } = getCommandWritePath(commandName, workingDirectory); + const mdExists = mdPath && fs.existsSync(mdPath); + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'command', commandName); + const jsonSection = jsonSource.section; + const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0; + const jsonTarget = jsonSource.exists + ? { config: jsonSource.config, path: jsonSource.path } + : getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER); + let config = jsonTarget.config || {}; + + const isBuiltinOverride = !mdExists && !hasJsonFields; + + let targetPath = mdPath; + let targetScope = scope; + + if (!mdExists && isBuiltinOverride) { + targetPath = getUserCommandPath(commandName); + targetScope = COMMAND_SCOPE.USER; + } + + const mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null); + + let mdModified = false; + let jsonModified = false; + const creatingNewMd = isBuiltinOverride; + + for (const [field, value] of Object.entries(updates)) { + if (field === 'template') { + const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value)); + + if (mdExists || creatingNewMd) { + if (mdData) { + mdData.body = normalizedValue; + mdModified = true; + } + continue; + } else if (isPromptFileReference(jsonSection?.template)) { + const templateFilePath = resolvePromptFilePath(jsonSection.template); + if (!templateFilePath) { + throw new Error(`Invalid template file reference for command ${commandName}`); + } + writePromptFile(templateFilePath, normalizedValue); + continue; + } else if (isPromptFileReference(normalizedValue)) { + if (!config.command) config.command = {}; + if (!config.command[commandName]) config.command[commandName] = {}; + config.command[commandName].template = normalizedValue; + jsonModified = true; + continue; + } + + if (!config.command) config.command = {}; + if (!config.command[commandName]) config.command[commandName] = {}; + config.command[commandName].template = normalizedValue; + jsonModified = true; + continue; + } + + const inMd = mdData?.frontmatter?.[field] !== undefined; + const inJson = jsonSection?.[field] !== undefined; + + if (inJson) { + if (!config.command) config.command = {}; + if (!config.command[commandName]) config.command[commandName] = {}; + config.command[commandName][field] = value; + jsonModified = true; + } else if (inMd || creatingNewMd) { + if (mdData) { + mdData.frontmatter[field] = value; + mdModified = true; + } + } else { + if ((mdExists || creatingNewMd) && mdData) { + mdData.frontmatter[field] = value; + mdModified = true; + } else { + if (!config.command) config.command = {}; + if (!config.command[commandName]) config.command[commandName] = {}; + config.command[commandName][field] = value; + jsonModified = true; + } + } + } + + if (mdModified && mdData) { + writeMdFile(targetPath, mdData.frontmatter, mdData.body); + } + + if (jsonModified) { + writeConfig(config, jsonTarget.path || CONFIG_FILE); + } + + console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`); +} + +function deleteCommand(commandName, workingDirectory) { + let deleted = false; + + if (workingDirectory) { + const projectPath = getProjectCommandPath(workingDirectory, commandName); + if (fs.existsSync(projectPath)) { + fs.unlinkSync(projectPath); + console.log(`Deleted project-level command .md file: ${projectPath}`); + deleted = true; + } + } + + const userPath = getUserCommandPath(commandName); + if (fs.existsSync(userPath)) { + fs.unlinkSync(userPath); + console.log(`Deleted user-level command .md file: ${userPath}`); + deleted = true; + } + + const layers = readConfigLayers(workingDirectory); + const jsonSource = getJsonEntrySource(layers, 'command', commandName); + if (jsonSource.exists && jsonSource.config && jsonSource.path) { + if (!jsonSource.config.command) jsonSource.config.command = {}; + delete jsonSource.config.command[commandName]; + writeConfig(jsonSource.config, jsonSource.path); + console.log(`Removed command from opencode.json: ${commandName}`); + deleted = true; + } + + if (!deleted) { + throw new Error(`Command "${commandName}" not found`); + } +} + +export { + ensureProjectCommandDir, + getProjectCommandPath, + getUserCommandPath, + getCommandScope, + getCommandWritePath, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, +}; diff --git a/packages/web/server/lib/opencode/index.js b/packages/web/server/lib/opencode/index.js new file mode 100644 index 00000000..3f0ce7bb --- /dev/null +++ b/packages/web/server/lib/opencode/index.js @@ -0,0 +1,58 @@ +export { + AGENT_DIR, + COMMAND_DIR, + SKILL_DIR, + CONFIG_FILE, + AGENT_SCOPE, + COMMAND_SCOPE, + SKILL_SCOPE, + readConfig, + writeConfig, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, +} from './shared.js'; + +export { + getAgentScope, + getAgentPermissionSource, + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, +} from './agents.js'; + +export { + getCommandScope, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, +} from './commands.js'; + +export { + getSkillSources, + getSkillScope, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, +} from './skills.js'; + +export { + getProviderSources, + removeProviderConfig, +} from './providers.js'; + +export { + readAuthFile, + writeAuthFile, + removeProviderAuth, + getProviderAuth, + listProviderAuths, + AUTH_FILE, + OPENCODE_DATA_DIR, +} from './auth.js'; + +export { createUiAuth } from './ui-auth.js'; diff --git a/packages/web/server/lib/opencode/providers.js b/packages/web/server/lib/opencode/providers.js new file mode 100644 index 00000000..419cfe59 --- /dev/null +++ b/packages/web/server/lib/opencode/providers.js @@ -0,0 +1,96 @@ +import { + CONFIG_FILE, + readConfigLayers, + isPlainObject, + getConfigForPath, + writeConfig, +} from './shared.js'; + +function getProviderSources(providerId, workingDirectory) { + const layers = readConfigLayers(workingDirectory); + const { userConfig, projectConfig, customConfig, paths } = layers; + + const customProviders = isPlainObject(customConfig?.provider) ? customConfig.provider : {}; + const customProvidersAlias = isPlainObject(customConfig?.providers) ? customConfig.providers : {}; + const projectProviders = isPlainObject(projectConfig?.provider) ? projectConfig.provider : {}; + const projectProvidersAlias = isPlainObject(projectConfig?.providers) ? projectConfig.providers : {}; + const userProviders = isPlainObject(userConfig?.provider) ? userConfig.provider : {}; + const userProvidersAlias = isPlainObject(userConfig?.providers) ? userConfig.providers : {}; + + const customExists = + Object.prototype.hasOwnProperty.call(customProviders, providerId) || + Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId); + const projectExists = + Object.prototype.hasOwnProperty.call(projectProviders, providerId) || + Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId); + const userExists = + Object.prototype.hasOwnProperty.call(userProviders, providerId) || + Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId); + + return { + sources: { + auth: { exists: false }, + user: { exists: userExists, path: paths.userPath }, + project: { exists: projectExists, path: paths.projectPath || null }, + custom: { exists: customExists, path: paths.customPath } + } + }; +} + +function removeProviderConfig(providerId, workingDirectory, scope = 'user') { + if (!providerId || typeof providerId !== 'string') { + throw new Error('Provider ID is required'); + } + + const layers = readConfigLayers(workingDirectory); + let targetPath = layers.paths.userPath; + + if (scope === 'project') { + if (!workingDirectory) { + throw new Error('Working directory is required for project scope'); + } + targetPath = layers.paths.projectPath || targetPath; + } else if (scope === 'custom') { + if (!layers.paths.customPath) { + return false; + } + targetPath = layers.paths.customPath; + } + + const targetConfig = getConfigForPath(layers, targetPath); + const providerConfig = isPlainObject(targetConfig.provider) ? targetConfig.provider : {}; + const providersConfig = isPlainObject(targetConfig.providers) ? targetConfig.providers : {}; + const removedProvider = Object.prototype.hasOwnProperty.call(providerConfig, providerId); + const removedProviders = Object.prototype.hasOwnProperty.call(providersConfig, providerId); + + if (!removedProvider && !removedProviders) { + return false; + } + + if (removedProvider) { + delete providerConfig[providerId]; + if (Object.keys(providerConfig).length === 0) { + delete targetConfig.provider; + } else { + targetConfig.provider = providerConfig; + } + } + + if (removedProviders) { + delete providersConfig[providerId]; + if (Object.keys(providersConfig).length === 0) { + delete targetConfig.providers; + } else { + targetConfig.providers = providersConfig; + } + } + + writeConfig(targetConfig, targetPath || CONFIG_FILE); + console.log(`Removed provider ${providerId} from config: ${targetPath}`); + return true; +} + +export { + getProviderSources, + removeProviderConfig, +}; diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js new file mode 100644 index 00000000..881aad2b --- /dev/null +++ b/packages/web/server/lib/opencode/shared.js @@ -0,0 +1,514 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import yaml from 'yaml'; +import { parse as parseJsonc } from 'jsonc-parser'; + +// ============== PATH CONSTANTS ============== + +const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); +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) + : null; +const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i; + +// ============== SCOPE TYPE CONSTANTS ============== + +const AGENT_SCOPE = { + USER: 'user', + PROJECT: 'project' +}; + +const COMMAND_SCOPE = { + USER: 'user', + PROJECT: 'project' +}; + +const SKILL_SCOPE = { + USER: 'user', + PROJECT: 'project' +}; + +// ============== DIRECTORY OPERATIONS ============== + +function ensureDirs() { + if (!fs.existsSync(OPENCODE_CONFIG_DIR)) { + fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }); + } + if (!fs.existsSync(AGENT_DIR)) { + fs.mkdirSync(AGENT_DIR, { recursive: true }); + } + if (!fs.existsSync(COMMAND_DIR)) { + fs.mkdirSync(COMMAND_DIR, { recursive: true }); + } + if (!fs.existsSync(SKILL_DIR)) { + fs.mkdirSync(SKILL_DIR, { recursive: true }); + } +} + +// ============== MARKDOWN FILE OPERATIONS ============== + +function parseMdFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + + if (!match) { + return { frontmatter: {}, body: content.trim() }; + } + + let frontmatter = {}; + try { + frontmatter = yaml.parse(match[1]) || {}; + } catch (error) { + console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error); + frontmatter = {}; + } + + const body = match[2].trim(); + return { frontmatter, body }; +} + +function writeMdFile(filePath, frontmatter, body) { + try { + const cleanedFrontmatter = Object.fromEntries( + Object.entries(frontmatter).filter(([, value]) => value != null) + ); + const yamlStr = yaml.stringify(cleanedFrontmatter); + const content = `---\n${yamlStr}---\n\n${body}`; + fs.writeFileSync(filePath, content, 'utf8'); + console.log(`Successfully wrote markdown file: ${filePath}`); + } catch (error) { + console.error(`Failed to write markdown file ${filePath}:`, error); + throw new Error('Failed to write agent markdown file'); + } +} + +// ============== CONFIG FILE OPERATIONS ============== + +function getProjectConfigCandidates(workingDirectory) { + if (!workingDirectory) return []; + return [ + path.join(workingDirectory, 'opencode.json'), + path.join(workingDirectory, 'opencode.jsonc'), + path.join(workingDirectory, '.opencode', 'opencode.json'), + path.join(workingDirectory, '.opencode', 'opencode.jsonc'), + ]; +} + +function getProjectConfigPath(workingDirectory) { + if (!workingDirectory) return null; + + const candidates = getProjectConfigCandidates(workingDirectory); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return candidates[0]; +} + +function getConfigPaths(workingDirectory) { + return { + userPath: CONFIG_FILE, + projectPath: getProjectConfigPath(workingDirectory), + customPath: CUSTOM_CONFIG_FILE + }; +} + +function readConfigFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) { + return {}; + } + try { + const content = fs.readFileSync(filePath, 'utf8'); + const normalized = content.trim(); + if (!normalized) { + return {}; + } + return parseJsonc(normalized, [], { allowTrailingComma: true }); + } catch (error) { + console.error(`Failed to read config file: ${filePath}`, error); + throw new Error('Failed to read OpenCode configuration'); + } +} + +function isPlainObject(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + +function mergeConfigs(base, override) { + if (!isPlainObject(base) || !isPlainObject(override)) { + return override; + } + const result = { ...base }; + for (const [key, value] of Object.entries(override)) { + if (key in result) { + const baseValue = result[key]; + if (isPlainObject(baseValue) && isPlainObject(value)) { + result[key] = mergeConfigs(baseValue, value); + } else { + result[key] = value; + } + } else { + result[key] = value; + } + } + return result; +} + +function readConfigLayers(workingDirectory) { + const { userPath, projectPath, customPath } = getConfigPaths(workingDirectory); + const userConfig = readConfigFile(userPath); + const projectConfig = readConfigFile(projectPath); + const customConfig = readConfigFile(customPath); + const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig); + + return { + userConfig, + projectConfig, + customConfig, + mergedConfig, + paths: { userPath, projectPath, customPath } + }; +} + +function readConfig(workingDirectory) { + return readConfigLayers(workingDirectory).mergedConfig; +} + +function getConfigForPath(layers, targetPath) { + if (!targetPath) { + return layers.userConfig; + } + if (layers.paths.customPath && targetPath === layers.paths.customPath) { + return layers.customConfig; + } + if (layers.paths.projectPath && targetPath === layers.paths.projectPath) { + return layers.projectConfig; + } + return layers.userConfig; +} + +function writeConfig(config, filePath = CONFIG_FILE) { + try { + if (fs.existsSync(filePath)) { + const backupFile = `${filePath}.openchamber.backup`; + fs.copyFileSync(filePath, backupFile); + console.log(`Created config backup: ${backupFile}`); + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8'); + console.log(`Successfully wrote config file: ${filePath}`); + } catch (error) { + console.error(`Failed to write config file: ${filePath}`, error); + throw new Error('Failed to write OpenCode configuration'); + } +} + +function getJsonEntrySource(layers, sectionKey, entryName) { + const { userConfig, projectConfig, customConfig, paths } = layers; + const customSection = customConfig?.[sectionKey]?.[entryName]; + if (customSection !== undefined) { + return { section: customSection, config: customConfig, path: paths.customPath, exists: true }; + } + + const projectSection = projectConfig?.[sectionKey]?.[entryName]; + if (projectSection !== undefined) { + return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true }; + } + + const userSection = userConfig?.[sectionKey]?.[entryName]; + if (userSection !== undefined) { + return { section: userSection, config: userConfig, path: paths.userPath, exists: true }; + } + + return { section: null, config: null, path: null, exists: false }; +} + +function getJsonWriteTarget(layers, preferredScope) { + const { userConfig, projectConfig, customConfig, paths } = layers; + if (paths.customPath) { + return { config: customConfig, path: paths.customPath }; + } + if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) { + return { config: projectConfig, path: paths.projectPath }; + } + if (paths.projectPath) { + return { config: projectConfig, path: paths.projectPath }; + } + return { config: userConfig, path: paths.userPath }; +} + +// ============== GIT/WORKTREE HELPERS ============== + +function getAncestors(startDir, stopDir) { + if (!startDir) return []; + const result = []; + let current = path.resolve(startDir); + const resolvedStop = stopDir ? path.resolve(stopDir) : null; + + while (true) { + result.push(current); + if (resolvedStop && current === resolvedStop) { + break; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return result; +} + +function findWorktreeRoot(startDir) { + if (!startDir) return null; + let current = path.resolve(startDir); + + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +// ============== PROMPT FILE HELPERS ============== + +function isPromptFileReference(value) { + if (typeof value !== 'string') { + return false; + } + return PROMPT_FILE_PATTERN.test(value.trim()); +} + +function resolvePromptFilePath(reference) { + const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null; + if (!match) { + return null; + } + let target = match[1].trim(); + if (!target) { + return null; + } + + if (target.startsWith('./')) { + target = target.slice(2); + target = path.join(OPENCODE_CONFIG_DIR, target); + } else if (!path.isAbsolute(target)) { + target = path.join(OPENCODE_CONFIG_DIR, target); + } + + return target; +} + +function writePromptFile(filePath, content) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, content ?? '', 'utf8'); + console.log(`Updated prompt file: ${filePath}`); +} + +// ============== SKILL FILE OPERATIONS ============== + +function walkSkillMdFiles(rootDir) { + if (!rootDir || !fs.existsSync(rootDir)) return []; + + const results = []; + const walk = (dir) => { + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (entry.isFile() && entry.name === 'SKILL.md') { + results.push(fullPath); + } + } + }; + + walk(rootDir); + return results; +} + +function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) { + let parsed; + try { + parsed = parseMdFile(skillMdPath); + } catch { + return; + } + + const name = typeof parsed.frontmatter?.name === 'string' + ? parsed.frontmatter.name.trim() + : ''; + const description = typeof parsed.frontmatter?.description === 'string' + ? parsed.frontmatter.description + : ''; + + if (!name) { + return; + } + + skillsMap.set(name, { + name, + path: skillMdPath, + scope, + source, + description, + }); +} + +function resolveSkillSearchDirectories(workingDirectory) { + const directories = []; + const pushDir = (dir) => { + if (!dir) return; + const resolved = path.resolve(dir); + if (!directories.includes(resolved)) { + directories.push(resolved); + } + }; + + pushDir(OPENCODE_CONFIG_DIR); + + if (workingDirectory) { + const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); + const projectDirs = getAncestors(workingDirectory, worktreeRoot) + .map((dir) => path.join(dir, '.opencode')); + projectDirs.forEach(pushDir); + } + + pushDir(path.join(os.homedir(), '.opencode')); + + const customConfigDir = process.env.OPENCODE_CONFIG_DIR + ? path.resolve(process.env.OPENCODE_CONFIG_DIR) + : null; + pushDir(customConfigDir); + + return directories; +} + +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; +} + +function readSkillSupportingFile(skillDir, relativePath) { + const fullPath = path.join(skillDir, relativePath); + if (!fs.existsSync(fullPath)) { + return null; + } + return fs.readFileSync(fullPath, 'utf8'); +} + +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'); +} + +function deleteSkillSupportingFile(skillDir, relativePath) { + const fullPath = path.join(skillDir, relativePath); + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + 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; + } + } + } +} + +export { + OPENCODE_CONFIG_DIR, + AGENT_DIR, + COMMAND_DIR, + SKILL_DIR, + CONFIG_FILE, + CUSTOM_CONFIG_FILE, + PROMPT_FILE_PATTERN, + AGENT_SCOPE, + COMMAND_SCOPE, + SKILL_SCOPE, + ensureDirs, + parseMdFile, + writeMdFile, + getProjectConfigCandidates, + getProjectConfigPath, + getConfigPaths, + readConfigFile, + isPlainObject, + mergeConfigs, + readConfigLayers, + readConfig, + getConfigForPath, + writeConfig, + getJsonEntrySource, + getJsonWriteTarget, + getAncestors, + findWorktreeRoot, + isPromptFileReference, + resolvePromptFilePath, + writePromptFile, + walkSkillMdFiles, + addSkillFromMdFile, + resolveSkillSearchDirectories, + listSkillSupportingFiles, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, +}; diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js new file mode 100644 index 00000000..5db96de7 --- /dev/null +++ b/packages/web/server/lib/opencode/skills.js @@ -0,0 +1,480 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + SKILL_DIR, + OPENCODE_CONFIG_DIR, + SKILL_SCOPE, + ensureDirs, + parseMdFile, + writeMdFile, + readConfigLayers, + readConfig, + walkSkillMdFiles, + addSkillFromMdFile, + resolveSkillSearchDirectories, + listSkillSupportingFiles, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + getAncestors, + findWorktreeRoot, +} from './shared.js'; + +function ensureProjectSkillDir(workingDirectory) { + 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; +} + +function getProjectSkillDir(workingDirectory, 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; +} + +function getProjectSkillPath(workingDirectory, skillName) { + 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; +} + +function getUserSkillDir(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; +} + +function getUserSkillPath(skillName) { + 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; +} + +function getClaudeSkillDir(workingDirectory, skillName) { + return path.join(workingDirectory, '.claude', 'skills', skillName); +} + +function getClaudeSkillPath(workingDirectory, skillName) { + return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md'); +} + +function getUserAgentsSkillDir(skillName) { + return path.join(os.homedir(), '.agents', 'skills', skillName); +} + +function getUserAgentsSkillPath(skillName) { + return path.join(getUserAgentsSkillDir(skillName), 'SKILL.md'); +} + +function getProjectAgentsSkillDir(workingDirectory, skillName) { + return path.join(workingDirectory, '.agents', 'skills', skillName); +} + +function getProjectAgentsSkillPath(workingDirectory, skillName) { + return path.join(getProjectAgentsSkillDir(workingDirectory, skillName), 'SKILL.md'); +} + +function getSkillScope(skillName, workingDirectory) { + const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName); + if (discovered?.path) { + return { scope: discovered.scope || null, path: discovered.path, source: discovered.source || null }; + } + + if (workingDirectory) { + const projectPath = getProjectSkillPath(workingDirectory, skillName); + if (fs.existsSync(projectPath)) { + return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' }; + } + + 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 }; +} + +function getSkillWritePath(skillName, workingDirectory, requestedScope) { + const existing = getSkillScope(skillName, workingDirectory); + if (existing.path) { + return existing; + } + + 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' + }; +} + +function discoverSkills(workingDirectory) { + const skills = new Map(); + + for (const externalRootName of ['.claude', '.agents']) { + const homeRoot = path.join(os.homedir(), externalRootName, 'skills'); + const source = externalRootName === '.agents' ? 'agents' : 'claude'; + for (const skillMdPath of walkSkillMdFiles(homeRoot)) { + addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source); + } + } + + if (workingDirectory) { + const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); + const ancestors = getAncestors(workingDirectory, worktreeRoot); + for (const ancestor of ancestors) { + for (const externalRootName of ['.claude', '.agents']) { + const source = externalRootName === '.agents' ? 'agents' : 'claude'; + const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills'); + for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) { + addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source); + } + } + } + } + + const configDirectories = resolveSkillSearchDirectories(workingDirectory); + const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode')); + const customConfigDir = process.env.OPENCODE_CONFIG_DIR + ? path.resolve(process.env.OPENCODE_CONFIG_DIR) + : null; + for (const dir of configDirectories) { + for (const subDir of ['skill', 'skills']) { + const root = path.join(dir, subDir); + for (const skillMdPath of walkSkillMdFiles(root)) { + const isUserConfigDir = dir === OPENCODE_CONFIG_DIR + || dir === homeOpencodeDir + || (customConfigDir && dir === customConfigDir); + const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT; + addSkillFromMdFile(skills, skillMdPath, scope, 'opencode'); + } + } + } + + let configuredPaths = []; + try { + const config = readConfig(workingDirectory); + configuredPaths = Array.isArray(config?.skills?.paths) ? config.skills.paths : []; + } catch { + configuredPaths = []; + } + for (const skillPath of configuredPaths) { + if (typeof skillPath !== 'string' || !skillPath.trim()) continue; + const expanded = skillPath.startsWith('~/') + ? path.join(os.homedir(), skillPath.slice(2)) + : skillPath; + const resolved = path.isAbsolute(expanded) + ? path.resolve(expanded) + : path.resolve(workingDirectory || process.cwd(), expanded); + for (const skillMdPath of walkSkillMdFiles(resolved)) { + addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode'); + } + } + + const cacheCandidates = []; + if (process.env.XDG_CACHE_HOME) { + cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills')); + } + cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills')); + cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills')); + + for (const cacheRoot of cacheCandidates) { + if (!fs.existsSync(cacheRoot)) continue; + const entries = fs.readdirSync(cacheRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillRoot = path.join(cacheRoot, entry.name); + for (const skillMdPath of walkSkillMdFiles(skillRoot)) { + addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode'); + } + } + } + + return Array.from(skills.values()); +} + +function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { + 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; + + const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName + ? discoveredSkill + : discoverSkills(workingDirectory).find((skill) => skill.name === skillName); + + 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; + } else if (matchedDiscovered?.path) { + mdPath = matchedDiscovered.path; + mdScope = matchedDiscovered.scope || null; + mdSource = matchedDiscovered.source || null; + mdDir = path.dirname(matchedDiscovered.path); + } + + const mdExists = !!mdPath; + + const sources = { + md: { + exists: mdExists, + path: mdPath, + dir: mdDir, + scope: mdScope, + source: mdSource, + fields: [], + supportingFiles: [] + }, + 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); + 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(); + + 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.`); + } + + const existing = getSkillScope(skillName, workingDirectory); + if (existing.path) { + throw new Error(`Skill ${skillName} already exists at ${existing.path}`); + } + + let targetDir; + let targetPath; + let targetScope; + + const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER; + const requestedSource = config?.source === 'agents' ? 'agents' : 'opencode'; + + if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) { + ensureProjectSkillDir(workingDirectory); + if (requestedSource === 'agents') { + targetDir = getProjectAgentsSkillDir(workingDirectory, skillName); + targetPath = getProjectAgentsSkillPath(workingDirectory, skillName); + } else { + targetDir = getProjectSkillDir(workingDirectory, skillName); + targetPath = getProjectSkillPath(workingDirectory, skillName); + } + targetScope = SKILL_SCOPE.PROJECT; + } else { + if (requestedSource === 'agents') { + targetDir = getUserAgentsSkillDir(skillName); + targetPath = getUserAgentsSkillPath(skillName); + } else { + targetDir = getUserSkillDir(skillName); + targetPath = getUserSkillPath(skillName); + } + targetScope = SKILL_SCOPE.USER; + } + + fs.mkdirSync(targetDir, { recursive: true }); + + const { instructions, scope: _scopeFromConfig, source: _sourceFromConfig, supportingFiles, ...frontmatter } = config; + void _scopeFromConfig; + void _sourceFromConfig; + + if (!frontmatter.name) { + frontmatter.name = skillName; + } + if (!frontmatter.description) { + throw new Error('Skill description is required'); + } + + writeMdFile(targetPath, frontmatter, instructions || ''); + + 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(); + + 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') { + 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; + } + + 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; + + 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; + } + + 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; + } + + const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName); + if (fs.existsSync(projectAgentsDir)) { + fs.rmSync(projectAgentsDir, { recursive: true, force: true }); + console.log(`Deleted project-level agents skill directory: ${projectAgentsDir}`); + deleted = true; + } + } + + 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; + } + + const userAgentsDir = getUserAgentsSkillDir(skillName); + if (fs.existsSync(userAgentsDir)) { + fs.rmSync(userAgentsDir, { recursive: true, force: true }); + console.log(`Deleted user-level agents skill directory: ${userAgentsDir}`); + deleted = true; + } + + if (!deleted) { + throw new Error(`Skill "${skillName}" not found`); + } +} + +export { + getSkillSources, + getSkillScope, + getSkillWritePath, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, +}; diff --git a/packages/web/server/lib/ui-auth.js b/packages/web/server/lib/opencode/ui-auth.js similarity index 98% rename from packages/web/server/lib/ui-auth.js rename to packages/web/server/lib/opencode/ui-auth.js index 21e95c1b..2e1f2477 100644 --- a/packages/web/server/lib/ui-auth.js +++ b/packages/web/server/lib/opencode/ui-auth.js @@ -4,18 +4,15 @@ const SESSION_COOKIE_NAME = 'oc_ui_session'; const SESSION_TTL_MS = 12 * 60 * 60 * 1000; const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; -// Login rate limit configuration const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10; const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000; const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000; const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3; -// Rate limit tracker: IP -> { count, lastAttempt, lockedUntil } const loginRateLimiter = new Map(); let rateLimitCleanupTimer = null; -// Concurrency control: key -> Promise const rateLimitLocks = new Map(); const getClientIp = (req) => { diff --git a/packages/web/server/lib/quota/providers/claude.js b/packages/web/server/lib/quota/providers/claude.js index a871a3f8..6bba363e 100644 --- a/packages/web/server/lib/quota/providers/claude.js +++ b/packages/web/server/lib/quota/providers/claude.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/codex.js b/packages/web/server/lib/quota/providers/codex.js index a323d3f6..77c1ee0f 100644 --- a/packages/web/server/lib/quota/providers/codex.js +++ b/packages/web/server/lib/quota/providers/codex.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/copilot.js b/packages/web/server/lib/quota/providers/copilot.js index 1064562d..c3ebfe61 100644 --- a/packages/web/server/lib/quota/providers/copilot.js +++ b/packages/web/server/lib/quota/providers/copilot.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/google/auth.js b/packages/web/server/lib/quota/providers/google/auth.js index 9f7477c3..a36aacba 100644 --- a/packages/web/server/lib/quota/providers/google/auth.js +++ b/packages/web/server/lib/quota/providers/google/auth.js @@ -14,7 +14,7 @@ import { asNonEmptyString, toTimestamp } from '../../utils/index.js'; -import { readAuthFile } from '../../../opencode-auth.js'; +import { readAuthFile } from '../../../opencode/auth.js'; import { parseGoogleRefreshToken } from './transforms.js'; const ANTIGRAVITY_GOOGLE_CLIENT_ID = diff --git a/packages/web/server/lib/quota/providers/kimi.js b/packages/web/server/lib/quota/providers/kimi.js index a3196aa6..cb5f83ba 100644 --- a/packages/web/server/lib/quota/providers/kimi.js +++ b/packages/web/server/lib/quota/providers/kimi.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/nanogpt.js b/packages/web/server/lib/quota/providers/nanogpt.js index 099b1be8..00875f58 100644 --- a/packages/web/server/lib/quota/providers/nanogpt.js +++ b/packages/web/server/lib/quota/providers/nanogpt.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/openai.js b/packages/web/server/lib/quota/providers/openai.js index 88b5470e..28fc5c1c 100644 --- a/packages/web/server/lib/quota/providers/openai.js +++ b/packages/web/server/lib/quota/providers/openai.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/openrouter.js b/packages/web/server/lib/quota/providers/openrouter.js index 91437ec7..dc30655a 100644 --- a/packages/web/server/lib/quota/providers/openrouter.js +++ b/packages/web/server/lib/quota/providers/openrouter.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/quota/providers/zai.js b/packages/web/server/lib/quota/providers/zai.js index a0a55c1c..4eec1350 100644 --- a/packages/web/server/lib/quota/providers/zai.js +++ b/packages/web/server/lib/quota/providers/zai.js @@ -1,4 +1,4 @@ -import { readAuthFile } from '../../opencode-auth.js'; +import { readAuthFile } from '../../opencode/auth.js'; import { getAuthEntry, normalizeAuthEntry, diff --git a/packages/web/server/lib/tts-service.js b/packages/web/server/lib/tts-service.js index 4fbcadff..13e63aeb 100644 --- a/packages/web/server/lib/tts-service.js +++ b/packages/web/server/lib/tts-service.js @@ -6,7 +6,7 @@ */ import OpenAI from 'openai'; -import { readAuthFile } from './opencode-auth.js'; +import { readAuthFile } from './opencode/auth.js'; // Voice options from OpenAI export const TTS_VOICES = [