#!/usr/bin/env node /** * import-agency-agents.mjs — Import https://github.com/msitarzewski/agency-agents * agents into this market's split catalog (data/experts.d/, one file per * expert, division subdirectories). * * Mapping (agency-agents .md → catalog expert JSON): * filename stem → id (already [a-z0-9-], division-prefixed, unique) * frontmatter name → name * frontmatter emoji → avatar * frontmatter description → description * division (dir name) → category (head categories rewritten from divisions.json) * division label + provenance → tags * markdown body → persona * * Persona escaping: the host interpolates strict {{variable}} groups against * the registered prompt variables (model / cwd / provider) and THROWS on any * other complete group — agency-agents bodies contain template syntax inside * code samples (Twig, GitHub Actions, Alertmanager, Handlebars). Every group * whose name is not a registered variable is neutralized by splitting the * opening braces ({{ → { {), which the renderer treats as literal prose. * * Usage: * node scripts/import-agency-agents.mjs [--repo ] [--replace] [--dry-run] * * --repo agency-agents checkout (default: sibling of this plugin) * --replace wipe data/experts.d/ first and rewrite head categories to the * division set only — full replacement of the current catalog * --dry-run parse + validate everything, write nothing */ import { readdirSync, readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), '..') const DATA_DIR = join(pluginRoot, 'data') const HEAD_PATH = join(DATA_DIR, 'experts.json') const EXPERTS_DIR = join(DATA_DIR, 'experts.d') /** Registered prompt variables (agent-loop global set) — kept interpolable. */ const KNOWN_VARS = new Set(['model', 'cwd', 'provider']) /** The roster's expert-id rule, mirrored from src/experts.js. */ const ID_RE = /^[a-z0-9][a-z0-9-]*$/ /** Split {{ groups that would not interpolate (see header comment). */ function escapeTemplateGroups(text) { let out = '' let i = 0 while (i < text.length) { const open = text.indexOf('{{', i) // A `{{` with no later `}}` is literal prose already — keep the tail. if (open < 0 || text.indexOf('}}', open + 2) < 0) return out + text.slice(i) const group = /^\{\{([^{}]*)\}\}/.exec(text.slice(open)) if (group !== null && KNOWN_VARS.has(group[1])) { out += text.slice(i, open + group[0].length) i = open + group[0].length } else { out += text.slice(i, open) + '{ {' i = open + 2 } } return out } /** Minimal frontmatter reader: top-level `key: value` lines only. */ function parseAgentFile(text, file) { const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text) if (m === null) throw new Error(`${file}: no frontmatter block`) const fields = {} for (const line of m[1].split(/\r?\n/)) { if (/^\s/.test(line)) continue // nested block (services:, …) const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line) if (kv !== null && fields[kv[1]] === undefined) { const raw = kv[2].trim() fields[kv[1]] = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw } } return { fields, body: text.slice(m[0].length).trim() } } /** Recursively collect .md files under one division directory. */ function listMarkdown(dir) { const out = [] for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith('.')) continue const full = join(dir, entry.name) if (entry.isDirectory()) out.push(...listMarkdown(full)) else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full) } return out.sort() } // ── arguments ──────────────────────────────────────────────────────────────── const argv = process.argv.slice(2) const flag = (name) => argv.includes(name) const option = (name) => { const at = argv.indexOf(name) return at >= 0 && at + 1 < argv.length ? argv[at + 1] : undefined } const repoPath = option('--repo') ?? join(pluginRoot, '..', 'agency-agents') const replace = flag('--replace') const dryRun = flag('--dry-run') if (!existsSync(join(repoPath, 'divisions.json'))) { console.error(`error: ${repoPath} has no divisions.json — is it the agency-agents checkout?`) process.exit(1) } // ── parse the source repo ──────────────────────────────────────────────────── const divisions = Object.entries(JSON.parse(readFileSync(join(repoPath, 'divisions.json'), 'utf8')).divisions) const experts = [] const problems = [] for (const [division, meta] of divisions) { const dir = join(repoPath, division) for (const file of listMarkdown(dir)) { const rel = relative(repoPath, file) try { const { fields, body } = parseAgentFile(readFileSync(file, 'utf8'), rel) const id = file.slice(dir.length + 1, -3).replaceAll(/[\\/]+/g, '-') if (!ID_RE.test(id)) throw new Error(`id invalid: ${id}`) if (fields.name === undefined || fields.name === '') throw new Error('no frontmatter name') if (fields.description === undefined || fields.description === '') { throw new Error('no frontmatter description') } if (fields.emoji === undefined || fields.emoji === '') throw new Error('no frontmatter emoji') if (body.length === 0) throw new Error('empty persona body') experts.push({ division, rel, expert: { id, name: fields.name, avatar: fields.emoji, category: division, tags: [String(meta.label ?? division), 'agency-agents'], description: fields.description, base: 'standard', persona: escapeTemplateGroups(body), }, }) } catch (error) { problems.push(`${rel}: ${error.message}`) } } } // Unique ids across divisions (duplicate ids would degrade at load time). const ids = new Set() for (const { expert } of experts) { if (ids.has(expert.id)) problems.push(`duplicate expert id: ${expert.id}`) ids.add(expert.id) } const divisionCategories = divisions.map(([id, meta]) => ({ id, label: String(meta.label ?? id) })) // ── write (unless --dry-run) ───────────────────────────────────────────────── let written = 0 if (!dryRun) { if (replace) rmSync(EXPERTS_DIR, { recursive: true, force: true }) for (const { division, expert } of experts) { const target = join(EXPERTS_DIR, division, `${expert.id}.json`) mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, JSON.stringify(expert, null, 2) + '\n', 'utf8') written++ } // Head: on --replace, categories become exactly the division set; otherwise // merge division categories in beside whatever the head already carries. const head = replace ? { version: 2, categories: divisionCategories, $comment: 'Catalog head: version + categories shared by every expert. Experts live one-per-file in data/experts.d//.json, imported from agency-agents (scripts/import-agency-agents.mjs --replace). A file that fails to parse or validate is skipped with a warning surfaced via /api/state — one bad file never takes the catalog down.', } : (() => { const current = JSON.parse(readFileSync(HEAD_PATH, 'utf8')) const known = new Set((current.categories ?? []).map((c) => c?.id)) const merged = [...(current.categories ?? []), ...divisionCategories.filter((c) => !known.has(c.id))] return { ...current, categories: merged } })() writeFileSync(HEAD_PATH, JSON.stringify(head, null, 2) + '\n', 'utf8') } // ── report ─────────────────────────────────────────────────────────────────── const escaped = experts.filter(({ expert }) => expert.persona.includes('{ {')).length console.log(`parsed : ${experts.length} agents across ${divisionCategories.length} divisions`) console.log(`escaped : ${escaped} personas contained non-variable {{…}} groups (split to "{ {")`) // Translation coverage: --replace rewrites experts.d/ but never touches the // i18n overlay (data/i18n.d/), so report drift instead of failing the import. if (!dryRun) { const zhPath = join(DATA_DIR, 'i18n.d', 'zh.json') if (existsSync(zhPath)) { try { const zhExperts = JSON.parse(readFileSync(zhPath, 'utf8')).experts ?? {} const covered = experts.filter(({ expert }) => typeof zhExperts[expert.id]?.name === 'string' && zhExperts[expert.id].name !== '', ).length const note = covered === experts.length ? '' : ' — smoke lists the missing ids; update data/i18n.d/zh.json' console.log(`zh i18n : ${covered}/${experts.length} experts carry zh translations${note}`) } catch (error) { console.error(`zh i18n : overlay unreadable (${error.message})`) } } } // Subgroup mapping drift: data/subgroups.json survives --replace like the // zh overlay, so report coverage of the freshly written catalog instead of // failing the import — unmapped experts simply degrade to no subcategory. if (!dryRun) { const subgroupsPath = join(DATA_DIR, 'subgroups.json') if (existsSync(subgroupsPath)) { try { const parsedSubgroups = JSON.parse(readFileSync(subgroupsPath, 'utf8')) const mapped = new Set() for (const list of Object.values(parsedSubgroups.categories ?? {})) { if (!Array.isArray(list)) continue for (const group of list) { if (group && Array.isArray(group.experts)) for (const id of group.experts) mapped.add(id) } } const covered = experts.filter(({ expert }) => mapped.has(expert.id)).length const note = covered === experts.length ? '' : ' — unmapped experts degrade to no subcategory until subgroups.json is extended' console.log(`subgroups: ${covered}/${experts.length} experts carry a subgroup mapping${note}`) } catch (error) { console.error(`subgroups: mapping unreadable (${error.message})`) } } } if (problems.length > 0) { console.error(`problems : ${problems.length}`) for (const p of problems) console.error(` - ${p}`) process.exitCode = 1 } console.log(dryRun ? 'dry-run : nothing written' : `written : ${written} files under ${relative(pluginRoot, EXPERTS_DIR)}/`) // Post-write validation: the real loader must merge everything warning-free. if (!dryRun && process.exitCode !== 1) { const { loadCatalogFromPaths } = await import(join(pluginRoot, 'src', 'experts.js')) const catalog = loadCatalogFromPaths(HEAD_PATH, EXPERTS_DIR) console.log(`loaded : ${catalog.experts.length} experts, ${catalog.warnings.length} warnings`) if (catalog.warnings.length > 0) { for (const w of catalog.warnings) console.error(` - ${w}`) process.exitCode = 1 } }