const fs = require("fs"); const path = require("path"); class ToolSettings { constructor(options = {}) { this.installer = options.installer; } describe(toolId) { const local = this.installer.local(toolId); if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid."); const schema = normalizeSchema(local.metadata.settings_schema); if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings."); const values = this.readValues(local.dir, schema); return { tool_id: toolId, display_name: local.metadata.display_name, schema, values: redactSecrets(values, schema), configured_secrets: Object.fromEntries( Object.entries(schema) .filter(([, field]) => field.secret === true) .map(([key]) => [key, Boolean(values[key])]) ) }; } save(toolId, input) { const local = this.installer.local(toolId); if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid."); const schema = normalizeSchema(local.metadata.settings_schema); if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings."); const current = this.readValues(local.dir, schema); const next = {}; for (const [key, field] of Object.entries(schema)) { const incoming = input?.[key]; if (field.secret === true && (incoming == null || String(incoming) === "")) { next[key] = current[key] ?? field.default; } else { next[key] = normalizeValue(incoming, field, key); } } const file = settingsFile(local.dir); fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); try { fs.chmodSync(temporary, 0o600); } catch {} fs.renameSync(temporary, file); return this.describe(toolId); } readRaw(toolId) { const local = this.installer.local(toolId); if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid."); const schema = normalizeSchema(local.metadata.settings_schema); return this.readValues(local.dir, schema); } readValues(toolDir, schema) { let stored = {}; try { stored = JSON.parse(fs.readFileSync(settingsFile(toolDir), "utf8")); } catch {} return Object.fromEntries( Object.entries(schema).map(([key, field]) => { try { return [key, normalizeValue(stored[key] ?? field.default, field, key)]; } catch { return [key, normalizeValue(field.default, field, key)]; } }) ); } } function normalizeSchema(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).map(([key, field]) => { if (!/^[a-z][a-z0-9_]*$/i.test(key) || !field || typeof field !== "object" || Array.isArray(field)) { throw new Error("settings_schema contains an invalid field."); } const type = String(field.type || "string"); if (!["string", "integer", "number", "boolean", "enum", "string_list", "multi_select"].includes(type)) { throw new Error(`Unsupported settings type for ${key}.`); } const options = Array.isArray(field.options) ? field.options.map(String) : []; if (["enum", "multi_select"].includes(type) && !options.length) { throw new Error(`${key} must define options.`); } return [key, { type, label: String(field.label || key.replaceAll("_", " ")), description: String(field.description || ""), default: field.default ?? defaultValue(type, options), options, minimum: finiteOrNull(field.minimum), maximum: finiteOrNull(field.maximum), secret: field.secret === true, required: field.required === true, rows: Math.max(2, Math.min(12, Number.parseInt(field.rows, 10) || 3)) }]; })); } function normalizeValue(value, field, key = "setting") { if (field.type === "boolean") return value === true || value === "true" || value === "1" || value === "on"; if (field.type === "integer" || field.type === "number") { const number = field.type === "integer" ? Number.parseInt(value, 10) : Number(value); if (!Number.isFinite(number)) throw new Error(`${field.label || key} must be a number.`); return clamp(number, field.minimum, field.maximum); } if (field.type === "string_list") { const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/); return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 200); } if (field.type === "multi_select") { const selected = Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)]; return [...new Set(selected)].filter((entry) => field.options.includes(entry)); } const text = String(value ?? "").trim(); if (field.required && !text) throw new Error(`${field.label || key} is required.`); if (field.type === "enum" && !field.options.includes(text)) throw new Error(`${field.label || key} is invalid.`); return text; } function redactSecrets(values, schema) { return Object.fromEntries(Object.entries(values).map(([key, value]) => [ key, schema[key]?.secret === true ? "" : value ])); } function settingsFile(toolDir) { return path.join(toolDir, "data", "settings.json"); } function defaultValue(type, options) { if (type === "boolean") return false; if (type === "integer" || type === "number") return 0; if (type === "string_list" || type === "multi_select") return []; if (type === "enum") return options[0] || ""; return ""; } function finiteOrNull(value) { if (value == null || value === "") return null; const number = Number(value); return Number.isFinite(number) ? number : null; } function clamp(value, minimum, maximum) { return Math.max(minimum ?? value, Math.min(maximum ?? value, value)); } module.exports = { ToolSettings, normalizeSchema, normalizeValue, redactSecrets, settingsFile };