import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter, } from "@earendil-works/pi-coding-agent"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { basename, isAbsolute, join, resolve } from "node:path"; export type RoleSource = "user" | "project"; export interface RoleDefinition { key: string; name: string; description: string; meta?: Record; body: string; source: RoleSource; path: string; } export interface RoleDiscoveryResult { roles: RoleDefinition[]; warnings: string[]; userRolesDir: string; projectRolesDir?: string; } export interface DiscoverRolesOptions { projectTrusted: boolean; agentDir?: string; } interface LoadedScope { roles: RoleDefinition[]; claimedKeys: Set; warnings: string[]; } interface PiRoleSettings { rolesDir?: unknown; } const RESERVED_KEYS = new Set(["current", "list", "none", "reload"]); export function normalizeRoleKey(key: string): string { return key.trim().toLowerCase(); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function readProjectRolesDir(cwd: string): { dir: string; warnings: string[]; } { const defaultDir = join(cwd, CONFIG_DIR_NAME, "roles"); const settingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); if (!existsSync(settingsPath)) return { dir: defaultDir, warnings: [] }; let settings: unknown; try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { dir: defaultDir, warnings: [`Cannot parse ${settingsPath}: ${detail}`], }; } if (!isRecord(settings)) return { dir: defaultDir, warnings: [] }; const namespace = settings["pi-role"]; if (namespace === undefined) return { dir: defaultDir, warnings: [] }; if (!isRecord(namespace)) { return { dir: defaultDir, warnings: [`"pi-role" in ${settingsPath} must be an object.`], }; } const { rolesDir } = namespace as PiRoleSettings; if (rolesDir === undefined) return { dir: defaultDir, warnings: [] }; if (typeof rolesDir !== "string" || rolesDir.trim().length === 0) { return { dir: defaultDir, warnings: [ `"pi-role.rolesDir" in ${settingsPath} must be a non-empty string.`, ], }; } const configured = rolesDir.trim(); return { dir: isAbsolute(configured) ? resolve(configured) : resolve(cwd, configured), warnings: [], }; } function roleKeyFromPath(path: string): string { return basename(path, ".md"); } function loadRoleFile( path: string, source: RoleSource, ): { role?: RoleDefinition; warning?: string } { const key = roleKeyFromPath(path); const normalizedKey = normalizeRoleKey(key); if (RESERVED_KEYS.has(normalizedKey)) { return { warning: `${path}: role key "${key}" is reserved by the /role command.`, }; } let content: string; try { content = readFileSync(path, "utf8"); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { warning: `${path}: cannot read role file: ${detail}` }; } let parsed: ReturnType; try { parsed = parseFrontmatter(content); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { warning: `${path}: invalid frontmatter: ${detail}` }; } const { frontmatter, body } = parsed; if ( typeof frontmatter.name !== "string" || frontmatter.name.trim().length === 0 ) { return { warning: `${path}: frontmatter "name" must be a non-empty string.`, }; } if ( typeof frontmatter.description !== "string" || frontmatter.description.trim().length === 0 ) { return { warning: `${path}: frontmatter "description" must be a non-empty string.`, }; } if (frontmatter.meta !== undefined && !isRecord(frontmatter.meta)) { return { warning: `${path}: frontmatter "meta" must be an object when present.`, }; } return { role: { key, name: frontmatter.name.trim(), description: frontmatter.description.trim(), ...(frontmatter.meta === undefined ? {} : { meta: frontmatter.meta as Record }), body, source, path, }, }; } function loadRolesFromDir(dir: string, source: RoleSource): LoadedScope { if (!existsSync(dir)) { return { roles: [], claimedKeys: new Set(), warnings: [] }; } try { if (!statSync(dir).isDirectory()) { return { roles: [], claimedKeys: new Set(), warnings: [`${dir}: configured roles path is not a directory.`], }; } } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { roles: [], claimedKeys: new Set(), warnings: [`${dir}: cannot inspect roles directory: ${detail}`], }; } let entries; try { entries = readdirSync(dir, { withFileTypes: true }) .filter( (entry) => !entry.name.startsWith(".") && entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink()), ) .sort((left, right) => left.name.localeCompare(right.name)); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { roles: [], claimedKeys: new Set(), warnings: [`${dir}: cannot list roles directory: ${detail}`], }; } const claimedKeys = new Set(); const collisions = new Set(); const byKey = new Map(); const warnings: string[] = []; for (const entry of entries) { const path = join(dir, entry.name); const key = roleKeyFromPath(path); const normalizedKey = normalizeRoleKey(key); claimedKeys.add(normalizedKey); if (byKey.has(normalizedKey) || collisions.has(normalizedKey)) { byKey.delete(normalizedKey); collisions.add(normalizedKey); warnings.push( `${dir}: role filenames collide case-insensitively for key "${normalizedKey}".`, ); continue; } const loaded = loadRoleFile(path, source); if (loaded.warning) warnings.push(loaded.warning); if (loaded.role) byKey.set(normalizedKey, loaded.role); } for (const collision of collisions) byKey.delete(collision); return { roles: [...byKey.values()], claimedKeys, warnings, }; } export function discoverRoles( cwd: string, options: DiscoverRolesOptions, ): RoleDiscoveryResult { const userRolesDir = join(options.agentDir ?? getAgentDir(), "roles"); const user = loadRolesFromDir(userRolesDir, "user"); const effective = new Map( user.roles.map((role) => [normalizeRoleKey(role.key), role]), ); const warnings = [...user.warnings]; let projectRolesDir: string | undefined; if (options.projectTrusted) { const configured = readProjectRolesDir(cwd); projectRolesDir = configured.dir; warnings.push(...configured.warnings); const project = loadRolesFromDir(projectRolesDir, "project"); warnings.push(...project.warnings); for (const key of project.claimedKeys) effective.delete(key); for (const role of project.roles) { effective.set(normalizeRoleKey(role.key), role); } } return { roles: [...effective.values()].sort((left, right) => left.key.localeCompare(right.key), ), warnings: [...new Set(warnings)], userRolesDir, projectRolesDir, }; } export function findRole( roles: readonly RoleDefinition[], key: string, ): RoleDefinition | undefined { const normalized = normalizeRoleKey(key); return roles.find((role) => normalizeRoleKey(role.key) === normalized); }