300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
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 };
|