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