/** * State manager for the Extension Control Center. * Handles data loading, tree building, filtering, and toggle persistence. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils"; import type { ContextFile } from "../../../capability/context-file"; import type { ExtensionModule } from "../../../capability/extension-module"; import type { Hook } from "../../../capability/hook"; import type { MCPServer } from "../../../capability/mcp"; import type { Prompt } from "../../../capability/prompt"; import type { Rule } from "../../../capability/rule"; import type { Skill } from "../../../capability/skill"; import type { SlashCommand } from "../../../capability/slash-command"; import type { CustomTool } from "../../../capability/tool"; import type { EffectiveExtensionRoots, SourceMeta } from "../../../capability/types"; import { disableProvider, disableUserSource, enableProvider, enableUserSource, getProviderInfo, isProviderEnabled, isUserSourceEnabled, loadCapability, } from "../../../discovery"; import { createSourceMeta, getExtensionNameFromPath } from "../../../discovery/helpers"; import { isExtensionFile, listPackageExtensionModules } from "../../../extensibility/extensions/loader"; import { readDisabledServers, readEnabledServers } from "../../../mcp/config-writer"; import { commandPreview } from "@oh-my-pi/pi-tui/overlays/extensions/inspector-model"; import { inferMcpTransport } from "@oh-my-pi/pi-tui/overlays/extensions/mcp-runtime"; import { type DisabledReason, type Extension, type ExtensionKind, type ExtensionState, makeExtensionId, sourceFromMeta, } from "@oh-my-pi/pi-tui/overlays/extensions/types"; /** * Provider-level gate for one discovered item: the whole-provider switch, plus * the `~/` opt-in for user-level items of foreign tools. Item-level disable and * shadowing take precedence and are decided by the caller. * * Mirrors the loader's foreign gate (`allowedRoots` / `isSourceEnabled`, * #10666/#10743): a user-scope `claude-plugins` item whose `origin` is not the * foreign `~/.claude` tree is an omp-native marketplace root under * `~/.omp/plugins` and loads without the opt-in — so the dashboard must render * it active, not a phantom `user-opt-in` disabled (#12776). */ function resolveState( source: SourceMeta, isDisabled: boolean, isShadowed: boolean | undefined, ): { state: ExtensionState; disabledReason?: DisabledReason } { if (isDisabled) return { state: "disabled", disabledReason: "item-disabled" }; if (isShadowed) return { state: "shadowed", disabledReason: "shadowed" }; if (!isProviderEnabled(source.provider)) return { state: "disabled", disabledReason: "provider-disabled" }; if (source.provider === "claude-plugins" && source.origin !== undefined && source.origin !== "claude") { return { state: "active" }; } if (source.level === "user" && !isUserSourceEnabled(source.provider)) { return { state: "disabled", disabledReason: "user-opt-in" }; } return { state: "active" }; } /** Provider that labels installed-plugin and configured-package modules; it owns their sibling skills/tools. */ const PACKAGE_MODULE_PROVIDER = "omp-plugins"; /** * Extension modules the loader appends after native discovery — installed * plugin entries and configured `extensions` paths — that `disabledExtensions` * can toggle. Configured file paths are omitted: they load regardless of that * list. Explicit-only sessions ignore the list entirely, so they contribute none. * Entries whose real file a native module or a loaded JS/TS hook factory * already covers are skipped, matching the loader's realpath dedup: the loader * adds those paths first, so `extension-module:` could not unload them. */ async function loadPackageModules( cwd: string, roots: EffectiveExtensionRoots, nativeModules: readonly ExtensionModule[], disabledIds: string[], ): Promise { if (roots.mode !== "merge") return []; const [explicit, configured, hooks] = await Promise.all([ listPackageExtensionModules(roots.explicit, cwd, { plugins: true, configuredLevel: "user" }), listPackageExtensionModules(roots.configured, cwd, { plugins: false, configuredLevel: roots.configuredLevel }), // Runtime view (no includeDisabled): a disabled hook is not loaded, so its // file falls through to the package lane and the module id gates it. loadCapability("hooks", { cwd, disabledExtensions: disabledIds, extensionRoots: roots }), ]); const hookFactories = hooks.items.map(hook => hook.path).filter(file => isExtensionFile(path.basename(file))); const realpath = (file: string) => fs.realpath(file).catch(() => path.resolve(file)); const seen = new Set( await Promise.all([...nativeModules.map(module => module.path), ...hookFactories].map(realpath)), ); const providerName = getProviderInfo(PACKAGE_MODULE_PROVIDER)?.displayName ?? PACKAGE_MODULE_PROVIDER; const modules: ExtensionModule[] = []; for (const entry of [...explicit, ...configured]) { if (!entry.toggleable) continue; const key = await realpath(entry.path); if (seen.has(key)) continue; seen.add(key); modules.push({ name: getExtensionNameFromPath(entry.path), path: entry.path, level: entry.level, _source: { ...createSourceMeta(PACKAGE_MODULE_PROVIDER, entry.path, entry.level), providerName }, }); } return modules; } /** * Load all extensions from all capabilities. * * `extensionRoots` are the session's extension-package lanes; when given, * installed-plugin and configured-package modules are listed beside native * ones. Only `disabledExtensions` gates those at runtime, so their state * ignores provider switches. */ export async function loadAllExtensions( cwd?: string, disabledIds?: string[], extensionRoots?: EffectiveExtensionRoots, ): Promise { const extensions: Extension[] = []; const effectiveDisabledIds = disabledIds ?? []; const disabledExtensions = new Set(effectiveDisabledIds); // Helper to convert capability items to extensions function addItems( items: T[], kind: ExtensionKind, opts?: { getDescription?: (item: T) => string | undefined; getTrigger?: (item: T) => string | undefined; getShadowedBy?: (item: T) => string | undefined; /** Skip provider/user-source gates for items only `disabledExtensions` controls. */ itemGateOnly?: boolean; }, ): void { for (const item of items) { const id = makeExtensionId(kind, item.name); const isDisabled = disabledExtensions.has(id); const { state, disabledReason }: { state: ExtensionState; disabledReason?: DisabledReason } = opts?.itemGateOnly && !isDisabled ? { state: "active" } : resolveState(item._source, isDisabled, (item as { _shadowed?: boolean })._shadowed); extensions.push({ id, kind, name: item.name, displayName: item.name, description: opts?.getDescription?.(item), trigger: opts?.getTrigger?.(item), path: item.path, source: sourceFromMeta(item._source), state, disabledReason, shadowedBy: opts?.getShadowedBy?.(item), raw: item, }); } } const loadOpts = { cwd, includeDisabled: true, disabledExtensions: effectiveDisabledIds, isSourceDisabled: (source: SourceMeta) => resolveState(source, false, undefined).state === "disabled", }; // Load skills try { const skills = await loadCapability("skills", loadOpts); addItems(skills.all, "skill", { getDescription: s => s.frontmatter?.description, getTrigger: s => s.frontmatter?.globs?.join(", "), }); } catch (error) { logger.warn("Failed to load skills capability", { error: String(error) }); } // Load rules try { const rules = await loadCapability("rules", loadOpts); addItems(rules.all, "rule", { getDescription: r => r.description, getTrigger: r => r.globs?.join(", ") || (r.alwaysApply ? "always" : undefined), }); } catch (error) { logger.warn("Failed to load rules capability", { error: String(error) }); } // Load custom tools try { const tools = await loadCapability("tools", loadOpts); addItems(tools.all, "tool", { getDescription: t => t.description, }); } catch (error) { logger.warn("Failed to load tools capability", { error: String(error) }); } // Load extension modules: native capability items, then the plugin and // configured-package entries the loader appends outside that capability. try { const modules = await loadCapability("extension-modules", loadOpts); const nativeModules = modules.all.filter(module => module._source.provider === "native"); addItems(nativeModules, "extension-module"); if (extensionRoots) { const packageModules = await loadPackageModules( cwd ?? process.cwd(), extensionRoots, nativeModules, effectiveDisabledIds, ); addItems(packageModules, "extension-module", { itemGateOnly: true }); } } catch (error) { logger.warn("Failed to load extension-modules capability", { error: String(error) }); } // Load MCP servers. The dashboard mirrors `/mcp list` (issue #3827) by // honoring the same disable signals: the dashboard-private settings list, // the per-server `enabled: false` flag, and the user-level `disabledServers` // denylist that `/mcp disable` writes through `setServerDisabled`. The // user-level `enabledServers` allowlist overrides a non-writable source's // `enabled: false` (e.g. opencode.json) but never the denylist. try { const userMcpPath = cwd ? getMCPConfigPath("user", cwd) : undefined; const [mcpDisabledNames, mcpForcedEnabled] = await Promise.all([ userMcpPath ? readDisabledServers(userMcpPath) .then(list => new Set(list)) .catch(() => new Set()) : Promise.resolve(new Set()), userMcpPath ? readEnabledServers(userMcpPath) .then(list => new Set(list)) .catch(() => new Set()) : Promise.resolve(new Set()), ]); const mcps = await loadCapability("mcps", loadOpts); for (const server of mcps.all) { const id = makeExtensionId("mcp", server.name); const forced = mcpForcedEnabled.has(server.name); const sourceSaysDisabled = server.enabled === false && !forced; const isDisabled = mcpDisabledNames.has(server.name) || disabledExtensions.has(id) || sourceSaysDisabled; const { state, disabledReason } = resolveState( server._source, isDisabled, (server as { _shadowed?: boolean })._shadowed, ); extensions.push({ id, kind: "mcp", name: server.name, displayName: server.name, // Config command/url is plumbing, not a description. Live // identity comes from serverInfo at inspector render time. description: undefined, trigger: inferMcpTransport(server), path: server._source.path, source: sourceFromMeta(server._source), state, disabledReason, raw: server, }); } } catch (error) { logger.warn("Failed to load mcps capability", { error: String(error) }); } // Load prompts try { const prompts = await loadCapability("prompts", loadOpts); addItems(prompts.all, "prompt", { getDescription: () => undefined, getTrigger: p => `/prompts:${p.name}`, }); } catch (error) { logger.warn("Failed to load prompts capability", { error: String(error) }); } // Load slash commands try { const commands = await loadCapability("slash-commands", loadOpts); addItems(commands.all, "slash-command", { getDescription: c => { const preserved = typeof c.description === "string" ? c.description.trim() : ""; return preserved.length > 0 ? preserved : commandPreview(c.content).description; }, getTrigger: c => `/${c.name}`, }); } catch (error) { logger.warn("Failed to load slash-commands capability", { error: String(error) }); } // Load hooks try { const hooks = await loadCapability("hooks", loadOpts); for (const hook of hooks.all) { const id = makeExtensionId("hook", `${hook.type}:${hook.tool}:${hook.name}`); const { state, disabledReason } = resolveState( hook._source, disabledExtensions.has(id), (hook as { _shadowed?: boolean })._shadowed, ); extensions.push({ id, kind: "hook", name: hook.name, displayName: hook.name, description: `${hook.type}-${hook.tool}`, trigger: `${hook.type}:${hook.tool}`, path: hook.path, source: sourceFromMeta(hook._source), state, disabledReason, raw: hook, }); } } catch (error) { logger.warn("Failed to load hooks capability", { error: String(error) }); } // Load context files try { const contextFiles = await loadCapability("context-files", loadOpts); for (const file of contextFiles.all) { // Extract filename from path for display const name = path.basename(file.path); const id = makeExtensionId("context-file", `${file.level}:${name}`); const { state, disabledReason } = resolveState( file._source, disabledExtensions.has(id), (file as { _shadowed?: boolean })._shadowed, ); extensions.push({ id, kind: "context-file", name, displayName: name, description: file.level === "user" ? "User-level context" : "Project-level context", path: file.path, source: sourceFromMeta(file._source), state, disabledReason, raw: file, }); } } catch (error) { logger.warn("Failed to load context-files capability", { error: String(error) }); } return extensions; } /** * Toggle provider enabled state. */ export function toggleProvider(providerId: string): boolean { if (isProviderEnabled(providerId)) { disableProvider(providerId); return false; } else { enableProvider(providerId); return true; } } /** * Toggle a foreign provider's `~/` config opt-in. */ export function toggleUserSource(providerId: string): boolean { if (isUserSourceEnabled(providerId)) { disableUserSource(providerId); return false; } enableUserSource(providerId); return true; }