* fix: make textarea focus highlight render inside Apply inset focus ring to shared textarea component Prevent focus border from appearing clipped near container edges * fix: build desktop sidecar with target-matched architecture Map Tauri target triples to Bun compile targets Pass explicit Bun compile target for sidecar builds Prevent x86_64 releases from shipping arm64 sidecar binaries * fix: allow Windows git custom binary paths Enable safe use of resolved custom git executable paths Prevent git status failures when path contains restricted characters Keep default behavior unchanged for plain git invocations * fix: allow toggling diff line wrap on mobile Stops forcing wrapped lines in mobile diff view Line-wrap button now reflects and applies user preference * fix: align VS Code managed server env with shell settings Import login-shell environment variables before starting managed OpenCode Apply Windows and Unix shell snapshot resolution for parity Improve proxy-dependent provider connectivity in VS Code extension * fix: respect user scope when adding MCP servers Prevent user-scope MCP entries from being written to project config Keep project writes only for explicit project scope * fix: show linked GitHub issues and PRs as user message attachments Preserve synthetic issue/PR context parts during message filtering. Convert synthetic GitHub context JSON into attachment-style user parts. Open issue/PR attachment links via shared external URL helper. * fix: restore and polish project notes in sessions sidebar Restored the Notes button in the left sessions sidebar header Improved notes panel layout with wider dialog, larger notes area, and project name in the header Refined todo rows with inline expand/collapse text and stable action/checkbox alignment * fix: hide sidebar footer actions in VS Code runtime Remove Settings, About, and Shortcuts buttons from the sessions sidebar footer in VS Code Keep update button behavior unchanged across runtimes * fix: normalize Windows paths for VS Code session loading Canonicalize drive-letter casing in session path normalization Align VS Code workspace path persistence with the same Windows path format Normalize client directory context before API calls to keep session filtering consistent * fix: open linked GitHub attachments with shared URL helper Use runtime-aware external URL opening for issue/PR attachment links. Keep GitHub attachment labels readable without altering normal file name rendering. * fix: keep user MCP config writes out of project files Respect user scope when selecting config write target Prevent MCP user entries from being written to project opencode.json * fix: prevent project menu from overlapping new session button Align project menu positioning for non-git and git project rows Avoid kebab-menu and plus-button overlap in sessions sidebar
528 lines
14 KiB
JavaScript
528 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 };
|
|
}
|
|
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,
|
|
};
|