531 lines
14 KiB
JavaScript
531 lines
14 KiB
JavaScript
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,
|
|
};
|