|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * build-catalog.mjs — Agent Observatory catalog generator |
| 4 | + * |
| 5 | + * Scans the ACOS source of truth and emits a normalized, data-driven |
| 6 | + * catalog.json consumed by BOTH Observatory surfaces: |
| 7 | + * - the public showcase on frankx.ai (committed copy in public/observatory/) |
| 8 | + * - the localhost live monitor (tools/observatory/) |
| 9 | + * |
| 10 | + * Sources: |
| 11 | + * .claude/agents/**.md → agent nodes (YAML frontmatter: name, description, model, tools) |
| 12 | + * skills/registry.json → skill nodes + skill→skill dependency edges |
| 13 | + * .claude/skill-rules.json → skill auto-activation triggers (priority, keywords) |
| 14 | + * .claude/agent-iam.json → IAM profiles (rendered as the IAM matrix view) |
| 15 | + * .claude/workflows/*.js → workflow nodes + workflow→workflow compose edges |
| 16 | + * commands (union of trigger.commands) → command nodes + skill→command edges |
| 17 | + * |
| 18 | + * Zero runtime dependencies — uses a minimal frontmatter parser so this can |
| 19 | + * run anywhere Node 18+ is present. |
| 20 | + * |
| 21 | + * Usage: |
| 22 | + * node scripts/build-catalog.mjs [--out <path>] |
| 23 | + * Defaults to writing tools/observatory/public/catalog.json |
| 24 | + */ |
| 25 | + |
| 26 | +import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs' |
| 27 | +import { join, dirname, relative, basename } from 'node:path' |
| 28 | +import { fileURLToPath } from 'node:url' |
| 29 | + |
| 30 | +const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 31 | +const ROOT = join(__dirname, '..') |
| 32 | + |
| 33 | +const argv = process.argv.slice(2) |
| 34 | +const outFlag = argv.indexOf('--out') |
| 35 | +if (outFlag !== -1 && (!argv[outFlag + 1] || argv[outFlag + 1].startsWith('--'))) { |
| 36 | + console.error('✗ --out requires a path, e.g. --out tools/observatory/public/catalog.json') |
| 37 | + process.exit(1) |
| 38 | +} |
| 39 | +const OUT = outFlag !== -1 ? argv[outFlag + 1] : join(ROOT, 'tools/observatory/public/catalog.json') |
| 40 | + |
| 41 | +// --------------------------------------------------------------------------- |
| 42 | +// Minimal YAML frontmatter parser (handles the two ACOS agent schemas: |
| 43 | +// `tools: A, B, C` comma lists and `key:\n - item` block lists). |
| 44 | +// --------------------------------------------------------------------------- |
| 45 | +function parseFrontmatter(raw) { |
| 46 | + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/) |
| 47 | + if (!m) return {} |
| 48 | + const out = {} |
| 49 | + let key = null |
| 50 | + for (const line of m[1].split(/\r?\n/)) { |
| 51 | + if (!line.trim()) continue |
| 52 | + const listItem = line.match(/^\s+-\s+(.*)$/) |
| 53 | + if (listItem && key) { |
| 54 | + if (!Array.isArray(out[key])) out[key] = [] |
| 55 | + out[key].push(listItem[1].trim()) |
| 56 | + continue |
| 57 | + } |
| 58 | + const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/) |
| 59 | + if (kv) { |
| 60 | + key = kv[1] |
| 61 | + let val = kv[2].trim() |
| 62 | + // strip matching surrounding quotes |
| 63 | + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { |
| 64 | + val = val.slice(1, -1) |
| 65 | + } |
| 66 | + out[key] = val === '' ? null : val |
| 67 | + } |
| 68 | + } |
| 69 | + return out |
| 70 | +} |
| 71 | + |
| 72 | +function toToolList(val) { |
| 73 | + if (!val) return [] |
| 74 | + if (Array.isArray(val)) return val |
| 75 | + return String(val).split(',').map((s) => s.trim()).filter(Boolean) |
| 76 | +} |
| 77 | + |
| 78 | +function normalizeTier(model) { |
| 79 | + const m = String(model || '').toLowerCase() |
| 80 | + if (m.includes('opus')) return 'opus' |
| 81 | + if (m.includes('haiku')) return 'haiku' |
| 82 | + if (m.includes('sonnet')) return 'sonnet' |
| 83 | + return 'sonnet' // sensible default — builders/writers |
| 84 | +} |
| 85 | + |
| 86 | +// --------------------------------------------------------------------------- |
| 87 | +// Walk helpers |
| 88 | +// --------------------------------------------------------------------------- |
| 89 | +function walk(dir, predicate, acc = []) { |
| 90 | + if (!existsSync(dir)) return acc |
| 91 | + for (const entry of readdirSync(dir)) { |
| 92 | + const full = join(dir, entry) |
| 93 | + const st = statSync(full) |
| 94 | + if (st.isDirectory()) walk(full, predicate, acc) |
| 95 | + else if (predicate(full)) acc.push(full) |
| 96 | + } |
| 97 | + return acc |
| 98 | +} |
| 99 | + |
| 100 | +const nodes = [] |
| 101 | +const edges = [] |
| 102 | +const seenEdge = new Set() |
| 103 | +function addEdge(source, target, rel) { |
| 104 | + const k = `${source}->${target}:${rel}` |
| 105 | + if (seenEdge.has(k) || source === target) return |
| 106 | + seenEdge.add(k) |
| 107 | + edges.push({ source, target, rel }) |
| 108 | +} |
| 109 | + |
| 110 | +// --------------------------------------------------------------------------- |
| 111 | +// 1. Agents — .claude/agents/**/*.md (skip CLAUDE.md / README.md docs) |
| 112 | +// --------------------------------------------------------------------------- |
| 113 | +const agentDir = join(ROOT, '.claude/agents') |
| 114 | +const agentFiles = walk(agentDir, (f) => f.endsWith('.md') && !/\/(CLAUDE|README)\.md$/i.test(f)) |
| 115 | +const agentIds = new Set() |
| 116 | +for (const file of agentFiles) { |
| 117 | + const raw = readFileSync(file, 'utf8') |
| 118 | + const fm = parseFrontmatter(raw) |
| 119 | + const rel = relative(agentDir, file) |
| 120 | + const sub = rel.includes('/') ? rel.split('/')[0] : 'core' |
| 121 | + const id = `agent:${rel.replace(/\.md$/, '')}` |
| 122 | + const name = fm.name || basename(file, '.md') |
| 123 | + agentIds.add(id) |
| 124 | + nodes.push({ |
| 125 | + id, |
| 126 | + kind: 'agent', |
| 127 | + name, |
| 128 | + group: sub, // directory grouping: core, specialized, devops, consensus, ... |
| 129 | + tier: normalizeTier(fm.model), |
| 130 | + status: 'shipped', // a committed .md file is dispatchable |
| 131 | + tools: toToolList(fm.tools), |
| 132 | + mcpServers: toToolList(fm.mcpServers), |
| 133 | + description: (fm.description || '').slice(0, 400), |
| 134 | + file: relative(ROOT, file), |
| 135 | + }) |
| 136 | +} |
| 137 | + |
| 138 | +// --------------------------------------------------------------------------- |
| 139 | +// 2. Skills — skills/registry.json (+ skill-rules.json for triggers/priority) |
| 140 | +// --------------------------------------------------------------------------- |
| 141 | +const skillNodeId = (name) => `skill:${name}` |
| 142 | +const commandIds = new Set() |
| 143 | +function ensureCommand(cmd) { |
| 144 | + const name = cmd.replace(/^\//, '') |
| 145 | + const id = `command:${name}` |
| 146 | + if (!commandIds.has(id)) { |
| 147 | + commandIds.add(id) |
| 148 | + nodes.push({ id, kind: 'command', name: `/${name}`, group: 'command', description: '' }) |
| 149 | + } |
| 150 | + return id |
| 151 | +} |
| 152 | + |
| 153 | +let skillRules = { activation_rules: [] } |
| 154 | +try { |
| 155 | + skillRules = JSON.parse(readFileSync(join(ROOT, '.claude/skill-rules.json'), 'utf8')) |
| 156 | +} catch {} |
| 157 | +const ruleBySkill = new Map() |
| 158 | +for (const r of skillRules.activation_rules || []) ruleBySkill.set(r.skill, r) |
| 159 | + |
| 160 | +let registry = { skills: {} } |
| 161 | +try { |
| 162 | + registry = JSON.parse(readFileSync(join(ROOT, 'skills/registry.json'), 'utf8')) |
| 163 | +} catch {} |
| 164 | + |
| 165 | +// Canonical skill set = every .claude/skills/*/SKILL.md directory, enriched |
| 166 | +// with registry.json metadata when the directory name matches a registry key. |
| 167 | +const skillsDir = join(ROOT, '.claude/skills') |
| 168 | +const skillMdFiles = walk(skillsDir, (f) => /\/SKILL\.md$/i.test(f)) |
| 169 | +const skillIds = new Set() |
| 170 | +const skillNamesSeen = new Set() |
| 171 | +function addSkill(name, fromRegistry, fm) { |
| 172 | + const id = skillNodeId(name) |
| 173 | + if (skillNamesSeen.has(name)) return |
| 174 | + skillNamesSeen.add(name) |
| 175 | + skillIds.add(id) |
| 176 | + const s = fromRegistry || {} |
| 177 | + const rule = ruleBySkill.get(name) |
| 178 | + nodes.push({ |
| 179 | + id, |
| 180 | + kind: 'skill', |
| 181 | + name, |
| 182 | + group: s.category || 'skill', |
| 183 | + status: 'shipped', |
| 184 | + priority: rule?.priority || null, |
| 185 | + keywords: s.triggers?.keywords || rule?.triggers?.keywords || [], |
| 186 | + description: s.description || fm?.description || '', |
| 187 | + _deps: s.dependencies || [], |
| 188 | + _commands: [...(s.triggers?.commands || []), ...(rule?.triggers?.commands || [])], |
| 189 | + }) |
| 190 | +} |
| 191 | +// 1. directory-backed skills (canonical) — only top-level .claude/skills/<name>/SKILL.md |
| 192 | +// (ignore nested SKILL.md belonging to bundled sub-skills / templates) |
| 193 | +for (const file of skillMdFiles) { |
| 194 | + if (dirname(dirname(file)) !== skillsDir) continue |
| 195 | + const name = basename(dirname(file)) |
| 196 | + const fm = parseFrontmatter(readFileSync(file, 'utf8')) |
| 197 | + addSkill(name, registry.skills?.[name], fm) |
| 198 | +} |
| 199 | +// 2. registry-only skills not present as a directory (declared but mirrored elsewhere) |
| 200 | +for (const [name, s] of Object.entries(registry.skills || {})) addSkill(name, s) |
| 201 | +// skill→skill dependency edges + skill→command trigger edges |
| 202 | +for (const n of nodes.filter((x) => x.kind === 'skill')) { |
| 203 | + for (const dep of n._deps || []) { |
| 204 | + if (skillIds.has(skillNodeId(dep))) addEdge(n.id, skillNodeId(dep), 'uses-skill') |
| 205 | + } |
| 206 | + for (const cmd of n._commands || []) addEdge(n.id, ensureCommand(cmd), 'triggers') |
| 207 | + delete n._deps |
| 208 | + delete n._commands |
| 209 | +} |
| 210 | +// commands referenced only in skill-rules |
| 211 | +for (const r of skillRules.activation_rules || []) { |
| 212 | + for (const cmd of r.triggers?.commands || []) { |
| 213 | + if (skillIds.has(skillNodeId(r.skill))) addEdge(skillNodeId(r.skill), ensureCommand(cmd), 'triggers') |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +// --------------------------------------------------------------------------- |
| 218 | +// 3. Workflows — .claude/workflows/*.js (parse the `export const meta = {...}`) |
| 219 | +// --------------------------------------------------------------------------- |
| 220 | +const wfDir = join(ROOT, '.claude/workflows') |
| 221 | +const wfFiles = walk(wfDir, (f) => f.endsWith('.js')) |
| 222 | +const wfIds = new Set() |
| 223 | +const wfMeta = [] |
| 224 | +for (const file of wfFiles) { |
| 225 | + const raw = readFileSync(file, 'utf8') |
| 226 | + // Best-effort extraction without importing (avoids side effects). |
| 227 | + const nameM = raw.match(/name:\s*['"]([^'"]+)['"]/) |
| 228 | + const descM = raw.match(/description:\s*['"]([^'"]+)['"]/) |
| 229 | + const tierM = raw.match(/tier:\s*['"]([^'"]+)['"]/) |
| 230 | + const composes = [...raw.matchAll(/composes:\s*\[([^\]]*)\]/g)].map((x) => x[1]) |
| 231 | + const composedBy = [...raw.matchAll(/composedBy:\s*\[([^\]]*)\]/g)].map((x) => x[1]) |
| 232 | + const name = nameM ? nameM[1] : basename(file, '.js') |
| 233 | + const id = `workflow:${name}` |
| 234 | + wfIds.add(id) |
| 235 | + const parseList = (s) => (s || '').split(',').map((t) => t.replace(/['"\s]/g, '')).filter(Boolean) |
| 236 | + wfMeta.push({ id, composes: parseList(composes[0]), composedBy: parseList(composedBy[0]) }) |
| 237 | + nodes.push({ |
| 238 | + id, |
| 239 | + kind: 'workflow', |
| 240 | + name, |
| 241 | + group: 'workflow', |
| 242 | + tierLabel: tierM ? tierM[1] : null, |
| 243 | + status: 'shipped', |
| 244 | + description: descM ? descM[1] : '', |
| 245 | + file: relative(ROOT, file), |
| 246 | + }) |
| 247 | +} |
| 248 | +for (const w of wfMeta) { |
| 249 | + for (const c of w.composes) addEdge(w.id, `workflow:${c}`, 'composes') |
| 250 | + for (const c of w.composedBy) addEdge(`workflow:${c}`, w.id, 'composes') |
| 251 | +} |
| 252 | + |
| 253 | +// --------------------------------------------------------------------------- |
| 254 | +// 4. IAM profiles — kept as a structured block for the matrix view |
| 255 | +// --------------------------------------------------------------------------- |
| 256 | +let iam = { profiles: {} } |
| 257 | +try { |
| 258 | + iam = JSON.parse(readFileSync(join(ROOT, '.claude/agent-iam.json'), 'utf8')) |
| 259 | +} catch {} |
| 260 | +for (const [name, p] of Object.entries(iam.profiles || {})) { |
| 261 | + nodes.push({ |
| 262 | + id: `iam:${name}`, |
| 263 | + kind: 'iam-profile', |
| 264 | + name, |
| 265 | + group: 'iam', |
| 266 | + description: p.description || '', |
| 267 | + allowedTools: p.allowedTools || [], |
| 268 | + deniedTools: p.deniedTools || [], |
| 269 | + }) |
| 270 | +} |
| 271 | + |
| 272 | +// --------------------------------------------------------------------------- |
| 273 | +// Drop orphan edges — compose/dependency targets may reference names that have |
| 274 | +// no node (e.g. a workflow that composes one not present as a file). |
| 275 | +// --------------------------------------------------------------------------- |
| 276 | +const nodeIds = new Set(nodes.map((n) => n.id)) |
| 277 | +const cleanEdges = edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)) |
| 278 | + |
| 279 | +// --------------------------------------------------------------------------- |
| 280 | +// Emit |
| 281 | +// --------------------------------------------------------------------------- |
| 282 | +const counts = nodes.reduce((acc, n) => { |
| 283 | + acc[n.kind] = (acc[n.kind] || 0) + 1 |
| 284 | + return acc |
| 285 | +}, {}) |
| 286 | + |
| 287 | +const catalog = { |
| 288 | + generatedAt: new Date().toISOString(), |
| 289 | + version: '1.0.0', |
| 290 | + source: 'agentic-creator-os', |
| 291 | + counts: { ...counts, edges: cleanEdges.length }, |
| 292 | + iam: iam.profiles || {}, |
| 293 | + nodes, |
| 294 | + edges: cleanEdges, |
| 295 | +} |
| 296 | + |
| 297 | +mkdirSync(dirname(OUT), { recursive: true }) |
| 298 | +writeFileSync(OUT, JSON.stringify(catalog, null, 2)) |
| 299 | +console.log(`✓ catalog written → ${relative(ROOT, OUT)}`) |
| 300 | +console.log(` ${JSON.stringify(counts)} · ${cleanEdges.length} edges`) |
0 commit comments