/** * Runtime Skill Exposure — read-time discovery of Projected Skill directories contributed to Pi * through the host resource-discovery seam (`resources_discover` → `skillPaths`). * See CONTEXT.md: Runtime Skill Exposure, Projected Skill, Projected Plugin, Effective State, * Runtime Skill Collision, Source Cache. * * Exposure derives entirely from the current Minimal Bridge State (enabled Installations) and its * collision survivors. It performs passive existence inspection only: no fingerprint * recomputation and no Bridge State mutation. Missing cache entries or unreadable snapshot * material are skipped individually; discovery always completes. Exposure never establishes * Skill Availability. * * Path resolution follows the retained snapshot locations: * - Git Registrations resolve inside the Source Cache entry pinned by their recorded base-tree * fingerprint (the cache entry is the projection runtime material — projection reads it * directly, so the fingerprint addressing must never be replaced by another identity). * - Local Registrations resolve inside their live Marketplace Root at its canonical real path. */ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, join } from 'node:path'; import { parseFrontmatter } from '@earendil-works/pi-coding-agent'; import { getCacheEntriesDir, getCacheDir } from '../cache/paths.js'; import { isInstallationEnabled, readMinimalBridgeStatePassive, createEmptyMinimalState, skillExclusionsOf, type MarketplaceFormat, type MinimalBridgeState, } from '../bridge/state.js'; import { catalogContractFor } from '../registration/format.js'; import { findEntryByManifestName, type Catalog } from '../registration/catalog.js'; import { BUDGET } from '../registration/budget.js'; import { resolveContained } from '../registration/contained.js'; import { resolveRuntimeSkillCollisions, type SkillCandidate } from './collision.js'; export interface RuntimeSkillExposureOptions { /** Pi agent dir; defaults to getAgentDir(). */ agentDir?: string; /** Exact names already claimed by pre-existing Pi skills (host-owned namespace layer). */ piSkillNames?: string[]; } /** One Projected Skill contributed at its original snapshot location (no copy). */ export interface ExposedSkill { name: string; skillId: string; pluginId: string; installationId: string; /** Absolute skill directory containing SKILL.md. */ skillDir: string; } export type SkippedInstallationReason = | 'missing-snapshot' | 'missing-cache-entry' | 'catalog-unreadable' | 'entry-not-found' | 'no-skills'; export interface SkippedInstallation { installationId: string; reason: SkippedInstallationReason; } export interface ExposureResult { /** Deterministically ordered skill directories for `resources_discover` → `skillPaths`. */ skillPaths: string[]; exposed: ExposedSkill[]; skipped: SkippedInstallation[]; } function safeFingerprint(fp: string | undefined): fp is string { return typeof fp === 'string' && /^[0-9a-f]{64}$/.test(fp); } /** Passive fail-reset read allowed by the fail-reset contract: corrupted state contributes nothing. */ function readGlobalOrEmpty(opts: RuntimeSkillExposureOptions): MinimalBridgeState { try { return readMinimalBridgeStatePassive({ agentDir: opts.agentDir }); } catch { return createEmptyMinimalState(); } } /** * Locate one Installation's plugin directory inside a snapshot root by resolving its * catalog entry (authoritative manifest name first, then entry path basename), then * verify containment. */ function resolvePluginDirInSnapshot( snapshotRoot: string, manifestName: string, format: MarketplaceFormat = 'codex', ): string | undefined { const contract = catalogContractFor(format); const catalogPath = join(snapshotRoot, ...contract.relPath.split('/')); try { if (!existsSync(catalogPath) || statSync(catalogPath).size > BUDGET.maxCatalogBytes) return undefined; const parsed: unknown = JSON.parse(readFileSync(catalogPath, 'utf8')); const result = contract.parse(parsed); if (!result.catalog) return undefined; return resolveEntryPluginDir(snapshotRoot, result.catalog, manifestName); } catch { return undefined; } } function resolveEntryPluginDir(snapshotRoot: string, catalog: Catalog, manifestName: string): string | undefined { const entry = manifestName ? findEntryByManifestName(catalog, manifestName) : undefined; if (!entry) return undefined; const contained = resolveContained(snapshotRoot, entry.path!, 'directory'); return contained.outcome.kind === 'ok' ? contained.outcome.canonicalPath : undefined; } /** Read each skills//SKILL.md descriptor name exactly as Pi resolves it (frontmatter name, else directory name). */ function skillCandidates(pluginDir: string, format: MarketplaceFormat = 'codex'): Array<{ name: string; skillDir: string }> { if (format === 'claude') { // Declared manifest paths win; when absent or yielding nothing, fall through to the shared // skills/ directory scan so convention-structured claude plugins project too (#91). const manifestPath = join(pluginDir, '.claude-plugin', 'plugin.json'); if (existsSync(manifestPath) && statSync(manifestPath).isFile()) { try { const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record; if (Array.isArray(manifest.skills)) { const found: Array<{ name: string; skillDir: string }> = []; for (const skillDecl of manifest.skills) { if (typeof skillDecl !== 'string') continue; const resolved = resolveContained(pluginDir, skillDecl, 'directory'); if (resolved.outcome.kind !== 'ok') continue; const skillDir = resolved.outcome.canonicalPath; const descriptor = join(skillDir, 'SKILL.md'); if (!existsSync(descriptor)) continue; const name = descriptorSkillName(skillDir, descriptor); if (name) found.push({ name, skillDir }); } if (found.length > 0) return found; } } catch { // fall through to directory scan } } } const skillsDir = join(pluginDir, 'skills'); if (!existsSync(skillsDir) || !statSync(skillsDir).isDirectory()) return []; const found: Array<{ name: string; skillDir: string }> = []; let entries; try { entries = readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { return []; } for (const entry of entries) { if (!entry.isDirectory()) continue; const skillDir = join(skillsDir, entry.name); const descriptor = join(skillDir, 'SKILL.md'); if (!existsSync(descriptor)) continue; const name = descriptorSkillName(skillDir, descriptor); if (name) found.push({ name, skillDir }); } return found; } function descriptorSkillName(skillDir: string, descriptorPath: string): string | undefined { try { // Pi drops skills whose descriptor cannot be parsed or lacks a description; mirror that // here. Name validation stays with Pi (it rejects a name that is not a lowercase kebab-case // identifier), so contributing such a path is inert rather than a projection claim. const text = readFileSync(descriptorPath, 'utf-8'); const { frontmatter } = parseFrontmatter>(text); const description = frontmatter?.description; if (typeof description !== 'string' || description.trim().length === 0) return undefined; const declared = frontmatter?.name; return typeof declared === 'string' && declared.trim().length > 0 ? declared.trim() : basename(skillDir); } catch { return undefined; } } /** * Compute the Runtime Skill Exposure for the current Effective State (enabled Installations in * Minimal Bridge State). Pure read-time behavior: never mutates Bridge State and never throws * on missing material. */ export function discoverProjectedSkillPaths(opts: RuntimeSkillExposureOptions = {}): ExposureResult { const globalState = readGlobalOrEmpty(opts); const installations = globalState.installations.filter(isInstallationEnabled); const registrationsById = new Map(globalState.registrations.map((registration) => [registration.id, registration])); const entriesRoot = getCacheEntriesDir(getCacheDir(opts.agentDir)); interface Candidate extends SkillCandidate { installationId: string; skillDir: string; } const candidates: Candidate[] = []; const skipped: SkippedInstallation[] = []; for (const installation of installations) { const registration = registrationsById.get(installation.registrationId ?? ''); const manifestName = installation.manifestName || installation.pluginId; if (!registration || !manifestName) { skipped.push({ installationId: installation.id, reason: 'entry-not-found' }); continue; } let snapshotRoot: string | undefined; if (registration.sourceKind === 'local') { // Live registered Marketplace Root at its canonical real path. try { snapshotRoot = registration.source && existsSync(registration.source) ? realpathSync.native(registration.source) : undefined; } catch { snapshotRoot = undefined; } } else if (registration.sourceKind === 'git') { const fingerprint = registration.snapshot ?? installation.snapshot; if (!safeFingerprint(fingerprint)) { skipped.push({ installationId: installation.id, reason: 'missing-snapshot' }); continue; } // Source Cache addressing: projection reads the registered base-tree fingerprint // directly; the entry directory is the projection runtime material. const entryDir = join(entriesRoot, fingerprint); if (!existsSync(entryDir)) { skipped.push({ installationId: installation.id, reason: 'missing-cache-entry' }); continue; } snapshotRoot = entryDir; } else { skipped.push({ installationId: installation.id, reason: 'missing-snapshot' }); continue; } if (!snapshotRoot) { skipped.push({ installationId: installation.id, reason: 'catalog-unreadable' }); continue; } const format = (registration.format ?? 'codex') as MarketplaceFormat; const pluginDir = resolvePluginDirInSnapshot(snapshotRoot, manifestName, format); if (!pluginDir) { const contract = catalogContractFor(format); skipped.push({ installationId: installation.id, reason: existsSync(join(snapshotRoot, ...contract.relPath.split('/'))) ? 'entry-not-found' : 'catalog-unreadable', }); continue; } const skills = skillCandidates(pluginDir, format); if (skills.length === 0) { skipped.push({ installationId: installation.id, reason: 'no-skills' }); continue; } // Skill Exclusions are applied here, before collision resolution: an excluded skill never // becomes a candidate, so it neither reserves its name nor blocks another source's // same-named skill. A fully excluded Plugin contributes nothing and stays installed. const excluded = new Set(skillExclusionsOf(installation)); for (const skill of skills) { if (excluded.has(skill.name)) continue; candidates.push({ layer: 'global', name: skill.name, skillId: `${installation.pluginId}/${skill.name}`, pluginId: installation.pluginId, installationId: installation.id, skillDir: skill.skillDir, }); } } // Only collision survivors are contributed; layering is Pi → Global. const bridgeCandidates: SkillCandidate[] = opts.piSkillNames?.length ? [ ...opts.piSkillNames.map((name): SkillCandidate => ({ layer: 'pi', name, skillId: `pi/${name}`, pluginId: '(pi)' })), ...candidates.map(({ name, skillId, pluginId }): SkillCandidate => ({ layer: 'global', name, skillId, pluginId })), ] : candidates.map(({ name, skillId, pluginId }): SkillCandidate => ({ layer: 'global', name, skillId, pluginId })); const survivors = new Set(resolveRuntimeSkillCollisions(bridgeCandidates).survivors.map((s) => s.skillId)); const exposed = candidates .filter((candidate) => survivors.has(candidate.skillId)) .sort((a, b) => a.skillDir.localeCompare(b.skillDir)) .map((candidate): ExposedSkill => ({ name: candidate.name, skillId: candidate.skillId, pluginId: candidate.pluginId, installationId: candidate.installationId, skillDir: candidate.skillDir, })); return { skillPaths: exposed.map((skill) => skill.skillDir), exposed, skipped }; }