2026-05-21 20:00:35 +03:00
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import os from 'os';
|
|
|
|
|
import yaml from 'yaml';
|
|
|
|
|
|
|
|
|
|
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
|
|
|
|
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
|
|
|
|
|
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
|
|
|
|
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_EXPANSION_COUNT = 15;
|
|
|
|
|
|
|
|
|
|
function getProjectSnippetDirs(workingDirectory) {
|
|
|
|
|
if (!workingDirectory) return [];
|
|
|
|
|
return [
|
|
|
|
|
path.join(workingDirectory, '.opencode', 'snippets'),
|
|
|
|
|
path.join(workingDirectory, '.opencode', 'snippet'),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getGlobalSnippetDirs() {
|
|
|
|
|
return [GLOBAL_SNIPPET_DIR_ALT, GLOBAL_SNIPPET_DIR];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getLoadDirs(workingDirectory) {
|
|
|
|
|
return [
|
|
|
|
|
...getGlobalSnippetDirs().map((dir) => ({ dir, source: 'global' })),
|
|
|
|
|
...getProjectSnippetDirs(workingDirectory).map((dir) => ({ dir, source: 'project' })),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertValidSnippetName(name) {
|
|
|
|
|
if (typeof name !== 'string' || !SNIPPET_NAME_PATTERN.test(name)) {
|
|
|
|
|
throw new Error('Snippet name must use letters, numbers, dashes, or underscores');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseMarkdownFile(filePath) {
|
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
|
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
|
|
|
if (!match) {
|
|
|
|
|
return { frontmatter: {}, body: content.trim() };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
frontmatter: yaml.parse(match[1]) || {},
|
|
|
|
|
body: match[2].trim(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeAliases(frontmatter) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeMarkdownFile(filePath, { content, aliases = [], description }) {
|
|
|
|
|
const frontmatter = {};
|
|
|
|
|
const normalizedAliases = aliases.map((alias) => String(alias).trim()).filter(Boolean);
|
|
|
|
|
if (normalizedAliases.length > 0) frontmatter.aliases = normalizedAliases;
|
|
|
|
|
if (description?.trim()) frontmatter.description = description.trim();
|
|
|
|
|
|
|
|
|
|
const body = content ?? '';
|
|
|
|
|
const output = Object.keys(frontmatter).length > 0
|
|
|
|
|
? `---\n${yaml.stringify(frontmatter)}---\n${body ? `\n${body}` : ''}`
|
|
|
|
|
: body;
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
|
|
|
fs.writeFileSync(filePath, output, 'utf8');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadSnippetFile(dir, filename, source) {
|
|
|
|
|
const name = path.basename(filename, SNIPPET_EXTENSION);
|
|
|
|
|
if (!SNIPPET_NAME_PATTERN.test(name)) return null;
|
|
|
|
|
const filePath = path.join(dir, filename);
|
|
|
|
|
const { frontmatter, body } = parseMarkdownFile(filePath);
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
content: body,
|
|
|
|
|
aliases: normalizeAliases(frontmatter),
|
|
|
|
|
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
|
|
|
|
|
filePath,
|
|
|
|
|
source,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 13:51:45 -04:00
|
|
|
function removeSnippetAliases(registry, snippet, canonicalNames) {
|
|
|
|
|
for (const alias of snippet.aliases) {
|
|
|
|
|
const aliasKey = alias.toLowerCase();
|
|
|
|
|
if (canonicalNames.has(aliasKey)) continue;
|
|
|
|
|
if (registry.get(aliasKey) === snippet) registry.delete(aliasKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function registerSnippet(registry, snippet, canonicalNames) {
|
2026-05-21 20:00:35 +03:00
|
|
|
const key = snippet.name.toLowerCase();
|
|
|
|
|
const existing = registry.get(key);
|
2026-05-23 13:51:45 -04:00
|
|
|
if (existing?.name.toLowerCase() === key) {
|
|
|
|
|
removeSnippetAliases(registry, existing, canonicalNames);
|
2026-05-21 20:00:35 +03:00
|
|
|
}
|
|
|
|
|
registry.set(key, snippet);
|
2026-05-23 13:51:45 -04:00
|
|
|
canonicalNames.add(key);
|
2026-05-21 20:00:35 +03:00
|
|
|
for (const alias of snippet.aliases) {
|
2026-05-23 13:51:45 -04:00
|
|
|
const aliasKey = alias.toLowerCase();
|
|
|
|
|
if (SNIPPET_NAME_PATTERN.test(alias) && !canonicalNames.has(aliasKey)) registry.set(aliasKey, snippet);
|
2026-05-21 20:00:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadSnippetRegistry(workingDirectory) {
|
|
|
|
|
const registry = new Map();
|
2026-05-23 13:51:45 -04:00
|
|
|
const canonicalNames = new Set();
|
2026-05-21 20:00:35 +03:00
|
|
|
for (const { dir, source } of getLoadDirs(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);
|
2026-05-23 13:51:45 -04:00
|
|
|
if (snippet) registerSnippet(registry, snippet, canonicalNames);
|
2026-05-21 20:00:35 +03:00
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`[Snippets] Failed to load ${path.join(dir, filename)}:`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return registry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listUniqueSnippets(registry) {
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
const snippets = [];
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getWritableSnippetDir(scope, workingDirectory) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findSnippetByName(name, workingDirectory) {
|
|
|
|
|
assertValidSnippetName(name);
|
|
|
|
|
const registry = loadSnippetRegistry(workingDirectory);
|
|
|
|
|
return registry.get(name.toLowerCase()) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseSnippetBlocks(content) {
|
|
|
|
|
const blocks = { prepend: [], append: [] };
|
|
|
|
|
let inline = content;
|
|
|
|
|
for (const type of ['prepend', 'append']) {
|
|
|
|
|
const regex = new RegExp(`<${type}>([\\s\\S]*?)(?:<\\/${type}>|$)`, 'gi');
|
|
|
|
|
inline = inline.replace(regex, (_match, value) => {
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expandText(text, registry, expansionCounts, collector) {
|
|
|
|
|
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, offset, input) => {
|
|
|
|
|
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_EXPANSION_COUNT) {
|
|
|
|
|
loopDetected = true;
|
|
|
|
|
return match;
|
|
|
|
|
}
|
|
|
|
|
expansionCounts.set(key, count);
|
|
|
|
|
|
|
|
|
|
const parsed = parseSnippetBlocks(snippet.content);
|
|
|
|
|
for (const block of parsed.prepend) collector.prepend.push(expandText(block, registry, expansionCounts, collector));
|
|
|
|
|
for (const block of parsed.append) collector.append.push(expandText(block, registry, expansionCounts, collector));
|
|
|
|
|
return expandText(parsed.inline, registry, expansionCounts, collector);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
changed = expanded !== previous && !loopDetected;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return expanded;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listSnippets(workingDirectory) {
|
|
|
|
|
return listUniqueSnippets(loadSnippetRegistry(workingDirectory));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getSnippet(name, workingDirectory) {
|
|
|
|
|
return findSnippetByName(name, workingDirectory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createSnippet(name, config, workingDirectory, scope = 'global') {
|
|
|
|
|
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`);
|
|
|
|
|
writeMarkdownFile(filePath, config || {});
|
|
|
|
|
return getSnippet(name, workingDirectory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function updateSnippet(name, updates, workingDirectory) {
|
|
|
|
|
const existing = findSnippetByName(name, workingDirectory);
|
|
|
|
|
if (!existing) throw new Error(`Snippet "${name}" not found`);
|
|
|
|
|
writeMarkdownFile(existing.filePath, { ...existing, ...(updates || {}) });
|
|
|
|
|
return getSnippet(name, workingDirectory);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deleteSnippet(name, workingDirectory) {
|
|
|
|
|
const existing = findSnippetByName(name, workingDirectory);
|
|
|
|
|
if (!existing) throw new Error(`Snippet "${name}" not found`);
|
|
|
|
|
fs.unlinkSync(existing.filePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function expandSnippets(text, workingDirectory) {
|
|
|
|
|
const registry = loadSnippetRegistry(workingDirectory);
|
|
|
|
|
const collector = { prepend: [], append: [] };
|
|
|
|
|
const expanded = expandText(text || '', registry, new Map(), collector).trim();
|
|
|
|
|
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
|
|
|
|
|
}
|