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, }; } 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) { const key = snippet.name.toLowerCase(); const existing = registry.get(key); if (existing?.name.toLowerCase() === key) { removeSnippetAliases(registry, existing, canonicalNames); } registry.set(key, snippet); canonicalNames.add(key); for (const alias of snippet.aliases) { const aliasKey = alias.toLowerCase(); if (SNIPPET_NAME_PATTERN.test(alias) && !canonicalNames.has(aliasKey)) registry.set(aliasKey, snippet); } } function loadSnippetRegistry(workingDirectory) { const registry = new Map(); const canonicalNames = new Set(); 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); if (snippet) registerSnippet(registry, snippet, canonicalNames); } 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(/[\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'); }