Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
530
web/server/lib/opencode/shared.js
Normal file
530
web/server/lib/opencode/shared.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user