Files
openchamber/packages/web/server/lib/opencode/snippets.js
T

243 lines
8.5 KiB
JavaScript
Raw Normal View History

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(/<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');
}