import * as fs from "node:fs"; import * as path from "node:path"; import type { ThinkingLevel } from "@gajae-code/agent-core"; import type { FileType as FileTypeEnum, glob as globFn } from "@gajae-code/natives"; import { CONFIG_DIR_NAME, getConfigDirName, getPluginsDir, getProjectDir, getTrustedHomeDir, logger, parseFrontmatter, tryParseJson, } from "@gajae-code/utils"; import type { ExtensionModule } from "../capability/extension-module"; import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../capability/fs"; import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule"; import type { Skill, SkillFrontmatter } from "../capability/skill"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import type { ForkContextPolicy } from "../task/types"; import { parseThinkingLevel } from "../thinking"; type DiscoveryNativeModule = { FileType: typeof FileTypeEnum; glob: typeof globFn; }; let discoveryNativeModule: DiscoveryNativeModule | undefined; let discoveryNativeLoad: Promise | undefined; async function discoveryNatives(): Promise { if (discoveryNativeModule) return discoveryNativeModule; discoveryNativeLoad ??= Promise.resolve( require("@gajae-code/natives") as { FileType: typeof FileTypeEnum; glob: typeof globFn }, ).then(mod => { discoveryNativeModule = { FileType: mod.FileType, glob: mod.glob }; return discoveryNativeModule; }); return await discoveryNativeLoad; } /** * Standard paths for each config source. */ export const SOURCE_PATHS = { native: { get userBase() { return getConfigDirName(); }, get userAgent() { return `${getConfigDirName()}/agent`; }, projectDir: CONFIG_DIR_NAME, }, claude: { userBase: ".claude", userAgent: ".claude", projectDir: ".claude", }, codex: { userBase: ".codex", userAgent: ".codex", projectDir: ".codex", }, gemini: { userBase: ".gemini", userAgent: ".gemini", projectDir: ".gemini", }, opencode: { userBase: ".config/opencode", userAgent: ".config/opencode", projectDir: ".opencode", }, cursor: { userBase: ".cursor", userAgent: ".cursor", projectDir: ".cursor", }, windsurf: { userBase: ".codeium/windsurf", userAgent: ".codeium/windsurf", projectDir: ".windsurf", }, cline: { userBase: ".cline", userAgent: ".cline", projectDir: null, // Cline uses root-level .clinerules }, github: { userBase: null, userAgent: null, projectDir: ".github", }, vscode: { userBase: ".vscode", userAgent: ".vscode", projectDir: ".vscode", }, } as const; export type SourceId = keyof typeof SOURCE_PATHS; /** * Get user-level path for a source. */ export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null { const paths = SOURCE_PATHS[source]; if (!paths.userAgent) return null; return path.join(ctx.home, paths.userAgent, subpath); } /** * Get project-level path for a source (cwd only). */ export function getProjectPath(ctx: LoadContext, source: SourceId, subpath: string): string | null { const paths = SOURCE_PATHS[source]; if (!paths.projectDir) return null; return path.join(ctx.cwd, paths.projectDir, subpath); } /** * Create source metadata for an item. */ export function createSourceMeta(provider: string, filePath: string, level: "user" | "project"): SourceMeta { return { provider, providerName: "", // Filled in by registry path: path.resolve(filePath), level, }; } export function parseBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (normalized === "true") return true; if (normalized === "false") return false; } return undefined; } /** * Parse a comma-separated string into an array of trimmed, non-empty strings. */ export function parseCSV(value: string): string[] { return value .split(",") .map(s => s.trim()) .filter(Boolean); } /** * Parse a value that may be an array of strings or a comma-separated string. * Returns undefined if the result would be empty. */ export function parseArrayOrCSV(value: unknown): string[] | undefined { if (Array.isArray(value)) { const filtered = value.filter((item): item is string => typeof item === "string"); return filtered.length > 0 ? filtered : undefined; } if (typeof value === "string") { const parsed = parseCSV(value); return parsed.length > 0 ? parsed : undefined; } return undefined; } /** * Build a canonical rule item from a markdown/markdown-frontmatter document. */ export function buildRuleFromMarkdown( name: string, content: string, filePath: string, source: SourceMeta, options?: { ruleName?: string; stripNamePattern?: RegExp; }, ): Rule { const { frontmatter, body } = parseFrontmatter(content, { source: filePath }); const { condition, scope } = parseRuleConditionAndScope(frontmatter as RuleFrontmatter); let globs: string[] | undefined; if (Array.isArray(frontmatter.globs)) { globs = frontmatter.globs.filter((item): item is string => typeof item === "string"); } else if (typeof frontmatter.globs === "string") { globs = [frontmatter.globs]; } const resolvedName = options?.ruleName ?? name.replace(options?.stripNamePattern ?? /\.(md|mdc)$/, ""); const rawMode = frontmatter.interruptMode; const interruptMode: Rule["interruptMode"] = rawMode === "never" || rawMode === "prose-only" || rawMode === "tool-only" || rawMode === "always" ? rawMode : undefined; const rawRepeatMode = frontmatter.repeatMode; const repeatMode: Rule["repeatMode"] = rawRepeatMode === "once" || rawRepeatMode === "after-gap" ? rawRepeatMode : undefined; const repeatGap = typeof frontmatter.repeatGap === "number" && Number.isInteger(frontmatter.repeatGap) && frontmatter.repeatGap > 0 ? frontmatter.repeatGap : undefined; return { name: resolvedName, path: filePath, content: body, globs, alwaysApply: frontmatter.alwaysApply === true, description: typeof frontmatter.description === "string" ? frontmatter.description : undefined, condition, scope, interruptMode, repeatMode, repeatGap, _source: source, }; } /** * Parse model field into a prioritized list. */ export function parseModelList(value: unknown): string[] | undefined { const parsed = parseArrayOrCSV(value); if (!parsed) return undefined; const normalized = parsed.map(entry => entry.trim()).filter(Boolean); return normalized.length > 0 ? normalized : undefined; } /** Parsed agent fields from frontmatter (excludes source/filePath/systemPrompt) */ export interface ParsedAgentFields { name: string; description: string; tools?: string[]; spawns?: string[] | "*"; model?: string[]; output?: unknown; thinkingLevel?: ThinkingLevel; autoloadSkills?: string[]; blocking?: boolean; hide?: boolean; forkContext?: ForkContextPolicy; bashAllowedPrefixes?: string[]; } /** * Parse agent fields from frontmatter. * Returns null if required fields (name, description) are missing. */ export function parseAgentFields(frontmatter: Record): ParsedAgentFields | null { const name = typeof frontmatter.name === "string" ? frontmatter.name : undefined; const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; if (!name || !description) { return null; } let tools = parseArrayOrCSV(frontmatter.tools)?.map(tool => tool.toLowerCase()); // Subagents with explicit tool lists always need yield if (tools && !tools.includes("yield")) { tools = [...tools, "yield"]; } // Parse spawns field (array, "*", or CSV) let spawns: string[] | "*" | undefined; if (frontmatter.spawns === "*") { spawns = "*"; } else if (typeof frontmatter.spawns === "string") { const trimmed = frontmatter.spawns.trim(); if (trimmed === "*") { spawns = "*"; } else { spawns = parseArrayOrCSV(trimmed); } } else { spawns = parseArrayOrCSV(frontmatter.spawns); } // Backward compat: infer spawns: "*" when tools includes "task" if (spawns === undefined && tools?.includes("task")) { spawns = "*"; } const output = frontmatter.output !== undefined ? frontmatter.output : undefined; const rawThinkingLevel = typeof frontmatter.thinkingLevel === "string" ? frontmatter.thinkingLevel : typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined; const thinkingLevel = parseThinkingLevel(rawThinkingLevel); const model = parseModelList(frontmatter.model); const blocking = parseBoolean(frontmatter.blocking); const hide = parseBoolean(frontmatter.hide); const forkContext = parseForkContextPolicy(frontmatter.forkContext); const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills) ?.map(s => s.trim()) .filter(Boolean); const bashAllowedPrefixes = parseArrayOrCSV(frontmatter.bashAllowedPrefixes) ?.map(prefix => prefix.trim()) .filter(Boolean); return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills, hide, forkContext, bashAllowedPrefixes, }; } function parseForkContextPolicy(value: unknown): ForkContextPolicy | undefined { if (value === undefined) return undefined; if (value === "forbidden" || value === "allowed") return value; logger.warn("Invalid agent forkContext frontmatter; expected 'allowed' or 'forbidden', ignoring", { value }); return undefined; } async function globIf( dir: string, pattern: string, fileType: FileTypeEnum, recursive: boolean = true, ): Promise> { try { const { glob } = await discoveryNatives(); const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType, recursive }); return result.matches; } catch { return []; } } export interface ScanSkillsFromDirOptions { dir: string; providerId: string; level: "user" | "project"; requireDescription?: boolean; } // Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path. export function compareSkillOrder(aName: string, aPath: string, bName: string, bPath: string): number { const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); const lowerCompare = cmp(aName.toLowerCase(), bName.toLowerCase()); if (lowerCompare !== 0) return lowerCompare; const nameCompare = cmp(aName, bName); if (nameCompare !== 0) return nameCompare; return cmp(aPath, bPath); } /** Maximum bytes read per incremental frontmatter scan chunk. */ export const SKILL_FRONTMATTER_SCAN_BYTES = 4 * 1024; /** Maximum total bytes read while seeking the frontmatter closing delimiter. */ export const SKILL_FRONTMATTER_SCAN_TOTAL_BYTES = 64 * 1024; async function readSkillFrontmatter(skillPath: string): Promise { const file = Bun.file(skillPath); const size = (await fs.promises.stat(skillPath)).size; const scanLimit = Math.min(size, SKILL_FRONTMATTER_SCAN_TOTAL_BYTES); let offset = 0; let prefix = ""; const decoder = new TextDecoder(); while (offset < scanLimit) { const end = Math.min(offset + SKILL_FRONTMATTER_SCAN_BYTES, scanLimit); const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer()); const chunk = decoder.decode(bytes, { stream: end < scanLimit }); if (!chunk) break; prefix += chunk; offset = end; const opening = prefix.match(/^---[ \t]*(?:\r?\n|$)/); if (!opening) return null; const afterOpening = prefix.slice(opening[0].length); const closing = afterOpening.match(/\r?\n---[ \t]*(?:\r?\n|$)/); if (!closing || closing.index === undefined) continue; const bounded = prefix.slice(0, opening[0].length + closing.index + closing[0].length); return parseFrontmatter(bounded, { source: skillPath }).frontmatter as SkillFrontmatter; } return null; } export async function scanSkillsFromDir( _ctx: LoadContext, options: ScanSkillsFromDirOptions, ): Promise> { const items: Skill[] = []; const warnings: string[] = []; const { dir, level, providerId, requireDescription = false } = options; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { warnings.push(`Failed to read skills directory: ${dir} (${String(error)})`); } return { items, warnings }; } const loadSkill = async (skillPath: string) => { try { const frontmatter = await readSkillFrontmatter(skillPath); if (!frontmatter) { if (fs.statSync(skillPath).size > SKILL_FRONTMATTER_SCAN_TOTAL_BYTES) { warnings.push( `Skill frontmatter exceeded ${SKILL_FRONTMATTER_SCAN_TOTAL_BYTES} byte scan cap: ${skillPath}`, ); } else { warnings.push( `Skill file has no parseable frontmatter (expected a leading \`---\` YAML block with name/description): ${skillPath}`, ); } return; } if (frontmatter.enabled === false) return; if (requireDescription && !frontmatter.description) { warnings.push(`Skill is missing a description in frontmatter: ${skillPath}`); return; } const skillDirName = path.basename(path.dirname(skillPath)); const rawName = frontmatter.name; const name = typeof rawName === "string" ? rawName.trim() || skillDirName : skillDirName; items.push({ name, path: skillPath, loadContent: async () => { const content = await Bun.file(skillPath).text(); return parseFrontmatter(content, { source: skillPath }).body; }, frontmatter: frontmatter as SkillFrontmatter, level, _source: createSourceMeta(providerId, skillPath, level), }); } catch { warnings.push(`Failed to read skill file: ${skillPath}`); } }; const work = []; for (const entry of entries) { if (entry.name.startsWith(".")) continue; if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; const skillPath = path.join(dir, entry.name, "SKILL.md"); if (fs.existsSync(skillPath)) { work.push(loadSkill(skillPath)); } } await Promise.all(work); // Deterministic ordering: async file reads complete nondeterministically, so sort after loading. items.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path)); return { items, warnings }; } /** * Expand environment variables in a string. * Supports ${VAR} and ${VAR:-default} syntax. */ function expandEnvVars(value: string, extraEnv?: Record): string { return value.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, varName: string, defaultValue?: string) => { const envValue = extraEnv?.[varName] ?? Bun.env[varName]; if (envValue !== undefined) return envValue; if (defaultValue !== undefined) return defaultValue; return `\${${varName}}`; }); } /** * Recursively expand environment variables in an object. */ export function expandEnvVarsDeep(obj: T, extraEnv?: Record): T { if (typeof obj === "string") { return expandEnvVars(obj, extraEnv) as T; } if (Array.isArray(obj)) { return obj.map(item => expandEnvVarsDeep(item, extraEnv)) as T; } if (obj !== null && typeof obj === "object") { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { result[key] = expandEnvVarsDeep(value, extraEnv); } return result as T; } return obj; } /** * Load files from a directory matching extensions. * Uses native glob for fast filesystem scanning with gitignore support. */ export async function loadFilesFromDir( _ctx: LoadContext, dir: string, provider: string, level: "user" | "project", options: { /** File extensions to match (without dot) */ extensions?: string[]; /** Transform file to item (return null to skip) */ transform: (name: string, content: string, path: string, source: SourceMeta) => T | null; /** Whether to recurse into subdirectories (default: false) */ recursive?: boolean; }, ): Promise> { const items: T[] = []; const warnings: string[] = []; // Build glob pattern based on extensions and recursion const { extensions, recursive = false } = options; let pattern: string; if (extensions && extensions.length > 0) { const extPattern = extensions.length === 1 ? extensions[0] : `{${extensions.join(",")}}`; pattern = recursive ? `**/*.${extPattern}` : `*.${extPattern}`; } else { pattern = recursive ? "**/*" : "*"; } // Use native glob for fast scanning with gitignore support let matches: Array<{ path: string }>; try { const { glob, FileType } = await discoveryNatives(); const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType: FileType.File, }); matches = result.matches; } catch { // Directory doesn't exist or isn't readable return { items, warnings }; } // Read all matching files in parallel const fileResults = await Promise.all( matches.map(async match => { const filePath = path.join(dir, match.path); const content = await readFile(filePath); return { filePath, content }; }), ); for (const { filePath, content } of fileResults) { if (content === null) { warnings.push(`Failed to read file: ${filePath}`); continue; } const name = path.basename(filePath); const source = createSourceMeta(provider, filePath, level); try { const item = options.transform(name, content, filePath, source); if (item !== null) { items.push(item); } } catch (err) { warnings.push(`Failed to parse ${filePath}: ${err}`); } } return { items, warnings }; } /** * Calculate depth of target directory relative to current working directory. * Depth is the number of directory levels from cwd to target. * - Positive depth: target is above cwd (parent/ancestor) * - Zero depth: target is cwd * - This uses path splitting to count directory levels */ export function calculateDepth(cwd: string, targetDir: string, separator: string): number { return cwd.split(separator).length - targetDir.split(separator).length; } interface ExtensionModuleManifest { extensions?: string[]; } async function readExtensionModuleManifest( _ctx: LoadContext, packageJsonPath: string, ): Promise { const content = await readFile(packageJsonPath); if (!content) return null; const pkg = tryParseJson<{ gjc?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content); const manifest = pkg?.gjc ?? pkg?.pi; if (manifest && typeof manifest === "object") { return manifest; } return null; } /** * Discover extension module entry points in a directory. * * Discovery rules: * 1. Direct files: `extensions/*.ts` or `*.js` → load * 2. Subdirectory with index: `extensions//index.ts` or `index.js` → load * 3. Subdirectory with package.json: `extensions//package.json` with "gjc"/"pi" field → load declared paths * * No recursion beyond one level. Complex packages must use package.json manifest. * Uses native glob for fast filesystem scanning with gitignore support. */ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: string): Promise { const discovered = new Set(); const { FileType } = await discoveryNatives(); // Find all candidate files in parallel using glob const [directFiles, indexFiles, packageJsonFiles] = await Promise.all([ // 1. Direct *.ts or *.js files globIf(dir, "*.{ts,js}", FileType.File, false), // 2. Subdirectory index files globIf(dir, "*/index.{ts,js}", FileType.File, false), // 3. Subdirectory package.json files globIf(dir, "*/package.json", FileType.File, false), ]); // Process direct files for (const match of directFiles) { if (match.path.includes("/")) continue; discovered.add(path.join(dir, match.path)); } // Track which subdirectories have package.json manifests with declared extensions const subdirsWithDeclaredExtensions = new Set(); for (const match of packageJsonFiles) { const subdir = path.dirname(match.path); // e.g., "my-extension" const packageJsonPath = path.join(dir, match.path); const manifest = await readExtensionModuleManifest(_ctx, packageJsonPath); const declaredExtensions = manifest?.extensions?.filter((extPath): extPath is string => typeof extPath === "string") ?? []; if (declaredExtensions.length === 0) continue; subdirsWithDeclaredExtensions.add(subdir); const subdirPath = path.join(dir, subdir); for (const extPath of declaredExtensions) { let resolvedExtPath = path.resolve(subdirPath, extPath); const entries = await readDirEntries(resolvedExtPath); if (entries.length !== 0) { const pluginFilePath = entries.find( e => e.isFile() && (e.name === "index.ts" || e.name === "index.js"), )?.name; resolvedExtPath = pluginFilePath ? path.join(resolvedExtPath, pluginFilePath) : resolvedExtPath; } const content = await readFile(resolvedExtPath); if (content !== null) { discovered.add(resolvedExtPath); } } } const preferredIndexBySubdir = new Map(); for (const match of indexFiles) { if (match.path.split("/").length !== 2) continue; const subdir = path.dirname(match.path); if (subdirsWithDeclaredExtensions.has(subdir)) continue; const existing = preferredIndexBySubdir.get(subdir); if (!existing || (existing.endsWith("index.js") && match.path.endsWith("index.ts"))) { preferredIndexBySubdir.set(subdir, match.path); } } for (const preferredPath of preferredIndexBySubdir.values()) { discovered.add(path.join(dir, preferredPath)); } return [...discovered]; } /** * Derive a stable extension name from a path. */ export function getExtensionNameFromPath(extensionPath: string): string { const base = extensionPath.replace(/\\/g, "/").split("/").pop() ?? extensionPath; if (base === "index.ts" || base === "index.js") { const parts = extensionPath.replace(/\\/g, "/").split("/"); const parent = parts[parts.length - 2]; return parent ?? base; } const dot = base.lastIndexOf("."); if (dot > 0) { return base.slice(0, dot); } return base; } /** * Build ExtensionModule items from discovered user/project paths. * Shared across providers that expose extension modules via user + project dirs. */ export function buildExtensionModuleItems( providerId: string, userPaths: string[], projectPaths: string[], ): ExtensionModule[] { return [ ...userPaths.map(extPath => ({ name: getExtensionNameFromPath(extPath), path: extPath, level: "user" as const, _source: createSourceMeta(providerId, extPath, "user"), })), ...projectPaths.map(extPath => ({ name: getExtensionNameFromPath(extPath), path: extPath, level: "project" as const, _source: createSourceMeta(providerId, extPath, "project"), })), ]; } // ============================================================================= // Anthropic Code Plugin Cache Helpers // ============================================================================= /** * Entry for an installed Anthropic Code plugin. */ export interface ClaudePluginEntry { scope: "user" | "project"; installPath: string; version: string; installedAt: string; lastUpdated: string; gitCommitSha?: string; enabled?: boolean; } /** * Anthropic Code installed_plugins.json registry format. */ export interface ClaudePluginsRegistry { version: number; plugins: Record; } /** * Resolved plugin root for loading. */ export interface ClaudePluginRoot { /** Plugin ID (e.g., "simpleAnthropic model-core@simpleAnthropic model") */ id: string; /** Marketplace name */ marketplace: string; /** Plugin name */ plugin: string; /** Version string */ version: string; /** Absolute path to plugin root */ path: string; /** Whether this is a user or project scope plugin */ scope: "user" | "project"; } /** * Parse Anthropic Code installed_plugins.json content. */ export function parseClaudePluginsRegistry(content: string): ClaudePluginsRegistry | null { const data = tryParseJson(content); if (!data || typeof data !== "object") return null; if ( typeof data.version !== "number" || !data.plugins || typeof data.plugins !== "object" || Array.isArray(data.plugins) ) return null; return data; } /** * Resolve the active project registry path by walking up from `cwd`. * * Walk order: * 1. Walk up from `cwd` looking for the nearest directory containing `.gjc/`. * The first match returns `/.gjc/plugins/installed_plugins.json`. * 2. If no `.gjc/` is found, rescan from `cwd` upward looking for `.git`. * The git root is used as an anchor: `/.gjc/plugins/installed_plugins.json`. * 3. If neither is found, return `null` — no project context is active. * * This is the single source of truth for "active project root" used by install, * uninstall, list, upgrade, discovery, and doctor. Deterministic for a given `cwd`. */ export async function resolveActiveProjectRegistryPath(cwd: string): Promise { // Pass 1: walk up looking for an existing .gjc/ directory (nearest wins). // Stop before the provenance-checked home — ~/.gjc/ is the user-level config dir, not a project root. const homeDir = getTrustedHomeDir(); let dir = path.resolve(cwd); while (dir !== homeDir) { try { const stat = await fs.promises.stat(path.join(dir, getConfigDirName())); if (stat.isDirectory()) { return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json"); } } catch { // not found at this level — continue up } const parent = path.dirname(dir); if (parent === dir) break; // filesystem root dir = parent; } // Pass 2: walk up looking for .git as a fallback anchor. dir = path.resolve(cwd); while (dir !== homeDir) { try { await fs.promises.stat(path.join(dir, ".git")); return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json"); } catch { // not found at this level — continue up } const parent = path.dirname(dir); if (parent === dir) break; // filesystem root dir = parent; } return null; // not inside any project } /** * Like resolveActiveProjectRegistryPath, but falls back to `/.gjc/plugins/installed_plugins.json` * when no project anchor (.gjc/ or .git/) is found. * * Use this when the caller accepts an explicit --scope project so that installing into a freshly * bootstrapped directory (no .gjc/ or .git/ yet) works: writeInstalledPluginsRegistry auto-creates * the directory tree on first write. * * Returns undefined when cwd is the trusted home — that path is already the user registry and must * never alias as the project registry. */ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise { const resolved = await resolveActiveProjectRegistryPath(cwd); if (resolved) return resolved; // Home directory must not be treated as a project root: the fallback path would alias // getInstalledPluginsRegistryPath(), causing MarketplaceManager to load the same file // as both user and project registry and producing duplicates / disambiguation errors. if (path.resolve(cwd) === getTrustedHomeDir()) return undefined; return path.join(cwd, getConfigDirName(), "plugins", "installed_plugins.json"); } const pluginRootsCache = new Map(); /** * List installed GJC plugin roots from the GJC plugin registry and, when present, * the nearest project-scoped registry resolved from `cwd`. * * Results are cached per `home:resolvedProjectPath` key to avoid repeated parsing. */ export async function listClaudePluginRoots( home: string, cwd?: string, ): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> { const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null; const cacheKey = `${home}:${resolvedProjectPath ?? ""}`; const cached = pluginRootsCache.get(cacheKey); if (cached) return cached; const roots: ClaudePluginRoot[] = []; const warnings: string[] = []; const projectRoots: ClaudePluginRoot[] = []; // ── GJC installed plugins registry ─────────────────────────────────────── // In production `home` is the provenance-checked home, so `getPluginsDir(home)` resolves to the // same XDG-aware path the marketplace writer uses (reads and writes always agree). // Tests pass a temp dir, which short-circuits the resolver for deterministic isolation. const gjcRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json"); const gjcContent = await readFile(gjcRegistryPath); if (gjcContent) { const gjcRegistry = parseClaudePluginsRegistry(gjcContent); if (gjcRegistry) { for (const [pluginId, entries] of Object.entries(gjcRegistry.plugins)) { if (!Array.isArray(entries) || entries.length === 0) continue; const atIndex = pluginId.lastIndexOf("@"); if (atIndex === -1) { warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); continue; } const pluginName = pluginId.slice(0, atIndex); const marketplace = pluginId.slice(atIndex + 1); for (const entry of entries) { if (!entry.installPath || typeof entry.installPath !== "string") { warnings.push(`Plugin ${pluginId} entry has no installPath`); continue; } if (entry.enabled === false) continue; // Deduplicate by installPath within same ID if (roots.some(r => r.id === pluginId && r.path === entry.installPath)) continue; roots.push({ id: pluginId, marketplace, plugin: pluginName, version: entry.version || "unknown", path: entry.installPath, scope: entry.scope || "user", }); } } } else { warnings.push(`Failed to parse GJC plugin registry: ${gjcRegistryPath}`); } } // ── Project-scoped GJC registry ──────────────────────────────────────── // Loaded from the nearest .gjc/plugins/installed_plugins.json relative to cwd. // Project entries take precedence over user entries for the same plugin ID. if (resolvedProjectPath) { const projectContent = await readFile(resolvedProjectPath); if (projectContent) { const projectRegistry = parseClaudePluginsRegistry(projectContent); if (projectRegistry) { for (const [pluginId, entries] of Object.entries(projectRegistry.plugins)) { if (!Array.isArray(entries) || entries.length === 0) continue; const atIndex = pluginId.lastIndexOf("@"); if (atIndex === -1) { warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); continue; } const pluginName = pluginId.slice(0, atIndex); const marketplace = pluginId.slice(atIndex + 1); for (const entry of entries) { if (!entry.installPath || typeof entry.installPath !== "string") { warnings.push(`Plugin ${pluginId} entry has no installPath`); continue; } if (entry.enabled === false) continue; projectRoots.push({ id: pluginId, marketplace, plugin: pluginName, version: entry.version || "unknown", path: entry.installPath, scope: "project", }); } } } else { warnings.push(`Failed to parse project plugin registry: ${resolvedProjectPath}`); } } } // Project entries shadow user entries for the same plugin ID. if (projectRoots.length > 0) { const projectIds = new Set(projectRoots.map(r => r.id)); const deduped = roots.filter(r => !projectIds.has(r.id)); roots.length = 0; roots.push(...projectRoots, ...deduped); } const result = { roots, warnings }; pluginRootsCache.set(cacheKey, result); return result; } /** * Clear the plugin roots cache (useful for testing or when plugins change). */ export function clearClaudePluginRootsCache(): void { pluginRootsCache.clear(); preloadedPluginRoots = []; // Re-warm preloaded roots asynchronously so sync LSP config reads stay valid if (lastPreloadHome) { void preloadPluginRoots(lastPreloadHome, getProjectDir()); } } /** * Invalidate fs caches for installed-plugin registry files and reset the * in-memory plugin roots cache. Used by MarketplaceManager clients after * installing/uninstalling/enabling/disabling plugins. */ export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void { invalidateFsCache(path.join(getPluginsDir(), "installed_plugins.json")); for (const p of extraPaths ?? []) invalidateFsCache(p); clearClaudePluginRootsCache(); } // ── Preloaded plugin roots (for sync consumers like LSP config) ───────────── // Populated at startup by preloadPluginRoots(). Read synchronously by // getPreloadedPluginRoots(). Safe degradation: empty array if not warmed. let preloadedPluginRoots: ClaudePluginRoot[] = []; let lastPreloadHome: string | undefined; /** * Populate the module-level plugin roots cache for sync consumers. * Call during session initialization, after dir resolution completes * but before any LSP config is read. */ export async function preloadPluginRoots(home: string, cwd?: string): Promise { lastPreloadHome = home; const { roots } = await listClaudePluginRoots(home, cwd); preloadedPluginRoots = roots; } /** * Get pre-loaded plugin roots synchronously. * Returns empty array if preloadPluginRoots() hasn't been called. */ export function getPreloadedPluginRoots(): readonly ClaudePluginRoot[] { return preloadedPluginRoots; }