* 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
1792 lines
63 KiB
TypeScript
1792 lines
63 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import yaml from 'yaml';
|
|
import { parse as parseJsonc } from 'jsonc-parser';
|
|
|
|
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 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)
|
|
export const AGENT_SCOPE = {
|
|
USER: 'user',
|
|
PROJECT: 'project'
|
|
} as const;
|
|
|
|
export const COMMAND_SCOPE = {
|
|
USER: 'user',
|
|
PROJECT: 'project'
|
|
} as const;
|
|
|
|
export type AgentScope = typeof AGENT_SCOPE[keyof typeof AGENT_SCOPE];
|
|
export type CommandScope = typeof COMMAND_SCOPE[keyof typeof COMMAND_SCOPE];
|
|
|
|
export type ConfigSources = {
|
|
md: { exists: boolean; path: string | null; fields: string[]; scope?: AgentScope | CommandScope | null };
|
|
json: { exists: boolean; path: string; fields: string[]; scope?: AgentScope | CommandScope | null };
|
|
projectMd?: { exists: boolean; path: string | null };
|
|
userMd?: { exists: boolean; path: string | null };
|
|
};
|
|
|
|
const 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 ==============
|
|
|
|
const ensureProjectAgentDir = (workingDirectory: string): string => {
|
|
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
|
|
if (!fs.existsSync(projectAgentDir)) {
|
|
fs.mkdirSync(projectAgentDir, { recursive: true });
|
|
}
|
|
const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
|
|
if (!fs.existsSync(legacyProjectAgentDir)) {
|
|
fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
|
|
}
|
|
return projectAgentDir;
|
|
};
|
|
|
|
const getProjectAgentPath = (workingDirectory: string, agentName: string): string => {
|
|
const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`);
|
|
const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
};
|
|
|
|
type AgentLookupCache = {
|
|
userAgentIndexByName: Map<string, string>;
|
|
userAgentLookupByName: Map<string, string | null>;
|
|
userAgentIndexReady: boolean;
|
|
userAgentIndexBuiltAt: number;
|
|
};
|
|
|
|
const AGENT_LOOKUP_CACHE_TTL_MS = 1000;
|
|
|
|
const createAgentLookupCache = (): AgentLookupCache => ({
|
|
userAgentIndexByName: new Map<string, string>(),
|
|
userAgentLookupByName: new Map<string, string | null>(),
|
|
userAgentIndexReady: false,
|
|
userAgentIndexBuiltAt: 0,
|
|
});
|
|
|
|
const globalAgentLookupCache = createAgentLookupCache();
|
|
|
|
const resetAgentLookupCache = (cache: AgentLookupCache): void => {
|
|
cache.userAgentIndexByName.clear();
|
|
cache.userAgentLookupByName.clear();
|
|
cache.userAgentIndexReady = false;
|
|
cache.userAgentIndexBuiltAt = 0;
|
|
};
|
|
|
|
const buildUserAgentIndex = (cache: AgentLookupCache): void => {
|
|
if (cache.userAgentIndexReady && Date.now() - cache.userAgentIndexBuiltAt < AGENT_LOOKUP_CACHE_TTL_MS) {
|
|
return;
|
|
}
|
|
|
|
cache.userAgentIndexByName.clear();
|
|
cache.userAgentLookupByName.clear();
|
|
cache.userAgentIndexReady = true;
|
|
cache.userAgentIndexBuiltAt = Date.now();
|
|
|
|
if (!fs.existsSync(AGENT_DIR)) return;
|
|
|
|
const dirsToVisit: string[] = [AGENT_DIR];
|
|
while (dirsToVisit.length > 0) {
|
|
const dir = dirsToVisit.pop();
|
|
if (!dir) continue;
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
const discoveredAgentName = entry.name.slice(0, -3);
|
|
if (!cache.userAgentIndexByName.has(discoveredAgentName)) {
|
|
cache.userAgentIndexByName.set(discoveredAgentName, path.join(dir, entry.name));
|
|
}
|
|
}
|
|
|
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
const entry = entries[i];
|
|
if (entry?.isDirectory()) {
|
|
dirsToVisit.push(path.join(dir, entry.name));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const getIndexedUserAgentPath = (agentName: string, cache: AgentLookupCache): string | null => {
|
|
if (cache.userAgentLookupByName.has(agentName)) {
|
|
return cache.userAgentLookupByName.get(agentName) || null;
|
|
}
|
|
|
|
buildUserAgentIndex(cache);
|
|
const found = cache.userAgentIndexByName.get(agentName) || null;
|
|
cache.userAgentLookupByName.set(agentName, found);
|
|
return found;
|
|
};
|
|
|
|
const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = globalAgentLookupCache): string => {
|
|
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
|
|
|
if (fs.existsSync(pluralPath)) return pluralPath;
|
|
|
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`);
|
|
if (fs.existsSync(legacyPath)) return legacyPath;
|
|
|
|
const found = getIndexedUserAgentPath(agentName, lookupCache);
|
|
if (found) return found;
|
|
|
|
return pluralPath;
|
|
};
|
|
|
|
export const getAgentScope = (
|
|
agentName: string,
|
|
workingDirectory?: string,
|
|
lookupCache: AgentLookupCache = globalAgentLookupCache
|
|
): { scope: AgentScope | null; path: string | null } => {
|
|
if (workingDirectory) {
|
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
if (fs.existsSync(projectPath)) {
|
|
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
|
|
}
|
|
}
|
|
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
|
if (fs.existsSync(userPath)) {
|
|
return { scope: AGENT_SCOPE.USER, path: userPath };
|
|
}
|
|
|
|
return { scope: null, path: null };
|
|
};
|
|
|
|
const getAgentWritePath = (
|
|
agentName: string,
|
|
workingDirectory?: string,
|
|
requestedScope?: AgentScope,
|
|
lookupCache: AgentLookupCache = globalAgentLookupCache
|
|
): { scope: AgentScope; path: string } => {
|
|
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
|
|
if (existing.path) {
|
|
return { scope: existing.scope!, path: existing.path };
|
|
}
|
|
|
|
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, lookupCache)
|
|
};
|
|
};
|
|
|
|
// ============== COMMAND SCOPE HELPERS ==============
|
|
|
|
const ensureProjectCommandDir = (workingDirectory: string): string => {
|
|
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;
|
|
};
|
|
|
|
const getProjectCommandPath = (workingDirectory: string, commandName: string): string => {
|
|
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;
|
|
};
|
|
|
|
const getUserCommandPath = (commandName: string): string => {
|
|
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;
|
|
};
|
|
|
|
export const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
|
|
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 };
|
|
};
|
|
|
|
const getCommandWritePath = (commandName: string, workingDirectory?: string, requestedScope?: CommandScope): { scope: CommandScope; path: string } => {
|
|
const existing = getCommandScope(commandName, workingDirectory);
|
|
if (existing.path) {
|
|
return { scope: existing.scope!, path: existing.path };
|
|
}
|
|
|
|
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)
|
|
};
|
|
};
|
|
|
|
const isPromptFileReference = (value: unknown): value is string => {
|
|
return typeof value === 'string' && PROMPT_FILE_PATTERN.test(value.trim());
|
|
};
|
|
|
|
const resolvePromptFilePath = (reference: string): string | null => {
|
|
const match = reference.trim().match(PROMPT_FILE_PATTERN);
|
|
if (!match?.[1]) return null;
|
|
let target = match[1].trim();
|
|
if (!target) return null;
|
|
|
|
if (target.startsWith('./')) {
|
|
target = path.join(OPENCODE_CONFIG_DIR, target.slice(2));
|
|
} else if (!path.isAbsolute(target)) {
|
|
target = path.join(OPENCODE_CONFIG_DIR, target);
|
|
}
|
|
|
|
return target;
|
|
};
|
|
|
|
const writePromptFile = (filePath: string, content: string) => {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
};
|
|
|
|
/**
|
|
* Get all possible project config paths in priority order
|
|
* Priority: root > .opencode/, json > jsonc
|
|
*/
|
|
const getProjectConfigCandidates = (workingDirectory?: string): string[] => {
|
|
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'),
|
|
];
|
|
};
|
|
|
|
/**
|
|
* Find existing project config file or return default path for new config
|
|
*/
|
|
const getProjectConfigPath = (workingDirectory?: string): string | null => {
|
|
if (!workingDirectory) return null;
|
|
|
|
const candidates = getProjectConfigCandidates(workingDirectory);
|
|
|
|
// Return first existing config file
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
// Default to root opencode.json for new configs
|
|
return candidates[0] || null;
|
|
};
|
|
|
|
const getConfigPaths = (workingDirectory?: string) => ({
|
|
userPath: CONFIG_FILE,
|
|
projectPath: getProjectConfigPath(workingDirectory),
|
|
customPath: CUSTOM_CONFIG_FILE
|
|
});
|
|
|
|
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
|
|
if (!filePath || !fs.existsSync(filePath)) return {};
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const normalized = content.trim();
|
|
if (!normalized) return {};
|
|
return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record<string, unknown>;
|
|
};
|
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
const mergeConfigs = (base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = { ...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;
|
|
};
|
|
|
|
const readConfigLayers = (workingDirectory?: string) => {
|
|
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 }
|
|
};
|
|
};
|
|
|
|
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
|
|
readConfigLayers(workingDirectory).mergedConfig;
|
|
|
|
const getAncestors = (startDir?: string, stopDir?: string): string[] => {
|
|
if (!startDir) return [];
|
|
const result: string[] = [];
|
|
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;
|
|
};
|
|
|
|
const findWorktreeRoot = (startDir?: string): string | null => {
|
|
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;
|
|
}
|
|
};
|
|
|
|
const walkSkillMdFiles = (rootDir?: string | null): string[] => {
|
|
if (!rootDir || !fs.existsSync(rootDir)) return [];
|
|
|
|
const results: string[] = [];
|
|
const walkDir = (dir: string) => {
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walkDir(fullPath);
|
|
continue;
|
|
}
|
|
if (entry.isFile() && entry.name === 'SKILL.md') {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
};
|
|
|
|
walkDir(rootDir);
|
|
return results;
|
|
};
|
|
|
|
const resolveSkillSearchDirectories = (workingDirectory?: string): string[] => {
|
|
const directories: string[] = [];
|
|
const pushDir = (dir?: string | null) => {
|
|
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'));
|
|
pushDir(process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null);
|
|
|
|
return directories;
|
|
};
|
|
|
|
const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPath?: string | null) => {
|
|
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;
|
|
};
|
|
|
|
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
|
|
if (fs.existsSync(filePath)) {
|
|
const backupFile = `${filePath}.openchamber.backup`;
|
|
try {
|
|
fs.copyFileSync(filePath, backupFile);
|
|
} catch {
|
|
// ignore backup failures
|
|
}
|
|
}
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
};
|
|
|
|
export type McpLocalConfig = {
|
|
type: 'local';
|
|
command?: string[];
|
|
environment?: Record<string, string>;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type McpRemoteConfig = {
|
|
type: 'remote';
|
|
url?: string;
|
|
environment?: Record<string, string>;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type McpConfigPayload = McpLocalConfig | McpRemoteConfig;
|
|
|
|
export type McpConfigEntry = {
|
|
name: string;
|
|
scope?: AgentScope | null;
|
|
type: 'local' | 'remote';
|
|
command?: string[];
|
|
url?: string;
|
|
environment?: Record<string, string>;
|
|
enabled: boolean;
|
|
};
|
|
|
|
const resolveMcpScopeFromPath = (layers: ReturnType<typeof readConfigLayers>, sourcePath?: string | null): AgentScope | null => {
|
|
if (!sourcePath) return null;
|
|
return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
|
};
|
|
|
|
const ensureProjectMcpConfigPath = (workingDirectory: string): string => {
|
|
const projectConfigDir = path.join(workingDirectory, '.opencode');
|
|
if (!fs.existsSync(projectConfigDir)) {
|
|
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
}
|
|
return path.join(projectConfigDir, 'opencode.json');
|
|
};
|
|
|
|
const validateMcpName = (name: string): void => {
|
|
if (!name || typeof name !== 'string') {
|
|
throw new Error('MCP server name is required');
|
|
}
|
|
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
|
throw new Error('MCP server name must be lowercase alphanumeric with hyphens/underscores');
|
|
}
|
|
};
|
|
|
|
const buildMcpEntry = (data: Record<string, unknown>): Omit<McpConfigEntry, 'name'> => {
|
|
const entry: Omit<McpConfigEntry, 'name'> = {
|
|
type: data.type === 'remote' ? 'remote' : 'local',
|
|
enabled: data.enabled !== false,
|
|
};
|
|
|
|
if (entry.type === 'local') {
|
|
if (Array.isArray(data.command) && data.command.length > 0) {
|
|
entry.command = data.command.map((value) => String(value));
|
|
}
|
|
} else if (typeof data.url === 'string' && data.url.trim()) {
|
|
entry.url = data.url.trim();
|
|
}
|
|
|
|
if (isPlainObject(data.environment)) {
|
|
const cleaned: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(data.environment)) {
|
|
if (key && value != null) {
|
|
cleaned[key] = String(value);
|
|
}
|
|
}
|
|
if (Object.keys(cleaned).length > 0) {
|
|
entry.environment = cleaned;
|
|
}
|
|
}
|
|
|
|
return entry;
|
|
};
|
|
|
|
export const listMcpConfigs = (workingDirectory?: string): McpConfigEntry[] => {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const merged = (layers.mergedConfig as Record<string, unknown>) || {};
|
|
const mcp = isPlainObject(merged.mcp) ? merged.mcp : {};
|
|
return Object.entries(mcp)
|
|
.filter(([, value]) => isPlainObject(value))
|
|
.map(([name, value]) => {
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
return {
|
|
name,
|
|
...buildMcpEntry(value as Record<string, unknown>),
|
|
scope: resolveMcpScopeFromPath(layers, source.path),
|
|
};
|
|
});
|
|
};
|
|
|
|
export const getMcpConfig = (name: string, workingDirectory?: string): McpConfigEntry | null => {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const merged = (layers.mergedConfig as Record<string, unknown>) || {};
|
|
const mcp = isPlainObject(merged.mcp) ? merged.mcp : {};
|
|
const entry = mcp[name];
|
|
if (!isPlainObject(entry)) {
|
|
return null;
|
|
}
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
return {
|
|
name,
|
|
...buildMcpEntry(entry as Record<string, unknown>),
|
|
scope: resolveMcpScopeFromPath(layers, source.path),
|
|
};
|
|
};
|
|
|
|
export const createMcpConfig = (
|
|
name: string,
|
|
mcpConfig: Record<string, unknown>,
|
|
workingDirectory?: string,
|
|
scope?: AgentScope,
|
|
): void => {
|
|
validateMcpName(name);
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
if (source.exists) {
|
|
throw new Error(`MCP server "${name}" already exists`);
|
|
}
|
|
|
|
let targetPath = CONFIG_FILE;
|
|
let config: Record<string, unknown> = {};
|
|
|
|
if (scope === AGENT_SCOPE.PROJECT) {
|
|
if (!workingDirectory) {
|
|
throw new Error('Project scope requires working directory');
|
|
}
|
|
targetPath = ensureProjectMcpConfigPath(workingDirectory);
|
|
config = readConfigFile(targetPath);
|
|
} else {
|
|
const jsonTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
targetPath = jsonTarget.path || CONFIG_FILE;
|
|
config = (jsonTarget.config || {}) as Record<string, unknown>;
|
|
}
|
|
|
|
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
|
|
|
const { name: _ignoredName, ...entryData } = mcpConfig;
|
|
void _ignoredName;
|
|
mcp[name] = buildMcpEntry(entryData);
|
|
config.mcp = mcp;
|
|
writeConfig(config, targetPath);
|
|
};
|
|
|
|
export const updateMcpConfig = (name: string, updates: Record<string, unknown>, workingDirectory?: string): void => {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
const targetPath = source.path || CONFIG_FILE;
|
|
const config = (source.config || readConfigFile(targetPath)) as Record<string, unknown>;
|
|
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
|
const existing = isPlainObject(mcp[name]) ? mcp[name] : {};
|
|
|
|
const { name: _ignoredName, ...updateData } = updates;
|
|
void _ignoredName;
|
|
mcp[name] = buildMcpEntry({ ...(existing as Record<string, unknown>), ...updateData });
|
|
config.mcp = mcp;
|
|
writeConfig(config, targetPath);
|
|
};
|
|
|
|
export const deleteMcpConfig = (name: string, workingDirectory?: string): void => {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
const targetPath = source.path || CONFIG_FILE;
|
|
const config = (source.config || readConfigFile(targetPath)) as Record<string, unknown>;
|
|
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
|
|
|
if (mcp[name] === undefined) {
|
|
throw new Error(`MCP server "${name}" not found`);
|
|
}
|
|
|
|
delete mcp[name];
|
|
if (Object.keys(mcp).length === 0) {
|
|
delete config.mcp;
|
|
} else {
|
|
config.mcp = mcp;
|
|
}
|
|
|
|
writeConfig(config, targetPath);
|
|
};
|
|
|
|
const getJsonEntrySource = (
|
|
layers: ReturnType<typeof readConfigLayers>,
|
|
sectionKey: 'agent' | 'command' | 'mcp',
|
|
entryName: string
|
|
) => {
|
|
const { userConfig, projectConfig, customConfig, paths } = layers;
|
|
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
|
if (customSection?.[entryName] !== undefined) {
|
|
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
|
|
}
|
|
|
|
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
|
if (projectSection?.[entryName] !== undefined) {
|
|
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
|
|
}
|
|
|
|
const userSection = (userConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
|
if (userSection?.[entryName] !== undefined) {
|
|
return { section: userSection[entryName], config: userConfig, path: paths.userPath, exists: true };
|
|
}
|
|
|
|
return { section: null, config: null, path: null, exists: false };
|
|
};
|
|
|
|
const getJsonWriteTarget = (
|
|
layers: ReturnType<typeof readConfigLayers>,
|
|
preferredScope: AgentScope | CommandScope
|
|
) => {
|
|
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 };
|
|
};
|
|
|
|
const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>; body: string } => {
|
|
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: Record<string, unknown> = {};
|
|
try {
|
|
frontmatter = (yaml.parse(match[1]) || {}) as Record<string, unknown>;
|
|
} catch (error) {
|
|
console.warn(`[OpenChamber][VSCode] Failed to parse frontmatter for ${filePath}, treating as empty:`, error);
|
|
frontmatter = {};
|
|
}
|
|
return { frontmatter, body: (match[2] || '').trim() };
|
|
};
|
|
|
|
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
|
|
// 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 ?? ''}`.trimEnd();
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
};
|
|
|
|
export const getAgentSources = (agentName: string, workingDirectory?: string): ConfigSources => {
|
|
// Check project level first (takes precedence)
|
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
|
|
|
|
// 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 agentSection = jsonSource.section as Record<string, unknown> | undefined;
|
|
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: ConfigSources = {
|
|
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
|
|
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
|
|
projectMd: { exists: projectExists, path: projectPath },
|
|
userMd: { exists: userExists, path: userPath }
|
|
};
|
|
|
|
if (mdExists && mdPath) {
|
|
const { frontmatter, body } = parseMdFile(mdPath);
|
|
sources.md.fields = Object.keys(frontmatter);
|
|
if (body) sources.md.fields.push('prompt');
|
|
}
|
|
|
|
if (agentSection) {
|
|
sources.json.fields = Object.keys(agentSection);
|
|
}
|
|
|
|
return sources;
|
|
};
|
|
|
|
export const createAgent = (agentName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: AgentScope) => {
|
|
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: string;
|
|
|
|
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
|
ensureProjectAgentDir(workingDirectory);
|
|
targetPath = projectPath!;
|
|
} else {
|
|
targetPath = userPath;
|
|
}
|
|
|
|
// Extract scope and prompt from config - scope is only used for path determination, not written to file
|
|
const { prompt, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
|
|
void _ignored; // Scope is only used for path determination
|
|
writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : '');
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
};
|
|
|
|
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
|
|
ensureDirs();
|
|
|
|
// Determine correct path: project level takes precedence
|
|
const { path: mdPath } = getAgentWritePath(agentName, workingDirectory);
|
|
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
|
|
|
|
// 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 as Record<string, unknown> | undefined;
|
|
const hasJsonFields = Boolean(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);
|
|
const config = (jsonTarget.config || {}) as Record<string, unknown>;
|
|
|
|
// 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;
|
|
|
|
if (!mdExists && isBuiltinOverride) {
|
|
// Built-in agent override - create at user level
|
|
targetPath = getUserAgentPath(agentName);
|
|
}
|
|
|
|
// Only create md data for existing md files or built-in overrides
|
|
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, body: '' } : null);
|
|
|
|
let mdModified = false;
|
|
let jsonModified = false;
|
|
// Only create new md if it's a built-in override
|
|
const 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// For JSON-only agents, store prompt inline in JSON
|
|
if (!config.agent) config.agent = {};
|
|
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
|
|
(config.agent as Record<string, unknown>)[agentName] = { ...current, prompt: normalizedValue };
|
|
jsonModified = true;
|
|
continue;
|
|
}
|
|
|
|
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
|
|
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
|
|
|
|
// JSON takes precedence over md, so update JSON first if field exists there
|
|
if (hasJsonField) {
|
|
if (!config.agent) config.agent = {};
|
|
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
|
|
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
|
|
jsonModified = true;
|
|
continue;
|
|
}
|
|
|
|
if (hasMdField || creatingNewMd) {
|
|
if (mdData) {
|
|
mdData.frontmatter[field] = value;
|
|
mdModified = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// New field - add to appropriate location based on agent source
|
|
if ((mdExists || creatingNewMd) && mdData) {
|
|
mdData.frontmatter[field] = value;
|
|
mdModified = true;
|
|
} else {
|
|
if (!config.agent) config.agent = {};
|
|
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
|
|
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
|
|
jsonModified = true;
|
|
}
|
|
}
|
|
|
|
if (mdModified && mdData && targetPath) {
|
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
|
}
|
|
|
|
if (jsonModified) {
|
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
|
}
|
|
|
|
if (mdModified || isBuiltinOverride) {
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
}
|
|
};
|
|
|
|
export const deleteAgent = (agentName: string, workingDirectory?: string) => {
|
|
let deleted = false;
|
|
|
|
// Check project level first (takes precedence)
|
|
if (workingDirectory) {
|
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
if (fs.existsSync(projectPath)) {
|
|
fs.unlinkSync(projectPath);
|
|
deleted = true;
|
|
}
|
|
}
|
|
|
|
// Then check user level
|
|
const userPath = getUserAgentPath(agentName);
|
|
if (fs.existsSync(userPath)) {
|
|
fs.unlinkSync(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) {
|
|
const targetConfig = jsonSource.config as Record<string, unknown>;
|
|
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
|
|
delete agentMap[agentName];
|
|
targetConfig.agent = agentMap;
|
|
writeConfig(targetConfig, jsonSource.path);
|
|
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 || {}) as Record<string, unknown>;
|
|
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
|
|
agentMap[agentName] = { disable: true };
|
|
targetConfig.agent = agentMap;
|
|
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
|
|
}
|
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
};
|
|
|
|
export const getCommandSources = (commandName: string, workingDirectory?: string): ConfigSources => {
|
|
// Check project level first (takes precedence)
|
|
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
|
|
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
|
|
|
|
// 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 commandSection = jsonSource.section as Record<string, unknown> | undefined;
|
|
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: ConfigSources = {
|
|
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
|
|
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
|
|
projectMd: { exists: projectExists, path: projectPath },
|
|
userMd: { exists: userExists, path: userPath }
|
|
};
|
|
|
|
if (mdExists && mdPath) {
|
|
const { frontmatter, body } = parseMdFile(mdPath);
|
|
sources.md.fields = Object.keys(frontmatter);
|
|
if (body) sources.md.fields.push('template');
|
|
}
|
|
|
|
if (commandSection) {
|
|
sources.json.fields = Object.keys(commandSection);
|
|
}
|
|
|
|
return sources;
|
|
};
|
|
|
|
export const createCommand = (commandName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: CommandScope) => {
|
|
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: string;
|
|
|
|
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
|
|
ensureProjectCommandDir(workingDirectory);
|
|
targetPath = projectPath!;
|
|
} else {
|
|
targetPath = userPath;
|
|
}
|
|
|
|
// Extract scope from config - it's only used for path determination, not written to file
|
|
const { template, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { template?: unknown; scope?: unknown };
|
|
void _ignored; // Scope is only used for path determination
|
|
writeMdFile(targetPath, frontmatter, typeof template === 'string' ? template : '');
|
|
};
|
|
|
|
export const updateCommand = (commandName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
|
|
ensureDirs();
|
|
|
|
// Determine correct path: project level takes precedence
|
|
const { path: mdPath } = getCommandWritePath(commandName, workingDirectory);
|
|
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
|
const jsonSection = jsonSource.section as Record<string, unknown> | undefined;
|
|
const hasJsonFields = Boolean(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);
|
|
const config = (jsonTarget.config || {}) as Record<string, unknown>;
|
|
|
|
// Only create a new md file for built-in overrides (no md + no json)
|
|
const isBuiltinOverride = !mdExists && !hasJsonFields;
|
|
|
|
let targetPath = mdPath;
|
|
if (!mdExists && isBuiltinOverride) {
|
|
// Built-in command override - create at user level
|
|
targetPath = getUserCommandPath(commandName);
|
|
}
|
|
|
|
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// For JSON-only commands, store template inline in JSON
|
|
if (!config.command) config.command = {};
|
|
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
|
|
(config.command as Record<string, unknown>)[commandName] = { ...current, template: normalizedValue };
|
|
jsonModified = true;
|
|
continue;
|
|
}
|
|
|
|
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
|
|
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
|
|
|
|
// JSON takes precedence over md, so update JSON first if field exists there
|
|
if (hasJsonField) {
|
|
if (!config.command) config.command = {};
|
|
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
|
|
(config.command as Record<string, unknown>)[commandName] = { ...current, [field]: value };
|
|
jsonModified = true;
|
|
continue;
|
|
}
|
|
|
|
if (hasMdField || creatingNewMd) {
|
|
if (mdData) {
|
|
mdData.frontmatter[field] = value;
|
|
mdModified = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 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 = {};
|
|
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
|
|
(config.command as Record<string, unknown>)[commandName] = { ...current, [field]: value };
|
|
jsonModified = true;
|
|
}
|
|
}
|
|
|
|
if (mdModified && mdData && targetPath) {
|
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
|
}
|
|
|
|
if (jsonModified) {
|
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
|
}
|
|
};
|
|
|
|
export const getProviderSources = (providerId: string, workingDirectory?: string) => {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const customProviders = isPlainObject((layers.customConfig as Record<string, unknown>)?.provider)
|
|
? (layers.customConfig as Record<string, unknown>).provider as Record<string, unknown>
|
|
: {};
|
|
const customProvidersAlias = isPlainObject((layers.customConfig as Record<string, unknown>)?.providers)
|
|
? (layers.customConfig as Record<string, unknown>).providers as Record<string, unknown>
|
|
: {};
|
|
const projectProviders = isPlainObject((layers.projectConfig as Record<string, unknown>)?.provider)
|
|
? (layers.projectConfig as Record<string, unknown>).provider as Record<string, unknown>
|
|
: {};
|
|
const projectProvidersAlias = isPlainObject((layers.projectConfig as Record<string, unknown>)?.providers)
|
|
? (layers.projectConfig as Record<string, unknown>).providers as Record<string, unknown>
|
|
: {};
|
|
const userProviders = isPlainObject((layers.userConfig as Record<string, unknown>)?.provider)
|
|
? (layers.userConfig as Record<string, unknown>).provider as Record<string, unknown>
|
|
: {};
|
|
const userProvidersAlias = isPlainObject((layers.userConfig as Record<string, unknown>)?.providers)
|
|
? (layers.userConfig as Record<string, unknown>).providers as Record<string, unknown>
|
|
: {};
|
|
|
|
const customExists = Object.prototype.hasOwnProperty.call(customProviders, providerId)
|
|
|| Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId);
|
|
const projectExists = Object.prototype.hasOwnProperty.call(projectProviders, providerId)
|
|
|| Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId);
|
|
const userExists = Object.prototype.hasOwnProperty.call(userProviders, providerId)
|
|
|| Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId);
|
|
|
|
return {
|
|
auth: { exists: false },
|
|
user: { exists: userExists, path: layers.paths.userPath },
|
|
project: { exists: projectExists, path: layers.paths.projectPath ?? null },
|
|
custom: { exists: customExists, path: layers.paths.customPath },
|
|
};
|
|
};
|
|
|
|
export const removeProviderConfig = (providerId: string, workingDirectory?: string, scope: 'user' | 'project' | 'custom' = 'user') => {
|
|
if (!providerId) throw new Error('Provider ID is required');
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
let targetPath: string | null | undefined = layers.paths.userPath;
|
|
|
|
if (scope === 'project') {
|
|
if (!workingDirectory) {
|
|
throw new Error('Working directory is required for project scope');
|
|
}
|
|
targetPath = layers.paths.projectPath ?? targetPath;
|
|
}
|
|
|
|
if (scope === 'custom') {
|
|
if (!layers.paths.customPath) {
|
|
return false;
|
|
}
|
|
targetPath = layers.paths.customPath;
|
|
}
|
|
|
|
const targetConfig = getConfigForPath(layers, targetPath);
|
|
const providerConfig = isPlainObject((targetConfig as Record<string, unknown>).provider)
|
|
? (targetConfig as Record<string, unknown>).provider as Record<string, unknown>
|
|
: {};
|
|
const providersConfig = isPlainObject((targetConfig as Record<string, unknown>).providers)
|
|
? (targetConfig as Record<string, unknown>).providers as Record<string, unknown>
|
|
: {};
|
|
|
|
const removedProvider = Object.prototype.hasOwnProperty.call(providerConfig, providerId);
|
|
const removedProviders = Object.prototype.hasOwnProperty.call(providersConfig, providerId);
|
|
|
|
if (!removedProvider && !removedProviders) {
|
|
return false;
|
|
}
|
|
|
|
if (removedProvider) {
|
|
delete providerConfig[providerId];
|
|
if (Object.keys(providerConfig).length === 0) {
|
|
delete (targetConfig as Record<string, unknown>).provider;
|
|
} else {
|
|
(targetConfig as Record<string, unknown>).provider = providerConfig;
|
|
}
|
|
}
|
|
|
|
if (removedProviders) {
|
|
delete providersConfig[providerId];
|
|
if (Object.keys(providersConfig).length === 0) {
|
|
delete (targetConfig as Record<string, unknown>).providers;
|
|
} else {
|
|
(targetConfig as Record<string, unknown>).providers = providersConfig;
|
|
}
|
|
}
|
|
|
|
writeConfig(targetConfig as Record<string, unknown>, targetPath || CONFIG_FILE);
|
|
return true;
|
|
};
|
|
|
|
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
|
|
let deleted = false;
|
|
|
|
// Check project level first (takes precedence)
|
|
if (workingDirectory) {
|
|
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
|
if (fs.existsSync(projectPath)) {
|
|
fs.unlinkSync(projectPath);
|
|
deleted = true;
|
|
}
|
|
}
|
|
|
|
// Then check user level
|
|
const userPath = getUserCommandPath(commandName);
|
|
if (fs.existsSync(userPath)) {
|
|
fs.unlinkSync(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) {
|
|
const targetConfig = jsonSource.config as Record<string, unknown>;
|
|
const commandMap = (targetConfig.command as Record<string, unknown> | undefined) ?? {};
|
|
delete commandMap[commandName];
|
|
targetConfig.command = commandMap;
|
|
writeConfig(targetConfig, jsonSource.path);
|
|
deleted = true;
|
|
}
|
|
|
|
if (!deleted) {
|
|
throw new Error(`Command "${commandName}" not found`);
|
|
}
|
|
};
|
|
|
|
// ============== SKILL SCOPE HELPERS ==============
|
|
|
|
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
|
|
|
export const SKILL_SCOPE = {
|
|
USER: 'user',
|
|
PROJECT: 'project'
|
|
} as const;
|
|
|
|
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
|
|
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
|
|
|
export type SupportingFile = {
|
|
name: string;
|
|
path: string;
|
|
fullPath: string;
|
|
};
|
|
|
|
export type SkillConfigSources = {
|
|
md: {
|
|
exists: boolean;
|
|
path: string | null;
|
|
dir: string | null;
|
|
fields: string[];
|
|
scope?: SkillScope | null;
|
|
source?: SkillSource | null;
|
|
supportingFiles: SupportingFile[];
|
|
};
|
|
projectMd?: { exists: boolean; path: string | null };
|
|
claudeMd?: { exists: boolean; path: string | null };
|
|
userMd?: { exists: boolean; path: string | null };
|
|
};
|
|
|
|
export type DiscoveredSkill = {
|
|
name: string;
|
|
path: string;
|
|
scope: SkillScope;
|
|
source: SkillSource;
|
|
description?: string;
|
|
};
|
|
|
|
const addSkillFromMdFile = (
|
|
skillsMap: Map<string, DiscoveredSkill>,
|
|
skillMdPath: string,
|
|
scope: SkillScope,
|
|
source: SkillSource
|
|
) => {
|
|
try {
|
|
const parsed = parseMdFile(skillMdPath);
|
|
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,
|
|
});
|
|
} catch {
|
|
// Ignore invalid SKILL.md entries.
|
|
}
|
|
};
|
|
|
|
const ensureSkillDirs = () => {
|
|
if (!fs.existsSync(SKILL_DIR)) {
|
|
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
|
}
|
|
};
|
|
|
|
const getUserSkillDir = (skillName: string): string => {
|
|
const pluralPath = path.join(SKILL_DIR, skillName);
|
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName);
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
};
|
|
|
|
const getUserSkillPath = (skillName: string): string => {
|
|
const pluralPath = path.join(SKILL_DIR, skillName, 'SKILL.md');
|
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName, 'SKILL.md');
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
};
|
|
|
|
const getProjectSkillDir = (workingDirectory: string, skillName: string): string => {
|
|
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName);
|
|
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName);
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
};
|
|
|
|
const getProjectSkillPath = (workingDirectory: string, skillName: string): string => {
|
|
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName, 'SKILL.md');
|
|
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName, 'SKILL.md');
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
};
|
|
|
|
const getClaudeSkillDir = (workingDirectory: string, skillName: string): string => {
|
|
return path.join(workingDirectory, '.claude', 'skills', skillName);
|
|
};
|
|
|
|
const getClaudeSkillPath = (workingDirectory: string, skillName: string): string => {
|
|
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
|
};
|
|
|
|
const getUserAgentsSkillDir = (skillName: string): string => {
|
|
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
|
};
|
|
|
|
const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string): string => {
|
|
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
|
};
|
|
|
|
export const getSkillScope = (skillName: string, workingDirectory?: string): {
|
|
scope: SkillScope | null;
|
|
path: string | null;
|
|
source: SkillSource | null;
|
|
} => {
|
|
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
|
if (discovered?.path) {
|
|
return { scope: discovered.scope, path: discovered.path, source: discovered.source };
|
|
}
|
|
|
|
if (workingDirectory) {
|
|
// Check .opencode/skill first
|
|
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
|
if (fs.existsSync(projectPath)) {
|
|
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
|
|
}
|
|
|
|
// Check .claude/skills (claude-compat)
|
|
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
|
|
if (fs.existsSync(claudePath)) {
|
|
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
|
|
}
|
|
}
|
|
|
|
const userPath = getUserSkillPath(skillName);
|
|
if (fs.existsSync(userPath)) {
|
|
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
|
|
}
|
|
|
|
return { scope: null, path: null, source: null };
|
|
};
|
|
|
|
const listSupportingFiles = (skillDir: string): SupportingFile[] => {
|
|
if (!fs.existsSync(skillDir)) return [];
|
|
|
|
const files: SupportingFile[] = [];
|
|
|
|
const walkDir = (dir: string, relativePath: string = '') => {
|
|
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
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
walkDir(skillDir);
|
|
return files;
|
|
};
|
|
|
|
export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => {
|
|
const skills = new Map<string, DiscoveredSkill>();
|
|
|
|
// 1) External global (.claude, .agents)
|
|
for (const externalRootName of ['.claude', '.agents']) {
|
|
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
|
|
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
|
|
for (const skillMdPath of walkSkillMdFiles(homeRoot)) {
|
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source);
|
|
}
|
|
}
|
|
|
|
// 2) External project ancestors (.claude, .agents)
|
|
if (workingDirectory) {
|
|
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
|
const ancestors = getAncestors(workingDirectory, worktreeRoot);
|
|
for (const ancestor of ancestors) {
|
|
for (const externalRootName of ['.claude', '.agents']) {
|
|
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
|
|
const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills');
|
|
for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) {
|
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Config directories: {skill,skills}/**/SKILL.md
|
|
const configDirectories = resolveSkillSearchDirectories(workingDirectory);
|
|
const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode'));
|
|
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
|
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
|
: null;
|
|
for (const dir of configDirectories) {
|
|
for (const subDir of ['skill', 'skills']) {
|
|
const root = path.join(dir, subDir);
|
|
for (const skillMdPath of walkSkillMdFiles(root)) {
|
|
const isUserConfigDir = dir === OPENCODE_CONFIG_DIR
|
|
|| dir === homeOpencodeDir
|
|
|| (customConfigDir && dir === customConfigDir);
|
|
const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT;
|
|
addSkillFromMdFile(skills, skillMdPath, scope, 'opencode');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4) Additional config.skills.paths
|
|
let configuredPaths: unknown[] = [];
|
|
try {
|
|
const config = readConfig(workingDirectory);
|
|
const skillsConfig = isPlainObject(config.skills) ? config.skills : null;
|
|
configuredPaths = Array.isArray(skillsConfig?.paths) ? skillsConfig.paths : [];
|
|
} catch {
|
|
configuredPaths = [];
|
|
}
|
|
for (const skillPath of configuredPaths) {
|
|
if (typeof skillPath !== 'string' || !skillPath.trim()) continue;
|
|
const expanded = skillPath.startsWith('~/')
|
|
? path.join(os.homedir(), skillPath.slice(2))
|
|
: skillPath;
|
|
const resolved = path.isAbsolute(expanded)
|
|
? path.resolve(expanded)
|
|
: path.resolve(workingDirectory || process.cwd(), expanded);
|
|
for (const skillMdPath of walkSkillMdFiles(resolved)) {
|
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
|
}
|
|
}
|
|
|
|
// 5) Cached skills from config.skills.urls pulls (best-effort, no network)
|
|
const cacheCandidates: string[] = [];
|
|
if (process.env.XDG_CACHE_HOME) {
|
|
cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills'));
|
|
}
|
|
cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills'));
|
|
cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills'));
|
|
|
|
for (const cacheRoot of cacheCandidates) {
|
|
if (!fs.existsSync(cacheRoot)) continue;
|
|
const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillRoot = path.join(cacheRoot, entry.name);
|
|
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
|
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(skills.values());
|
|
};
|
|
|
|
export const getSkillSources = (
|
|
skillName: string,
|
|
workingDirectory?: string,
|
|
discoveredSkill?: DiscoveredSkill | null
|
|
): SkillConfigSources => {
|
|
ensureSkillDirs();
|
|
|
|
// Check all possible locations
|
|
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
|
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
|
|
const projectDir = projectExists && workingDirectory ? getProjectSkillDir(workingDirectory, skillName) : null;
|
|
|
|
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
|
|
const claudeExists = claudePath ? fs.existsSync(claudePath) : false;
|
|
const claudeDir = claudeExists && workingDirectory ? getClaudeSkillDir(workingDirectory, skillName) : null;
|
|
|
|
const userPath = getUserSkillPath(skillName);
|
|
const userExists = fs.existsSync(userPath);
|
|
const userDir = userExists ? getUserSkillDir(skillName) : null;
|
|
|
|
const matchedDiscovered = discoveredSkill?.name === skillName
|
|
? discoveredSkill
|
|
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
|
|
|
// Determine which md file to use (priority: project > claude > user)
|
|
let mdPath: string | null = null;
|
|
let mdScope: SkillScope | null = null;
|
|
let mdSource: SkillSource | null = null;
|
|
let mdDir: string | null = null;
|
|
|
|
if (projectExists) {
|
|
mdPath = projectPath;
|
|
mdScope = SKILL_SCOPE.PROJECT;
|
|
mdSource = 'opencode';
|
|
mdDir = projectDir;
|
|
} else if (claudeExists) {
|
|
mdPath = claudePath;
|
|
mdScope = SKILL_SCOPE.PROJECT;
|
|
mdSource = 'claude';
|
|
mdDir = claudeDir;
|
|
} else if (userExists) {
|
|
mdPath = userPath;
|
|
mdScope = SKILL_SCOPE.USER;
|
|
mdSource = 'opencode';
|
|
mdDir = userDir;
|
|
} else if (matchedDiscovered?.path) {
|
|
mdPath = matchedDiscovered.path;
|
|
mdScope = matchedDiscovered.scope;
|
|
mdSource = matchedDiscovered.source;
|
|
mdDir = path.dirname(matchedDiscovered.path);
|
|
}
|
|
|
|
const mdExists = !!mdPath;
|
|
let mdFields: string[] = [];
|
|
let supportingFiles: SupportingFile[] = [];
|
|
|
|
if (mdExists && mdPath) {
|
|
const { frontmatter, body } = parseMdFile(mdPath);
|
|
mdFields = Object.keys(frontmatter);
|
|
if (body) mdFields.push('instructions');
|
|
if (mdDir) {
|
|
supportingFiles = listSupportingFiles(mdDir);
|
|
}
|
|
}
|
|
|
|
return {
|
|
md: {
|
|
exists: mdExists,
|
|
path: mdPath,
|
|
dir: mdDir,
|
|
fields: mdFields,
|
|
scope: mdScope,
|
|
source: mdSource,
|
|
supportingFiles
|
|
},
|
|
projectMd: { exists: projectExists, path: projectPath },
|
|
claudeMd: { exists: claudeExists, path: claudePath },
|
|
userMd: { exists: userExists, path: userPath }
|
|
};
|
|
};
|
|
|
|
export const readSkillSupportingFile = (skillDir: string, relativePath: string): string | null => {
|
|
const fullPath = path.join(skillDir, relativePath);
|
|
if (!fs.existsSync(fullPath)) return null;
|
|
return fs.readFileSync(fullPath, 'utf8');
|
|
};
|
|
|
|
export const writeSkillSupportingFile = (skillDir: string, relativePath: string, content: string): void => {
|
|
const fullPath = path.join(skillDir, relativePath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
};
|
|
|
|
export const deleteSkillSupportingFile = (skillDir: string, relativePath: string): void => {
|
|
const fullPath = path.join(skillDir, relativePath);
|
|
if (fs.existsSync(fullPath)) {
|
|
fs.unlinkSync(fullPath);
|
|
// Clean up empty parent directories
|
|
let parentDir = path.dirname(fullPath);
|
|
while (parentDir !== skillDir) {
|
|
try {
|
|
const entries = fs.readdirSync(parentDir);
|
|
if (entries.length === 0) {
|
|
fs.rmdirSync(parentDir);
|
|
parentDir = path.dirname(parentDir);
|
|
} else {
|
|
break;
|
|
}
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const validateSkillName = (skillName: string): void => {
|
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
|
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
|
|
}
|
|
};
|
|
|
|
export const createSkill = (skillName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: SkillScope): void => {
|
|
ensureSkillDirs();
|
|
validateSkillName(skillName);
|
|
|
|
// Check if skill already exists
|
|
const existing = getSkillScope(skillName, workingDirectory);
|
|
if (existing.path) {
|
|
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
|
|
}
|
|
|
|
// Determine target directory
|
|
let targetDir: string;
|
|
|
|
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
|
|
const requestedSource: SkillSource = config.source === 'agents' ? 'agents' : 'opencode';
|
|
|
|
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
|
targetDir = requestedSource === 'agents'
|
|
? getProjectAgentsSkillDir(workingDirectory, skillName)
|
|
: getProjectSkillDir(workingDirectory, skillName);
|
|
} else {
|
|
targetDir = requestedSource === 'agents'
|
|
? getUserAgentsSkillDir(skillName)
|
|
: getUserSkillDir(skillName);
|
|
}
|
|
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
const targetPath = path.join(targetDir, 'SKILL.md');
|
|
|
|
// Extract fields
|
|
const { instructions, scope: _ignored, source: _sourceIgnored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
|
|
instructions?: unknown;
|
|
scope?: unknown;
|
|
source?: unknown;
|
|
supportingFiles?: Array<{ path: string; content: string }>;
|
|
};
|
|
void _ignored;
|
|
void _sourceIgnored;
|
|
|
|
// Ensure required fields
|
|
if (!frontmatter.name) {
|
|
frontmatter.name = skillName;
|
|
}
|
|
if (!frontmatter.description) {
|
|
throw new Error('Skill description is required');
|
|
}
|
|
|
|
writeMdFile(targetPath, frontmatter, typeof instructions === 'string' ? instructions : '');
|
|
|
|
// Write supporting files if provided
|
|
if (supportingFilesData && Array.isArray(supportingFilesData)) {
|
|
for (const file of supportingFilesData) {
|
|
if (file.path && file.content !== undefined) {
|
|
writeSkillSupportingFile(targetDir, file.path, file.content);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export const updateSkill = (skillName: string, updates: Record<string, unknown>, workingDirectory?: string): void => {
|
|
const existing = getSkillScope(skillName, workingDirectory);
|
|
if (!existing.path) {
|
|
throw new Error(`Skill "${skillName}" not found`);
|
|
}
|
|
|
|
const mdPath = existing.path;
|
|
const mdDir = path.dirname(mdPath);
|
|
const mdData = parseMdFile(mdPath);
|
|
let mdModified = false;
|
|
|
|
for (const [field, value] of Object.entries(updates || {})) {
|
|
if (field === 'scope') continue;
|
|
|
|
if (field === 'instructions') {
|
|
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
mdData.body = normalizedValue;
|
|
mdModified = true;
|
|
continue;
|
|
}
|
|
|
|
if (field === 'supportingFiles' && Array.isArray(value)) {
|
|
for (const file of value as Array<{ delete?: boolean; path?: string; content?: string }>) {
|
|
if (file.delete && file.path) {
|
|
deleteSkillSupportingFile(mdDir, file.path);
|
|
} else if (file.path && file.content !== undefined) {
|
|
writeSkillSupportingFile(mdDir, file.path, file.content);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
mdData.frontmatter[field] = value;
|
|
mdModified = true;
|
|
}
|
|
|
|
if (mdModified) {
|
|
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
|
|
}
|
|
};
|
|
|
|
export const deleteSkill = (skillName: string, workingDirectory?: string): void => {
|
|
let deleted = false;
|
|
|
|
// Check and delete from all locations
|
|
if (workingDirectory) {
|
|
// Project level .opencode/skill/
|
|
const projectDir = getProjectSkillDir(workingDirectory, skillName);
|
|
if (fs.existsSync(projectDir)) {
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
deleted = true;
|
|
}
|
|
|
|
// Claude-compat .claude/skills/
|
|
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
|
|
if (fs.existsSync(claudeDir)) {
|
|
fs.rmSync(claudeDir, { recursive: true, force: true });
|
|
deleted = true;
|
|
}
|
|
|
|
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
|
if (fs.existsSync(projectAgentsDir)) {
|
|
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
|
|
deleted = true;
|
|
}
|
|
}
|
|
|
|
// User level
|
|
const userDir = getUserSkillDir(skillName);
|
|
if (fs.existsSync(userDir)) {
|
|
fs.rmSync(userDir, { recursive: true, force: true });
|
|
deleted = true;
|
|
}
|
|
|
|
const userAgentsDir = getUserAgentsSkillDir(skillName);
|
|
if (fs.existsSync(userAgentsDir)) {
|
|
fs.rmSync(userAgentsDir, { recursive: true, force: true });
|
|
deleted = true;
|
|
}
|
|
|
|
if (!deleted) {
|
|
throw new Error(`Skill "${skillName}" not found`);
|
|
}
|
|
};
|