Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View File

@@ -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: `<workingDirectory>/.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.

View File

@@ -0,0 +1,634 @@
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;
}
/**
* Create a per-request lookup cache for user-level agent path resolution.
*/
function createAgentLookupCache() {
return {
userAgentIndexByName: new Map(),
userAgentLookupByName: new Map(),
userAgentIndexReady: false,
};
}
function buildUserAgentIndex(cache) {
if (cache.userAgentIndexReady) return;
cache.userAgentIndexReady = true;
if (!fs.existsSync(AGENT_DIR)) return;
const dirsToVisit = [AGENT_DIR];
while (dirsToVisit.length > 0) {
const dir = dirsToVisit.pop();
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const agentName = entry.name.slice(0, -3);
if (!cache.userAgentIndexByName.has(agentName)) {
cache.userAgentIndexByName.set(agentName, path.join(dir, entry.name));
}
}
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry.isDirectory()) {
dirsToVisit.push(path.join(dir, entry.name));
}
}
}
}
function getIndexedUserAgentPath(agentName, cache) {
if (cache.userAgentLookupByName.has(agentName)) {
return cache.userAgentLookupByName.get(agentName);
}
buildUserAgentIndex(cache);
const found = cache.userAgentIndexByName.get(agentName) || null;
cache.userAgentLookupByName.set(agentName, found);
return found;
}
/**
* Get user-level agent path — walks subfolders to support grouped layouts.
* e.g. ~/.config/opencode/agents/business/ceo-diginno.md
*/
function getUserAgentPath(agentName, lookupCache = null) {
// 1. Check flat path first (legacy / newly created agents)
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(pluralPath)) return pluralPath;
const legacyPath = path.join(AGENT_DIR, '..', 'agent', `${agentName}.md`);
if (fs.existsSync(legacyPath)) return legacyPath;
// 2. Lookup subfolders for grouped layout
const cache = lookupCache || createAgentLookupCache();
const found = getIndexedUserAgentPath(agentName, cache);
if (found) return found;
// 3. Return expected flat path as default (for new agent creation)
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, lookupCache = null) {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
}
}
const userPath = getUserAgentPath(agentName, lookupCache);
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, lookupCache = null) {
// For updates: check existing location first (project takes precedence)
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
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, lookupCache)
};
}
/**
* 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, lookupCache = null) {
// 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, lookupCache);
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, lookupCache = createAgentLookupCache()) {
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
const userPath = getUserAgentPath(agentName, lookupCache);
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, lookupCache = createAgentLookupCache()) {
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
const userPath = getUserAgentPath(agentName, lookupCache);
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 lookupCache = createAgentLookupCache();
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const userPath = getUserAgentPath(agentName, lookupCache);
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 lookupCache = createAgentLookupCache();
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory, undefined, lookupCache);
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, lookupCache);
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, lookupCache);
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) {
const lookupCache = createAgentLookupCache();
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, lookupCache);
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,
};

View File

@@ -0,0 +1,81 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
function readAuthFile() {
if (!fs.existsSync(AUTH_FILE)) {
return {};
}
try {
const content = fs.readFileSync(AUTH_FILE, 'utf8');
const trimmed = content.trim();
if (!trimmed) {
return {};
}
return JSON.parse(trimmed);
} catch (error) {
console.error('Failed to read auth file:', error);
throw new Error('Failed to read OpenCode auth configuration');
}
}
function writeAuthFile(auth) {
try {
if (!fs.existsSync(OPENCODE_DATA_DIR)) {
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
}
if (fs.existsSync(AUTH_FILE)) {
const backupFile = `${AUTH_FILE}.openchamber.backup`;
fs.copyFileSync(AUTH_FILE, backupFile);
console.log(`Created auth backup: ${backupFile}`);
}
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
console.log('Successfully wrote auth file');
} catch (error) {
console.error('Failed to write auth file:', error);
throw new Error('Failed to write OpenCode auth configuration');
}
}
function removeProviderAuth(providerId) {
if (!providerId || typeof providerId !== 'string') {
throw new Error('Provider ID is required');
}
const auth = readAuthFile();
if (!auth[providerId]) {
console.log(`Provider ${providerId} not found in auth file, nothing to remove`);
return false;
}
delete auth[providerId];
writeAuthFile(auth);
console.log(`Removed provider auth: ${providerId}`);
return true;
}
function getProviderAuth(providerId) {
const auth = readAuthFile();
return auth[providerId] || null;
}
function listProviderAuths() {
const auth = readAuthFile();
return Object.keys(auth);
}
export {
readAuthFile,
writeAuthFile,
removeProviderAuth,
getProviderAuth,
listProviderAuths,
AUTH_FILE,
OPENCODE_DATA_DIR
};

View File

@@ -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,
};

View File

@@ -0,0 +1,66 @@
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';
export {
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
} from './mcp.js';

View File

@@ -0,0 +1,206 @@
import fs from 'fs';
import path from 'path';
import {
CONFIG_FILE,
AGENT_SCOPE,
readConfigFile,
readConfigLayers,
getJsonEntrySource,
getJsonWriteTarget,
writeConfig,
} from './shared.js';
// ============== MCP CONFIG HELPERS ==============
/**
* Validate MCP server name
*/
function validateMcpName(name) {
if (!name || typeof name !== 'string') {
throw new Error('MCP server name is required');
}
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
throw new Error('MCP server name must be lowercase alphanumeric with hyphens/underscores');
}
}
/**
* List all MCP server configs from user-level opencode.json
*/
function resolveMcpScopeFromPath(layers, sourcePath) {
if (!sourcePath) return null;
return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
}
function ensureProjectMcpConfigPath(workingDirectory) {
const configDir = path.join(workingDirectory, '.opencode');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
return path.join(configDir, 'opencode.json');
}
function listMcpConfigs(workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const mcp = layers?.mergedConfig?.mcp || {};
return Object.entries(mcp)
.filter(([, entry]) => entry && typeof entry === 'object' && !Array.isArray(entry))
.map(([name, entry]) => {
const source = getJsonEntrySource(layers, 'mcp', name);
return {
name,
...buildMcpEntry(entry),
scope: resolveMcpScopeFromPath(layers, source.path),
};
});
}
/**
* Get a single MCP server config by name
*/
function getMcpConfig(name, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const entry = layers?.mergedConfig?.mcp?.[name];
if (!entry) {
return null;
}
const source = getJsonEntrySource(layers, 'mcp', name);
return {
name,
...buildMcpEntry(entry),
scope: resolveMcpScopeFromPath(layers, source.path),
};
}
/**
* Create a new MCP server config entry
*/
function createMcpConfig(name, mcpConfig, workingDirectory, scope) {
validateMcpName(name);
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
if (source.exists) {
throw new Error(`MCP server "${name}" already exists`);
}
let targetPath = CONFIG_FILE;
let config = {};
if (scope === AGENT_SCOPE.PROJECT) {
if (!workingDirectory) {
throw new Error('Project scope requires working directory');
}
targetPath = ensureProjectMcpConfigPath(workingDirectory);
config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
} else {
const jsonTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
targetPath = jsonTarget.path || CONFIG_FILE;
config = jsonTarget.config || {};
}
if (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
config.mcp = {};
}
const { name: _ignoredName, ...entryData } = mcpConfig;
config.mcp[name] = buildMcpEntry(entryData);
writeConfig(config, targetPath);
console.log(`Created MCP server config: ${name}`);
}
/**
* Update an existing MCP server config entry
*/
function updateMcpConfig(name, updates, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
const targetPath = source.path || CONFIG_FILE;
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
if (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
config.mcp = {};
}
const existing = config.mcp[name] ?? {};
const { name: _ignoredName, ...updateData } = updates;
config.mcp[name] = buildMcpEntry({ ...existing, ...updateData });
writeConfig(config, targetPath);
console.log(`Updated MCP server config: ${name}`);
}
/**
* Delete an MCP server config entry
*/
function deleteMcpConfig(name, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
const targetPath = source.path || CONFIG_FILE;
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
if (!config.mcp || typeof config.mcp !== 'object' || config.mcp[name] === undefined) {
throw new Error(`MCP server "${name}" not found`);
}
delete config.mcp[name];
if (Object.keys(config.mcp).length === 0) {
delete config.mcp;
}
writeConfig(config, targetPath);
console.log(`Deleted MCP server config: ${name}`);
}
/**
* Build a clean MCP entry object, omitting undefined/null values
*/
function buildMcpEntry(data) {
const entry = {};
// type is required
entry.type = data.type === 'remote' ? 'remote' : 'local';
if (entry.type === 'local') {
// command must be a non-empty array of strings
if (Array.isArray(data.command) && data.command.length > 0) {
entry.command = data.command.map(String);
}
} else {
// remote: url required
if (data.url && typeof data.url === 'string') {
entry.url = data.url.trim();
}
}
// environment: flat Record<string, string>
if (data.environment && typeof data.environment === 'object' && !Array.isArray(data.environment)) {
const cleaned = {};
for (const [k, v] of Object.entries(data.environment)) {
if (k && v !== undefined && v !== null) {
cleaned[k] = String(v);
}
}
if (Object.keys(cleaned).length > 0) {
entry.environment = cleaned;
}
}
// enabled defaults to true
entry.enabled = data.enabled !== false;
return entry;
}
export {
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
};

View File

@@ -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,
};

View File

@@ -0,0 +1,530 @@
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 assertPathWithinSkillDir(skillDir, relativePath) {
const root = fs.realpathSync(skillDir);
const target = path.resolve(root, relativePath);
const relative = path.relative(root, target);
const isWithin = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
if (!isWithin) {
const error = new Error('Access to file denied');
error.code = 'EACCES';
throw error;
}
return target;
}
function readSkillSupportingFile(skillDir, relativePath) {
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
if (!fs.existsSync(fullPath)) {
return null;
}
return fs.readFileSync(fullPath, 'utf8');
}
function writeSkillSupportingFile(skillDir, relativePath, content) {
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
const dir = path.dirname(fullPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function deleteSkillSupportingFile(skillDir, relativePath) {
const root = fs.realpathSync(skillDir);
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
let parentDir = path.dirname(fullPath);
while (parentDir !== root) {
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,
};

View File

@@ -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,
};

View File

@@ -0,0 +1,510 @@
import crypto from 'crypto';
import { SignJWT, jwtVerify } from 'jose';
import fs from 'fs';
import path from 'path';
import os from 'os';
const SESSION_COOKIE_NAME = 'oc_ui_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
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;
const loginRateLimiter = new Map();
let rateLimitCleanupTimer = null;
const rateLimitLocks = new Map();
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
const ip = forwarded.split(',')[0].trim();
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
const ip = req.ip || req.connection?.remoteAddress;
if (ip) {
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
return null;
};
const getRateLimitKey = (req) => {
const ip = getClientIp(req);
if (ip) return ip;
return 'rate-limit:no-ip';
};
const getRateLimitConfig = (key) => {
if (key === 'rate-limit:no-ip') {
return {
maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
windowMs: RATE_LIMIT_WINDOW_MS
};
}
return {
maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
windowMs: RATE_LIMIT_WINDOW_MS
};
};
const acquireRateLimitLock = async (key) => {
const prev = rateLimitLocks.get(key) || Promise.resolve();
const curr = prev.then(() => rateLimitLocks.delete(key));
rateLimitLocks.set(key, curr);
await curr;
};
const checkRateLimit = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
const now = Date.now();
const { maxAttempts } = getRateLimitConfig(key);
let record;
try {
record = loginRateLimiter.get(key);
} catch (err) {
console.error('[RateLimit] Failed to get record', { key, error: err.message });
return {
allowed: true,
limit: maxAttempts,
remaining: maxAttempts,
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
};
}
if (record?.lockedUntil && now < record.lockedUntil) {
return {
allowed: false,
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
locked: true,
limit: maxAttempts,
remaining: 0,
reset: Math.ceil(record.lockedUntil / 1000)
};
}
if (record?.lockedUntil && now >= record.lockedUntil) {
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
}
}
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
return {
allowed: true,
limit: maxAttempts,
remaining: maxAttempts,
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
};
}
if (record.count >= maxAttempts) {
const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
try {
loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
} catch (err) {
console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
}
return {
allowed: false,
retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
locked: true,
limit: maxAttempts,
remaining: 0,
reset: Math.ceil(lockedUntil / 1000)
};
}
const remaining = maxAttempts - record.count;
const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
return {
allowed: true,
limit: maxAttempts,
remaining,
reset
};
};
const recordFailedAttempt = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
const now = Date.now();
const { maxAttempts } = getRateLimitConfig(key);
const record = loginRateLimiter.get(key);
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
try {
loginRateLimiter.set(key, { count: 1, lastAttempt: now });
} catch (err) {
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
}
} else {
const newCount = record.count + 1;
try {
loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
} catch (err) {
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
}
}
};
const clearRateLimit = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Failed to clear', { key, error: err.message });
}
};
const cleanupRateLimitRecords = () => {
const now = Date.now();
for (const [key, record] of loginRateLimiter.entries()) {
const isExpired = record.lockedUntil && now >= record.lockedUntil;
const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
if (isExpired || isStale) {
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Cleanup failed', { key, error: err.message });
}
}
}
};
const startRateLimitCleanup = () => {
if (!rateLimitCleanupTimer) {
rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
rateLimitCleanupTimer.unref();
}
}
};
const stopRateLimitCleanup = () => {
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
};
const isSecureRequest = (req) => {
if (req.secure) {
return true;
}
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
return firstProto === 'https';
}
return false;
};
const parseCookies = (cookieHeader) => {
if (!cookieHeader || typeof cookieHeader !== 'string') {
return {};
}
return cookieHeader.split(';').reduce((acc, segment) => {
const [name, ...rest] = segment.split('=');
if (!name) {
return acc;
}
const key = name.trim();
if (!key) {
return acc;
}
const value = rest.join('=').trim();
acc[key] = decodeURIComponent(value || '');
return acc;
}, {});
};
const buildCookie = ({
name,
value,
maxAge,
secure,
}) => {
const attributes = [
`${name}=${value}`,
'Path=/',
'HttpOnly',
'SameSite=Strict',
];
if (typeof maxAge === 'number') {
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
}
const expires = maxAge === 0
? 'Thu, 01 Jan 1970 00:00:00 GMT'
: new Date(Date.now() + maxAge * 1000).toUTCString();
attributes.push(`Expires=${expires}`);
if (secure) {
attributes.push('Secure');
}
return attributes.join('; ');
};
const normalizePassword = (candidate) => {
if (typeof candidate !== 'string') {
return '';
}
return candidate.normalize().trim();
};
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const JWT_SECRET_FILE = path.join(OPENCHAMBER_DATA_DIR, 'jwt-secret');
function getOrCreateJwtSecret() {
const envSecret = process.env.OPENCODE_JWT_SECRET;
if (envSecret) {
return new TextEncoder().encode(envSecret);
}
try {
if (fs.existsSync(JWT_SECRET_FILE)) {
return new TextEncoder().encode(fs.readFileSync(JWT_SECRET_FILE, 'utf8').trim());
}
} catch (e) {
console.warn('[JWT] Failed to read secret file:', e.message);
}
const secret = crypto.randomBytes(32).toString('hex');
try {
fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
console.log('[JWT] Generated and persisted new secret to', JWT_SECRET_FILE);
} catch (e) {
console.warn('[JWT] Failed to persist secret:', e.message);
}
return new TextEncoder().encode(secret);
}
export const createUiAuth = ({
password,
cookieName = SESSION_COOKIE_NAME,
sessionTtlMs = SESSION_TTL_MS,
} = {}) => {
const normalizedPassword = normalizePassword(password);
if (!normalizedPassword) {
const setSessionCookie = (req, res, token) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
maxAge: maxAgeSeconds,
secure,
});
res.setHeader('Set-Cookie', header);
};
const ensureSessionToken = async (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return cookies[cookieName];
}
const token = crypto.randomBytes(32).toString('base64url');
setSessionCookie(req, res, token);
return token;
};
return {
enabled: false,
requireAuth: (_req, _res, next) => next(),
handleSessionStatus: (_req, res) => {
res.json({ authenticated: true, disabled: true });
},
handleSessionCreate: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
ensureSessionToken,
dispose: () => {
},
};
}
const salt = crypto.randomBytes(16);
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
const JWT_SECRET = getOrCreateJwtSecret();
const getTokenFromRequest = (req) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return cookies[cookieName];
}
return null;
};
const setSessionCookie = (req, res, token) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
maxAge: maxAgeSeconds,
secure,
});
res.setHeader('Set-Cookie', header);
};
const clearSessionCookie = (req, res) => {
const secure = isSecureRequest(req);
const header = buildCookie({
name: cookieName,
value: '',
maxAge: 0,
secure,
});
res.setHeader('Set-Cookie', header);
};
const verifyPassword = (candidate) => {
if (!candidate) {
return false;
}
const normalizedCandidate = normalizePassword(candidate);
if (!normalizedCandidate) {
return false;
}
try {
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
return crypto.timingSafeEqual(candidateHash, expectedHash);
} catch {
return false;
}
};
const isSessionValid = async (token) => {
if (!token) {
return false;
}
try {
await jwtVerify(token, JWT_SECRET);
return true;
} catch {
return false;
}
};
const issueSession = async (req, res) => {
const token = await new SignJWT({ type: 'ui-session' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(sessionTtlMs / 1000 + 's')
.sign(JWT_SECRET);
setSessionCookie(req, res, token);
return token;
};
startRateLimitCleanup();
const respondUnauthorized = (req, res) => {
res.status(401);
const acceptsJson = req.headers.accept?.includes('application/json');
if (acceptsJson || req.path.startsWith('/api')) {
res.json({ error: 'UI authentication required', locked: true });
} else {
res.type('text/plain').send('Authentication required');
}
};
const requireAuth = async (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
return next();
}
clearSessionCookie(req, res);
return respondUnauthorized(req, res);
};
const handleSessionStatus = async (req, res) => {
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
res.json({ authenticated: true });
return;
}
clearSessionCookie(req, res);
res.status(401).json({ authenticated: false, locked: true });
};
const handleSessionCreate = async (req, res) => {
const rateLimitResult = await checkRateLimit(req);
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
if (!rateLimitResult.allowed) {
res.setHeader('Retry-After', rateLimitResult.retryAfter);
res.status(429).json({
error: 'Too many login attempts, please try again later',
retryAfter: rateLimitResult.retryAfter
});
return;
}
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
if (!verifyPassword(candidate)) {
await recordFailedAttempt(req);
clearSessionCookie(req, res);
res.status(401).json({ error: 'Invalid credentials' });
return;
}
await clearRateLimit(req);
await issueSession(req, res);
res.json({ authenticated: true });
};
const dispose = () => {
loginRateLimiter.clear();
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
};
return {
enabled: true,
requireAuth,
handleSessionStatus,
handleSessionCreate,
ensureSessionToken: async (req, _res) => {
const token = getTokenFromRequest(req);
return (await isSessionValid(token)) ? token : null;
},
dispose,
};
};