import { createRouter } from '../create-router'; import { readdir, mkdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { itemsDir, type ItemType } from '../data-path'; // Shared router for the flat, file-based item types (skills, tools, processes). Each item is a // directory under $OFFICER_ITEMS_DIR/// holding a single markdown file (SKILL.md / // TOOL.md / PROCESS.md) plus an optional chat/ subdir. No scope tiers — every item is editable. type Frontmatter = { name: string; description: string }; export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string; rawYaml: string } { const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) return { frontmatter: { name: '', description: '' }, body: raw, rawYaml: '' }; const yaml = match[1]!; const body = match[2]!; const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? ''; const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ''; return { frontmatter: { name, description }, body, rawYaml: yaml }; } async function readItemDirs(dir: string, fileName: string): Promise> { const result = new Map(); try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const file = join(dir, entry.name, fileName); if (await Bun.file(file).exists()) result.set(entry.name, file); } } catch { // directory doesn't exist yet } return result; } const slugify = (name: string) => name .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, ''); type ItemRouterConfig = { type: ItemType; fileName: string; label: string }; export function createItemRouter({ type, fileName, label }: ItemRouterConfig) { const router = createRouter(); const rootDir = () => itemsDir(type); const filePathFor = (name: string) => join(rootDir(), name, fileName); const resolve = async (name: string): Promise => { const filePath = filePathFor(name); return (await Bun.file(filePath).exists()) ? filePath : null; }; router.get('/', async (ctx) => { const dirs = await readItemDirs(rootDir(), fileName); const items = await Promise.all( Array.from(dirs.entries()).map(async ([dirName, filePath]) => { const { frontmatter } = parseFrontmatter(await Bun.file(filePath).text()); return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, filePath }; }), ); items.sort((a, b) => a.name.localeCompare(b.name)); return ctx.json(items); }); router.get('/:name', async (ctx) => { const name = ctx.req.param('name'); const filePath = await resolve(name); if (!filePath) return ctx.text('Not found', 404); const { frontmatter, body, rawYaml } = parseFrontmatter(await Bun.file(filePath).text()); const chatMeta = join(dirname(filePath), 'chat', 'meta.json'); const chatSessionId = await Bun.file(chatMeta) .json() .then((m: { id: string }) => m.id) .catch(() => null); return ctx.json({ name: frontmatter.name || name, description: frontmatter.description, body, rawFrontmatter: rawYaml, filePath, chatSessionId, }); }); router.get('/:name/chat', async (ctx) => { const name = ctx.req.param('name'); const filePath = await resolve(name); if (!filePath) return ctx.text('Not found', 404); const chatDir = join(dirname(filePath), 'chat'); const sessionId = await Bun.file(join(chatDir, 'meta.json')) .json() .then((m: { id: string }) => m.id) .catch(() => null); const messages = await Bun.file(join(chatDir, 'messages.json')) .json() .catch(() => []); return ctx.json({ sessionId, messages }); }); router.put('/:name/chat', async (ctx) => { const name = ctx.req.param('name'); const filePath = await resolve(name); if (!filePath) return ctx.text('Not found', 404); const chatDir = join(dirname(filePath), 'chat'); const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>(); await mkdir(chatDir, { recursive: true }); await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages)); if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId })); return ctx.json({ ok: true }); }); router.delete('/:name/chat', async (ctx) => { const name = ctx.req.param('name'); const filePath = await resolve(name); if (!filePath) return ctx.text('Not found', 404); await rm(join(dirname(filePath), 'chat'), { recursive: true, force: true }); return ctx.json({ ok: true }); }); router.post('/', async (ctx) => { const { name } = await ctx.req.json<{ name: string }>(); if (!name?.trim()) return ctx.text('Name is required', 400); const dirName = slugify(name); if (!dirName) return ctx.text('Invalid name', 400); const filePath = filePathFor(dirName); if (await Bun.file(filePath).exists()) return ctx.text(`${label} already exists`, 409); await mkdir(dirname(filePath), { recursive: true }); await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`); return ctx.json({ name: name.trim(), dirName, filePath }); }); router.delete('/:name', async (ctx) => { const name = ctx.req.param('name'); const filePath = await resolve(name); if (!filePath) return ctx.text('Not found', 404); await rm(dirname(filePath), { recursive: true }); return ctx.json({ ok: true }); }); return router; }