UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

171 lines 6.25 kB
/** * Contributor Discovery * * Walks the installed framework registry and project-local override directory * to find `<framework>/<kind>/contributor.md` files. Parses, validates, and * runs detection on each candidate. Returns kept records and skipped entries * with reasons. Failures of one contributor never abort discovery. * * @architecture @.aiwg/architecture/decisions/ADR-023-contributor-discovery-convention.md * @issue #938 */ import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import { glob } from 'glob'; import path from 'path'; import { parseFrontmatter } from '../artifacts/index-builder.js'; import { isInUse } from './detect.js'; import { validateContributor } from './validation.js'; /** * Subdirectories under a framework root that may host installable units. * The discovery loop checks each in order so a single id can resolve to * the right source path regardless of whether it's a framework, addon, or * extension. First match wins. */ const FRAMEWORK_SUBDIRS = ['frameworks', 'addons', 'extensions']; /** * Read the registry. Returns empty list if missing or unparseable — discovery * is non-fatal and project-local contributors can still be found. */ async function readRegistry(registryPath) { if (!existsSync(registryPath)) return { frameworks: [] }; try { const raw = await readFile(registryPath, 'utf-8'); const parsed = JSON.parse(raw); return parsed && Array.isArray(parsed.frameworks) ? parsed : { frameworks: [] }; } catch { return { frameworks: [] }; } } /** * Resolve a framework id to its source path under the AIWG installation. * Tries `frameworks/`, `addons/`, `extensions/` in order. Returns null if * none exist on disk — the id is registered but its source is missing. */ function resolveFrameworkSourcePath(frameworkRoot, id) { for (const sub of FRAMEWORK_SUBDIRS) { const candidate = path.join(frameworkRoot, 'agentic', 'code', sub, id); if (existsSync(candidate)) return candidate; } return null; } /** * Process one contributor source: parse, validate, run detection. * Returns either a record (in-use) or a skip entry. Never throws. */ async function processContributor(src, projectRoot) { let raw; try { raw = await readFile(src.path, 'utf-8'); } catch (err) { return { kind: 'skip', entry: { ...src, reason: 'parse-error', message: `cannot read file: ${err.message}` }, }; } let data; let body; try { const parsed = parseFrontmatter(raw); data = parsed.data; body = parsed.body; } catch (err) { return { kind: 'skip', entry: { ...src, reason: 'parse-error', message: `cannot parse frontmatter: ${err.message}` }, }; } const validation = validateContributor(data); if (!validation.ok) { return { kind: 'skip', entry: { ...src, reason: 'schema-violation', message: validation.errors.join('; ') }, }; } const validData = validation.data; let inUse; try { inUse = await isInUse(validData.detect, projectRoot); } catch (err) { return { kind: 'skip', entry: { ...src, reason: 'detection-error', message: `detection threw: ${err.message}` }, }; } if (!inUse) { return { kind: 'skip', entry: { ...src, reason: 'detection-no-match', message: 'declared globs matched fewer files than minCount' }, }; } return { kind: 'record', record: { ...src, data: validData, body }, }; } /** * Discover all in-use contributors of a given kind for a project. * * Walks two source classes: * * 1. Framework-shipped contributors — for each framework id in the project's * `.aiwg/frameworks/registry.json`, look up its source path under the * AIWG installation and check for `<kind>/contributor.md`. * * 2. Project-local contributors — every `.aiwg/contributors/<kind>/*.md` * file in the project root. Lets users add custom contributors without * forking a framework. * * Order is preserved: framework contributors appear in registry order, then * project-local contributors in glob order. Each kept record carries its * `origin` (framework id or `'project-local'`) and absolute `path`. * * Failures (parse, validation, detection-throw, detection-no-match) are * captured as `SkippedContributor` entries — discovery never aborts on a * single bad contributor. */ export async function discoverContributors(kind, options) { const { frameworkRoot, projectRoot } = options; const registryPath = options.registryPath ?? path.join(projectRoot, '.aiwg', 'frameworks', 'registry.json'); const sources = []; // 1. Framework-shipped contributors via registry. const registry = await readRegistry(registryPath); for (const entry of registry.frameworks) { const sourcePath = resolveFrameworkSourcePath(frameworkRoot, entry.id); if (!sourcePath) continue; const candidate = path.join(sourcePath, kind, 'contributor.md'); if (existsSync(candidate)) { sources.push({ origin: entry.id, path: candidate }); } } // 2. Project-local contributors. const localDir = path.join(projectRoot, '.aiwg', 'contributors', kind); if (existsSync(localDir)) { const localFiles = await glob('*.md', { cwd: localDir, absolute: true, nodir: true }); localFiles.sort(); // deterministic ordering for (const file of localFiles) { sources.push({ origin: 'project-local', path: file }); } } // Process each source. Failures become skip entries; the rest become records. const records = []; const skipped = []; for (const src of sources) { const result = await processContributor(src, projectRoot); if (result.kind === 'record') { records.push(result.record); } else { skipped.push(result.entry); } } return { kind, records, skipped }; } //# sourceMappingURL=discover.js.map