Files
openchamber/packages/web/server/lib/opencode-config.js
T
Bohdan Triapitsyn c4b9c18c24 feat: enhance agent management with project-level support
- Added support for project-level agents, allowing agents to be created, updated, and deleted within a specified working directory.
- Introduced `getAgentScope` function to determine the scope of agents based on their existence in project or user directories.
- Updated `getAgentSources`, `createAgent`, `updateAgent`, and `deleteAgent` functions to handle project-level paths and configurations.
- Enhanced logging to provide clearer information about agent operations, including scope and working directory.
- Refactored configuration reading and writing to accommodate project-specific configurations alongside user-level settings.
2025-12-29 22:42:10 +02:00

899 lines
28 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
import stripJsonComments from 'strip-json-comments';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// Scope types (shared by agents and commands)
const AGENT_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
const COMMAND_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
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 });
}
}
// ============== AGENT SCOPE HELPERS ==============
/**
* Ensure project-level agent directory exists
*/
function ensureProjectAgentDir(workingDirectory) {
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
if (!fs.existsSync(projectAgentDir)) {
fs.mkdirSync(projectAgentDir, { recursive: true });
}
return projectAgentDir;
}
/**
* Get project-level agent path
*/
function getProjectAgentPath(workingDirectory, agentName) {
return path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
}
/**
* Get user-level agent path
*/
function getUserAgentPath(agentName) {
return path.join(AGENT_DIR, `${agentName}.md`);
}
/**
* Determine agent scope based on where the .md file exists
* Priority: project level > user level > null (built-in only)
*/
function getAgentScope(agentName, workingDirectory) {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
}
}
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
return { scope: AGENT_SCOPE.USER, path: userPath };
}
return { scope: null, path: null };
}
/**
* Get the path where an agent should be written based on scope
*/
function getAgentWritePath(agentName, workingDirectory, requestedScope) {
// For updates: check existing location first (project takes precedence)
const existing = getAgentScope(agentName, workingDirectory);
if (existing.path) {
return existing;
}
// For new agents or built-in overrides: use requested scope or default to user
const scope = requestedScope || AGENT_SCOPE.USER;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
return {
scope: AGENT_SCOPE.PROJECT,
path: getProjectAgentPath(workingDirectory, agentName)
};
}
return {
scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName)
};
}
// ============== COMMAND SCOPE HELPERS ==============
/**
* Ensure project-level command directory exists
*/
function ensureProjectCommandDir(workingDirectory) {
const projectCommandDir = path.join(workingDirectory, '.opencode', 'command');
if (!fs.existsSync(projectCommandDir)) {
fs.mkdirSync(projectCommandDir, { recursive: true });
}
return projectCommandDir;
}
/**
* Get project-level command path
*/
function getProjectCommandPath(workingDirectory, commandName) {
return path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`);
}
/**
* Get user-level command path
*/
function getUserCommandPath(commandName) {
return path.join(COMMAND_DIR, `${commandName}.md`);
}
/**
* 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 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}`);
}
function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return null;
return path.join(workingDirectory, 'opencode.json');
}
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 = stripJsonComments(content).trim();
if (!normalized) {
return {};
}
return JSON.parse(normalized);
} 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 writeConfig(config, filePath = CONFIG_FILE) {
try {
if (fs.existsSync(filePath)) {
const backupFile = `${filePath}.openchamber.backup`;
fs.copyFileSync(filePath, backupFile);
console.log(`Created config backup: ${backupFile}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
console.log(`Successfully wrote config file: ${filePath}`);
} catch (error) {
console.error(`Failed to write config file: ${filePath}`, error);
throw new Error('Failed to write OpenCode configuration');
}
}
function getJsonEntrySource(layers, sectionKey, entryName) {
const { userConfig, projectConfig, customConfig, paths } = layers;
const customSection = customConfig?.[sectionKey]?.[entryName];
if (customSection !== undefined) {
return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
}
const projectSection = projectConfig?.[sectionKey]?.[entryName];
if (projectSection !== undefined) {
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
}
const userSection = userConfig?.[sectionKey]?.[entryName];
if (userSection !== undefined) {
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
}
return { section: null, config: null, path: null, exists: false };
}
function getJsonWriteTarget(layers, preferredScope) {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
if (paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath };
}
function parseMdFile(filePath) {
try {
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() };
}
const frontmatter = yaml.parse(match[1]) || {};
const body = match[2].trim();
return { frontmatter, body };
} catch (error) {
console.error(`Failed to parse markdown file ${filePath}:`, error);
throw new Error('Failed to parse agent markdown file');
}
}
function writeMdFile(filePath, frontmatter, body) {
try {
// Filter out null/undefined values - OpenCode expects keys to be omitted rather than set to null
const cleanedFrontmatter = Object.fromEntries(
Object.entries(frontmatter).filter(([, value]) => value != null)
);
const yamlStr = yaml.stringify(cleanedFrontmatter);
const content = `---\n${yamlStr}---\n\n${body}`;
fs.writeFileSync(filePath, content, 'utf8');
console.log(`Successfully wrote markdown file: ${filePath}`);
} catch (error) {
console.error(`Failed to write markdown file ${filePath}:`, error);
throw new Error('Failed to write agent markdown file');
}
}
function getAgentSources(agentName, workingDirectory) {
// Check project level first (takes precedence)
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
// Then check user level
const userPath = getUserAgentPath(agentName);
const userExists = fs.existsSync(userPath);
// Determine which md file to use (project takes precedence)
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
const mdExists = !!mdPath;
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const jsonSection = jsonSource.section;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
const sources = {
md: {
exists: mdExists,
path: mdPath,
scope: mdScope,
fields: []
},
json: {
exists: jsonSource.exists,
path: jsonPath,
scope: jsonSource.exists ? jsonScope : null,
fields: []
},
json: {
exists: jsonSource.exists,
path: jsonPath,
scope: jsonSource.exists ? jsonScope : null,
fields: []
},
// Additional info about both levels
projectMd: {
exists: projectExists,
path: projectPath
},
userMd: {
exists: userExists,
path: userPath
}
};
if (mdExists) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) {
sources.md.fields.push('prompt');
}
}
if (jsonSection) {
sources.json.fields = Object.keys(jsonSection);
}
return sources;
}
function createAgent(agentName, config, workingDirectory, scope) {
ensureDirs();
// Check if agent already exists at either level
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const userPath = getUserAgentPath(agentName);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
}
if (fs.existsSync(userPath)) {
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
}
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists) {
throw new Error(`Agent ${agentName} already exists in opencode.json`);
}
// Determine target path based on requested scope
let targetPath;
let targetScope;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
ensureProjectAgentDir(workingDirectory);
targetPath = projectPath;
targetScope = AGENT_SCOPE.PROJECT;
} else {
targetPath = userPath;
targetScope = AGENT_SCOPE.USER;
}
// Extract scope and prompt from config - scope is only used for path determination, not written to file
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
writeMdFile(targetPath, frontmatter, prompt || '');
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
}
function updateAgent(agentName, updates, workingDirectory) {
ensureDirs();
// Determine correct path: project level takes precedence
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory);
const mdExists = mdPath && fs.existsSync(mdPath);
// Check if agent exists in opencode.json across all config layers
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const jsonSection = jsonSource.section;
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
let config = jsonTarget.config || {};
// Determine if we should create a new md file:
// Only for built-in agents (no md file AND no json config)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
let targetScope = scope;
if (!mdExists && isBuiltinOverride) {
// Built-in agent override - create at user level
targetPath = getUserAgentPath(agentName);
targetScope = AGENT_SCOPE.USER;
}
// Only create md data for existing md files or built-in overrides
let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
let mdModified = false;
let jsonModified = false;
// Only create new md if it's a built-in override
let creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates)) {
if (field === 'prompt') {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
} else if (isPromptFileReference(jsonSection?.prompt)) {
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
if (!promptFilePath) {
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
}
writePromptFile(promptFilePath, normalizedValue);
continue;
} else if (isPromptFileReference(normalizedValue)) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
continue;
}
// For JSON-only agents, store prompt inline in JSON
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
continue;
}
const inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[field] !== undefined;
// JSON takes precedence over md, so update JSON first if field exists there
if (inJson) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
} else if (inMd || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
} else {
// New field - add to the appropriate location based on agent source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
// JSON-only agent or has JSON fields - add to JSON
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
}
}
}
if (mdModified && mdData) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
}
function deleteAgent(agentName, workingDirectory) {
let deleted = false;
// Check project level first (takes precedence)
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
fs.unlinkSync(projectPath);
console.log(`Deleted project-level agent .md file: ${projectPath}`);
deleted = true;
}
}
// Then check user level
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
console.log(`Deleted user-level agent .md file: ${userPath}`);
deleted = true;
}
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
if (!jsonSource.config.agent) jsonSource.config.agent = {};
delete jsonSource.config.agent[agentName];
writeConfig(jsonSource.config, jsonSource.path);
console.log(`Removed agent from opencode.json: ${agentName}`);
deleted = true;
}
// If nothing was deleted (built-in agent), disable it in highest-precedence config
if (!deleted) {
const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
const targetConfig = jsonTarget.config || {};
if (!targetConfig.agent) targetConfig.agent = {};
targetConfig.agent[agentName] = { disable: true };
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
console.log(`Disabled built-in agent: ${agentName}`);
}
}
function getCommandSources(commandName, workingDirectory) {
// Check project level first (takes precedence)
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
// Then check user level
const userPath = getUserCommandPath(commandName);
const userExists = fs.existsSync(userPath);
// Determine which md file to use (project takes precedence)
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
const mdExists = !!mdPath;
const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const sources = {
md: {
exists: mdExists,
path: mdPath,
scope: mdScope,
fields: []
},
json: {
exists: jsonSource.exists,
path: jsonPath,
fields: []
},
// Additional info about both levels
projectMd: {
exists: projectExists,
path: projectPath
},
userMd: {
exists: userExists,
path: userPath
}
};
if (mdExists) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) {
sources.md.fields.push('template');
}
}
if (jsonSection) {
sources.json.fields = Object.keys(jsonSection);
}
return sources;
}
function createCommand(commandName, config, workingDirectory, scope) {
ensureDirs();
// Check if command already exists at either level
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
const userPath = getUserCommandPath(commandName);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Command ${commandName} already exists as project-level .md file`);
}
if (fs.existsSync(userPath)) {
throw new Error(`Command ${commandName} already exists as user-level .md file`);
}
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists) {
throw new Error(`Command ${commandName} already exists in opencode.json`);
}
// Determine target path based on requested scope
let targetPath;
let targetScope;
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
ensureProjectCommandDir(workingDirectory);
targetPath = projectPath;
targetScope = COMMAND_SCOPE.PROJECT;
} else {
targetPath = userPath;
targetScope = COMMAND_SCOPE.USER;
}
// Extract scope from config - it's only used for path determination, not written to file
const { template, scope: _scopeFromConfig, ...frontmatter } = config;
writeMdFile(targetPath, frontmatter, template || '');
console.log(`Created new command: ${commandName} (scope: ${targetScope}, path: ${targetPath})`);
}
function updateCommand(commandName, updates, workingDirectory) {
ensureDirs();
// Determine correct path: project level takes precedence
const { scope, path: mdPath } = getCommandWritePath(commandName, workingDirectory);
const mdExists = mdPath && fs.existsSync(mdPath);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section;
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER);
let config = jsonTarget.config || {};
// Only create a new md file for built-in overrides (no md + no json)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
let targetScope = scope;
if (!mdExists && isBuiltinOverride) {
// Built-in command override - create at user level
targetPath = getUserCommandPath(commandName);
targetScope = COMMAND_SCOPE.USER;
}
const mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
let mdModified = false;
let jsonModified = false;
let creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates)) {
if (field === 'template') {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
} else if (isPromptFileReference(jsonSection?.template)) {
const templateFilePath = resolvePromptFilePath(jsonSection.template);
if (!templateFilePath) {
throw new Error(`Invalid template file reference for command ${commandName}`);
}
writePromptFile(templateFilePath, normalizedValue);
continue;
} else if (isPromptFileReference(normalizedValue)) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName].template = normalizedValue;
jsonModified = true;
continue;
}
// For JSON-only commands, store template inline in JSON
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName].template = normalizedValue;
jsonModified = true;
continue;
}
const inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[field] !== undefined;
// JSON takes precedence over md, so update JSON first if field exists there
if (inJson) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName][field] = value;
jsonModified = true;
} else if (inMd || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
} else {
// New field - add to appropriate location based on command source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName][field] = value;
jsonModified = true;
}
}
}
if (mdModified && mdData) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
}
function deleteCommand(commandName, workingDirectory) {
let deleted = false;
// Check project level first (takes precedence)
if (workingDirectory) {
const projectPath = getProjectCommandPath(workingDirectory, commandName);
if (fs.existsSync(projectPath)) {
fs.unlinkSync(projectPath);
console.log(`Deleted project-level command .md file: ${projectPath}`);
deleted = true;
}
}
// Then check user level
const userPath = getUserCommandPath(commandName);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
console.log(`Deleted user-level command .md file: ${userPath}`);
deleted = true;
}
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
if (!jsonSource.config.command) jsonSource.config.command = {};
delete jsonSource.config.command[commandName];
writeConfig(jsonSource.config, jsonSource.path);
console.log(`Removed command from opencode.json: ${commandName}`);
deleted = true;
}
if (!deleted) {
throw new Error(`Command "${commandName}" not found`);
}
}
export {
getAgentSources,
getAgentScope,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
getCommandScope,
createCommand,
updateCommand,
deleteCommand,
readConfig,
writeConfig,
AGENT_DIR,
COMMAND_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE
};