2025-12-24 22:50:46 +02:00
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import yaml from 'yaml';
|
2026-01-03 15:02:50 +02:00
|
|
|
import { parse as parseJsonc } from 'jsonc-parser';
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
2026-01-23 00:27:01 +02:00
|
|
|
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
|
|
|
|
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
2026-06-02 00:43:05 +03:00
|
|
|
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
|
|
|
|
|
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
2026-04-21 15:47:42 -04:00
|
|
|
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
|
2025-12-29 22:42:10 +02:00
|
|
|
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
|
|
|
|
? path.resolve(process.env.OPENCODE_CONFIG)
|
|
|
|
|
: null;
|
2025-12-24 22:50:46 +02:00
|
|
|
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
2026-06-02 00:43:05 +03:00
|
|
|
const SNIPPET_EXTENSION = '.md';
|
|
|
|
|
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
|
|
|
|
|
const HASHTAG_PATTERN = /#([a-z0-9_-]+)/gi;
|
|
|
|
|
const MAX_SNIPPET_EXPANSION_COUNT = 15;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// Scope types (shared by agents and commands)
|
|
|
|
|
export const AGENT_SCOPE = {
|
|
|
|
|
USER: 'user',
|
|
|
|
|
PROJECT: 'project'
|
|
|
|
|
} as const;
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
export const COMMAND_SCOPE = {
|
|
|
|
|
USER: 'user',
|
|
|
|
|
PROJECT: 'project'
|
|
|
|
|
} as const;
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
export type AgentScope = typeof AGENT_SCOPE[keyof typeof AGENT_SCOPE];
|
2025-12-29 17:48:06 +02:00
|
|
|
export type CommandScope = typeof COMMAND_SCOPE[keyof typeof COMMAND_SCOPE];
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export type SnippetScope = 'global' | 'project';
|
|
|
|
|
|
|
|
|
|
export type Snippet = {
|
|
|
|
|
name: string;
|
|
|
|
|
content: string;
|
|
|
|
|
aliases: string[];
|
|
|
|
|
description?: string;
|
|
|
|
|
filePath: string;
|
|
|
|
|
source: SnippetScope;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type PluginScope = 'user' | 'project';
|
2026-06-26 19:27:53 +03:00
|
|
|
type PluginParsedKind = 'npm' | 'path';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
export type PluginEntry = {
|
|
|
|
|
id: string;
|
|
|
|
|
spec: string;
|
|
|
|
|
options?: Record<string, unknown>;
|
|
|
|
|
scope: PluginScope;
|
|
|
|
|
kind: 'config';
|
|
|
|
|
parsedKind: PluginParsedKind;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type PluginFile = {
|
|
|
|
|
id: string;
|
|
|
|
|
fileName: string;
|
|
|
|
|
scope: PluginScope;
|
|
|
|
|
kind: 'file';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type PluginRegistryResult =
|
|
|
|
|
| { kind: 'npm-ok'; spec: string; name: string; currentVersion: string | null; latestVersion: string | null; versions: string[]; hasUpdate: boolean }
|
|
|
|
|
| { kind: 'npm-missing-version'; spec: string; name: string; currentVersion: string; latestVersion: string | null; versions: string[] }
|
|
|
|
|
| { kind: 'npm-missing-package'; spec: string; name: string; error: string }
|
|
|
|
|
| { kind: 'npm-malformed'; spec: string; error: string }
|
|
|
|
|
| { kind: 'npm-network'; spec: string; error: string }
|
|
|
|
|
| { kind: 'path-ok'; spec: string; absolutePath: string }
|
|
|
|
|
| { kind: 'path-missing'; spec: string; absolutePath: string }
|
|
|
|
|
| { kind: 'path-unreadable'; spec: string; absolutePath: string };
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
export type ConfigSources = {
|
2025-12-29 22:42:10 +02:00
|
|
|
md: { exists: boolean; path: string | null; fields: string[]; scope?: AgentScope | CommandScope | null };
|
|
|
|
|
json: { exists: boolean; path: string; fields: string[]; scope?: AgentScope | CommandScope | null };
|
2025-12-29 17:48:06 +02:00
|
|
|
projectMd?: { exists: boolean; path: string | null };
|
|
|
|
|
userMd?: { exists: boolean; path: string | null };
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// ============== AGENT SCOPE HELPERS ==============
|
|
|
|
|
|
|
|
|
|
const ensureProjectAgentDir = (workingDirectory: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
|
2025-12-29 22:42:10 +02:00
|
|
|
if (!fs.existsSync(projectAgentDir)) {
|
|
|
|
|
fs.mkdirSync(projectAgentDir, { recursive: true });
|
|
|
|
|
}
|
2026-01-23 00:27:01 +02:00
|
|
|
const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
|
|
|
|
|
if (!fs.existsSync(legacyProjectAgentDir)) {
|
|
|
|
|
fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
|
|
|
|
|
}
|
2025-12-29 22:42:10 +02:00
|
|
|
return projectAgentDir;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getProjectAgentPath = (workingDirectory: string, agentName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-29 22:42:10 +02:00
|
|
|
};
|
|
|
|
|
|
2026-02-23 17:28:24 +07:00
|
|
|
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 => {
|
2026-01-23 00:27:01 +02:00
|
|
|
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
2026-02-23 17:28:24 +07:00
|
|
|
|
|
|
|
|
if (fs.existsSync(pluralPath)) return pluralPath;
|
|
|
|
|
|
2026-01-23 00:27:01 +02:00
|
|
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`);
|
2026-02-23 17:28:24 +07:00
|
|
|
if (fs.existsSync(legacyPath)) return legacyPath;
|
|
|
|
|
|
|
|
|
|
const found = getIndexedUserAgentPath(agentName, lookupCache);
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
2026-01-23 00:27:01 +02:00
|
|
|
return pluralPath;
|
2025-12-29 22:42:10 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
const getAgentScope = (
|
2026-02-23 17:28:24 +07:00
|
|
|
agentName: string,
|
|
|
|
|
workingDirectory?: string,
|
|
|
|
|
lookupCache: AgentLookupCache = globalAgentLookupCache
|
|
|
|
|
): { scope: AgentScope | null; path: string | null } => {
|
2025-12-29 22:42:10 +02:00
|
|
|
if (workingDirectory) {
|
|
|
|
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
|
|
|
if (fs.existsSync(projectPath)) {
|
|
|
|
|
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 17:28:24 +07:00
|
|
|
const userPath = getUserAgentPath(agentName, lookupCache);
|
2025-12-29 22:42:10 +02:00
|
|
|
if (fs.existsSync(userPath)) {
|
|
|
|
|
return { scope: AGENT_SCOPE.USER, path: userPath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { scope: null, path: null };
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-23 17:28:24 +07:00
|
|
|
const getAgentWritePath = (
|
|
|
|
|
agentName: string,
|
|
|
|
|
workingDirectory?: string,
|
|
|
|
|
requestedScope?: AgentScope,
|
|
|
|
|
lookupCache: AgentLookupCache = globalAgentLookupCache
|
|
|
|
|
): { scope: AgentScope; path: string } => {
|
|
|
|
|
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
|
2025-12-29 22:42:10 +02:00
|
|
|
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,
|
2026-02-23 17:28:24 +07:00
|
|
|
path: getUserAgentPath(agentName, lookupCache)
|
2025-12-29 22:42:10 +02:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ============== COMMAND SCOPE HELPERS ==============
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
const ensureProjectCommandDir = (workingDirectory: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands');
|
2025-12-29 17:48:06 +02:00
|
|
|
if (!fs.existsSync(projectCommandDir)) {
|
|
|
|
|
fs.mkdirSync(projectCommandDir, { recursive: true });
|
|
|
|
|
}
|
2026-01-23 00:27:01 +02:00
|
|
|
const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command');
|
|
|
|
|
if (!fs.existsSync(legacyProjectCommandDir)) {
|
|
|
|
|
fs.mkdirSync(legacyProjectCommandDir, { recursive: true });
|
|
|
|
|
}
|
2025-12-29 17:48:06 +02:00
|
|
|
return projectCommandDir;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getProjectCommandPath = (workingDirectory: string, commandName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-29 17:48:06 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getUserCommandPath = (commandName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-29 17:48:06 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
|
2025-12-29 17:48:06 +02:00
|
|
|
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)
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
// ============== SNIPPET HELPERS ==============
|
|
|
|
|
|
|
|
|
|
const getProjectSnippetDirs = (workingDirectory?: string): Array<{ dir: string; source: SnippetScope }> => {
|
|
|
|
|
if (!workingDirectory) return [];
|
|
|
|
|
return [
|
|
|
|
|
{ dir: path.join(workingDirectory, '.opencode', 'snippets'), source: 'project' },
|
|
|
|
|
{ dir: path.join(workingDirectory, '.opencode', 'snippet'), source: 'project' },
|
|
|
|
|
];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getGlobalSnippetDirs = (): Array<{ dir: string; source: SnippetScope }> => [
|
|
|
|
|
{ dir: GLOBAL_SNIPPET_DIR_ALT, source: 'global' },
|
|
|
|
|
{ dir: GLOBAL_SNIPPET_DIR, source: 'global' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const assertValidSnippetName = (name: string): void => {
|
|
|
|
|
if (typeof name !== 'string' || !SNIPPET_NAME_PATTERN.test(name)) {
|
|
|
|
|
throw new Error('Snippet name must use letters, numbers, dashes, or underscores');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeSnippetAliases = (frontmatter: Record<string, unknown>): string[] => {
|
|
|
|
|
const raw = frontmatter.aliases ?? frontmatter.alias;
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
const aliases = Array.isArray(raw) ? raw : [raw];
|
|
|
|
|
return aliases.map((alias) => String(alias).trim()).filter(Boolean);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadSnippetFile = (dir: string, filename: string, source: SnippetScope): Snippet | null => {
|
|
|
|
|
const name = path.basename(filename, SNIPPET_EXTENSION);
|
|
|
|
|
if (!SNIPPET_NAME_PATTERN.test(name)) return null;
|
|
|
|
|
const filePath = path.join(dir, filename);
|
|
|
|
|
const { frontmatter, body } = parseMdFile(filePath);
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
content: body,
|
|
|
|
|
aliases: normalizeSnippetAliases(frontmatter),
|
|
|
|
|
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
|
|
|
|
|
filePath,
|
|
|
|
|
source,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const registerSnippet = (registry: Map<string, Snippet>, snippet: Snippet): void => {
|
|
|
|
|
const key = snippet.name.toLowerCase();
|
|
|
|
|
const existing = registry.get(key);
|
|
|
|
|
if (existing) {
|
|
|
|
|
for (const alias of existing.aliases) registry.delete(alias.toLowerCase());
|
|
|
|
|
}
|
|
|
|
|
registry.set(key, snippet);
|
|
|
|
|
for (const alias of snippet.aliases) {
|
|
|
|
|
if (SNIPPET_NAME_PATTERN.test(alias)) registry.set(alias.toLowerCase(), snippet);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadSnippetRegistry = (workingDirectory?: string): Map<string, Snippet> => {
|
|
|
|
|
const registry = new Map<string, Snippet>();
|
|
|
|
|
for (const { dir, source } of [...getGlobalSnippetDirs(), ...getProjectSnippetDirs(workingDirectory)]) {
|
|
|
|
|
if (!fs.existsSync(dir)) continue;
|
|
|
|
|
for (const filename of fs.readdirSync(dir)) {
|
|
|
|
|
if (!filename.endsWith(SNIPPET_EXTENSION)) continue;
|
|
|
|
|
try {
|
|
|
|
|
const snippet = loadSnippetFile(dir, filename, source);
|
|
|
|
|
if (snippet) registerSnippet(registry, snippet);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`[OpenChamber][VSCode] Failed to load snippet ${path.join(dir, filename)}:`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return registry;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const listUniqueSnippets = (registry: Map<string, Snippet>): Snippet[] => {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const snippets: Snippet[] = [];
|
|
|
|
|
for (const snippet of registry.values()) {
|
|
|
|
|
const key = `${snippet.source}:${snippet.filePath}`;
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
snippets.push(snippet);
|
|
|
|
|
}
|
|
|
|
|
return snippets.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getWritableSnippetDir = (scope: SnippetScope, workingDirectory?: string): string => {
|
|
|
|
|
if (scope === 'project') {
|
|
|
|
|
if (!workingDirectory) throw new Error('Project directory is required for project snippets');
|
|
|
|
|
const preferred = path.join(workingDirectory, '.opencode', 'snippet');
|
|
|
|
|
const alternate = path.join(workingDirectory, '.opencode', 'snippets');
|
|
|
|
|
return fs.existsSync(alternate) && !fs.existsSync(preferred) ? alternate : preferred;
|
|
|
|
|
}
|
|
|
|
|
return fs.existsSync(GLOBAL_SNIPPET_DIR_ALT) && !fs.existsSync(GLOBAL_SNIPPET_DIR)
|
|
|
|
|
? GLOBAL_SNIPPET_DIR_ALT
|
|
|
|
|
: GLOBAL_SNIPPET_DIR;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const findSnippetByName = (name: string, workingDirectory?: string): Snippet | null => {
|
|
|
|
|
assertValidSnippetName(name);
|
|
|
|
|
return loadSnippetRegistry(workingDirectory).get(name.toLowerCase()) ?? null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const writeSnippetFile = (filePath: string, config: Record<string, unknown>): void => {
|
|
|
|
|
const aliases = Array.isArray(config.aliases)
|
|
|
|
|
? config.aliases.map((alias) => String(alias).trim()).filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
const frontmatter: Record<string, unknown> = {};
|
|
|
|
|
if (aliases.length > 0) frontmatter.aliases = aliases;
|
|
|
|
|
if (typeof config.description === 'string' && config.description.trim()) {
|
|
|
|
|
frontmatter.description = config.description.trim();
|
|
|
|
|
}
|
|
|
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
|
|
|
writeMdFile(filePath, frontmatter, typeof config.content === 'string' ? config.content : '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseSnippetBlocks = (content: string): { inline: string; prepend: string[]; append: string[] } => {
|
|
|
|
|
const blocks = { prepend: [] as string[], append: [] as string[] };
|
|
|
|
|
let inline = content;
|
|
|
|
|
for (const type of ['prepend', 'append'] as const) {
|
|
|
|
|
const regex = new RegExp(`<${type}>([\\s\\S]*?)(?:<\\/${type}>|$)`, 'gi');
|
|
|
|
|
inline = inline.replace(regex, (_match, value: string) => {
|
|
|
|
|
const normalized = String(value).trim();
|
|
|
|
|
if (normalized) blocks[type].push(normalized);
|
|
|
|
|
return '';
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
inline = inline.replace(/<inject>[\s\S]*?(?:<\/inject>|$)/gi, '').trim();
|
|
|
|
|
return { inline, prepend: blocks.prepend, append: blocks.append };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const expandSnippetText = (
|
|
|
|
|
text: string,
|
|
|
|
|
registry: Map<string, Snippet>,
|
|
|
|
|
expansionCounts: Map<string, number>,
|
|
|
|
|
collector: { prepend: string[]; append: string[] },
|
|
|
|
|
): string => {
|
|
|
|
|
let expanded = text;
|
|
|
|
|
let changed = true;
|
|
|
|
|
|
|
|
|
|
while (changed) {
|
|
|
|
|
const previous = expanded;
|
|
|
|
|
let loopDetected = false;
|
|
|
|
|
HASHTAG_PATTERN.lastIndex = 0;
|
|
|
|
|
|
|
|
|
|
expanded = expanded.replace(HASHTAG_PATTERN, (match, name: string, offset: number, input: string) => {
|
|
|
|
|
if (name.toLowerCase() === 'skill' && input[offset + match.length] === '(') return match;
|
|
|
|
|
const snippet = registry.get(name.toLowerCase());
|
|
|
|
|
if (!snippet) return match;
|
|
|
|
|
|
|
|
|
|
const key = snippet.name.toLowerCase();
|
|
|
|
|
const count = (expansionCounts.get(key) || 0) + 1;
|
|
|
|
|
if (count > MAX_SNIPPET_EXPANSION_COUNT) {
|
|
|
|
|
loopDetected = true;
|
|
|
|
|
return match;
|
|
|
|
|
}
|
|
|
|
|
expansionCounts.set(key, count);
|
|
|
|
|
|
|
|
|
|
const parsed = parseSnippetBlocks(snippet.content);
|
|
|
|
|
for (const block of parsed.prepend) collector.prepend.push(expandSnippetText(block, registry, expansionCounts, collector));
|
|
|
|
|
for (const block of parsed.append) collector.append.push(expandSnippetText(block, registry, expansionCounts, collector));
|
|
|
|
|
return expandSnippetText(parsed.inline, registry, expansionCounts, collector);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
changed = expanded !== previous && !loopDetected;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return expanded;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
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');
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2025-12-29 22:42:10 +02:00
|
|
|
const getProjectConfigPath = (workingDirectory?: string): string | null => {
|
|
|
|
|
if (!workingDirectory) return null;
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
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;
|
2025-12-29 22:42:10 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getConfigPaths = (workingDirectory?: string) => ({
|
2026-04-21 15:47:42 -04:00
|
|
|
userPaths: [
|
|
|
|
|
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
|
|
|
|
|
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
|
|
|
|
|
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
|
|
|
|
|
],
|
2025-12-29 22:42:10 +02:00
|
|
|
projectPath: getProjectConfigPath(workingDirectory),
|
|
|
|
|
customPath: CUSTOM_CONFIG_FILE
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 15:47:42 -04:00
|
|
|
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
|
|
|
|
|
for (const userPath of userPaths) {
|
|
|
|
|
if (fs.existsSync(userPath)) {
|
|
|
|
|
return userPath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return CONFIG_FILE;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
|
|
|
|
|
if (!filePath || !fs.existsSync(filePath)) return {};
|
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
2026-01-03 15:02:50 +02:00
|
|
|
const normalized = content.trim();
|
2025-12-24 22:50:46 +02:00
|
|
|
if (!normalized) return {};
|
2026-01-03 15:02:50 +02:00
|
|
|
return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record<string, unknown>;
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
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) => {
|
2026-04-21 15:47:42 -04:00
|
|
|
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
|
|
|
|
|
const userPath = getPrimaryUserConfigPath(userPaths);
|
2025-12-29 22:42:10 +02:00
|
|
|
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;
|
|
|
|
|
|
2026-02-18 12:26:08 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-22 13:45:30 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
|
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
|
|
|
const backupFile = `${filePath}.openchamber.backup`;
|
2025-12-24 22:50:46 +02:00
|
|
|
try {
|
2025-12-29 22:42:10 +02:00
|
|
|
fs.copyFileSync(filePath, backupFile);
|
2025-12-24 22:50:46 +02:00
|
|
|
} catch {
|
|
|
|
|
// ignore backup failures
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-29 22:42:10 +02:00
|
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
|
|
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const codedError = (message: string, code: string): Error & { code: string } => {
|
|
|
|
|
const error = new Error(message) as Error & { code: string };
|
|
|
|
|
error.code = code;
|
|
|
|
|
return error;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validatePluginScope = (scope: unknown): PluginScope => {
|
|
|
|
|
if (scope === 'user' || scope === 'project') return scope;
|
|
|
|
|
throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validatePluginSpec = (spec: unknown): string => {
|
|
|
|
|
if (typeof spec !== 'string' || spec.trim().length === 0) {
|
|
|
|
|
throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
|
|
|
|
|
}
|
|
|
|
|
if (spec.includes('\0')) {
|
|
|
|
|
throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
|
|
|
|
|
}
|
|
|
|
|
return spec.trim();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
|
|
|
|
|
|
|
|
|
|
const validatePluginFileName = (fileName: unknown): string => {
|
|
|
|
|
if (typeof fileName !== 'string' || fileName.trim().length === 0) {
|
|
|
|
|
throw codedError('Plugin file name is required', 'INVALID_FILENAME');
|
|
|
|
|
}
|
|
|
|
|
const normalized = fileName.trim();
|
|
|
|
|
if (
|
|
|
|
|
normalized.includes('/') ||
|
|
|
|
|
normalized.includes('\\') ||
|
|
|
|
|
normalized.includes('..') ||
|
|
|
|
|
!PLUGIN_FILE_NAME_PATTERN.test(normalized)
|
|
|
|
|
) {
|
|
|
|
|
throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const encodePluginId = (prefix: 'config' | 'file', value: string): string =>
|
|
|
|
|
Buffer.from(`${prefix}:${value}`, 'utf8').toString('base64url');
|
|
|
|
|
|
|
|
|
|
const decodePluginId = (id: string): { prefix: string; value: string } => {
|
|
|
|
|
try {
|
|
|
|
|
const decoded = Buffer.from(id, 'base64url').toString('utf8');
|
|
|
|
|
const separator = decoded.indexOf(':');
|
|
|
|
|
if (separator <= 0) throw new Error('invalid plugin id');
|
|
|
|
|
return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
|
|
|
|
|
} catch {
|
|
|
|
|
throw codedError('Invalid plugin id', 'INVALID_SPEC');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parsePluginIdValue = (value: string): { scope: PluginScope; rest: string } => {
|
|
|
|
|
const separator = value.indexOf(':');
|
|
|
|
|
if (separator <= 0) {
|
|
|
|
|
throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
scope: validatePluginScope(value.slice(0, separator)),
|
|
|
|
|
rest: value.slice(separator + 1),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parsePluginRaw = (raw: unknown): { spec: string; options?: Record<string, unknown> } => {
|
|
|
|
|
if (typeof raw === 'string') {
|
|
|
|
|
return { spec: validatePluginSpec(raw) };
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(raw) && typeof raw[0] === 'string' && isPlainObject(raw[1])) {
|
|
|
|
|
return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
|
|
|
|
|
}
|
|
|
|
|
throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const serializePluginEntry = (entry: { spec?: unknown; options?: unknown }): string | [string, Record<string, unknown>] => {
|
|
|
|
|
const spec = validatePluginSpec(entry.spec);
|
|
|
|
|
if (isPlainObject(entry.options) && Object.keys(entry.options).length > 0) {
|
|
|
|
|
return [spec, { ...entry.options }];
|
|
|
|
|
}
|
|
|
|
|
return spec;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isPluginPathSpec = (spec: string): boolean =>
|
|
|
|
|
spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('~') || path.win32.isAbsolute(spec);
|
|
|
|
|
|
|
|
|
|
const parsePluginPathSpec = (spec: string, workingDirectory?: string | null): { absolutePath: string } => {
|
|
|
|
|
if (spec === '~') return { absolutePath: path.resolve(os.homedir()) };
|
|
|
|
|
if (spec.startsWith('~/')) return { absolutePath: path.resolve(os.homedir(), spec.slice(2)) };
|
|
|
|
|
if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
|
|
|
return { absolutePath: path.resolve(workingDirectory || os.homedir(), spec) };
|
|
|
|
|
}
|
|
|
|
|
if (path.win32.isAbsolute(spec)) return { absolutePath: spec };
|
|
|
|
|
return { absolutePath: path.resolve(spec) };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parsePluginNpmSpec = (spec: string): { name: string; version: string | null } | { malformed: true } => {
|
|
|
|
|
if (spec.startsWith('@')) {
|
|
|
|
|
const slashIdx = spec.indexOf('/');
|
|
|
|
|
if (slashIdx < 2) return { malformed: true };
|
|
|
|
|
const afterSlash = spec.slice(slashIdx + 1);
|
|
|
|
|
if (!afterSlash) return { malformed: true };
|
|
|
|
|
const atIdx = afterSlash.indexOf('@');
|
|
|
|
|
if (atIdx === -1) return { name: spec, version: null };
|
|
|
|
|
const version = afterSlash.slice(atIdx + 1);
|
|
|
|
|
if (!version) return { malformed: true };
|
|
|
|
|
return { name: spec.slice(0, slashIdx + 1 + atIdx), version };
|
|
|
|
|
}
|
|
|
|
|
if (!spec) return { malformed: true };
|
|
|
|
|
const atIdx = spec.indexOf('@');
|
|
|
|
|
if (atIdx === -1) return { name: spec, version: null };
|
|
|
|
|
if (atIdx === 0) return { malformed: true };
|
|
|
|
|
const version = spec.slice(atIdx + 1);
|
|
|
|
|
if (!version) return { malformed: true };
|
|
|
|
|
return { name: spec.slice(0, atIdx), version };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isExactPluginSemver = (version: string): boolean => /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
|
|
|
|
|
|
|
|
|
|
const getActiveCustomConfigPath = (): string | null =>
|
|
|
|
|
process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
|
|
|
|
|
|
|
|
|
|
const getActiveOpencodeConfigDir = (): string => {
|
|
|
|
|
const customConfigPath = getActiveCustomConfigPath();
|
|
|
|
|
return customConfigPath ? path.dirname(customConfigPath) : OPENCODE_CONFIG_DIR;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActiveUserConfigPaths = (): string[] => {
|
|
|
|
|
const configDir = getActiveOpencodeConfigDir();
|
|
|
|
|
return [
|
|
|
|
|
path.join(configDir, 'config.json'),
|
|
|
|
|
path.join(configDir, 'opencode.json'),
|
|
|
|
|
path.join(configDir, 'opencode.jsonc'),
|
|
|
|
|
];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActivePrimaryUserConfigPath = (): string => {
|
|
|
|
|
const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
|
|
|
|
|
for (const userPath of [defaultPath, ...fallbackPaths]) {
|
|
|
|
|
if (fs.existsSync(userPath)) {
|
|
|
|
|
return userPath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return defaultPath;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensureProjectPluginConfigPath = (workingDirectory?: string | null): string => {
|
|
|
|
|
if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE');
|
|
|
|
|
return path.join(workingDirectory, '.opencode', 'opencode.json');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope: PluginScope; path: string; config: Record<string, unknown> }> => {
|
|
|
|
|
const customPath = getActiveCustomConfigPath();
|
|
|
|
|
const userPath = getActivePrimaryUserConfigPath();
|
|
|
|
|
const projectPath = getProjectConfigPath(workingDirectory || undefined);
|
|
|
|
|
return [
|
|
|
|
|
customPath
|
|
|
|
|
? { scope: 'user', path: customPath, config: readConfigFile(customPath) }
|
|
|
|
|
: { scope: 'user', path: userPath, config: readConfigFile(userPath) },
|
|
|
|
|
...(projectPath
|
|
|
|
|
? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }]
|
|
|
|
|
: []),
|
|
|
|
|
];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readPluginArray = (config: Record<string, unknown>): unknown[] => Array.isArray(config.plugin) ? config.plugin : [];
|
|
|
|
|
|
|
|
|
|
const writePluginArray = (config: Record<string, unknown>, plugin: unknown[]): Record<string, unknown> => {
|
|
|
|
|
const next = { ...config };
|
|
|
|
|
if (plugin.length > 0) {
|
|
|
|
|
next.plugin = plugin;
|
|
|
|
|
} else {
|
|
|
|
|
delete next.plugin;
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasPluginSpec = (plugin: unknown[], spec: string): boolean => plugin.some((raw) => {
|
|
|
|
|
try {
|
|
|
|
|
return parsePluginRaw(raw).spec === spec;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const getPluginTarget = (id: string, workingDirectory?: string | null): null | {
|
|
|
|
|
scope: PluginScope;
|
|
|
|
|
path: string;
|
|
|
|
|
config: Record<string, unknown>;
|
|
|
|
|
plugin: unknown[];
|
|
|
|
|
index: number;
|
|
|
|
|
spec: string;
|
|
|
|
|
} => {
|
|
|
|
|
const decoded = decodePluginId(id);
|
|
|
|
|
if (decoded.prefix !== 'config') {
|
|
|
|
|
throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
|
|
|
|
|
}
|
|
|
|
|
const { scope, rest: spec } = parsePluginIdValue(decoded.value);
|
|
|
|
|
const source = getPluginConfigSources(workingDirectory).find((candidate) => candidate.scope === scope);
|
|
|
|
|
if (!source) return null;
|
|
|
|
|
const plugin = readPluginArray(source.config);
|
|
|
|
|
const index = plugin.findIndex((raw) => {
|
|
|
|
|
try {
|
|
|
|
|
return parsePluginRaw(raw).spec === spec;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (index < 0) return null;
|
|
|
|
|
return { scope, path: source.path, config: source.config, plugin: [...plugin], index, spec };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const listPluginEntries = (workingDirectory?: string): PluginEntry[] => {
|
|
|
|
|
const entries: PluginEntry[] = [];
|
|
|
|
|
for (const source of getPluginConfigSources(workingDirectory)) {
|
|
|
|
|
for (const raw of readPluginArray(source.config)) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = parsePluginRaw(raw);
|
|
|
|
|
entries.push({
|
|
|
|
|
id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
|
|
|
|
|
spec: parsed.spec,
|
|
|
|
|
...(parsed.options ? { options: parsed.options } : {}),
|
|
|
|
|
scope: source.scope,
|
|
|
|
|
kind: 'config',
|
|
|
|
|
parsedKind: isPluginPathSpec(parsed.spec) ? 'path' : 'npm',
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed persisted plugin entries so the settings page remains usable.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return entries;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getPluginEntry = (id: string, workingDirectory?: string): PluginEntry | null =>
|
|
|
|
|
listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
|
|
|
|
|
|
|
|
|
|
export const createPluginEntry = (entry: { spec?: unknown; options?: unknown; scope?: unknown }, workingDirectory?: string): void => {
|
|
|
|
|
const spec = validatePluginSpec(entry.spec);
|
|
|
|
|
const scope = validatePluginScope(entry.scope || 'user');
|
|
|
|
|
const sources = getPluginConfigSources(workingDirectory);
|
|
|
|
|
if (sources.some((source) => source.scope === scope && hasPluginSpec(readPluginArray(source.config), spec))) {
|
|
|
|
|
throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
|
|
|
|
|
}
|
|
|
|
|
const userSource = sources.find((source) => source.scope === 'user');
|
|
|
|
|
const targetPath = scope === 'project'
|
|
|
|
|
? ensureProjectPluginConfigPath(workingDirectory)
|
|
|
|
|
: userSource?.path ?? getActivePrimaryUserConfigPath();
|
|
|
|
|
const config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
|
|
|
|
|
const plugin = readPluginArray(config);
|
|
|
|
|
writeConfig(writePluginArray(config, [...plugin, serializePluginEntry({ spec, options: entry.options })]), targetPath);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const updatePluginEntry = (id: string, updates: { spec?: unknown; options?: unknown }, workingDirectory?: string): void => {
|
|
|
|
|
const target = getPluginTarget(id, workingDirectory);
|
|
|
|
|
if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND');
|
|
|
|
|
const existing = parsePluginRaw(target.plugin[target.index]);
|
|
|
|
|
const nextSpec = updates.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
|
|
|
|
|
const nextOptions = updates.options === undefined ? existing.options : updates.options;
|
|
|
|
|
target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
|
|
|
|
|
writeConfig(writePluginArray(target.config, target.plugin), target.path);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const deletePluginEntry = (id: string, workingDirectory?: string): void => {
|
|
|
|
|
const target = getPluginTarget(id, workingDirectory);
|
|
|
|
|
if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND');
|
|
|
|
|
target.plugin.splice(target.index, 1);
|
|
|
|
|
writeConfig(writePluginArray(target.config, target.plugin), target.path);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getPluginDir = (scope: PluginScope, workingDirectory?: string | null): string => {
|
|
|
|
|
if (scope === 'project') {
|
|
|
|
|
if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE');
|
|
|
|
|
return path.join(workingDirectory, '.opencode', 'plugins');
|
|
|
|
|
}
|
|
|
|
|
return path.join(getActiveOpencodeConfigDir(), 'plugins');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getPluginFileTarget = (id: string, workingDirectory?: string): { scope: PluginScope; fileName: string; filePath: string } => {
|
|
|
|
|
const decoded = decodePluginId(id);
|
|
|
|
|
if (decoded.prefix !== 'file') {
|
|
|
|
|
throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
|
|
|
|
|
}
|
|
|
|
|
const { scope, rest } = parsePluginIdValue(decoded.value);
|
|
|
|
|
const fileName = validatePluginFileName(rest);
|
|
|
|
|
return { scope, fileName, filePath: path.join(getPluginDir(scope, workingDirectory), fileName) };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const listPluginDirFiles = (workingDirectory?: string): PluginFile[] => {
|
|
|
|
|
const files: PluginFile[] = [];
|
|
|
|
|
for (const scope of ['user', 'project'] as const) {
|
|
|
|
|
let dir: string;
|
|
|
|
|
try {
|
|
|
|
|
dir = getPluginDir(scope, workingDirectory);
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!fs.existsSync(dir)) continue;
|
|
|
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
|
|
|
if (!entry.isFile()) continue;
|
|
|
|
|
try {
|
|
|
|
|
const fileName = validatePluginFileName(entry.name);
|
|
|
|
|
files.push({
|
|
|
|
|
id: encodePluginId('file', `${scope}:${fileName}`),
|
|
|
|
|
fileName,
|
|
|
|
|
scope,
|
|
|
|
|
kind: 'file',
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore unsupported files in the plugins directory.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return files.sort((a, b) => `${a.scope}:${a.fileName}`.localeCompare(`${b.scope}:${b.fileName}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const readPluginDirFile = (id: string, workingDirectory?: string): { fileName: string; scope: PluginScope; content: string } | null => {
|
|
|
|
|
const target = getPluginFileTarget(id, workingDirectory);
|
|
|
|
|
if (!fs.existsSync(target.filePath)) return null;
|
|
|
|
|
return {
|
|
|
|
|
fileName: target.fileName,
|
|
|
|
|
scope: target.scope,
|
|
|
|
|
content: fs.readFileSync(target.filePath, 'utf8'),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const writePluginDirFile = (
|
|
|
|
|
file: { fileName?: unknown; content?: unknown; scope?: unknown },
|
|
|
|
|
workingDirectory?: string,
|
|
|
|
|
opts: { overwrite?: boolean } = {},
|
|
|
|
|
): void => {
|
|
|
|
|
const scope = validatePluginScope(file.scope || 'user');
|
|
|
|
|
const fileName = validatePluginFileName(file.fileName);
|
|
|
|
|
const filePath = path.join(getPluginDir(scope, workingDirectory), fileName);
|
|
|
|
|
if (!opts.overwrite && fs.existsSync(filePath)) {
|
|
|
|
|
throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
|
|
|
|
|
}
|
|
|
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
|
|
|
fs.writeFileSync(filePath, typeof file.content === 'string' ? file.content : '', 'utf8');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const deletePluginDirFile = (id: string, workingDirectory?: string): void => {
|
|
|
|
|
const target = getPluginFileTarget(id, workingDirectory);
|
|
|
|
|
if (!fs.existsSync(target.filePath)) {
|
|
|
|
|
throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
|
|
|
|
|
}
|
|
|
|
|
fs.rmSync(target.filePath, { force: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type NpmLookupResult =
|
|
|
|
|
| { ok: true; latest: string | null; versions: string[] }
|
|
|
|
|
| { ok: false; status: number | 'network'; error: string };
|
|
|
|
|
|
|
|
|
|
const npmInfoCache = new Map<string, { fetchedAt: number; payload: NpmLookupResult }>();
|
|
|
|
|
const npmInfoInFlight = new Map<string, Promise<NpmLookupResult>>();
|
|
|
|
|
const NPM_CACHE_TTL_MS = 3_600_000;
|
|
|
|
|
|
|
|
|
|
const lookupNpmPackage = async (name: string): Promise<NpmLookupResult> => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name).replace(/^%40/, '@')}`, {
|
|
|
|
|
headers: { Accept: 'application/json', 'User-Agent': 'openchamber-vscode/dev' },
|
|
|
|
|
signal: AbortSignal.timeout(5000),
|
|
|
|
|
});
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json() as { versions?: unknown; 'dist-tags'?: { latest?: unknown } };
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
latest: typeof data['dist-tags']?.latest === 'string' ? data['dist-tags'].latest : null,
|
|
|
|
|
versions: isPlainObject(data.versions) ? Object.keys(data.versions) : [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (response.status === 404) return { ok: false, status: 404, error: 'Package not found' };
|
|
|
|
|
return { ok: false, status: response.status, error: `Registry returned ${response.status}` };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { ok: false, status: 'network', error: error instanceof Error ? error.message : String(error) };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getNpmInfo = async (name: string, forceRefresh = false): Promise<NpmLookupResult> => {
|
|
|
|
|
const cached = npmInfoCache.get(name);
|
|
|
|
|
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) {
|
|
|
|
|
return cached.payload;
|
|
|
|
|
}
|
|
|
|
|
const existing = npmInfoInFlight.get(name);
|
|
|
|
|
if (existing && !forceRefresh) return existing;
|
|
|
|
|
const lookup = lookupNpmPackage(name);
|
|
|
|
|
npmInfoInFlight.set(name, lookup);
|
|
|
|
|
try {
|
|
|
|
|
const result = await lookup;
|
|
|
|
|
if (result.ok || result.status === 404) {
|
|
|
|
|
npmInfoCache.set(name, { fetchedAt: Date.now(), payload: result });
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
} finally {
|
|
|
|
|
if (npmInfoInFlight.get(name) === lookup) {
|
|
|
|
|
npmInfoInFlight.delete(name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const queryPluginRegistry = async (
|
|
|
|
|
specs: string[],
|
|
|
|
|
opts: { refresh?: boolean; workingDirectory?: string } = {},
|
|
|
|
|
): Promise<{ results: PluginRegistryResult[] }> => {
|
|
|
|
|
const uniqueSpecs = Array.from(new Set(specs.filter((spec) => spec.length > 0)));
|
|
|
|
|
if (uniqueSpecs.length > 100) {
|
|
|
|
|
throw codedError('too many specs', 'INVALID_SPEC');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const npmJobs = new Map<string, string[]>();
|
|
|
|
|
const malformedSpecs = new Set<string>();
|
|
|
|
|
for (const spec of uniqueSpecs) {
|
|
|
|
|
if (isPluginPathSpec(spec)) continue;
|
|
|
|
|
const parsed = parsePluginNpmSpec(spec);
|
|
|
|
|
if ('malformed' in parsed) {
|
|
|
|
|
malformedSpecs.add(spec);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
npmJobs.set(parsed.name, [...(npmJobs.get(parsed.name) || []), spec]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const npmInfoByName = new Map<string, NpmLookupResult>();
|
|
|
|
|
await Promise.all(Array.from(npmJobs.keys()).map(async (name) => {
|
|
|
|
|
npmInfoByName.set(name, await getNpmInfo(name, opts.refresh === true));
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const results: PluginRegistryResult[] = [];
|
|
|
|
|
for (const spec of uniqueSpecs) {
|
|
|
|
|
if (malformedSpecs.has(spec)) {
|
|
|
|
|
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPluginPathSpec(spec)) {
|
|
|
|
|
const { absolutePath } = parsePluginPathSpec(spec, opts.workingDirectory || os.homedir());
|
|
|
|
|
try {
|
|
|
|
|
fs.statSync(absolutePath);
|
|
|
|
|
} catch {
|
|
|
|
|
results.push({ kind: 'path-missing', spec, absolutePath });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
fs.accessSync(absolutePath, fs.constants.R_OK);
|
|
|
|
|
results.push({ kind: 'path-ok', spec, absolutePath });
|
|
|
|
|
} catch {
|
|
|
|
|
results.push({ kind: 'path-unreadable', spec, absolutePath });
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = parsePluginNpmSpec(spec);
|
|
|
|
|
if ('malformed' in parsed) {
|
|
|
|
|
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const info = npmInfoByName.get(parsed.name);
|
|
|
|
|
if (!info?.ok) {
|
|
|
|
|
if (info?.status === 404) {
|
|
|
|
|
results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error });
|
|
|
|
|
} else {
|
|
|
|
|
results.push({ kind: 'npm-network', spec, error: info?.status === 'network' ? info.error : `Registry returned ${info?.status ?? 'unknown'}` });
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const currentVersion = parsed.version;
|
|
|
|
|
if (currentVersion !== null && isExactPluginSemver(currentVersion) && !info.versions.includes(currentVersion)) {
|
|
|
|
|
results.push({
|
|
|
|
|
kind: 'npm-missing-version',
|
|
|
|
|
spec,
|
|
|
|
|
name: parsed.name,
|
|
|
|
|
currentVersion,
|
|
|
|
|
latestVersion: info.latest,
|
|
|
|
|
versions: info.versions,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
results.push({
|
|
|
|
|
kind: 'npm-ok',
|
|
|
|
|
spec,
|
|
|
|
|
name: parsed.name,
|
|
|
|
|
currentVersion,
|
|
|
|
|
latestVersion: info.latest,
|
|
|
|
|
versions: info.versions,
|
|
|
|
|
hasUpdate: currentVersion !== null && isExactPluginSemver(currentVersion) && currentVersion !== info.latest,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { results };
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
export type McpConfigEntry = {
|
|
|
|
|
name: string;
|
|
|
|
|
scope?: AgentScope | null;
|
|
|
|
|
type: 'local' | 'remote';
|
|
|
|
|
command?: string[];
|
|
|
|
|
url?: string;
|
|
|
|
|
environment?: Record<string, string>;
|
2026-04-30 22:34:12 +02:00
|
|
|
headers?: Record<string, string>;
|
2026-02-23 04:22:33 +07:00
|
|
|
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));
|
|
|
|
|
}
|
2026-04-30 22:34:12 +02:00
|
|
|
} else {
|
|
|
|
|
if (typeof data.url === 'string' && data.url.trim()) {
|
|
|
|
|
entry.url = data.url.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPlainObject(data.headers)) {
|
|
|
|
|
const cleanedHeaders: Record<string, string> = {};
|
|
|
|
|
for (const [key, value] of Object.entries(data.headers)) {
|
|
|
|
|
if (key && value != null) {
|
|
|
|
|
cleanedHeaders[key] = String(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(cleanedHeaders).length > 0) {
|
|
|
|
|
entry.headers = cleanedHeaders;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const getJsonEntrySource = (
|
|
|
|
|
layers: ReturnType<typeof readConfigLayers>,
|
2026-02-23 04:22:33 +07:00
|
|
|
sectionKey: 'agent' | 'command' | 'mcp',
|
2025-12-29 22:42:10 +02:00
|
|
|
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 };
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-08 00:12:53 +03:00
|
|
|
const getAgentPermissionSource = (agentName: string, workingDirectory?: string) => {
|
|
|
|
|
if (workingDirectory) {
|
|
|
|
|
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
|
|
|
|
if (fs.existsSync(projectMdPath)) {
|
|
|
|
|
const { frontmatter } = parseMdFile(projectMdPath);
|
|
|
|
|
if (frontmatter.permission !== undefined) {
|
|
|
|
|
return { source: 'md' as const, scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userMdPath = getUserAgentPath(agentName);
|
|
|
|
|
if (fs.existsSync(userMdPath)) {
|
|
|
|
|
const { frontmatter } = parseMdFile(userMdPath);
|
|
|
|
|
if (frontmatter.permission !== undefined) {
|
|
|
|
|
return { source: 'md' as const, scope: AGENT_SCOPE.USER, path: userMdPath };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
|
|
|
const customAgent = ((layers.customConfig as Record<string, unknown>)?.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
|
|
|
|
|
if (customAgent?.permission !== undefined && layers.paths.customPath) {
|
|
|
|
|
return { source: 'json' as const, scope: 'custom' as const, path: layers.paths.customPath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projectAgent = ((layers.projectConfig as Record<string, unknown>)?.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
|
|
|
|
|
if (projectAgent?.permission !== undefined && layers.paths.projectPath) {
|
|
|
|
|
return { source: 'json' as const, scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userAgent = ((layers.userConfig as Record<string, unknown>)?.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
|
|
|
|
|
if (userAgent?.permission !== undefined) {
|
|
|
|
|
return { source: 'json' as const, scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { source: null, scope: null, path: null };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mergePermissionWithNonWildcards = (newPermission: unknown, permissionSource: ReturnType<typeof getAgentPermissionSource>, agentName: string) => {
|
|
|
|
|
if (!permissionSource.source || !permissionSource.path) {
|
|
|
|
|
return newPermission;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let existingPermission: unknown = null;
|
|
|
|
|
if (permissionSource.source === 'md') {
|
|
|
|
|
const { frontmatter } = parseMdFile(permissionSource.path);
|
|
|
|
|
existingPermission = frontmatter.permission;
|
|
|
|
|
} else if (permissionSource.source === 'json') {
|
|
|
|
|
const config = readConfigFile(permissionSource.path) as Record<string, unknown>;
|
|
|
|
|
existingPermission = (((config.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined)?.permission);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!existingPermission || typeof existingPermission === 'string' || newPermission == null || typeof newPermission === 'string') {
|
|
|
|
|
return newPermission;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof existingPermission !== 'object' || Array.isArray(existingPermission) || typeof newPermission !== 'object' || Array.isArray(newPermission)) {
|
|
|
|
|
return newPermission;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nonWildcardPatterns: Record<string, Record<string, unknown>> = {};
|
|
|
|
|
for (const [permKey, permValue] of Object.entries(existingPermission as Record<string, unknown>)) {
|
|
|
|
|
if (permKey === '*' || typeof permValue !== 'object' || permValue === null || Array.isArray(permValue)) continue;
|
|
|
|
|
const nonWildcards: Record<string, unknown> = {};
|
|
|
|
|
for (const [pattern, action] of Object.entries(permValue as Record<string, unknown>)) {
|
|
|
|
|
if (pattern !== '*') {
|
|
|
|
|
nonWildcards[pattern] = action;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(nonWildcards).length > 0) {
|
|
|
|
|
nonWildcardPatterns[permKey] = nonWildcards;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(nonWildcardPatterns).length === 0) {
|
|
|
|
|
return newPermission;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const merged: Record<string, unknown> = { ...(newPermission as Record<string, unknown>) };
|
|
|
|
|
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
|
|
|
|
|
const newValue = merged[permKey];
|
|
|
|
|
if (typeof newValue === 'string') {
|
|
|
|
|
merged[permKey] = { '*': newValue, ...patterns };
|
|
|
|
|
} else if (typeof newValue === 'object' && newValue !== null && !Array.isArray(newValue)) {
|
|
|
|
|
merged[permKey] = { ...patterns, ...(newValue as Record<string, unknown>) };
|
|
|
|
|
} else {
|
|
|
|
|
const existingValue = (existingPermission as Record<string, unknown>)[permKey];
|
|
|
|
|
if (typeof existingValue === 'object' && existingValue !== null && !Array.isArray(existingValue)) {
|
|
|
|
|
const wildcard = (existingValue as Record<string, unknown>)['*'];
|
|
|
|
|
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return merged;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
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() };
|
2026-01-06 21:31:04 +02:00
|
|
|
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() };
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
|
2025-12-29 17:48:06 +02:00
|
|
|
// 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);
|
2025-12-24 22:50:46 +02:00
|
|
|
const content = `---\n${yamlStr}---\n\n${body ?? ''}`.trimEnd();
|
|
|
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
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);
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
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;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
const sources: ConfigSources = {
|
2025-12-29 22:42:10 +02:00
|
|
|
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 }
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
if (mdExists && mdPath) {
|
2025-12-24 22:50:46 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
export const createAgent = (agentName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: AgentScope) => {
|
2025-12-24 22:50:46 +02:00
|
|
|
ensureDirs();
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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`);
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
|
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
|
|
|
if (jsonSource.exists) throw new Error(`Agent ${agentName} already exists in opencode.json`);
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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
|
2026-06-29 11:56:40 +03:00
|
|
|
const { prompt, scope: _ignored, ...rawFrontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
|
2025-12-29 22:42:10 +02:00
|
|
|
void _ignored; // Scope is only used for path determination
|
2026-06-29 11:56:40 +03:00
|
|
|
const frontmatter = Object.fromEntries(
|
|
|
|
|
Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined)
|
|
|
|
|
);
|
2025-12-29 22:42:10 +02:00
|
|
|
writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : '');
|
2026-02-23 17:28:24 +07:00
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-29 11:56:40 +03:00
|
|
|
const deleteAgentJsonField = (config: Record<string, unknown>, agentName: string, field: string): boolean => {
|
|
|
|
|
const agentMap = config.agent as Record<string, unknown> | undefined;
|
|
|
|
|
const current = agentMap?.[agentName] as Record<string, unknown> | undefined;
|
|
|
|
|
if (!agentMap || !current || !(field in current)) return false;
|
|
|
|
|
|
|
|
|
|
delete current[field];
|
|
|
|
|
if (Object.keys(current).length === 0) {
|
|
|
|
|
delete agentMap[agentName];
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(agentMap).length === 0) {
|
|
|
|
|
delete config.agent;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
|
2025-12-24 22:50:46 +02:00
|
|
|
ensureDirs();
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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 }
|
2026-06-08 00:12:53 +03:00
|
|
|
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
2025-12-29 22:42:10 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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);
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
let mdModified = false;
|
|
|
|
|
let jsonModified = false;
|
2025-12-29 22:42:10 +02:00
|
|
|
// Only create new md if it's a built-in override
|
2025-12-30 00:04:48 +02:00
|
|
|
const creatingNewMd = isBuiltinOverride;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
for (const [field, value] of Object.entries(updates || {})) {
|
2026-07-12 00:05:16 +11:00
|
|
|
// Skip undefined values — they would overwrite existing frontmatter fields with nothing
|
|
|
|
|
if (value === undefined) continue;
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
if (field === 'prompt') {
|
2026-06-08 13:43:45 +03:00
|
|
|
if (value === null) {
|
|
|
|
|
if (mdExists || creatingNewMd) {
|
|
|
|
|
if (mdData) {
|
|
|
|
|
mdData.body = '';
|
|
|
|
|
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, '');
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config.agent) {
|
|
|
|
|
const agentMap = config.agent as Record<string, unknown>;
|
|
|
|
|
const current = agentMap[agentName] as Record<string, unknown> | undefined;
|
|
|
|
|
if (current) {
|
|
|
|
|
delete current.prompt;
|
|
|
|
|
if (Object.keys(current).length === 0) {
|
|
|
|
|
delete agentMap[agentName];
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(agentMap).length === 0) {
|
|
|
|
|
delete config.agent;
|
|
|
|
|
}
|
|
|
|
|
jsonModified = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
if (mdExists || creatingNewMd) {
|
|
|
|
|
if (mdData) {
|
|
|
|
|
mdData.body = normalizedValue;
|
|
|
|
|
mdModified = true;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// For JSON-only agents, store prompt inline in JSON
|
2025-12-24 22:50:46 +02:00
|
|
|
if (!config.agent) config.agent = {};
|
2025-12-29 22:42:10 +02:00
|
|
|
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
|
|
|
|
|
(config.agent as Record<string, unknown>)[agentName] = { ...current, prompt: normalizedValue };
|
2025-12-24 22:50:46 +02:00
|
|
|
jsonModified = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 00:12:53 +03:00
|
|
|
if (field === 'permission') {
|
|
|
|
|
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
|
|
|
|
|
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName);
|
|
|
|
|
|
|
|
|
|
if (permissionSource.source === 'md' && permissionSource.path) {
|
|
|
|
|
if (mdData && permissionSource.path === targetPath) {
|
|
|
|
|
mdData.frontmatter.permission = newPermission;
|
|
|
|
|
mdModified = true;
|
|
|
|
|
} else {
|
|
|
|
|
const existingMdData = parseMdFile(permissionSource.path);
|
|
|
|
|
existingMdData.frontmatter.permission = newPermission;
|
|
|
|
|
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
|
|
|
|
|
}
|
|
|
|
|
} else if (permissionSource.source === 'json' && permissionSource.path) {
|
|
|
|
|
if (permissionSource.path === (jsonTarget.path || CONFIG_FILE)) {
|
|
|
|
|
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, permission: newPermission };
|
|
|
|
|
jsonModified = true;
|
|
|
|
|
} else {
|
|
|
|
|
const existingConfig = readConfigFile(permissionSource.path) as Record<string, unknown>;
|
|
|
|
|
const agentMap = (existingConfig.agent as Record<string, unknown> | undefined) ?? {};
|
|
|
|
|
const current = (agentMap[agentName] as Record<string, unknown> | undefined) ?? {};
|
|
|
|
|
agentMap[agentName] = { ...current, permission: newPermission };
|
|
|
|
|
existingConfig.agent = agentMap;
|
|
|
|
|
writeConfig(existingConfig, permissionSource.path);
|
|
|
|
|
}
|
|
|
|
|
} else if (mdExists && mdData) {
|
|
|
|
|
mdData.frontmatter.permission = newPermission;
|
|
|
|
|
mdModified = true;
|
|
|
|
|
} else if (hasJsonFields) {
|
|
|
|
|
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, permission: newPermission };
|
|
|
|
|
jsonModified = true;
|
|
|
|
|
} else {
|
|
|
|
|
const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
|
|
|
const targetConfig = (writeTarget.config || {}) as Record<string, unknown>;
|
|
|
|
|
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
|
|
|
|
|
const current = (agentMap[agentName] as Record<string, unknown> | undefined) ?? {};
|
|
|
|
|
agentMap[agentName] = { ...current, permission: newPermission };
|
|
|
|
|
targetConfig.agent = agentMap;
|
|
|
|
|
writeConfig(targetConfig, writeTarget.path || CONFIG_FILE);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
|
|
|
|
|
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
|
|
|
|
|
|
2026-06-29 11:56:40 +03:00
|
|
|
if (value === null) {
|
|
|
|
|
if (hasMdField && mdData) {
|
|
|
|
|
delete mdData.frontmatter[field];
|
|
|
|
|
mdModified = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasJsonField && deleteAgentJsonField(config, agentName, field)) {
|
|
|
|
|
jsonModified = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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;
|
2025-12-24 22:50:46 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
if (hasMdField || creatingNewMd) {
|
|
|
|
|
if (mdData) {
|
|
|
|
|
mdData.frontmatter[field] = value;
|
|
|
|
|
mdModified = true;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-29 22:42:10 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
if (mdModified && mdData && targetPath) {
|
|
|
|
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (jsonModified) {
|
2025-12-29 22:42:10 +02:00
|
|
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
2026-02-23 17:28:24 +07:00
|
|
|
|
|
|
|
|
if (mdModified || isBuiltinOverride) {
|
|
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-16 13:38:24 +03:00
|
|
|
const deleteJsonAgentEntry = (config: Record<string, unknown>, agentName: string): boolean => {
|
|
|
|
|
const agentMap = config.agent as Record<string, unknown> | undefined;
|
|
|
|
|
if (!agentMap?.[agentName]) return false;
|
|
|
|
|
delete agentMap[agentName];
|
|
|
|
|
if (Object.keys(agentMap).length > 0) {
|
|
|
|
|
config.agent = agentMap;
|
|
|
|
|
} else {
|
|
|
|
|
delete config.agent;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const deleteAgent = (agentName: string, workingDirectory?: string, scope?: AgentScope) => {
|
|
|
|
|
const requestedScope = scope === AGENT_SCOPE.PROJECT || scope === AGENT_SCOPE.USER ? scope : null;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// Check project level first (takes precedence)
|
2026-06-16 13:38:24 +03:00
|
|
|
if ((!requestedScope || requestedScope === AGENT_SCOPE.PROJECT) && workingDirectory) {
|
2025-12-29 22:42:10 +02:00
|
|
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
|
|
|
if (fs.existsSync(projectPath)) {
|
|
|
|
|
fs.unlinkSync(projectPath);
|
2026-06-16 13:38:24 +03:00
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
|
|
|
return;
|
2025-12-29 22:42:10 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Then check user level
|
2026-06-16 13:38:24 +03:00
|
|
|
if (!requestedScope || requestedScope === AGENT_SCOPE.USER) {
|
|
|
|
|
const userPath = getUserAgentPath(agentName);
|
|
|
|
|
if (fs.existsSync(userPath)) {
|
|
|
|
|
fs.unlinkSync(userPath);
|
|
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const layers = readConfigLayers(workingDirectory);
|
2026-06-16 13:38:24 +03:00
|
|
|
|
|
|
|
|
if (requestedScope === AGENT_SCOPE.PROJECT) {
|
|
|
|
|
if (layers.paths.projectPath && deleteJsonAgentEntry(layers.projectConfig, agentName)) {
|
|
|
|
|
writeConfig(layers.projectConfig, layers.paths.projectPath);
|
|
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Project agent ${agentName} not found`);
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 13:38:24 +03:00
|
|
|
if (requestedScope === AGENT_SCOPE.USER) {
|
|
|
|
|
const userJsonPath = layers.paths.customPath || layers.paths.userPath;
|
|
|
|
|
const userJsonConfig = layers.paths.customPath ? layers.customConfig : layers.userConfig;
|
|
|
|
|
if (userJsonPath && deleteJsonAgentEntry(userJsonConfig, agentName)) {
|
|
|
|
|
writeConfig(userJsonConfig, userJsonPath);
|
|
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`User agent ${agentName} not found`);
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
2026-02-23 17:28:24 +07:00
|
|
|
|
2026-06-16 13:38:24 +03:00
|
|
|
// Also check json config (highest precedence entry only)
|
|
|
|
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
|
|
|
if (jsonSource.exists && jsonSource.config && jsonSource.path && deleteJsonAgentEntry(jsonSource.config, agentName)) {
|
|
|
|
|
writeConfig(jsonSource.config, jsonSource.path);
|
|
|
|
|
resetAgentLookupCache(globalAgentLookupCache);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`Agent ${agentName} is built-in or not deletable`);
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
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);
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
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;
|
2026-01-06 21:31:04 +02:00
|
|
|
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
const sources: ConfigSources = {
|
2025-12-29 17:48:06 +02:00
|
|
|
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
|
2026-01-06 21:31:04 +02:00
|
|
|
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
|
2025-12-29 17:48:06 +02:00
|
|
|
projectMd: { exists: projectExists, path: projectPath },
|
|
|
|
|
userMd: { exists: userExists, path: userPath }
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
if (mdExists && mdPath) {
|
2025-12-24 22:50:46 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
export const createCommand = (commandName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: CommandScope) => {
|
2025-12-24 22:50:46 +02:00
|
|
|
ensureDirs();
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
// 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`);
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
|
|
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
|
|
|
|
if (jsonSource.exists) throw new Error(`Command ${commandName} already exists in opencode.json`);
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
// 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 : '');
|
2025-12-24 22:50:46 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
export const updateCommand = (commandName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
|
2025-12-24 22:50:46 +02:00
|
|
|
ensureDirs();
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
// Determine correct path: project level takes precedence
|
|
|
|
|
const { path: mdPath } = getCommandWritePath(commandName, workingDirectory);
|
|
|
|
|
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
|
2025-12-29 22:42:10 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
let targetPath = mdPath;
|
2025-12-29 22:42:10 +02:00
|
|
|
if (!mdExists && isBuiltinOverride) {
|
|
|
|
|
// Built-in command override - create at user level
|
2025-12-29 17:48:06 +02:00
|
|
|
targetPath = getUserCommandPath(commandName);
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, body: '' } : null);
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
let mdModified = false;
|
|
|
|
|
let jsonModified = false;
|
2025-12-30 00:04:48 +02:00
|
|
|
const creatingNewMd = isBuiltinOverride;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
for (const [field, value] of Object.entries(updates || {})) {
|
|
|
|
|
if (field === 'template') {
|
|
|
|
|
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
if (mdExists || creatingNewMd) {
|
2025-12-29 22:42:10 +02:00
|
|
|
if (mdData) {
|
|
|
|
|
mdData.body = normalizedValue;
|
|
|
|
|
mdModified = true;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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;
|
2025-12-24 22:50:46 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
|
|
|
|
|
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// JSON takes precedence over md, so update JSON first if field exists there
|
2025-12-24 22:50:46 +02:00
|
|
|
if (hasJsonField) {
|
2025-12-29 17:48:06 +02:00
|
|
|
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;
|
2025-12-24 22:50:46 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-29 17:48:06 +02:00
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
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) {
|
2025-12-29 17:48:06 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
if (mdModified && mdData && targetPath) {
|
2025-12-29 17:48:06 +02:00
|
|
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (jsonModified) {
|
2025-12-29 22:42:10 +02:00
|
|
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
2025-12-24 22:50:46 +02:00
|
|
|
}
|
|
|
|
|
};
|
2026-01-22 13:45:30 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
|
2025-12-24 22:50:46 +02:00
|
|
|
let deleted = false;
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
// 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);
|
2025-12-24 22:50:46 +02:00
|
|
|
deleted = true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:42:10 +02:00
|
|
|
// 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) ?? {};
|
2025-12-24 22:50:46 +02:00
|
|
|
delete commandMap[commandName];
|
2025-12-29 22:42:10 +02:00
|
|
|
targetConfig.command = commandMap;
|
|
|
|
|
writeConfig(targetConfig, jsonSource.path);
|
2025-12-24 22:50:46 +02:00
|
|
|
deleted = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!deleted) {
|
|
|
|
|
throw new Error(`Command "${commandName}" not found`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export const listSnippets = (workingDirectory?: string): Snippet[] => {
|
|
|
|
|
return listUniqueSnippets(loadSnippetRegistry(workingDirectory));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getSnippet = (name: string, workingDirectory?: string): Snippet | null => {
|
|
|
|
|
return findSnippetByName(name, workingDirectory);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const createSnippet = (
|
|
|
|
|
name: string,
|
|
|
|
|
config: Record<string, unknown>,
|
|
|
|
|
workingDirectory?: string,
|
|
|
|
|
scope: SnippetScope = 'global',
|
|
|
|
|
): Snippet | null => {
|
|
|
|
|
assertValidSnippetName(name);
|
|
|
|
|
const dir = getWritableSnippetDir(scope, workingDirectory);
|
|
|
|
|
const filePath = path.join(dir, `${name}${SNIPPET_EXTENSION}`);
|
|
|
|
|
if (fs.existsSync(filePath)) throw new Error(`Snippet "${name}" already exists`);
|
|
|
|
|
writeSnippetFile(filePath, config || {});
|
|
|
|
|
return getSnippet(name, workingDirectory);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const updateSnippet = (name: string, updates: Record<string, unknown>, workingDirectory?: string): Snippet | null => {
|
|
|
|
|
const existing = findSnippetByName(name, workingDirectory);
|
|
|
|
|
if (!existing) throw new Error(`Snippet "${name}" not found`);
|
|
|
|
|
writeSnippetFile(existing.filePath, { ...existing, ...(updates || {}) });
|
|
|
|
|
return getSnippet(name, workingDirectory);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const deleteSnippet = (name: string, workingDirectory?: string): void => {
|
|
|
|
|
const existing = findSnippetByName(name, workingDirectory);
|
|
|
|
|
if (!existing) throw new Error(`Snippet "${name}" not found`);
|
|
|
|
|
fs.unlinkSync(existing.filePath);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const expandSnippets = (text: string, workingDirectory?: string): string => {
|
|
|
|
|
const registry = loadSnippetRegistry(workingDirectory);
|
|
|
|
|
const collector = { prepend: [] as string[], append: [] as string[] };
|
|
|
|
|
const expanded = expandSnippetText(text || '', registry, new Map(), collector).trim();
|
|
|
|
|
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-30 15:11:07 +02:00
|
|
|
// ============== SKILL SCOPE HELPERS ==============
|
|
|
|
|
|
2026-01-23 00:27:01 +02:00
|
|
|
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
2025-12-30 15:11:07 +02:00
|
|
|
|
|
|
|
|
export const SKILL_SCOPE = {
|
|
|
|
|
USER: 'user',
|
|
|
|
|
PROJECT: 'project'
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
|
2026-02-18 12:26:08 +02:00
|
|
|
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
2025-12-30 15:11:07 +02:00
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
type SupportingFile = {
|
2025-12-30 15:11:07 +02:00
|
|
|
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[];
|
2026-05-23 10:58:12 -07:00
|
|
|
name?: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
instructions?: string;
|
2025-12-30 15:11:07 +02:00
|
|
|
};
|
|
|
|
|
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;
|
2026-02-18 12:26:08 +02:00
|
|
|
description?: string;
|
2026-05-23 10:58:12 -07:00
|
|
|
content?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const BUILT_IN_SKILL_LOCATION = '<built-in>';
|
|
|
|
|
|
|
|
|
|
export const mergeDiscoveredSkills = (
|
|
|
|
|
primarySkills: DiscoveredSkill[] = [],
|
|
|
|
|
fallbackSkills: DiscoveredSkill[] = []
|
|
|
|
|
): DiscoveredSkill[] => {
|
|
|
|
|
const merged: DiscoveredSkill[] = [];
|
|
|
|
|
const seenNames = new Set<string>();
|
|
|
|
|
|
|
|
|
|
const appendSkill = (skill: DiscoveredSkill | null | undefined) => {
|
|
|
|
|
if (!skill) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
|
|
|
|
|
if (!name || seenNames.has(name)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
seenNames.add(name);
|
|
|
|
|
merged.push(skill);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const skill of primarySkills || []) appendSkill(skill);
|
|
|
|
|
for (const skill of fallbackSkills || []) appendSkill(skill);
|
|
|
|
|
|
|
|
|
|
return merged;
|
2026-02-18 12:26:08 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
}
|
2025-12-30 15:11:07 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensureSkillDirs = () => {
|
|
|
|
|
if (!fs.existsSync(SKILL_DIR)) {
|
|
|
|
|
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getUserSkillDir = (skillName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-30 15:11:07 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getUserSkillPath = (skillName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-30 15:11:07 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getProjectSkillDir = (workingDirectory: string, skillName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-30 15:11:07 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getProjectSkillPath = (workingDirectory: string, skillName: string): string => {
|
2026-01-23 00:27:01 +02:00
|
|
|
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;
|
2025-12-30 15:11:07 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 12:26:08 +02:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
const getSkillScope = (skillName: string, workingDirectory?: string): {
|
|
|
|
|
scope: SkillScope | null;
|
|
|
|
|
path: string | null;
|
2025-12-30 15:11:07 +02:00
|
|
|
source: SkillSource | null;
|
|
|
|
|
} => {
|
2026-02-18 12:26:08 +02:00
|
|
|
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
|
|
|
|
if (discovered?.path) {
|
|
|
|
|
return { scope: discovered.scope, path: discovered.path, source: discovered.source };
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 15:11:07 +02:00
|
|
|
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>();
|
2026-02-18 12:26:08 +02:00
|
|
|
|
|
|
|
|
// 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);
|
2025-12-30 15:11:07 +02:00
|
|
|
}
|
2026-02-18 12:26:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2) External project ancestors (.claude, .agents)
|
2025-12-30 15:11:07 +02:00
|
|
|
if (workingDirectory) {
|
2026-02-18 12:26:08 +02:00
|
|
|
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);
|
2025-12-30 15:11:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-18 12:26:08 +02:00
|
|
|
}
|
2026-01-23 00:27:01 +02:00
|
|
|
|
2026-02-18 12:26:08 +02:00
|
|
|
// 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');
|
2025-12-30 15:11:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-18 12:26:08 +02:00
|
|
|
|
|
|
|
|
// 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');
|
2025-12-30 15:11:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-23 00:27:01 +02:00
|
|
|
|
2026-02-18 12:26:08 +02:00
|
|
|
// 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 });
|
2026-01-23 00:27:01 +02:00
|
|
|
for (const entry of entries) {
|
2026-02-18 12:26:08 +02:00
|
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
|
const skillRoot = path.join(cacheRoot, entry.name);
|
|
|
|
|
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
|
|
|
|
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
2026-01-23 00:27:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-18 12:26:08 +02:00
|
|
|
|
2025-12-30 15:11:07 +02:00
|
|
|
return Array.from(skills.values());
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 12:26:08 +02:00
|
|
|
export const getSkillSources = (
|
|
|
|
|
skillName: string,
|
|
|
|
|
workingDirectory?: string,
|
|
|
|
|
discoveredSkill?: DiscoveredSkill | null
|
|
|
|
|
): SkillConfigSources => {
|
2025-12-30 15:11:07 +02:00
|
|
|
ensureSkillDirs();
|
2026-05-23 10:58:12 -07:00
|
|
|
const isReadableFile = (filePath: string | null): boolean => {
|
|
|
|
|
if (!filePath) return false;
|
|
|
|
|
try {
|
|
|
|
|
return fs.statSync(filePath).isFile();
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-12-30 15:11:07 +02:00
|
|
|
|
|
|
|
|
// 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;
|
2026-02-18 12:26:08 +02:00
|
|
|
|
|
|
|
|
const matchedDiscovered = discoveredSkill?.name === skillName
|
|
|
|
|
? discoveredSkill
|
|
|
|
|
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
2026-05-23 10:58:12 -07:00
|
|
|
const discoveredPath = typeof matchedDiscovered?.path === 'string' ? matchedDiscovered.path : null;
|
|
|
|
|
const isBuiltInDiscovered = discoveredPath === BUILT_IN_SKILL_LOCATION;
|
2025-12-30 15:11:07 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-05-23 10:58:12 -07:00
|
|
|
if (isBuiltInDiscovered) {
|
|
|
|
|
mdScope = matchedDiscovered?.scope || SKILL_SCOPE.USER;
|
|
|
|
|
mdSource = matchedDiscovered?.source || 'opencode';
|
|
|
|
|
} else if (discoveredPath && isReadableFile(discoveredPath)) {
|
|
|
|
|
mdPath = discoveredPath;
|
|
|
|
|
mdScope = matchedDiscovered?.scope || null;
|
|
|
|
|
mdSource = matchedDiscovered?.source || null;
|
|
|
|
|
mdDir = path.dirname(discoveredPath);
|
|
|
|
|
} else if (projectExists) {
|
2025-12-30 15:11:07 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 10:58:12 -07:00
|
|
|
const mdExists = isBuiltInDiscovered || !!mdPath;
|
|
|
|
|
let mdFields: string[] = isBuiltInDiscovered ? ['description', 'instructions'] : [];
|
2025-12-30 15:11:07 +02:00
|
|
|
let supportingFiles: SupportingFile[] = [];
|
2026-05-23 10:58:12 -07:00
|
|
|
let mdDescription = typeof matchedDiscovered?.description === 'string' ? matchedDiscovered.description : '';
|
|
|
|
|
let mdInstructions = isBuiltInDiscovered && typeof matchedDiscovered?.content === 'string' ? matchedDiscovered.content : '';
|
2025-12-30 15:11:07 +02:00
|
|
|
|
|
|
|
|
if (mdExists && mdPath) {
|
|
|
|
|
const { frontmatter, body } = parseMdFile(mdPath);
|
|
|
|
|
mdFields = Object.keys(frontmatter);
|
2026-05-23 10:58:12 -07:00
|
|
|
mdDescription = typeof frontmatter.description === 'string' ? frontmatter.description : '';
|
2025-12-30 15:11:07 +02:00
|
|
|
if (body) mdFields.push('instructions');
|
2026-05-23 10:58:12 -07:00
|
|
|
mdInstructions = body || '';
|
2025-12-30 15:11:07 +02:00
|
|
|
if (mdDir) {
|
|
|
|
|
supportingFiles = listSupportingFiles(mdDir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
md: {
|
|
|
|
|
exists: mdExists,
|
|
|
|
|
path: mdPath,
|
|
|
|
|
dir: mdDir,
|
|
|
|
|
fields: mdFields,
|
|
|
|
|
scope: mdScope,
|
|
|
|
|
source: mdSource,
|
2026-05-23 10:58:12 -07:00
|
|
|
supportingFiles,
|
|
|
|
|
name: matchedDiscovered?.name || skillName,
|
|
|
|
|
description: mdDescription,
|
|
|
|
|
instructions: mdInstructions,
|
2025-12-30 15:11:07 +02:00
|
|
|
},
|
|
|
|
|
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;
|
|
|
|
|
|
2026-02-18 12:26:08 +02:00
|
|
|
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);
|
2025-12-30 15:11:07 +02:00
|
|
|
} else {
|
2026-02-18 12:26:08 +02:00
|
|
|
targetDir = requestedSource === 'agents'
|
|
|
|
|
? getUserAgentsSkillDir(skillName)
|
|
|
|
|
: getUserSkillDir(skillName);
|
2025-12-30 15:11:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
|
|
|
const targetPath = path.join(targetDir, 'SKILL.md');
|
|
|
|
|
|
|
|
|
|
// Extract fields
|
2026-02-18 12:26:08 +02:00
|
|
|
const { instructions, scope: _ignored, source: _sourceIgnored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
|
2025-12-30 15:11:07 +02:00
|
|
|
instructions?: unknown;
|
|
|
|
|
scope?: unknown;
|
2026-02-18 12:26:08 +02:00
|
|
|
source?: unknown;
|
2025-12-30 15:11:07 +02:00
|
|
|
supportingFiles?: Array<{ path: string; content: string }>;
|
|
|
|
|
};
|
|
|
|
|
void _ignored;
|
2026-02-18 12:26:08 +02:00
|
|
|
void _sourceIgnored;
|
2025-12-30 15:11:07 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-18 12:26:08 +02:00
|
|
|
|
|
|
|
|
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
|
|
|
|
if (fs.existsSync(projectAgentsDir)) {
|
|
|
|
|
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
|
|
|
|
|
deleted = true;
|
|
|
|
|
}
|
2025-12-30 15:11:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// User level
|
|
|
|
|
const userDir = getUserSkillDir(skillName);
|
|
|
|
|
if (fs.existsSync(userDir)) {
|
|
|
|
|
fs.rmSync(userDir, { recursive: true, force: true });
|
|
|
|
|
deleted = true;
|
|
|
|
|
}
|
2026-02-18 12:26:08 +02:00
|
|
|
|
|
|
|
|
const userAgentsDir = getUserAgentsSkillDir(skillName);
|
|
|
|
|
if (fs.existsSync(userAgentsDir)) {
|
|
|
|
|
fs.rmSync(userAgentsDir, { recursive: true, force: true });
|
|
|
|
|
deleted = true;
|
|
|
|
|
}
|
2025-12-30 15:11:07 +02:00
|
|
|
|
|
|
|
|
if (!deleted) {
|
|
|
|
|
throw new Error(`Skill "${skillName}" not found`);
|
|
|
|
|
}
|
|
|
|
|
};
|