const fs = require("fs"); const path = require("path"); const ejs = require("ejs"); const { assetRoots, compareVersions } = require("./tool_loader"); class ToolManager { constructor(options = {}) { this.repoClient = options.repoClient; this.installer = options.installer; this.loader = options.loader; this.settings = options.settings; } async list({ force = false } = {}) { await this.loader.reconcile({ reload: force }); const remoteResult = await this.repoClient.discover({ force }); const localRows = this.installer.scanLocal(); const remoteMap = new Map(remoteResult.tools.map((metadata) => [metadata.tool_id, metadata])); const localMap = new Map(localRows.map((entry) => [entry.tool_id, entry])); const ids = [...new Set([...remoteMap.keys(), ...localMap.keys()])].sort(); return { repository: remoteResult.repository, branch: remoteResult.branch, checked_at: remoteResult.checked_at, cached: remoteResult.cached, stale: remoteResult.stale, error: remoteResult.error || null, tools: ids.map((toolId) => { const remote = remoteMap.get(toolId) || null; const local = localMap.get(toolId) || null; const metadata = local?.metadata || remote; const installed = Boolean(local); const remoteMissing = installed && !remote; const enabled = installed && this.loader.isEnabled(toolId); const runtime = this.loader.status(toolId); const updateAvailable = Boolean( installed && remote && local.valid && !remote.remote_invalid && compareVersions(remote.version, local.metadata.version) > 0 ); const dependencies = local?.valid ? this.loader.inspectDependencies(local.metadata, local.dir) : { blocking: [], optional: [] }; return { ...metadata, installed, enabled, local_version: installed ? local.metadata.version : null, remote_version: remote?.version || null, update_available: updateAvailable, update_enabled: installed && Boolean(remote) && !remote?.remote_invalid, remote_missing: remoteMissing, local_only: remoteMissing, local_valid: local?.valid !== false, local_error: local?.error || null, remote_invalid: remote?.remote_invalid === true, remote_error: remote?.remote_error || null, runtime_state: runtime.state, runtime_message: runtime.message, registered_tools: [...this.loader.registry.tools.values()] .filter((definition) => definition.owning_plugin === toolId) .map((definition) => definition.tool_id), dependency_status: dependencies, primary_type: metadata?.tool_type || "general", primary_scope: displayScope(metadata?.scope), has_settings: installed && Boolean(metadata?.settings_schema && Object.keys(metadata.settings_schema).length) }; }) }; } async enable(toolId) { let installed = this.installer.local(toolId); if (!installed) { const remote = await this.repoClient.discover(); if (!remote.tools.some((entry) => entry.tool_id === toolId && !entry.remote_invalid)) { throw new Error("Remote AI tool plugin is unavailable."); } await this.installer.install(toolId); installed = this.installer.local(toolId); } return this.loader.enable(toolId); } async disable(toolId) { return this.loader.disable(toolId); } async update(toolId) { const local = this.installer.local(toolId); if (!local) throw new Error("Install the AI tool plugin before updating it."); const remote = await this.repoClient.discover({ force: true }); if (!remote.tools.some((entry) => entry.tool_id === toolId && !entry.remote_invalid)) { throw new Error("This installed tool is missing from the configured repository."); } const wasEnabled = this.loader.isEnabled(toolId); if (wasEnabled) await this.loader.disable(toolId, { persist: false }); try { const result = await this.installer.update(toolId); if (wasEnabled) await this.loader.enable(toolId, { persist: false }); this.loader.setEnabled(toolId, wasEnabled); return result; } catch (error) { if (wasEnabled && this.installer.local(toolId)) { try { await this.loader.enable(toolId, { persist: false }); } catch {} this.loader.setEnabled(toolId, true); } throw error; } } async delete(toolId) { await this.loader.disable(toolId); const deleted = this.installer.delete(toolId); this.loader.setEnabled(toolId, false); return { deleted }; } async readme(toolId) { const local = this.installer.local(toolId); if (local) { const file = path.join(local.dir, "readme.md"); if (!fs.existsSync(file)) throw new Error("Installed tool readme.md is missing."); return { markdown: fs.readFileSync(file, "utf8"), source: "local" }; } return { markdown: await this.repoClient.readReadme(toolId), source: "remote" }; } settingsFor(toolId) { const described = this.settings.describe(toolId); const local = this.installer.local(toolId); const ui = local?.valid ? settingsUi(local.metadata, local.dir, toolId) : null; const status = local?.valid ? settingsStatus(local.metadata, local.dir) : null; return { ...described, ui, status }; } resetSettings(toolId) { this.settings.reset(toolId); return this.loader.isEnabled(toolId) ? this.loader.enable(toolId, { persist: false }).then(() => this.settingsFor(toolId)) : Promise.resolve(this.settingsFor(toolId)); } async saveSettings(toolId, values) { const saved = this.settings.save(toolId, values); if (this.loader.isEnabled(toolId)) await this.loader.enable(toolId, { persist: false }); return saved; } async diagnostics({ role, user, context }) { await this.loader.reconcile(); const exposure = this.loader.registry.inspect({ role, user, context }); const decisionsByOwner = new Map(); for (const decision of exposure.considered) { const owner = decision.tool.owning_plugin; if (!decisionsByOwner.has(owner)) decisionsByOwner.set(owner, []); decisionsByOwner.get(owner).push(decision); } const plugins = this.loader.diagnostics().map((plugin) => { const decisions = decisionsByOwner.get(plugin.tool_id) || []; const local = this.installer.local(plugin.tool_id); const declared = Array.isArray(local?.metadata?.registered_capabilities) ? local.metadata.registered_capabilities : []; let rawSettings = {}; try { rawSettings = this.settings.readRaw(plugin.tool_id); } catch {} const configuredDetails = Object.fromEntries( (Array.isArray(local?.metadata?.diagnostic_settings) ? local.metadata.diagnostic_settings : [] ).filter((key) => Object.hasOwn(rawSettings, key)).map((key) => [key, rawSettings[key]]) ); const persistedStatus = local?.valid ? settingsStatus(local.metadata, local.dir) : {}; const capabilityDecisions = declared.map((capability) => { const registered = decisions.find((decision) => decision.tool.tool_id === capability.tool_id); if (registered) return registered; const enabled = capability.enabled_setting ? rawSettings[capability.enabled_setting] !== false : true; return { tool: { tool_id: capability.tool_id, description: capability.description || "", owning_plugin: plugin.tool_id }, exposed: false, reason: enabled ? "unavailable" : "disabled", message: enabled ? "The capability is enabled but not registered." : "The capability is disabled in tool settings." }; }); const allDecisions = [ ...decisions, ...capabilityDecisions.filter((candidate) => !decisions.some((decision) => decision.tool.tool_id === candidate.tool.tool_id) ) ]; let hiddenReason = null; if (!plugin.valid) hiddenReason = "schema_invalid"; else if (!plugin.enabled) hiddenReason = "disabled"; else if (plugin.dependencies.blocking.length) hiddenReason = "dependency_failed"; else if (plugin.state === "unavailable") hiddenReason = "unavailable"; else if (!allDecisions.some((decision) => decision.exposed)) { hiddenReason = allDecisions[0]?.reason || "unavailable"; } return { ...plugin, runtime_details: { ...configuredDetails, ...persistedStatus, ...(plugin.runtime_details || {}) }, prompt_exposed: allDecisions.some((decision) => decision.exposed), hidden_reason: hiddenReason, decisions: allDecisions }; }); return { role, origin: context?.origin || context?.platform || "other", considered_tools: plugins.flatMap((plugin) => plugin.decisions.map((decision) => decision.tool.tool_id) ), exposed_tools: exposure.exposed.map((tool) => tool.tool_id), prompt_tools: exposure.exposed, plugins }; } async loadEnabled() { return this.loader.loadEnabled(); } async stopAll() { return this.loader.stopAll(); } resolveAsset(toolId, relative, options = {}) { if (options.allowInstalled) { const local = this.installer.local(toolId); if (local?.valid) { for (const root of assetRoots(local.metadata, local.dir)) { const candidate = path.resolve(root, String(relative || "")); if ((candidate === root || candidate.startsWith(`${root}${path.sep}`)) && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; } } } return this.loader.resolveAsset(toolId, relative); } } function displayScope(scope) { if (typeof scope === "string") return scope; if (Array.isArray(scope)) return scope.join(", "); if (scope && typeof scope === "object") return scope.label || scope.required_role || JSON.stringify(scope); return "unspecified"; } function settingsUi(metadata, toolDir, toolId) { const config = metadata.settings_ui; if (!config || typeof config !== "object") return null; const view = safeToolPath(toolDir, config.view); let html = ""; if (view && fs.existsSync(view)) { html = ejs.render(fs.readFileSync(view, "utf8"), { tool: metadata, tool_id: toolId }, { filename: view }); } return { html, scripts: safeAssetList(config.scripts, toolId), styles: safeAssetList(config.styles, toolId) }; } function safeToolPath(root, relative) { if (!relative) return null; const target = path.resolve(root, String(relative)); return target.startsWith(`${path.resolve(root)}${path.sep}`) ? target : null; } function safeAssetList(values, toolId) { return (Array.isArray(values) ? values : []) .map((value) => String(value || "").replace(/^\/+/, "")) .filter((value) => value && !value.split("/").includes("..")) .map((value) => `/plugins/lumi_ai/tools/${toolId}/assets/${value}`); } function settingsStatus(metadata, toolDir) { const file = safeToolPath(toolDir, metadata.status_file); if (!file || !fs.existsSync(file)) return {}; try { const value = JSON.parse(fs.readFileSync(file, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } catch { return {}; } } module.exports = { ToolManager, displayScope, safeAssetList, settingsStatus, settingsUi };